forked from Foxyz/sync_replay_project
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,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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user