Player: now-playing title with cover, start banner, one status pill; media art for everyone
The swapper sends the media's poster and banner once (`art`, Jellyfin images or AniList by parsed name); state carries their hashes and every member fetches them from /i/ like avatars. state.media.swapBy names who put the media up. uosc and the standard bar show cover, title and episode; a banner card shows before playback starts. The top-left health pill and the waiting pill become one frosted status pill in plain words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d092ea371a
commit
b534c8c7ea
29 files changed
+1892
-236
No files matched your search
@@ -0,0 +1,176 @@
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../session/now_playing.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
import 'now_playing_title.dart';
|
||||
|
||||
/// Space kept free at the bottom of the start card for the controls and
|
||||
/// the status pill.
|
||||
const startCardBottom = 176.0;
|
||||
|
||||
/// The start card of [now]: its banner (cover-fit, darkened), else a soft
|
||||
/// blur of the cover, else dark; the cover, a big title and the episode
|
||||
/// line bottom left, above the controls.
|
||||
class StartCard extends StatelessWidget {
|
||||
final NowPlaying now;
|
||||
const StartCard({super.key, required this.now});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final banner = artImage(now.bannerHash);
|
||||
final cover = banner == null ? artImage(now.posterHash) : null;
|
||||
final episode = now.episode;
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final h = box.maxHeight;
|
||||
final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
|
||||
final titleSize = (h * 0.05).clamp(22.0, 44.0);
|
||||
final side = (box.maxWidth * 0.05).clamp(20.0, 72.0);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const ColoredBox(color: Color(0xFF0C0D11)),
|
||||
if (banner != null)
|
||||
fadeInImage(banner)
|
||||
else if (cover != null)
|
||||
Opacity(
|
||||
opacity: 0.5,
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 48, sigmaY: 48, tileMode: TileMode.decal),
|
||||
child: Transform.scale(scale: 1.3, child: fadeInImage(cover)),
|
||||
),
|
||||
)
|
||||
else
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: Alignment(-0.7, 0.5),
|
||||
radius: 1.2,
|
||||
colors: [Color(0xFF23262E), Color(0xFF0C0D11)],
|
||||
),
|
||||
),
|
||||
),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0, 0.35, 1],
|
||||
colors: [Color(0x55000000), Color(0x22000000), Color(0xE6000000)],
|
||||
),
|
||||
),
|
||||
),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
stops: [0, 0.65],
|
||||
colors: [Color(0x99000000), Color(0x00000000)],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: side,
|
||||
right: side,
|
||||
bottom: startCardBottom,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
ArtCover(hash: now.posterHash, width: coverWidth),
|
||||
const SizedBox(width: 24),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
now.title,
|
||||
key: const ValueKey('start-card-title'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: titleSize,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.1,
|
||||
shadows: const [Shadow(color: Color(0x99000000), blurRadius: 16)],
|
||||
),
|
||||
),
|
||||
if (episode != null && episode.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
episode,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: Colors.white70, fontSize: titleSize * 0.42),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// [StartCard] over the picture until playback starts on this media
|
||||
/// (the server plays, or its position passed 1 s); then it fades out and
|
||||
/// stays gone for that media, whatever happens later. Never takes input.
|
||||
class StartBanner extends StatefulWidget {
|
||||
const StartBanner({super.key});
|
||||
|
||||
@override
|
||||
State<StartBanner> createState() => _StartBannerState();
|
||||
}
|
||||
|
||||
class _StartBannerState extends State<StartBanner> {
|
||||
int? _mediaId;
|
||||
bool _started = false;
|
||||
// Faded out: the card leaves the tree (and its images with it).
|
||||
bool _gone = true;
|
||||
NowPlaying? _last;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
return IgnorePointer(
|
||||
child: ListenableBuilder(
|
||||
listenable: artChanges(),
|
||||
builder: (context, _) => SignalBuilder(builder: (context) {
|
||||
final r = coord.serverState.value;
|
||||
final now = coord.nowPlaying.value;
|
||||
if (r != null && r.mediaId != _mediaId) {
|
||||
_mediaId = r.mediaId;
|
||||
_started = false;
|
||||
}
|
||||
if (r != null && (r.transport == 'playing' || r.position >= 1)) _started = true;
|
||||
final show = now != null && r?.media != null && !_started;
|
||||
if (show) {
|
||||
_last = now;
|
||||
_gone = false;
|
||||
}
|
||||
final last = _last;
|
||||
if (last == null || _gone) return const SizedBox.shrink();
|
||||
return AnimatedOpacity(
|
||||
key: const ValueKey('start-banner'),
|
||||
opacity: show ? 1 : 0,
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
onEnd: () {
|
||||
if (!show && mounted) setState(() => _gone = true);
|
||||
},
|
||||
child: StartCard(now: last),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user