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:
foxyzandClaude Opus 5.5 committed 2026-10-09 01:19:34 +03:00
1 parent d092ea371a
commit b534c8c7ea
29 files changed
+1892 -236

No files matched your search

@@ -0,0 +1,95 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import '../files/name_parser.dart';
import '../schedule/anilist.dart';
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
// art"): Jellyfin images with this install's account, else an AniList
// title search. Everything here fails quietly to "no art".
/// Poster and banner bytes; either may be null.
typedef ArtBytes = ({Uint8List? poster, Uint8List? banner});
/// Series poster (the item's own for movies) and series/item backdrop,
/// else its Banner image, scaled by Jellyfin to fit the caps.
Future<ArtBytes> jellyfinArt(JellyfinClient client, String itemId,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final item = await client.item(itemId).timeout(const Duration(seconds: 10));
final seriesPoster = item.seriesId != null && item.seriesPrimaryImageTag != null
? JellyfinImageRef(item.seriesId!, JellyfinImageType.primary, item.seriesPrimaryImageTag!)
: null;
final poster = seriesPoster ?? item.image(JellyfinImageType.primary);
var banner = item.image(JellyfinImageType.backdrop);
if (banner == null) {
var owner = item;
if (item.seriesId != null && item.ownImage(JellyfinImageType.banner) == null) {
try {
owner = await client.item(item.seriesId!).timeout(const Duration(seconds: 10));
} catch (_) {}
}
banner = owner.ownImage(JellyfinImageType.banner);
}
final headers = client.authHeaders;
final (p, b) = await (
poster == null
? Future<Uint8List?>.value()
: fetch(client.imageUrl(poster, maxHeight: 450, quality: 85), headers, RelayConstants.maxPosterBytes),
banner == null
? Future<Uint8List?>.value()
: fetch(client.imageUrl(banner, maxWidth: 1280, quality: 85), headers, RelayConstants.maxBannerBytes),
).wait;
return (poster: p, banner: b);
}
/// The series of release name [name] on AniList: cover and banner; null
/// when the name says nothing or AniList knows no match.
Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final series = parseReleaseName(name).series.trim();
if (series.isEmpty) return null;
final match = await aniList.searchMedia(series);
if (match == null) return null;
Future<Uint8List?> get(String? url, int maxBytes) async {
final uri = url == null ? null : Uri.tryParse(url);
return uri == null ? null : fetch(uri, const {}, maxBytes);
}
final (cover, banner) = await (
get(match.cover, RelayConstants.maxPosterBytes),
get(match.banner, RelayConstants.maxBannerBytes),
).wait;
final poster = cover ?? (match.coverSmall == match.cover ? null : await get(match.coverSmall, RelayConstants.maxPosterBytes));
return (poster: poster, banner: banner);
}
/// GET [url]: the bytes when 200, at most [maxBytes] and a known image
/// type (see `avatarImageType`); else null.
Future<Uint8List?> fetchImage(Uri url, Map<String, String> headers, int maxBytes) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final req = await client.getUrl(url);
headers.forEach(req.headers.set);
final resp = await req.close().timeout(const Duration(seconds: 15));
if (resp.statusCode != 200 || resp.contentLength > maxBytes) {
await resp.drain<void>().catchError((_) {});
return null;
}
final builder = BytesBuilder(copy: false);
await for (final chunk in resp.timeout(const Duration(seconds: 15))) {
builder.add(chunk);
if (builder.length > maxBytes) return null;
}
final bytes = builder.takeBytes();
return avatarImageType(bytes) == null ? null : bytes;
} catch (e) {
debugPrint('art: fetch failed: $e');
return null;
} finally {
client.close(force: true);
}
}
@@ -0,0 +1,24 @@
/// What the player shows about the current media: a title, an episode line
/// and art. Art is by content hash (`state.media.art`), fetched by everyone
/// (guests included) from the server they dialled; '' when there is none.
class NowPlaying {
/// Series name for episodes, else the movie, file or link name.
final String title;
/// "S2 E4 · Episode title", "E12", or null (movies, links).
final String? episode;
/// Portrait cover (2:3) and wide banner; '' when none.
final String posterHash;
final String bannerHash;
const NowPlaying({required this.title, this.episode, this.posterHash = '', this.bannerHash = ''});
@override
bool operator ==(Object other) =>
other is NowPlaying &&
other.title == title &&
other.episode == episode &&
other.posterHash == posterHash &&
other.bannerHash == bannerHash;
@override
int get hashCode => Object.hash(title, episode, posterHash, bannerHash);
}
@@ -0,0 +1,56 @@
import '../files/name_parser.dart';
import '../jellyfin/session_media.dart';
import 'now_playing.dart';
// Title and episode line of `state.media` (pure; see [NowPlaying]).
// "S1E6" (pickFor) or "S01E02".
final _episodeLabel = RegExp(r'^S(\d{1,3})\s?E(\d{1,4})$', caseSensitive: false);
final _year = RegExp(r'^(?:19|20)\d{2}$');
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
/// What the player shows for [media] (`state.media`); null with nothing up.
NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
if (media == null) return null;
final name = media['name'] is String ? media['name'] as String : '';
final art = media['art'] is Map ? media['art'] as Map : const {};
final poster = art['poster'] is String ? art['poster'] as String : '';
final banner = art['banner'] is String ? art['banner'] as String : '';
final ref = refOfStateMedia(media);
final (title, episode) = ref != null
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
: fileTitle(name);
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
}
/// Jellyfin: [name] is the item's, [subtitle] "Series · S1E6" (or
/// "Series - S01E02") for episodes, the year for movies. Episodes give the
/// series and "S1 E6 · Name"; anything else the name alone.
(String, String?) jellyfinTitle(String name, String? subtitle) {
final sub = subtitle?.trim() ?? '';
if (sub.isEmpty || _year.hasMatch(sub)) return (name, null);
// The label is after the last separator (or the whole subtitle).
final cut = RegExp(r' [·-] ').allMatches(sub).lastOrNull;
final m = _episodeLabel.firstMatch(cut == null ? sub : sub.substring(cut.end).trim());
final series = m == null ? sub : (cut == null ? '' : sub.substring(0, cut.start).trim());
final label = m == null ? null : 'S${int.parse(m.group(1)!)} E${int.parse(m.group(2)!)}';
if (series.isEmpty) return (name, label);
final episode = [?label, if (name.isNotEmpty) name].join(' · ');
return (series, episode.isEmpty ? null : episode);
}
/// Files and links: the release name's series and "S1 E3", "E25" or its
/// extra marker (NCOP1, OVA 2); the name minus extension when it says
/// nothing better.
(String, String?) fileTitle(String name) {
final p = parseReleaseName(name);
final series = p.series.trim();
if (series.isEmpty) return (name.replaceFirst(_ext, ''), null);
final String? episode;
if (p.episode != null) {
episode = p.season != null ? 'S${p.season} E${p.episode}' : 'E${p.episode}';
} else {
episode = p.extra;
}
return (series, episode);
}
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/widgets.dart';
@@ -11,6 +12,7 @@ import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart';
import '../playback/media_marks.dart';
import '../playback/playback_controller.dart';
import '../schedule/anilist.dart';
import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart';
import '../state/settings.dart';
@@ -20,6 +22,9 @@ import '../sync/sync_client.dart';
import '../sync/user_seek.dart';
import 'health.dart';
import 'idle.dart';
import 'media_art.dart';
import 'now_playing.dart';
import 'now_playing_info.dart';
/// Thrown when a connect is superseded by cancel/leave before it finished.
class ConnectCancelled implements Exception {
@@ -146,6 +151,8 @@ class SessionCoordinator {
final SyncClient sync = SyncClient();
/// Member pictures by `avatarHash`, fetched from the server.
late final AvatarCache avatars = AvatarCache(sync);
/// The current media's poster and banner by `state.media.art` hash.
late final ArtCache art = ArtCache(sync);
final PlaybackController playback = PlaybackController();
final StateDiff diff = StateDiff();
@@ -160,6 +167,9 @@ class SessionCoordinator {
/// The current media's Jellyfin ref (null for links and files).
final currentRef = signal<JellyfinRef?>(null);
/// Title, episode line and art hashes of the current media; null with
/// nothing up.
late final nowPlaying = computed<NowPlaying?>(() => nowPlayingOf(serverState.value?.media));
/// How this viewer plays [currentRef]; null while resolving.
final jellyfinPlan = signal<JellyfinPlan?>(null);
/// Set when this viewer cannot play the Jellyfin media: no account and
@@ -221,6 +231,10 @@ class SessionCoordinator {
String _wsHost = '';
int _wsPort = 0;
_RelayOrigin? _pendingOrigin;
// This client's last swap, until a state shows it landed (a newer
// mediaId than [after] with this member as `swapBy`): its art goes up then.
({HelloMedia media, int after})? _pendingArt;
AniListClient? _aniList;
// What this member serves for media [$1]; survives a reconnect (the
// state then looks new again).
(int, _RelayOrigin)? _activeOrigin;
@@ -386,7 +400,7 @@ class SessionCoordinator {
final media = params.media ??
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
if (epoch != _epoch) throw const ConnectCancelled();
if (media != null) sync.sendSwap(media);
if (media != null) _swap(media);
_startTicker();
} catch (e) {
if (epoch != _epoch) throw const ConnectCancelled();
@@ -522,6 +536,7 @@ class SessionCoordinator {
void _onState(StateBroadcast r) {
_lastStateAt = DateTime.now().toUtc();
serverState.value = r;
_maybeSendArt(r);
_queuedState = r;
if (_applying) return;
_applying = true;
@@ -984,6 +999,43 @@ class SessionCoordinator {
sync.sendKick(memberId, reason: reason);
}
void _swap(HelloMedia media) {
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0);
sync.sendSwap(media);
}
void _maybeSendArt(StateBroadcast r) {
final pending = _pendingArt;
if (pending == null || r.mediaId <= pending.after) return;
if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return;
_pendingArt = null;
unawaited(_sendArt(r.mediaId, pending.media));
}
/// Fetches the art of [media] (this client's swap, now [mediaId]) and
/// sends it; quietly nothing on any failure.
Future<void> _sendArt(int mediaId, HelloMedia media) async {
try {
final ref = media.jellyfin;
final client = ref == null ? null : jellyfin.clientFor(ref.serverId);
final ArtBytes? bytes;
if (ref != null && client != null) {
bytes = await jellyfinArt(client, ref.itemId);
} else {
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
bytes = await aniListArt(_aniList ??= AniListClient(), name);
}
final poster = bytes?.poster, banner = bytes?.banner;
if (poster == null && banner == null) return;
if (serverState.peek()?.mediaId != mediaId) return;
sync.sendArt(mediaId,
poster: poster == null ? null : base64Encode(poster),
banner: banner == null ? null : base64Encode(banner));
} catch (e) {
debugPrint('art for media $mediaId: $e');
}
}
/// A pasted link: a recognised Jellyfin item link goes up as a ref
/// (see [mediaForLink]) that this member relays for viewers without an
/// account (with the link's key, which stays here); anything else as the
@@ -993,11 +1045,11 @@ class SessionCoordinator {
final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url);
sync.sendSwap(media.copyWith(relay: const MediaRelay()));
_swap(media.copyWith(relay: const MediaRelay()));
return null;
}
if (relay == LinkRelay.direct) {
sync.sendSwap(media);
_swap(media);
return null;
}
// Relayed: it must be a single file the relay can range-read (not a
@@ -1015,9 +1067,9 @@ class SessionCoordinator {
}
if (relay == LinkRelay.me) {
_pendingOrigin = _LinkOrigin(url);
sync.sendSwap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
_swap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
} else {
sync.sendSwap(HelloMedia(
_swap(HelloMedia(
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true)));
}
return null;
@@ -1028,7 +1080,7 @@ class SessionCoordinator {
void swapJellyfin(JellyfinMedia media) {
markInput();
_pendingOrigin = const _JellyfinOrigin(null);
sync.sendSwap(mediaForPick(media).copyWith(relay: const MediaRelay()));
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()));
}
/// A file on this computer. The host's app serves it itself; anyone
@@ -1040,13 +1092,13 @@ class SessionCoordinator {
if (host != null) {
final file = await host(path);
if (file == null) return 'file not found — check the path';
sync.sendSwap(HelloMedia(name: file.name, size: file.size, duration: 0));
_swap(HelloMedia(name: file.name, size: file.size, duration: 0));
return null;
}
final file = File(path);
if (!await file.exists()) return 'file not found — check the path';
_pendingOrigin = _FileOrigin(path);
sync.sendSwap(HelloMedia(
_swap(HelloMedia(
name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()));
return null;
}
@@ -1541,6 +1593,7 @@ class SessionCoordinator {
await _stopJellyfin();
_stopRelaySource();
_pendingOrigin = null;
_pendingArt = null;
_activeOrigin = null;
_playerReady = false;
try {
@@ -1567,6 +1620,7 @@ class SessionCoordinator {
await _kickSub?.cancel();
await _errSub?.cancel();
await _statusSub?.cancel();
_aniList?.close();
await sync.dispose();
await playback.dispose();
}