181 lines
6.2 KiB
Dart
181 lines
6.2 KiB
Dart
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;
|
|
// Small (the mini player): no cover, a smaller title near the bottom.
|
|
final small = h < 320;
|
|
final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
|
|
final titleSize = small ? (h * 0.08).clamp(13.0, 18.0) : (h * 0.05).clamp(22.0, 44.0);
|
|
final side = small ? 12.0 : (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: small ? 12 : startCardBottom,
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
if (!small) ...[
|
|
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),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
}
|