Files
sync_replay_project/apps/desktop/lib/src/jellyfin/series_page.dart
T
foxyzandClaude Opus 5.5 6b10cae4e6 Protocol 12: Up next (a shared queue) and where the video comes from
Up next: a server-side queue anyone edits by item id (add at end or
next, move, remove, play now, skip); each item says who added it and
how it plays. At the real end the first item swaps in after the end
card's countdown, put up by whoever can (its adder for through-me links
and files, someone with access for Jellyfin, else anyone); items that
can't play are skipped with the reason. The next item's link is
resolved ahead. Greyed "Continues with" episodes below. A chip with the
controls opens it as a popover (leaf_widgets, local path dependency for
now); "Add to queue" next to Put it up everywhere.

Delivery: the server measures the source's rate, the media's bitrate
and each member's read rate; the members card shows a source line
(who passes it on, through what, keeping up or not, source left) and
each member's route and buffer. A slow source is told once per media.
A failed server fetch is announced to everyone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:40:26 +03:00

454 lines
17 KiB
Dart

import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'cards.dart' show doubleClickTime;
import 'format.dart';
import 'images.dart';
/// Series page: backdrop + logo header with overview and the smart "next
/// unwatched" button, a season selector and the season's episodes as dense
/// rows (several columns on wide windows).
class JellyfinSeriesPage extends StatefulWidget {
final JellyfinItem series;
final String? seasonId;
const JellyfinSeriesPage({super.key, required this.series, this.seasonId});
@override
State<JellyfinSeriesPage> createState() => _JellyfinSeriesPageState();
}
typedef _SeriesData = ({JellyfinItem series, List<JellyfinItem> seasons, JellyfinItem? next});
class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
String? _seasonId;
@override
void initState() {
super.initState();
_seasonId = widget.seasonId;
}
Future<_SeriesData> _load(BrowseScope scope) async {
final id = widget.series.id;
final results = await Future.wait<Object?>([
scope.load('item:$id', (c) => c.item(id)),
scope.load('seasons:$id', (c) => c.seasons(id)),
scope
.load('next:$id', (c) => c.nextUp(seriesId: id, limit: 1, enableResumable: true))
.then<List<JellyfinItem>?>((v) => v, onError: (_) => null),
]);
final next = results[2] as List<JellyfinItem>?;
return (
series: results[0] as JellyfinItem,
seasons: results[1] as List<JellyfinItem>,
next: next == null || next.isEmpty ? null : next.first,
);
}
/// Season to show first: the one holding the next episode, else the first
/// with something unwatched, else the first.
String? _defaultSeason(_SeriesData d) {
if (d.seasons.isEmpty) return null;
final nextSeason = d.next?.seasonId;
if (nextSeason != null && d.seasons.any((s) => s.id == nextSeason)) return nextSeason;
return (d.seasons.where((s) => !s.userData.played).firstOrNull ?? d.seasons.first).id;
}
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<_SeriesData>(
key: ValueKey('series-${widget.series.id}-${scope.account.serverId}'),
version: scope.controller.version,
load: () => _load(scope),
builder: (context, d) {
final seasonId = (_seasonId != null && d.seasons.any((s) => s.id == _seasonId))
? _seasonId
: _defaultSeason(d);
return CustomScrollView(
key: PageStorageKey('series-${widget.series.id}'),
slivers: [
SliverToBoxAdapter(child: _SeriesHeader(data: d)),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final s in d.seasons)
ChoiceChip(
key: ValueKey('season-${s.id}'),
label: Text(s.name),
avatar: s.userData.played
? const Icon(Icons.check, size: 16)
: null,
showCheckmark: false,
selected: s.id == seasonId,
onSelected: (_) => setState(() => _seasonId = s.id),
tooltip: s.userData.unplayedItemCount != null
? '${s.userData.unplayedItemCount} unwatched'
: null,
),
],
),
),
),
if (seasonId != null)
_EpisodeList(
key: ValueKey('episodes-$seasonId'),
seriesId: widget.series.id,
seasonId: seasonId,
version: scope.controller.version,
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
},
);
}
}
class _SeriesHeader extends StatefulWidget {
final _SeriesData data;
const _SeriesHeader({required this.data});
@override
State<_SeriesHeader> createState() => _SeriesHeaderState();
}
class _SeriesHeaderState extends State<_SeriesHeader> {
bool _more = false;
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final t = theme.textTheme;
final s = widget.data.series;
final next = widget.data.next;
final logo = s.image(JellyfinImageType.logo);
final meta = [
if (s.productionYear != null) '${s.productionYear}',
if (s.officialRating != null) s.officialRating!,
if (s.communityRating != null) '★ ${s.communityRating!.toStringAsFixed(1)}',
if (widget.data.seasons.isNotEmpty)
'${widget.data.seasons.length} ${widget.data.seasons.length == 1 ? 'season' : 'seasons'}',
if ((s.userData.unplayedItemCount ?? 0) > 0) '${s.userData.unplayedItemCount} unwatched',
].join(' · ');
final String nextLabel;
if (next == null) {
nextLabel = 'All watched';
} else if (next.userData.isResumable) {
nextLabel = 'Resume ${next.episodeLabel ?? next.name}';
} else {
nextLabel = 'Next up: ${next.episodeLabel ?? ''} ${next.name}'.trim();
}
final selectedNext = next != null && scope.selectedItemId == next.id;
return LayoutBuilder(builder: (context, box) {
final h = (box.maxWidth * 0.28).clamp(220.0, 380.0);
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: h,
child: Stack(
fit: StackFit.expand,
children: [
JellyfinImage(
client: scope.client,
image: s.image(JellyfinImageType.backdrop),
alignment: Alignment.topCenter,
icon: Icons.live_tv_outlined,
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
theme.colorScheme.surface.withValues(alpha: 0.96),
theme.colorScheme.surface.withValues(alpha: 0.75),
theme.colorScheme.surface.withValues(alpha: 0.1),
],
stops: const [0, 0.45, 1],
),
),
),
Padding(
padding: const EdgeInsets.all(24),
child: Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (logo != null)
SizedBox(
height: 72,
width: 320,
child: JellyfinImage(
client: scope.client,
image: logo,
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
placeholder: Align(
alignment: Alignment.centerLeft,
child: Text(s.name, style: t.headlineMedium)),
),
)
else
Text(s.name, style: t.headlineMedium),
const SizedBox(height: 8),
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
if (s.overview != null) ...[
const SizedBox(height: 8),
GestureDetector(
onTap: () => setState(() => _more = !_more),
child: Text(s.overview!,
maxLines: _more ? null : 3,
overflow: _more ? null : TextOverflow.ellipsis,
style: t.bodyMedium),
),
],
const SizedBox(height: 14),
Wrap(spacing: 8, runSpacing: 8, children: [
FilledButton.icon(
key: const ValueKey('series-next'),
onPressed: next == null ? null : () => scope.onSelect(next),
icon: Icon(selectedNext ? Icons.check : Icons.play_arrow),
label: Text(nextLabel, overflow: TextOverflow.ellipsis),
),
]),
],
),
),
),
),
),
],
),
),
);
});
}
}
/// Episodes of one season as a lazy grid of rows: one column, more past
/// ~760 px each.
class _EpisodeList extends StatefulWidget {
final String seriesId;
final String seasonId;
final int version;
const _EpisodeList(
{super.key, required this.seriesId, required this.seasonId, required this.version});
@override
State<_EpisodeList> createState() => _EpisodeListState();
}
class _EpisodeListState extends State<_EpisodeList> {
Future<List<JellyfinItem>>? _future;
List<JellyfinItem>? _last;
@override
void didUpdateWidget(_EpisodeList old) {
super.didUpdateWidget(old);
if (old.version != widget.version) _future = null;
}
void _load(BrowseScope scope) => _future = scope.load('episodes:${widget.seriesId}:${widget.seasonId}',
(c) => c.episodes(widget.seriesId, seasonId: widget.seasonId));
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
if (_future == null) _load(scope);
return FutureBuilder<List<JellyfinItem>>(
future: _future,
builder: (context, snap) {
if (snap.hasError) {
return SliverToBoxAdapter(
child: LoadError(error: snap.error!, onRetry: () => setState(() => _load(scope))));
}
final eps = snap.data ?? _last;
if (snap.data != null) _last = snap.data;
if (eps == null) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
}
if (eps.isEmpty) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('No episodes', style: TextStyle(color: Colors.grey)),
),
);
}
return SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 760,
mainAxisExtent: episodeTileHeight,
crossAxisSpacing: 12,
mainAxisSpacing: 8,
),
delegate: SliverChildBuilderDelegate(
(context, i) => EpisodeTile(episode: eps[i]),
childCount: eps.length,
),
);
},
);
}
}
const episodeTileHeight = 6 + 176 * 9 / 16 + 6 + 2;
/// One episode: thumbnail with progress, number + title, runtime / air
/// date, two lines of overview. Click selects it (details panel), a second
/// click soon after puts it up.
class EpisodeTile extends StatefulWidget {
final JellyfinItem episode;
const EpisodeTile({super.key, required this.episode});
@override
State<EpisodeTile> createState() => _EpisodeTileState();
}
class _EpisodeTileState extends State<EpisodeTile> {
bool _focus = false;
bool _hover = false;
DateTime? _lastTap;
void _tap(BrowseScope scope) {
final now = DateTime.now();
final last = _lastTap;
_lastTap = now;
if (last != null && now.difference(last) < doubleClickTime) {
_lastTap = null;
scope.onPutUp(widget.episode);
} else {
scope.onSelect(widget.episode);
}
}
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final t = theme.textTheme;
final e = widget.episode;
final selected = scope.selectedItemId == e.id;
final played = e.userData.played;
final progress = progressOf(e);
final meta = [
?e.episodeLabel,
?timeLeft(e),
if (timeLeft(e) == null && e.runTime != null) formatRuntime(e.runTime!),
if (e.premiereDate != null)
'${e.premiereDate!.year}-${e.premiereDate!.month.toString().padLeft(2, '0')}-${e.premiereDate!.day.toString().padLeft(2, '0')}',
].join(' · ');
return Material(
color: selected
? scheme.primaryContainer.withValues(alpha: 0.35)
: _hover || _focus
? scheme.surfaceContainerHigh
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: ValueKey('episode-${e.id}'),
focusColor: Colors.transparent,
onTap: () => _tap(scope),
onSecondaryTap: () => scope.cardMenu(context, widget.episode),
onHover: (h) => setState(() => _hover = h),
onFocusChange: (f) => setState(() => _focus = f),
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 176,
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(fit: StackFit.expand, children: [
JellyfinImage(
client: scope.client,
image: e.ownImage(JellyfinImageType.primary) ??
e.image(JellyfinImageType.thumb) ??
e.image(JellyfinImageType.backdrop),
icon: Icons.tv_outlined,
),
if (progress > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: progress, minHeight: 3, backgroundColor: Colors.black54),
),
if (played || selected)
Positioned(
top: 4,
right: 4,
child: CircleAvatar(
radius: 10,
backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20),
child: Icon(Icons.check,
size: 13, color: selected ? scheme.onPrimary : Colors.white),
),
),
]),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
color: selected ? scheme.primary : played ? Colors.grey : null,
),
),
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
if (e.overview != null) ...[
const SizedBox(height: 4),
Text(e.overview!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
],
],
),
),
],
),
),
),
);
}
}