diff --git a/README.md b/README.md index e446157..a50f892 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,12 @@ corner; double-click or ⤢ makes it big again, × stops watching (you stay in the server, muted) and the strip's screen button brings it back. F or double-click on the picture goes fullscreen (the strip hides, the party shows in a corner while paused, the window buttons sit in the player's top -bar while its controls are up), Esc leaves it. While playback waits, a -pill under the picture says for whom ("Waiting for you · press Space when -ready"). Chat and activity open in a panel on the right (chat button or +bar while its controls are up), Esc leaves it. The controls show the +show's cover, title and episode top left (guests get the cover too); before +a video first plays, its banner fills the picture until playback starts. +One pill above the controls says what playback waits for ("Press Space +when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is +loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or Tab); joins, leaves, chat and other news also pop up bottom left with the person's avatar. @@ -181,7 +184,7 @@ chapter title and the time there. With the controls hidden it stays as a thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the track count), the shader badge (the active Anime4K mode, e.g. "A HQ"), ready, chapters, 1:30, previous / next episode, change video, volume, ⚙ -and fullscreen; chat and back-to-library top right. Keys and skip buttons work +and fullscreen; the title top left, chat and back-to-library top right. Keys and skip buttons work the same. Skipping: during the opening a "Skip OP" button floats in the corner (it diff --git a/apps/desktop/lib/src/controls/uosc/controls.dart b/apps/desktop/lib/src/controls/uosc/controls.dart index 51c022e..89d0f23 100644 --- a/apps/desktop/lib/src/controls/uosc/controls.dart +++ b/apps/desktop/lib/src/controls/uosc/controls.dart @@ -6,7 +6,8 @@ import 'package:signals/signals_flutter.dart'; import '../../state/settings.dart'; // The mpv-style (uosc) controls frame: a full-width timeline at the -// bottom with a row of flat buttons above it, a corner widget top right. +// bottom with a row of flat buttons above it, the title top left and a +// corner widget top right. // Mouse movement shows them; after [UoscControls.hideAfter] without any // they fade out, the timeline shrinks to a 2 px line (gone when "Progress // line when hidden" is off) and the cursor hides. Our own frame: no @@ -20,6 +21,9 @@ class UoscControls extends StatefulWidget { final List right; /// Top-right corner (chat, back to the library), shown with the controls. final Widget? top; + /// Top left (what is playing), shown with the controls; gets the width + /// [top] leaves. + final Widget? topLeft; final VoidCallback? onDoubleTap; final Duration hideAfter; const UoscControls({ @@ -28,6 +32,7 @@ class UoscControls extends StatefulWidget { this.left = const [], this.right = const [], this.top, + this.topLeft, this.onDoubleTap, this.hideAfter = const Duration(seconds: 3), }); @@ -136,13 +141,54 @@ class UoscControlsState extends State { ), ), ), - if (widget.top != null) + if (widget.topLeft != null) + Positioned( + left: 0, + right: 0, + top: 0, + height: 120, + child: IgnorePointer( + child: AnimatedOpacity( + duration: fade, + opacity: visible ? 1 : 0, + child: const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x8C000000), Color(0x00000000)], + ), + ), + ), + ), + ), + ), + if (widget.top != null || widget.topLeft != null) Positioned( top: 0, + left: 0, right: 0, child: IgnorePointer( + key: const ValueKey('uosc-top'), ignoring: !visible, - child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: widget.top), + child: AnimatedOpacity( + duration: fade, + opacity: visible ? 1 : 0, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: widget.topLeft == null + ? const SizedBox.shrink() + : Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft), + ), + ), + ?widget.top, + ], + ), + ), ), ), Positioned( diff --git a/apps/desktop/lib/src/schedule/anilist.dart b/apps/desktop/lib/src/schedule/anilist.dart index bf02dfd..247d09c 100644 --- a/apps/desktop/lib/src/schedule/anilist.dart +++ b/apps/desktop/lib/src/schedule/anilist.dart @@ -9,7 +9,8 @@ import 'package:sync_relay_core/core.dart'; import '../update/updater.dart' show appVersion; // AniList airing schedule (public GraphQL, no login): every episode airing -// in a time range, with covers, banners and popularity. +// in a time range, with covers, banners and popularity; and a title search +// (cover + banner of a show, for the media art of files and links). // // AniList allows ~90 requests/min (sometimes degraded to 30), so the raw // pages are cached: in memory for 10 min (tab switches) and on disk for an @@ -76,6 +77,35 @@ class Airing { String toString() => 'Airing($anilistId "$title" ep $episode @ $airingAt)'; } +/// An anime found by title ([AniListClient.searchMedia]). +class AniListMedia { + final int id; + /// English title, else romaji. + final String title; + /// Cover (portrait) URL: extraLarge, else large. + final String? cover; + /// The smaller cover, for when [cover] is too big to send on. + final String? coverSmall; + final String? banner; + const AniListMedia({required this.id, required this.title, this.cover, this.coverSmall, this.banner}); + + static AniListMedia? fromJson(Object? m) { + if (m is! Map || m['id'] is! int) return null; + final t = m['title'] is Map ? m['title'] as Map : const {}; + final romaji = t['romaji'] is String ? t['romaji'] as String : ''; + final english = t['english'] is String ? t['english'] as String : ''; + final cov = m['coverImage'] is Map ? m['coverImage'] as Map : const {}; + final large = cov['large'] is String ? cov['large'] as String : null; + return AniListMedia( + id: m['id'] as int, + title: english.isNotEmpty ? english : romaji, + cover: cov['extraLarge'] is String ? cov['extraLarge'] as String : large, + coverSmall: large, + banner: m['bannerImage'] is String ? m['bannerImage'] as String : null, + ); + } +} + /// "#e4a15d" -> opaque [Color]; anything else -> null. Color? parseHexColor(Object? s) { if (s is! String || s.length != 7 || !s.startsWith('#')) return null; @@ -217,11 +247,19 @@ media{id title{romaji english} coverImage{large color} bannerImage isAdult popul } Future _page(int from, int to, int page) async { + final (data, status, j) = await _graphql(query, {'from': from - 1, 'to': to, 'page': page}); + final p = data?['Page']; + if (p is! Map) { + throw AniListException('AniList: bad response${_errorText(j)}', statusCode: status); + } + return p; + } + + /// One GraphQL request: `data` (null when missing), status and body. A + /// 429 waits out Retry-After once; other non-200s throw. + Future<(Map?, int, Object?)> _graphql(String query, Map variables) async { for (var attempt = 0;; attempt++) { - final (status, body, retryAfter) = await _post(jsonEncode({ - 'query': query, - 'variables': {'from': from - 1, 'to': to, 'page': page}, - })); + final (status, body, retryAfter) = await _post(jsonEncode({'query': query, 'variables': variables})); if (status == 429 && attempt == 0) { final wait = retryAfter ?? 60; _log.info('AniList rate limited, retrying in ${wait}s'); @@ -235,11 +273,37 @@ media{id title{romaji english} coverImage{large color} bannerImage isAdult popul if (status != 200) { throw AniListException('AniList HTTP $status${_errorText(j)}', statusCode: status); } - final data = j is Map && j['data'] is Map ? (j['data'] as Map)['Page'] : null; - if (data is! Map) { - throw AniListException('AniList: bad response${_errorText(j)}', statusCode: status); - } - return data; + final data = j is Map && j['data'] is Map ? j['data'] as Map : null; + return (data, status, j); + } + } + + static const searchQuery = r''' +query($search:String){Media(search:$search,type:ANIME,isAdult:false){id title{romaji english} +coverImage{extraLarge large} bannerImage}}'''; + + final _search = >{}; + + /// AniList's best anime match for [title], or null when there is none. + /// One request per title for this client's life (a failure is not kept, + /// so a later call asks again). + Future searchMedia(String title) { + final key = title.trim(); + if (key.isEmpty) return Future.value(null); + return _search[key] ??= _searchMedia(key).catchError((Object e) { + _search.remove(key); + throw e; + }); + } + + Future _searchMedia(String title) async { + try { + final (data, _, _) = await _graphql(searchQuery, {'search': title}); + return AniListMedia.fromJson(data?['Media']); + } on AniListException catch (e) { + // No match is a 404 with "Not Found." in errors. + if (e.statusCode == 404) return null; + rethrow; } } diff --git a/apps/desktop/lib/src/session/media_art.dart b/apps/desktop/lib/src/session/media_art.dart new file mode 100644 index 0000000..de535ef --- /dev/null +++ b/apps/desktop/lib/src/session/media_art.dart @@ -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 jellyfinArt(JellyfinClient client, String itemId, + {Future Function(Uri url, Map 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.value() + : fetch(client.imageUrl(poster, maxHeight: 450, quality: 85), headers, RelayConstants.maxPosterBytes), + banner == null + ? Future.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 aniListArt(AniListClient aniList, String name, + {Future Function(Uri url, Map 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 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 fetchImage(Uri url, Map 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().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); + } +} diff --git a/apps/desktop/lib/src/session/now_playing.dart b/apps/desktop/lib/src/session/now_playing.dart new file mode 100644 index 0000000..4601a25 --- /dev/null +++ b/apps/desktop/lib/src/session/now_playing.dart @@ -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); +} diff --git a/apps/desktop/lib/src/session/now_playing_info.dart b/apps/desktop/lib/src/session/now_playing_info.dart new file mode 100644 index 0000000..64715fa --- /dev/null +++ b/apps/desktop/lib/src/session/now_playing_info.dart @@ -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? 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); +} diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index f9494c6..4b63911 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -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(null); + /// Title, episode line and art hashes of the current media; null with + /// nothing up. + late final nowPlaying = computed(() => nowPlayingOf(serverState.value?.media)); /// How this viewer plays [currentRef]; null while resolving. final jellyfinPlan = signal(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 _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(); } diff --git a/apps/desktop/lib/src/shell/motion.dart b/apps/desktop/lib/src/shell/motion.dart index 5d6d8df..1531708 100644 --- a/apps/desktop/lib/src/shell/motion.dart +++ b/apps/desktop/lib/src/shell/motion.dart @@ -15,11 +15,16 @@ const fadeOutPart = 0.35; /// Fade through (Material motion): the old view is gone after the first /// 35%, then the new one fades and scales in; they never overlap. Key /// [child] by what it shows so a change of key runs the transition. -Widget fadeThrough(Widget child, {Key? key}) => AnimatedSwitcher( +/// [expand] false: sized by the views (at [alignment]) instead of filling +/// the box, for unbounded places (a row, a pill). +Widget fadeThrough(Widget child, + {Key? key, bool expand = true, AlignmentGeometry alignment = AlignmentDirectional.topStart}) => + AnimatedSwitcher( key: key, duration: fadeThroughTime, layoutBuilder: (current, previous) => Stack( - fit: StackFit.expand, + fit: expand ? StackFit.expand : StackFit.loose, + alignment: alignment, children: [...previous, ?current], ), transitionBuilder: (c, anim) { diff --git a/apps/desktop/lib/src/shell/now_playing_title.dart b/apps/desktop/lib/src/shell/now_playing_title.dart new file mode 100644 index 0000000..52df6ad --- /dev/null +++ b/apps/desktop/lib/src/shell/now_playing_title.dart @@ -0,0 +1,171 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; + +import '../session/now_playing.dart'; +import '../state/session_state.dart'; +import 'motion.dart'; + +/// Tests: art bytes by hash instead of the session's cache. +@visibleForTesting +Uint8List? Function(String hash)? debugArtBytes; + +/// The one lookup for media art (cover, banner) by content hash: null +/// when there is none or it has not arrived yet (listeners of +/// [artChanges] hear when it lands). +Uint8List? artBytes(String hash) { + if (hash.isEmpty) return null; + final debug = debugArtBytes; + if (debug != null) return debug(hash); + return SessionState.instance.coordinator.art.bytesFor(hash); +} + +/// Notifies when art arrives. +Listenable artChanges() => SessionState.instance.coordinator.art; + +final _images = {}; + +/// [hash]'s art as an image, the same provider while the bytes are the +/// same (so a rebuild never decodes again). +ImageProvider? artImage(String hash) { + final bytes = artBytes(hash); + if (bytes == null) return null; + final hit = _images[hash]; + if (hit != null && identical(hit.bytes, bytes)) return hit; + if (_images.length > 16) _images.clear(); + return _images[hash] = MemoryImage(bytes); +} + +/// [image] fading in (ease) once decoded; nothing before. +Widget fadeInImage(ImageProvider image, {BoxFit fit = BoxFit.cover}) => Image( + image: image, + fit: fit, + gaplessPlayback: true, + frameBuilder: (context, child, frame, sync) => sync + ? child + : AnimatedOpacity(opacity: frame == null ? 0 : 1, duration: easeTime, curve: ease, child: child), + ); + +/// The portrait cover (2:3) of the current media, a quiet tile with a +/// film icon until (or unless) its art arrives. +class ArtCover extends StatelessWidget { + final String hash; + final double width; + const ArtCover({super.key, required this.hash, this.width = 40}); + + @override + Widget build(BuildContext context) { + final image = artImage(hash); + final r = BorderRadius.circular(width >= 80 ? 10 : 6); + return Container( + width: width, + height: width * 1.5, + decoration: BoxDecoration( + borderRadius: r, + boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 10, offset: Offset(0, 3))], + ), + child: ClipRRect( + borderRadius: r, + child: Stack( + fit: StackFit.expand, + children: [ + DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF2A2D35), Color(0xFF17191E)], + ), + ), + child: Icon(Icons.movie_outlined, color: Colors.white38, size: width * 0.45), + ), + if (image != null) fadeInImage(image), + DecoratedBox( + decoration: BoxDecoration( + borderRadius: r, + border: Border.all(color: Colors.white12), + ), + ), + ], + ), + ), + ); + } +} + +const _shadow = [Shadow(color: Color(0x99000000), blurRadius: 8)]; + +/// Title and episode line of [now] (with its cover unless [cover] is +/// off), ellipsized; cross-fades when the media changes. +class NowPlayingBlock extends StatelessWidget { + final NowPlaying? now; + final bool cover; + /// Smaller type (the mini player). + final bool compact; + const NowPlayingBlock({super.key, required this.now, this.cover = true, this.compact = false}); + + @override + Widget build(BuildContext context) { + final n = now; + final Widget child; + if (n == null) { + child = const SizedBox.shrink(key: ValueKey('now-playing-none')); + } else { + final episode = n.episode; + child = Row( + key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'), + mainAxisSize: MainAxisSize.min, + children: [ + if (cover) ...[ArtCover(hash: n.posterHash), const SizedBox(width: 12)], + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + n.title, + key: const ValueKey('now-playing-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: compact ? 13 : 16, + shadows: _shadow), + ), + if (episode != null && episode.isNotEmpty) + Text( + episode, + key: const ValueKey('now-playing-episode'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.white70, fontSize: compact ? 11 : 13, shadows: _shadow), + ), + ], + ), + ), + ], + ); + } + return fadeThrough(child, expand: false, alignment: AlignmentDirectional.centerStart); + } +} + +/// [NowPlayingBlock] of the session's current media. +class NowPlayingTitle extends StatelessWidget { + final bool cover; + final bool compact; + const NowPlayingTitle({super.key, this.cover = true, this.compact = false}); + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: artChanges(), + builder: (context, _) => SignalBuilder( + builder: (context) => NowPlayingBlock( + now: SessionState.instance.coordinator.nowPlaying.value, + cover: cover, + compact: compact, + ), + ), + ); +} diff --git a/apps/desktop/lib/src/shell/player_status.dart b/apps/desktop/lib/src/shell/player_status.dart new file mode 100644 index 0000000..63857a7 --- /dev/null +++ b/apps/desktop/lib/src/shell/player_status.dart @@ -0,0 +1,366 @@ +import 'dart:async'; +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:sync_relay_core/core.dart'; + +import '../session/health.dart'; +import '../session/session_coordinator.dart'; +import '../state/session_state.dart'; +import 'avatar.dart'; +import 'motion.dart'; + +/// What the player's status pill says, most pressing first. +enum StatusKind { kicked, reconnecting, problem, connecting, finished, loading, you, waiting } + +/// The pill's button: stop reconnecting, retry, or mark yourself ready +/// (the whole pill). +enum StatusAction { cancel, retry, ready } + +/// One status line: [text], a muted [detail], a coloured [tone] for +/// problems (warn, error), the members it is about ([who]; [self] for you) +/// and a loading [progress] (0..1). +class StatusView { + final StatusKind kind; + final String text; + final String? detail; + final HealthSeverity? tone; + final List who; + final bool self; + final double? progress; + final StatusAction? action; + /// The mini player's shorter line. + final String short; + const StatusView( + this.kind, + this.text, { + this.detail, + this.tone, + this.who = const [], + this.self = false, + this.progress, + this.action, + String? short, + }) : short = short ?? text; +} + +/// "Bob", "Bob and Mika", "Bob, Mika and Cleo", "Bob, Mika and 3 others". +String nameList(List names) { + if (names.length <= 1) return names.join(); + if (names.length <= 3) return '${names.sublist(0, names.length - 1).join(', ')} and ${names.last}'; + return '${names[0]}, ${names[1]} and ${names.length - 2} others'; +} + +String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); + +/// The player's status from the session's signals; null when there is +/// nothing to say (playing, connected, everyone ready). [slowConnect]: +/// connecting has taken over 2 s. +StatusView? playerStatusFor({ + required HealthStatus health, + bool reconnecting = false, + String? kickReason, + StateBroadcast? state, + MemberView? self, + bool slowConnect = false, +}) { + if (kickReason != null) { + return StatusView(StatusKind.kicked, + kickReason.isEmpty ? 'You were removed from the server' : 'You were removed: $kickReason', + tone: HealthSeverity.error, short: 'Removed from the server'); + } + if (reconnecting) { + return const StatusView(StatusKind.reconnecting, 'Reconnecting…', + tone: HealthSeverity.warn, action: StatusAction.cancel); + } + if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) { + return StatusView(StatusKind.problem, _sentence(health.message), + tone: health.severity, action: StatusAction.retry); + } + if (slowConnect && health.severity == HealthSeverity.info && health.message.startsWith('connecting')) { + return const StatusView(StatusKind.connecting, 'Connecting…'); + } + final r = state; + if (r == null || r.media == null || r.transport == 'playing') return null; + if (r.transport == 'finished') { + return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished'); + } + // As the server holds playback: someone in the ready set short of the + // minimum buffer. + for (final m in r.members) { + if (m.inSet && m.buffered < RelayConstants.minBufferSecs) { + final you = self != null && m.id == self.id; + return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…', + who: [m], self: you, progress: (m.buffered / RelayConstants.minBufferSecs).clamp(0.0, 1.0)); + } + } + if (self != null && !self.ready) { + return StatusView(StatusKind.you, "Press Space when you're ready", + who: [self], self: true, action: StatusAction.ready, short: 'Press Space when ready'); + } + final active = [for (final m in r.members) if (!m.stale) m]; + final others = [ + for (final m in active) + if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m, + ]; + if (others.isEmpty) return null; + final ready = active.where((m) => m.ready).length; + return StatusView(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}', + who: others, detail: '$ready/${active.length} ready'); +} + +/// [playerStatusFor] from the session (tracked: call inside a builder). +StatusView? playerStatusNow({bool slowConnect = false}) { + final c = SessionState.instance.coordinator; + return playerStatusFor( + health: c.health.value, + reconnecting: c.reconnecting.value, + kickReason: c.kickReason.value, + state: c.serverState.value, + self: c.selfMember.value, + slowConnect: slowConnect, + ); +} + +/// The player's one status pill (bottom centre): frosted dark glass with +/// one short line, the members it is about and at most one button. Fades +/// in and out; a different kind of status fades through, the pill resizing +/// around it. +class PlayerStatus extends StatefulWidget { + const PlayerStatus({super.key}); + + static const connectGrace = Duration(seconds: 2); + + @override + State createState() => _PlayerStatusState(); +} + +class _PlayerStatusState extends State { + Timer? _connectTimer; + bool _slow = false; + StatusView? _last; + + @override + void dispose() { + _connectTimer?.cancel(); + super.dispose(); + } + + // "Connecting…" only once it takes a while. + void _trackConnecting(HealthStatus h) { + final connecting = h.severity == HealthSeverity.info && h.message.startsWith('connecting'); + if (!connecting) { + _connectTimer?.cancel(); + _connectTimer = null; + _slow = false; + } else if (_connectTimer == null && !_slow) { + _connectTimer = Timer(PlayerStatus.connectGrace, () { + if (mounted) setState(() => _slow = true); + }); + } + } + + void _act(SessionCoordinator coord, StatusAction a) { + switch (a) { + case StatusAction.cancel: + coord.cancelReconnect(); + case StatusAction.retry: + coord.retry(); + case StatusAction.ready: + coord.toggleReady(); + } + } + + @override + Widget build(BuildContext context) { + return PartyWatch(builder: (context) { + final coord = SessionState.instance.coordinator; + _trackConnecting(coord.health.value); + final view = playerStatusNow(slowConnect: _slow); + if (view != null) _last = view; + final shown = _last; + final ready = view?.action == StatusAction.ready; + return AnimatedOpacity( + duration: easeTime, + curve: ease, + opacity: view == null ? 0 : 1, + child: IgnorePointer( + ignoring: view == null, + child: shown == null + ? const SizedBox.shrink() + : MouseRegion( + cursor: ready ? SystemMouseCursors.click : MouseCursor.defer, + child: GestureDetector( + key: const ValueKey('player-status'), + onTap: ready ? coord.toggleReady : null, + child: _Glass( + child: AnimatedSize( + duration: easeTime, + curve: ease, + child: fadeThrough( + _StatusLine( + key: ValueKey(shown.kind), + view: shown, + coord: coord, + onAction: (a) => _act(coord, a), + ), + expand: false, + alignment: Alignment.center, + ), + ), + ), + ), + ), + ), + ); + }); + } +} + +class _Glass extends StatelessWidget { + final Widget child; + const _Glass({required this.child}); + + @override + Widget build(BuildContext context) { + final r = BorderRadius.circular(999); + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: ClipRRect( + borderRadius: r, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: r, + border: Border.all(color: Colors.white12, width: 0.5), + ), + child: child, + ), + ), + ), + ); + } +} + +class _StatusLine extends StatelessWidget { + final StatusView view; + final SessionCoordinator coord; + final ValueChanged onAction; + const _StatusLine({super.key, required this.view, required this.coord, required this.onAction}); + + static const _avatar = 24.0; + + Widget? _lead() { + final v = view; + switch (v.kind) { + case StatusKind.kicked: + case StatusKind.problem: + final error = v.tone == HealthSeverity.error; + return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded, + size: 18, color: error ? Colors.redAccent.shade100 : Colors.amber); + case StatusKind.reconnecting: + return const _Spinner(color: Colors.amber); + case StatusKind.connecting: + return const _Spinner(color: Colors.white70); + case StatusKind.finished: + return null; + case StatusKind.loading: + case StatusKind.you: + case StatusKind.waiting: + final r = coord.serverState.value; + final infos = [ + for (final m in v.who) + v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord), + ]; + if (infos.isEmpty) return null; + return infos.length == 1 + ? StatusAvatar(info: infos.first, size: _avatar, badges: false) + : AvatarStack(members: infos, shown: 4, size: _avatar); + } + } + + @override + Widget build(BuildContext context) { + final v = view; + final lead = _lead(); + final action = v.action == StatusAction.ready ? null : v.action; + return Padding( + padding: EdgeInsets.fromLTRB(lead == null ? 16 : 6, 6, action == null ? 16 : 6, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (lead != null) ...[ + SizedBox(height: _avatar, child: Center(child: lead)), + const SizedBox(width: 8), + ], + Flexible( + child: Text( + v.text, + key: const ValueKey('player-status-text'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600), + ), + ), + if (v.progress != null) ...[ + const SizedBox(width: 8), + SizedBox.square( + dimension: 14, + child: CircularProgressIndicator( + value: v.progress, + strokeWidth: 2, + color: Colors.white, + backgroundColor: Colors.white24, + ), + ), + ], + if (v.detail != null) ...[ + const SizedBox(width: 8), + Text(v.detail!, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.w500)), + ], + if (action != null) ...[ + const SizedBox(width: 10), + _PillButton( + label: action == StatusAction.cancel ? 'Cancel' : 'Retry', + onPressed: () => onAction(action), + ), + ], + ], + ), + ); + } +} + +class _Spinner extends StatelessWidget { + final Color color; + const _Spinner({required this.color}); + + @override + Widget build(BuildContext context) => + SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2, color: color)); +} + +class _PillButton extends StatelessWidget { + final String label; + final VoidCallback onPressed; + const _PillButton({required this.label, required this.onPressed}); + + @override + Widget build(BuildContext context) => TextButton( + key: ValueKey('player-status-$label'), + onPressed: onPressed, + style: TextButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.white12, + shape: const StadiumBorder(), + minimumSize: const Size(0, 26), + padding: const EdgeInsets.symmetric(horizontal: 12), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + child: Text(label), + ); +} diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 116e439..3656cf0 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -13,7 +13,6 @@ import '../controls/uosc/timeline.dart'; import '../playback/media_marks.dart' show SkipOffer, formatClock; import '../screens/session/jellyfin_widgets.dart'; import '../screens/session/player_controls.dart'; -import '../session/health.dart'; import '../session/session_coordinator.dart'; import '../shaders/shader_menu.dart'; import '../shaders/shader_presets.dart'; @@ -24,6 +23,9 @@ import '../widgets/text_focus.dart'; import '../widgets/window_buttons.dart'; import 'avatar.dart'; import 'motion.dart'; +import 'now_playing_title.dart'; +import 'player_status.dart'; +import 'start_banner.dart'; /// Where the session's video is shown: nowhere, the floating mini player, /// or filling the body (and, with fullscreen, the window). @@ -36,8 +38,8 @@ typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBu /// The session's player: the one `Video` of the current player and /// everything over it. Full: our bars on media_kit's controls or the uosc -/// set (Settings), health / waiting / Jellyfin overlays and the player -/// keys. Mini: hover controls and a progress line. The shell keeps this +/// set (Settings), the start card, the status pill, Jellyfin overlays and +/// the player keys. Mini: hover controls and a progress line. The shell keeps this /// widget mounted at one place in the tree while the mode changes, so the /// `Video` is never mounted twice nor rebuilt for a move. class SessionPlayer extends StatefulWidget { @@ -277,8 +279,7 @@ class _SessionPlayerState extends State { child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]), ), if (_full) ...[ - _healthOverlay(), - _waitingPill(bottom: 124), + _status(), _progressLine(), _jellyfinOverlay(), if (widget.fullscreen) _pausedParty(), @@ -306,6 +307,8 @@ class _SessionPlayerState extends State { } static const _barHeight = 56.0; + /// The standard top bar: room for the cover (2:3, 60 px tall). + static const _topBarHeight = 72.0; static const _controlsHide = Duration(seconds: 3); /// Fullscreen hides the strip and its window buttons: the top bar @@ -346,14 +349,6 @@ class _SessionPlayerState extends State { ), ); - String get _title { - final ref = _coord.currentRef.value; - if (ref != null && ref.title.isNotEmpty) { - return [ref.title, ?ref.subtitle].join(' · '); - } - return (_coord.serverState.value?.media?['name'] as String?) ?? ''; - } - MaterialDesktopVideoControlsThemeData _controlsTheme() { final coord = _coord; return MaterialDesktopVideoControlsThemeData( @@ -375,19 +370,10 @@ class _SessionPlayerState extends State { child: Align( alignment: Alignment.topCenter, child: SizedBox( - height: _barHeight, + height: _topBarHeight, child: Row( children: [ - Expanded( - child: SignalBuilder( - builder: (context) => Text( - _title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 16), - ), - ), - ), + const Expanded(child: NowPlayingTitle()), MaterialDesktopCustomButton( icon: const Icon(Icons.video_library_outlined), onPressed: widget.onChangeVideo, @@ -510,7 +496,12 @@ class _SessionPlayerState extends State { } Widget _sessionControls(VideoState state) { + // The start card sits under the controls (they stay usable on it). // The "Player design" setting swaps them live. + return Stack(fit: StackFit.expand, children: [const StartBanner(), _controlsSwitch(state)]); + } + + Widget _controlsSwitch(VideoState state) { return SessionControlsSwitch( material: (_) => ControlsHoverProbe( shown: _standardShown, @@ -518,7 +509,7 @@ class _SessionPlayerState extends State { child: DoubleClickListener( onDoubleClick: widget.onToggleFullscreen, // Clicks on the bars are button clicks, not the video. - skipTop: _barHeight, + skipTop: _topBarHeight, skipBottom: _barHeight + ChapterSeekBar.height, child: MaterialDesktopVideoControls(state), ), @@ -546,6 +537,7 @@ class _SessionPlayerState extends State { }); return UoscControls( onDoubleTap: widget.onToggleFullscreen, + topLeft: const NowPlayingTitle(), top: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -655,80 +647,9 @@ class _SessionPlayerState extends State { ); } - Widget _healthOverlay() { - final coord = _coord; - return Positioned( - top: _barHeight + 8, - left: 12, - right: 12, - child: Align( - alignment: Alignment.topLeft, - child: SignalBuilder( - builder: (context) { - final h = coord.health.value; - final starve = coord.starving.value; - final retrying = coord.reconnecting.value; - final kicked = coord.kickReason.value; - final unwell = h.severity == HealthSeverity.warn || - h.severity == HealthSeverity.error || - retrying || - starve != null; - if (!unwell) return const SizedBox.shrink(); - final color = switch (h.severity) { - HealthSeverity.ok => Colors.green, - HealthSeverity.info => Colors.grey, - HealthSeverity.warn => Colors.orange, - HealthSeverity.error => Colors.red, - }; - final IconData icon; - if (retrying) { - icon = Icons.sync; - } else if (h.severity == HealthSeverity.error) { - icon = Icons.error_outline; - } else if (h.severity == HealthSeverity.warn) { - icon = Icons.warning_amber_outlined; - } else { - icon = Icons.hourglass_empty; - } - final text = [h.message, ?starve, if (retrying) 'reconnecting…'].join(' · '); - final canRetry = !retrying && - kicked == null && - (h.severity == HealthSeverity.error || h.severity == HealthSeverity.warn); - final button = TextButton.styleFrom( - foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap); - return Container( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(999)), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: color, size: 16), - const SizedBox(width: 8), - Flexible(child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12))), - if (retrying) - TextButton( - style: button, - onPressed: coord.cancelReconnect, - child: const Text('Cancel', style: TextStyle(fontSize: 12)), - ), - if (canRetry) - TextButton( - style: button, - onPressed: coord.retry, - child: const Text('Retry', style: TextStyle(fontSize: 12)), - ), - ], - ), - ); - }, - ), - ), - ); - } - - Widget _waitingPill({required double bottom}) => - Positioned(left: 0, right: 0, bottom: bottom, child: const Center(child: WaitingPill())); + /// The one status pill, bottom centre above the controls. + Widget _status() => + const Positioned(left: 16, right: 16, bottom: 124, child: Center(child: PlayerStatus())); /// Jellyfin: the blocked card, the end-of-episode card (countdown) and /// the "Next episode" pill; for any media the skip pill (Skip OP / ED / @@ -807,7 +728,6 @@ class _SessionPlayerState extends State { Widget _miniOverlay() { final coord = _coord; final playback = coord.playback; - const white = TextStyle(color: Colors.white, fontWeight: FontWeight.w600); return MouseRegion( onEnter: (_) => setState(() => _miniHover = true), onExit: (_) => setState(() => _miniHover = false), @@ -832,12 +752,7 @@ class _SessionPlayerState extends State { Row( children: [ const SizedBox(width: 10), - Expanded( - child: SignalBuilder( - builder: (context) => - Text(_title, maxLines: 1, overflow: TextOverflow.ellipsis, style: white), - ), - ), + const Expanded(child: NowPlayingTitle(cover: false, compact: true)), IconButton( focusNode: _noFocus, onPressed: widget.onExpand, @@ -871,7 +786,7 @@ class _SessionPlayerState extends State { style: const TextStyle(color: Colors.white70, fontSize: 12)), const Spacer(), Flexible( - child: Text(waitingText(compact: true), + child: Text(playerStatusNow()?.short ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.amber, fontSize: 12)), @@ -904,81 +819,6 @@ const _shaderKeys = [ LogicalKeyboardKey.digit6, ]; -/// Who the server waits for: "Waiting for you · press Space when ready", -/// "Waiting for Alex, Mika", or '' while playing or with nothing up. -String waitingText({bool compact = false}) { - final coord = SessionState.instance.coordinator; - final r = coord.serverState.value; - if (r == null || r.media == null || r.transport != 'held') return ''; - final self = coord.selfMember.value; - if (self != null && !self.ready) { - return compact ? 'waiting for you' : 'Waiting for you · press Space when ready'; - } - final waiting = [for (final m in r.members) if (!m.ready && !m.stale) m.name]; - if (waiting.isEmpty) return compact ? '' : 'Paused'; - return '${compact ? 'waiting' : 'Waiting'} for ${waiting.join(', ')}'; -} - -/// While held: who everyone is waiting for, with their avatars. When it -/// is you, a click marks you ready. -class WaitingPill extends StatelessWidget { - const WaitingPill({super.key}); - - @override - Widget build(BuildContext context) { - return PartyWatch(builder: (context) { - final coord = SessionState.instance.coordinator; - final text = waitingText(); - final r = coord.serverState.value; - final self = coord.selfMember.value; - final meWaiting = self != null && !self.ready; - final who = meWaiting - ? [selfAvatarInfo()] - : [ - for (final m in r?.members ?? const []) - if (!m.ready && !m.stale) memberAvatarInfo(m, r, coord), - ]; - return AnimatedOpacity( - duration: easeTime, - curve: ease, - opacity: text.isEmpty ? 0 : 1, - child: IgnorePointer( - ignoring: text.isEmpty || !meWaiting, - child: GestureDetector( - key: const ValueKey('waiting-pill'), - onTap: coord.toggleReady, - child: MouseRegion( - cursor: meWaiting ? SystemMouseCursors.click : MouseCursor.defer, - child: Container( - padding: const EdgeInsets.fromLTRB(8, 6, 16, 6), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(30), - border: Border.all(color: Colors.white12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final m in who.take(4)) - Padding(padding: const EdgeInsets.only(right: 4), child: StatusAvatar(info: m, size: 28)), - const SizedBox(width: 6), - Flexible( - child: Text(text, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600)), - ), - ], - ), - ), - ), - ), - ), - ); - }); - } -} - /// Mini player progress: a 2 px line, or (hovered) a 4 px bar that seeks. class _MiniSeekBar extends StatelessWidget { final bool thin; diff --git a/apps/desktop/lib/src/shell/start_banner.dart b/apps/desktop/lib/src/shell/start_banner.dart new file mode 100644 index 0000000..85db03c --- /dev/null +++ b/apps/desktop/lib/src/shell/start_banner.dart @@ -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 createState() => _StartBannerState(); +} + +class _StartBannerState extends State { + 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), + ); + }), + ), + ); + } +} diff --git a/apps/desktop/lib/src/sync/avatar_cache.dart b/apps/desktop/lib/src/sync/avatar_cache.dart index e31ed10..7831f3b 100644 --- a/apps/desktop/lib/src/sync/avatar_cache.dart +++ b/apps/desktop/lib/src/sync/avatar_cache.dart @@ -3,15 +3,38 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; +import 'package:sync_relay_core/core.dart'; import 'sync_client.dart'; -/// Member pictures by content hash (`state.members[].avatarHash`), fetched -/// once from `/a///` on the host the client dialled and -/// kept in memory. A hash names fixed bytes, so entries never go stale; -/// listeners are notified when one arrives. -class AvatarCache extends ChangeNotifier { +/// Member pictures by content hash (`state.members[].avatarHash`), from `/a/`. +class AvatarCache extends PictureCache { + AvatarCache(super.sync, {super.fetch}) + : super(route: 'a', keep: 32, live: (s) => [for (final m in s.members) m.avatarHash]); +} + +/// The current media's art by content hash (`state.media.art`), from `/i/`. +class ArtCache extends PictureCache { + ArtCache(super.sync, {super.fetch}) : super(route: 'i', keep: 8, live: artHashesOf); + + /// Poster and banner hashes of [s]'s media. + static List artHashesOf(StateBroadcast s) { + final art = s.media?['art']; + if (art is! Map) return const []; + return [for (final v in art.values) if (v is String) v]; + } +} + +/// Pictures by content hash, fetched once from `////` +/// on the host the client dialled and kept in memory. A hash names fixed +/// bytes, so entries never go stale; listeners are notified when one +/// arrives. Hashes in each state ([live]) are fetched ahead of use. +class PictureCache extends ChangeNotifier { final SyncClient sync; + /// First path segment of the picture route: 'a' (avatars), 'i' (art). + final String route; + /// Above this many pictures, ones not in the latest state go. + final int keep; /// Fetches one picture; null on any failure. Injectable for tests. final Future Function(Uri url) _fetch; final Map _bytes = {}; @@ -21,15 +44,17 @@ class AvatarCache extends ChangeNotifier { bool _disposed = false; static const _retryAfter = Duration(seconds: 10); - /// Above this many pictures, ones nobody in the latest state uses go. - static const _keep = 32; - AvatarCache(this.sync, {Future Function(Uri url)? fetch}) + PictureCache(this.sync, + {required this.route, + required this.keep, + required Iterable Function(StateBroadcast state) live, + Future Function(Uri url)? fetch}) : _fetch = fetch ?? _httpGet { _sub = sync.states.listen((s) { - final live = {for (final m in s.members) if (m.avatarHash.isNotEmpty) m.avatarHash}; - if (_bytes.length > _keep) _bytes.removeWhere((h, _) => !live.contains(h)); - live.forEach(_want); + final hashes = {for (final h in live(s)) if (h.isNotEmpty) h}; + if (_bytes.length > keep) _bytes.removeWhere((h, _) => !hashes.contains(h)); + hashes.forEach(_want); }); } @@ -53,7 +78,7 @@ class AvatarCache extends ChangeNotifier { scheme: ws.scheme == 'wss' ? 'https' : 'http', host: ws.host, port: ws.port, - pathSegments: ['a', 'default', token, hash]); + pathSegments: [route, 'default', token, hash]); _inFlight.add(hash); unawaited(_fetch(url).then((bytes) { _inFlight.remove(hash); diff --git a/apps/desktop/lib/src/sync/sync_client.dart b/apps/desktop/lib/src/sync/sync_client.dart index 6e85151..7967f2c 100644 --- a/apps/desktop/lib/src/sync/sync_client.dart +++ b/apps/desktop/lib/src/sync/sync_client.dart @@ -250,6 +250,11 @@ class SyncClient { _send(ProfileIntent(name: name, avatar: avatar).toJson()); } + /// Art for media [mediaId] (base64 picture bytes; null = none). + void sendArt(int mediaId, {String? poster, String? banner}) { + _send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson()); + } + void sendKick(String target, {String reason = ''}) { _send(KickRequest(target: target, reason: reason).toJson()); } diff --git a/apps/desktop/test/avatar_cache_test.dart b/apps/desktop/test/avatar_cache_test.dart index 42b4273..885cf05 100644 --- a/apps/desktop/test/avatar_cache_test.dart +++ b/apps/desktop/test/avatar_cache_test.dart @@ -56,4 +56,35 @@ void main() { await server.stop(); } }); + + test('media art arrives by hash over /i/; a swap clears it', () async { + final server = SyncServer(const AppConfig(port: 0, secret: 's', name: 'art')); + await server.start(); + final client = SyncClient(); + final cache = ArtCache(client); + final poster = Uint8List.fromList([0xFF, 0xD8, 0xFF, ...List.generate(197, (i) => i % 256)]); + try { + await client.connect( + wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), + secret: 's', + name: 'alice', + ); + client.sendSwap(const HelloMedia(name: 'Show - 01.mkv', size: 0, duration: 0, url: 'http://example.com/a.mkv')); + await _poll(() => client.lastState?.media?['swapBy'] == client.memberId, 'swap'); + client.sendArt(client.lastState!.mediaId, poster: base64Encode(poster)); + await _poll(() => ArtCache.artHashesOf(client.lastState!).isNotEmpty, 'art in state'); + final hash = ArtCache.artHashesOf(client.lastState!).single; + expect(hash, avatarHashOf(poster)); + await _poll(() => cache.bytesFor(hash) != null, 'art bytes'); + expect(cache.bytesFor(hash), poster); + + client.sendSwap(const HelloMedia(name: 'Other.mkv', size: 0, duration: 0, url: 'http://example.com/b.mkv')); + await _poll(() => client.lastState?.media?['name'] == 'Other.mkv', 'second swap'); + expect(ArtCache.artHashesOf(client.lastState!), isEmpty); + } finally { + cache.dispose(); + await client.dispose(); + await server.stop(); + } + }); } diff --git a/apps/desktop/test/now_playing_info_test.dart b/apps/desktop/test/now_playing_info_test.dart new file mode 100644 index 0000000..ab40117 --- /dev/null +++ b/apps/desktop/test/now_playing_info_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:watch_together/src/session/now_playing.dart'; +import 'package:watch_together/src/session/now_playing_info.dart'; + +void main() { + group('jellyfinTitle', () { + test('episodes: series on top, "S1 E2 · Name" below', () { + expect(jellyfinTitle('The Pilot', 'Show · S1E2'), ('Show', 'S1 E2 · The Pilot')); + expect(jellyfinTitle('The Pilot', 'Show - S01E02'), ('Show', 'S1 E2 · The Pilot')); + expect(jellyfinTitle('Ep', 'Re:Zero - Starting Life · S2E10'), ('Re:Zero - Starting Life', 'S2 E10 · Ep')); + }); + + test('movies and partial subtitles', () { + expect(jellyfinTitle('Movie', '2019'), ('Movie', null)); + expect(jellyfinTitle('Movie', null), ('Movie', null)); + expect(jellyfinTitle('Pilot', 'S1E1'), ('Pilot', 'S1 E1')); + expect(jellyfinTitle('Special', 'Show'), ('Show', 'Special')); + }); + }); + + group('fileTitle', () { + test('release names', () { + expect(fileTitle('[SubsPlease] Frieren - 05 (1080p) [ABCD1234].mkv'), ('Frieren', 'E5')); + expect(fileTitle('Show.Name.S01E03.1080p.WEB-DL.x264-GRP.mkv'), ('Show Name', 'S1 E3')); + expect(fileTitle('[Grp] Show - NCOP1 [1080p].mkv'), ('Show', 'NCOP1')); + expect(fileTitle('Some Movie (2019).mkv'), ('Some Movie', null)); + }); + + test('nothing sensible: the raw name minus extension', () { + expect(fileTitle('[1080p].mkv').$1, '[1080p]'); + }); + }); + + test('nowPlayingOf: media map with ref and art', () { + expect(nowPlayingOf(null), isNull); + expect( + nowPlayingOf({ + 'name': 'The Pilot', + 'jellyfin': {'serverId': 's', 'itemId': 'i', 'title': 'The Pilot', 'subtitle': 'Show · S1E2'}, + 'art': {'poster': 'aaaa', 'banner': 'bbbb'}, + }), + const NowPlaying(title: 'Show', episode: 'S1 E2 · The Pilot', posterHash: 'aaaa', bannerHash: 'bbbb')); + expect(nowPlayingOf({'name': 'Show - 12.mkv'}), const NowPlaying(title: 'Show', episode: 'E12')); + }); +} diff --git a/apps/desktop/test/player_status_test.dart b/apps/desktop/test/player_status_test.dart new file mode 100644 index 0000000..5ccf2b1 --- /dev/null +++ b/apps/desktop/test/player_status_test.dart @@ -0,0 +1,283 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:watch_together/src/controls/uosc/buttons.dart'; +import 'package:watch_together/src/controls/uosc/controls.dart'; +import 'package:watch_together/src/controls/uosc/timeline.dart'; +import 'package:watch_together/src/session/health.dart'; +import 'package:watch_together/src/session/now_playing.dart'; +import 'package:watch_together/src/shell/now_playing_title.dart'; +import 'package:watch_together/src/shell/player_status.dart'; +import 'package:watch_together/src/shell/session_player.dart'; +import 'package:watch_together/src/shell/start_banner.dart'; +import 'package:watch_together/src/state/session_state.dart'; + +// The player's title block, start card and status pill (never a media_kit +// `Video`). + +/// A 1x1 transparent PNG. +final png = Uint8List.fromList(const [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, + 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, + 0x42, 0x60, 0x82, +]); + +MemberView mv(String id, String name, {bool ready = false, bool? inSet, double buffered = 30, bool stale = false}) => + MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale); + +StateBroadcast held(List members, {String transport = 'held', double position = 0, int mediaId = 1}) => + StateBroadcast( + eventId: 1, + transport: transport, + position: position, + serverTime: DateTime.now().toUtc(), + mediaId: mediaId, + media: {'name': 'Movie.mkv', 'size': 1, 'duration': 0}, + members: members, + ); + +const ok = HealthStatus(HealthSeverity.ok, 'connected'); + +void main() { + final coord = SessionState.instance.coordinator; + + tearDown(() { + debugArtBytes = null; + coord.serverState.value = null; + coord.health.value = const HealthStatus(HealthSeverity.info, 'idle'); + coord.playback.alive.value = false; + }); + + group('status', () { + final me = mv('me', 'Me', ready: true); + + test('problems first: removed, reconnecting, health', () { + var v = playerStatusFor( + health: const HealthStatus(HealthSeverity.error, 'kicked: spam'), + kickReason: 'spam', + reconnecting: true, + state: held([me])); + expect(v!.kind, StatusKind.kicked); + expect(v.text, 'You were removed: spam'); + expect(v.tone, HealthSeverity.error); + expect(v.action, isNull); + v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'), + reconnecting: true); + expect(v!.text, 'Reconnecting…'); + expect(v.action, StatusAction.cancel); + v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'waiting for Bob to pass the video on'), + state: held([me]), self: me); + expect(v!.kind, StatusKind.problem); + expect(v.text, 'Waiting for Bob to pass the video on'); + expect(v.tone, HealthSeverity.warn); + expect(v.action, StatusAction.retry); + }); + + test('info never shows; connecting only once slow', () { + expect(playerStatusFor(health: const HealthStatus(HealthSeverity.info, 'connected — waiting for host media…')), + isNull); + const connecting = HealthStatus(HealthSeverity.info, 'connecting…'); + expect(playerStatusFor(health: connecting), isNull); + expect(playerStatusFor(health: connecting, slowConnect: true)!.text, 'Connecting…'); + expect(playerStatusFor(health: ok, state: held([me], transport: 'playing'), self: me), isNull); + expect(playerStatusFor(health: ok, state: held([me]), self: me), isNull, reason: 'everyone ready'); + }); + + test('finished, then whoever is loading', () { + expect(playerStatusFor(health: ok, state: held([me], transport: 'finished'), self: me)!.text, + 'Finished · seek back or pick the next video'); + final slow = mv('b', 'Bob', ready: true, buffered: RelayConstants.minBufferSecs / 2); + final v = playerStatusFor(health: ok, state: held([me, slow, mv('c', 'Cleo')]), self: me)!; + expect(v.kind, StatusKind.loading); + expect(v.text, 'Bob is loading…'); + expect(v.who.single.id, 'b'); + expect(v.progress, closeTo(0.5, 1e-9)); + final mine = mv('me', 'Me', ready: true, buffered: 0); + expect(playerStatusFor(health: ok, state: held([mine]), self: mine)!.text, 'Loading the video…'); + }); + + test('you, then the others with a ready count', () { + final notMe = mv('me', 'Me'); + final v = playerStatusFor(health: ok, state: held([notMe, mv('b', 'Bob')]), self: notMe)!; + expect(v.kind, StatusKind.you); + expect(v.text, "Press Space when you're ready"); + expect(v.action, StatusAction.ready); + expect(v.self, isTrue); + final w = playerStatusFor( + health: ok, + state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', stale: true)]), + self: me)!; + expect(w.kind, StatusKind.waiting); + expect(w.text, 'Waiting for Bob and Mika'); + expect(w.detail, '1/3 ready'); + expect(w.who.map((m) => m.name), ['Bob', 'Mika']); + }); + + test('names read as a sentence', () { + expect(nameList(['Bob']), 'Bob'); + expect(nameList(['Bob', 'Mika']), 'Bob and Mika'); + expect(nameList(['Bob', 'Mika', 'Cleo']), 'Bob, Mika and Cleo'); + expect(nameList(['Bob', 'Mika', 'Cleo', 'Dan', 'Eve']), 'Bob, Mika and 3 others'); + }); + }); + + group('title block', () { + Widget host(Widget child, {double width = 400}) => MaterialApp( + home: Scaffold(backgroundColor: Colors.black, body: Center(child: SizedBox(width: width, child: child))), + ); + const now = NowPlaying(title: 'Frieren', episode: 'S1 E4 · The Land Where Souls Rest', posterHash: 'p'); + + testWidgets('title, episode and a placeholder cover without art', (tester) async { + await tester.pumpWidget(host(const NowPlayingBlock(now: now))); + expect(find.text('Frieren'), findsOneWidget); + expect(find.text('S1 E4 · The Land Where Souls Rest'), findsOneWidget); + expect(find.byIcon(Icons.movie_outlined), findsOneWidget); + expect(find.byType(Image), findsNothing); + final cover = tester.getSize(find.byType(ArtCover)); + expect(cover, const Size(40, 60)); + }); + + testWidgets('the cover image once its bytes are here', (tester) async { + debugArtBytes = (hash) => hash == 'p' ? png : null; + await tester.pumpWidget(host(const NowPlayingBlock(now: now))); + final image = tester.widget(find.byType(Image)); + expect((image.image as MemoryImage).bytes, png); + expect(image.gaplessPlayback, isTrue); + }); + + testWidgets('no cover, no episode: just the title; long titles ellipsize', (tester) async { + await tester.pumpWidget(host( + NowPlayingBlock(now: NowPlaying(title: 'A very long title ' * 10), cover: false, compact: true), + width: 200)); + expect(find.byType(ArtCover), findsNothing); + expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('a new media fades through', (tester) async { + await tester.pumpWidget(host(const NowPlayingBlock(now: now))); + await tester.pumpWidget(host(const NowPlayingBlock(now: NowPlaying(title: 'Dune')))); + await tester.pump(const Duration(milliseconds: 60)); + expect(find.text('Frieren'), findsOneWidget, reason: 'still fading out'); + await tester.pumpAndSettle(); + expect(find.text('Frieren'), findsNothing); + expect(find.text('Dune'), findsOneWidget); + }); + }); + + testWidgets('start banner: up before playback, fades on play, never back for that media', (tester) async { + await tester.pumpWidget(const MaterialApp(home: SizedBox(width: 1280, height: 720, child: StartBanner()))); + final card = find.byKey(const ValueKey('start-card-title')); + expect(card, findsNothing); + coord.serverState.value = held([mv('a', 'Alex')]); + await tester.pump(); + expect(card, findsOneWidget); + expect(find.text(coord.nowPlaying.value!.title), findsWidgets); + expect(tester.widget(find.byKey(const ValueKey('start-banner'))).opacity, 1); + coord.serverState.value = held([mv('a', 'Alex', ready: true)], transport: 'playing'); + await tester.pump(); + expect(tester.widget(find.byKey(const ValueKey('start-banner'))).opacity, 0); + await tester.pumpAndSettle(); + expect(card, findsNothing); + // Paused back at the start: still gone. + coord.serverState.value = held([mv('a', 'Alex')]); + await tester.pumpAndSettle(); + expect(card, findsNothing); + // The next media gets its own card. + coord.serverState.value = held([mv('a', 'Alex')], mediaId: 2); + await tester.pump(); + expect(card, findsOneWidget); + // Joined mid-way: none. + coord.serverState.value = held([mv('a', 'Alex')], mediaId: 3, position: 600); + await tester.pumpAndSettle(); + expect(card, findsNothing); + }); + + group('no overflow', () { + const sizes = [Size(900, 600), Size(2560, 1080)]; + final long = NowPlaying(title: 'An Extremely Long Series Title ' * 4, episode: 'S12 E104 · ${'Episode ' * 20}', + posterHash: 'p', bannerHash: 'b'); + final crowd = [ + for (var i = 0; i < 9; i++) mv('$i', 'Someone With A Long Name $i'), + ]; + + Future sized(WidgetTester tester, Size size, Widget child) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child))); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + + for (final size in sizes) { + testWidgets('session player, status pill: ${size.width.toInt()}', (tester) async { + coord.playback.alive.value = true; + coord.serverState.value = held(crowd); + await sized( + tester, + size, + SessionPlayer( + mode: PlayerMode.full, + fullscreen: false, + onExpand: () {}, + onCollapse: () {}, + onClose: () {}, + onToggleFullscreen: () {}, + onToggleChat: () {}, + onChangeVideo: () {}, + onOpenIntegrations: () {}, + onLeave: () {}, + windowControls: false, + videoBuilder: (context, controls) => const ColoredBox(color: Colors.black), + ), + ); + expect(find.byKey(const ValueKey('player-status')), findsOneWidget); + expect(find.textContaining('Waiting for Someone With A Long Name 0'), findsOneWidget); + }); + + testWidgets('uosc top bar and start card: ${size.width.toInt()}', (tester) async { + debugArtBytes = (hash) => png; + await sized( + tester, + size, + Stack(fit: StackFit.expand, children: [ + StartCard(now: long), + UoscControls( + topLeft: NowPlayingBlock(now: long), + top: Row(mainAxisSize: MainAxisSize.min, children: [ + for (final i in [Icons.chat_bubble_outline, Icons.fullscreen, Icons.close]) + UoscButton(icon: i, tooltip: '', onPressed: () {}), + ]), + timeline: (context, expanded) => + UoscTimeline(position: Duration.zero, duration: const Duration(minutes: 24), expanded: expanded, onSeek: (_) {}), + ), + ]), + ); + expect(find.byType(ArtCover), findsNWidgets(2)); + }); + + testWidgets('standard top bar row: ${size.width.toInt()}', (tester) async { + await sized( + tester, + size, + Align( + alignment: Alignment.topCenter, + child: SizedBox( + height: 72, + child: Row(children: [ + Expanded(child: NowPlayingBlock(now: long)), + for (final i in [Icons.video_library_outlined, Icons.chat_bubble_outline, Icons.close_fullscreen]) + IconButton(onPressed: () {}, icon: Icon(i)), + ]), + ), + ), + ); + }); + } + }); +} diff --git a/apps/desktop/test/schedule_anilist_test.dart b/apps/desktop/test/schedule_anilist_test.dart index c9de308..fb3a076 100644 --- a/apps/desktop/test/schedule_anilist_test.dart +++ b/apps/desktop/test/schedule_anilist_test.dart @@ -1,8 +1,10 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:watch_together/src/schedule/anilist.dart'; +import 'package:watch_together/src/session/media_art.dart'; import 'schedule_fake_server.dart'; @@ -163,6 +165,52 @@ void main() { await expectLater(client().schedule(from, to), throwsA(isA())); }); + test('searchMedia: one request per title, 404 is no match, art via aniListArt', () async { + server.handler = (req, res) async { + final search = ((jsonDecode(req.body) as Map)['variables'] as Map)['search']; + if (search != 'Frieren') { + json(res, {'errors': [{'message': 'Not Found.', 'status': 404}], 'data': {'Media': null}}, status: 404); + return; + } + json(res, { + 'data': { + 'Media': { + 'id': 154587, + 'title': {'romaji': 'Sousou no Frieren', 'english': 'Frieren: Beyond Journey\'s End'}, + 'coverImage': {'extraLarge': 'https://img/xl.jpg', 'large': 'https://img/l.jpg'}, + 'bannerImage': 'https://img/banner.jpg', + }, + }, + }); + }; + final c = client(); + final m = await c.searchMedia('Frieren'); + expect(m!.id, 154587); + expect(m.title, "Frieren: Beyond Journey's End"); + expect(m.cover, 'https://img/xl.jpg'); + expect(m.coverSmall, 'https://img/l.jpg'); + expect(m.banner, 'https://img/banner.jpg'); + await c.searchMedia('Frieren'); + expect(server.seen, hasLength(1), reason: 'cached per title'); + expect(await c.searchMedia('Nothing Like It'), isNull); + expect(await c.searchMedia(' '), isNull); + expect(server.seen, hasLength(2)); + + // Files: the release name's series; the extraLarge cover too big falls + // back to large. + final jpeg = Uint8List.fromList([0xFF, 0xD8, 0xFF, 1, 2, 3]); + final asked = []; + final art = await aniListArt(c, '[SubsPlease] Frieren - 05 (1080p) [ABCD1234].mkv', fetch: (url, headers, max) async { + asked.add('$url'); + return url.path == '/xl.jpg' ? null : jpeg; + }); + expect(art!.poster, jpeg); + expect(art.banner, jpeg); + expect(asked, unorderedEquals(['https://img/xl.jpg', 'https://img/banner.jpg', 'https://img/l.jpg'])); + expect(await aniListArt(c, 'Nothing Like It.mkv', fetch: (_, _, _) async => jpeg), isNull); + c.close(); + }); + test('parseHexColor', () { expect(parseHexColor('#000000')?.toARGB32(), 0xff000000); expect(parseHexColor('e4a15d'), isNull); diff --git a/apps/desktop/test/shell_test.dart b/apps/desktop/test/shell_test.dart index cc85149..5b83239 100644 --- a/apps/desktop/test/shell_test.dart +++ b/apps/desktop/test/shell_test.dart @@ -290,12 +290,13 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('waiting pill: who is not ready, or you', (tester) async { + testWidgets('status pill: who is not ready, with the ready count', (tester) async { coord.playback.alive.value = true; await pumpShell(tester, const Size(1280, 800)); online([member('a', 'Alex', ready: true), member('b', 'Bob'), member('c', 'Cleo')]); await tester.pumpAndSettle(); - expect(find.text('Waiting for Bob, Cleo'), findsOneWidget); + expect(find.text('Waiting for Bob and Cleo'), findsOneWidget); + expect(find.text('1/3 ready'), findsOneWidget); }); test('toasts: feed toasts carry the actor, the rest are the app\'s', () { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6111c50..1bc4bf7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -604,9 +604,22 @@ sideways until an end, then the page). The player keys (Settings → Keyboard shortcuts) work while the player is full and nothing is in front of it. Something coming up while the player is closed opens it: full right after joining or after putting it up - here, else mini. While held, a pill under the picture names who - everyone waits for, or says "Waiting for you · press Space" (a click - marks you ready); the mini shows it in its hover bar. ⚙ in both + here, else mini. Both control designs show `NowPlayingTitle` top left + (`now_playing_title.dart`: 2:3 cover, title, episode line from + `coordinator.nowPlaying`, art through `artBytes`; it fades through when + the media changes); the mini shows title and episode without the cover. + Until playback first starts on a media (server not playing, position + under 1 s) `StartBanner` (`start_banner.dart`) lies under the controls: + the banner (else a blur of the cover, else dark), darkened, with cover, + big title and episode; it fades out on play and stays gone for that + media. One status pill (`player_status.dart`) sits bottom centre above + the controls: `playerStatusFor` (pure) picks the most pressing of + removed, reconnecting (Cancel), a warn/error health message (Retry), + connecting (only after 2 s), finished, someone loading (their avatar + and buffer progress), you not ready (a click marks you ready) and who + everyone waits for (avatars, "2/3 ready"); nothing otherwise, info + never. Frosted glass, colour only for problems; it fades through + between kinds. The mini shows its short line in its hover bar. ⚙ in both control designs opens the player settings card (`controls/ player_settings.dart`, `showPopover`): player design, progress line when hidden, upscaling shader, hardware decoding (set live on mpv's @@ -686,7 +699,7 @@ Session controls (the Standard player design): media_kit's `screens/session/player_controls.dart`); its own double-click and keys are off (its double-click pushes a fullscreen route with a second `Video`), the picture's double-click is a raw pointer observer (`DoubleClickListener`). -The top bar holds the title, Change video, chat and back-to-library +The top bar holds the now-playing title, Change video, chat and back-to-library (fullscreen: exit, then the window buttons); the bottom bar ends with the shader button, ⚙ and fullscreen. Its seek bar is off; ours (`ChapterSeekBar`: chapter diamonds, hover time with the chapter title) @@ -714,7 +727,8 @@ change video, chat & activity, reload, mark everyone ready, fullscreen, leave), subtitles and audio with track counts, a shader badge ("A HQ", opening the shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30, -chapters, previous / next episode, change video, volume, ⚙, fullscreen; chat +chapters, previous / next episode, change video, volume, ⚙, fullscreen; the +now-playing title top left (`UoscControls.topLeft`, with a top gradient), chat and back-to-library (fullscreen: exit) top right. Menus (`showUoscMenu`: a popup route above the button, the current row highlighted) list tracks and chapters with start times. All actions are the coordinator's (seeks through `player.seek`, the user path; @@ -791,3 +805,10 @@ this repo, written by `tool/release.sh`. the bytes of current members in memory and serves them at `/a/...`, which clients fetch once per hash (`AvatarCache`). Uplink per broadcast stays small whatever the picture size (cap 256 KiB base64 per avatar). +- Media art the same way (`/i/...`, `ArtCache` as `coordinator.art`): the + member who swapped the media in fetches poster and banner once the state + shows its swap landed (`media_art.dart`: Jellyfin series/item images with + its own account, else an AniList title search on the parsed release + name) and sends `art`, so guests without Jellyfin get covers too. + `coordinator.nowPlaying` derives title, episode line and art hashes from + `state.media` (`now_playing_info.dart`). diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 5e0dc3a..0851d9d 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -24,7 +24,8 @@ with different `proto` refuse each other at `hello` (`proto mismatch`). `GET /j///` relays the current Jellyfin media (see "Jellyfin media" below). `/r///...` is the relay of a member's media (see "Relay" below). `GET /a///` serves - a member's picture (see "Avatars" below). + a member's picture (see "Avatars" below), `GET /i///` + the current media's art (see "Media art" below). ## Client to server @@ -44,6 +45,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`). {"t":"chat","text":"..."} {"t":"kick","target":"","reason":""} {"t":"profile","name":"...","avatar":""} +{"t":"art","mediaId":2,"poster":"","banner":""} ``` Field notes: @@ -77,6 +79,7 @@ Field notes: that is absent (or null) stays as it is; `avatar: ""` removes the picture. Same rules as `hello.user`; a too large avatar gets `err` `rejected: avatar too large` (the socket stays open, nothing changes). +- `art`: pictures of the current media (see "Media art"). - `chat.text` is cut to 500 characters. - `swap.media.url` is the link; `name`, `size`, `duration` are informational. `swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is @@ -91,7 +94,7 @@ Field notes: "state":{}} {"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3, "serverTime":"","mediaId":1, - "media":{name,size,duration,url?,jellyfin?}|absent, + "media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent, "members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?, "host"?}], "seekBy":"m1","seekSeq":3,"relay":true} @@ -116,7 +119,9 @@ Field notes: `eventId` increases on seek, swap and transport changes. `mediaId` increases on swap. `media.relay` is present for relayed media (see "Relay"). `media.duration` is 0 until a client reports - it. `seekBy` (member id) and `seekSeq` are present only while the current + it. `media.swapBy`: member id of who swapped it in (omitted once that + member left, and for the media the server started with). `media.art`: + see "Media art". `seekBy` (member id) and `seekSeq` are present only while the current `eventId` came from a seek. `relay` (omitted when false): the server can relay the current Jellyfin media. `members[].jf` is the member's last `report.jf`, omitted when it never sent one. `members[].avatarHash` @@ -150,6 +155,26 @@ Field notes: picture, 410 unknown session. Pictures go with their member (leave, change, removal). +## Media art + +``` +client {"t":"art","mediaId":2,"poster":"","banner":""} +state media.art = {"poster":"","banner":""} +``` + +- A portrait poster and a wide banner for the current media, so every + member (guests without Jellyfin included) can show them. Sent by the + member who made the current swap (`media.swapBy`) or the host, for the + current `mediaId`; from anyone else, or for another `mediaId`, it is + ignored. Each field is optional; one that is not a picture (same check + as avatars) or larger than its cap (poster 256 KiB, banner 768 KiB of + decoded bytes) counts as none. A later `art` replaces the earlier one + whole; any swap clears it. +- `media.art` carries hashes like `avatarHash`; a key is omitted when that + picture is absent, `art` when both are. +- `GET /i///`: as `/a/` (see "Avatars"); 404 when the + current media has no such art. + ## Jellyfin media ``` diff --git a/packages/core/lib/src/constants.dart b/packages/core/lib/src/constants.dart index 80308ff..7e39603 100644 --- a/packages/core/lib/src/constants.dart +++ b/packages/core/lib/src/constants.dart @@ -38,6 +38,9 @@ abstract class RelayConstants { // over-size for private-server use. PNG keeps transparency; JPEG did // not. Only the hash rides in `state`; the bytes are fetched once. static const int maxAvatarBytes = 256 * 1024; + /// Decoded-byte caps of the current media's art (`art` message). + static const int maxPosterBytes = 256 * 1024; + static const int maxBannerBytes = 768 * 1024; static const int maxNameLength = 32; static const int maxChatLength = 500; } diff --git a/packages/core/lib/src/jellyfin/models.dart b/packages/core/lib/src/jellyfin/models.dart index d41f318..ce1c58f 100644 --- a/packages/core/lib/src/jellyfin/models.dart +++ b/packages/core/lib/src/jellyfin/models.dart @@ -132,7 +132,8 @@ enum JellyfinImageType { primary('Primary'), backdrop('Backdrop'), thumb('Thumb'), - logo('Logo'); + logo('Logo'), + banner('Banner'); final String apiName; const JellyfinImageType(this.apiName); @@ -361,6 +362,8 @@ class JellyfinItem { if (parentLogoItemId != null && parentLogoImageTag != null) { return JellyfinImageRef(parentLogoItemId!, type, parentLogoImageTag!); } + case JellyfinImageType.banner: + break; } return null; } diff --git a/packages/core/lib/src/protocol.dart b/packages/core/lib/src/protocol.dart index e9861c9..a6ff032 100644 --- a/packages/core/lib/src/protocol.dart +++ b/packages/core/lib/src/protocol.dart @@ -478,6 +478,32 @@ class ProfileIntent { } } +/// Cover art for the current media, sent by the member who swapped it in +/// (or the host). Base64 picture bytes; null = none. +class ArtMessage { + final int mediaId; + final String? poster; + final String? banner; + const ArtMessage({required this.mediaId, this.poster, this.banner}); + + Map toJson() => { + 't': 'art', + 'mediaId': mediaId, + if (poster != null) 'poster': poster, + if (banner != null) 'banner': banner, + }; + + factory ArtMessage.fromJson(Map json) { + if (json['t'] != 'art') throw const ProtocolException('not an art message'); + final poster = json['poster']; + final banner = json['banner']; + return ArtMessage( + mediaId: finiteInt(json['mediaId'], 'mediaId'), + poster: poster is String ? poster : null, + banner: banner is String ? banner : null); + } +} + class MemberView { final String id; final String name; diff --git a/packages/core/lib/src/sync_engine.dart b/packages/core/lib/src/sync_engine.dart index 69a06e5..b210fcb 100644 --- a/packages/core/lib/src/sync_engine.dart +++ b/packages/core/lib/src/sync_engine.dart @@ -97,6 +97,27 @@ class EngineMember { ); } +/// Picture bytes from base64 when they decode, are at most [maxBytes] and +/// are a known image type (see [avatarImageType]); else null. +Uint8List? decodePicture(String base64, int maxBytes) { + // Base64 is 4 chars per 3 bytes: skip decoding what cannot fit. + if (base64.isEmpty || base64.length > (maxBytes + 2) ~/ 3 * 4) return null; + try { + final bytes = base64Decode(base64); + if (bytes.length > maxBytes || avatarImageType(bytes) == null) return null; + return bytes; + } on FormatException { + return null; + } +} + +/// One picture of the current media's art, by content hash. +class ArtImage { + final String hash; + final Uint8List bytes; + ArtImage(this.bytes) : hash = avatarHashOf(bytes); +} + class EngineFeed { final List pending = []; void add(FeedMessage message) => pending.add(message); @@ -120,6 +141,12 @@ class SyncEngine { DateTime serverTime; int mediaId = 1; HelloMedia? media; + /// Token of the member who made the current swap; null before any swap. + String? swapBy; + /// Art of the current media (`art`), served at `/i/...`; only the hashes + /// ride in `state.media.art`. Every swap clears it. + ArtImage? poster; + ArtImage? banner; int _nextId = 1; // Who sent the seek that produced [_seekEventId], for the echo check. String? _seekBy; @@ -207,6 +234,38 @@ class SyncEngine { return null; } + /// Art for the current media from [token]: only from the member who made + /// the current swap (or the host) and only for the current [forMediaId]. + /// It replaces any earlier art; an absent or invalid field is none. + /// Returns whether it was taken. + bool applyArt(String token, int forMediaId, {String? poster, String? banner}) { + final member = members[token]; + if (member == null || media == null || forMediaId != mediaId) return false; + if (token != swapBy && !member.host) return false; + _seen(member); + final p = poster == null ? null : decodePicture(poster, RelayConstants.maxPosterBytes); + final b = banner == null ? null : decodePicture(banner, RelayConstants.maxBannerBytes); + this.poster = p == null ? null : ArtImage(p); + this.banner = b == null ? null : ArtImage(b); + return true; + } + + /// Bytes of the current media's art by hash. + Uint8List? artFor(String hash) { + if (hash.isEmpty) return null; + if (poster?.hash == hash) return poster!.bytes; + if (banner?.hash == hash) return banner!.bytes; + return null; + } + + /// `state.media.art`; null when there is none. + Map? get artJson => poster == null && banner == null + ? null + : { + if (poster != null) 'poster': poster!.hash, + if (banner != null) 'banner': banner!.hash, + }; + /// A reconnect took over [token]'s member (see `hello.resume`). void applyResume(String token) { final member = members[token]; @@ -301,6 +360,9 @@ class SyncEngine { final actor = members[actorToken]; mediaId++; media = next; + swapBy = actorToken; + poster = null; + banner = null; position = 0; serverTime = now; eventId++; @@ -409,7 +471,13 @@ class SyncEngine { position: position, serverTime: serverTime, mediaId: mediaId, - media: media?.toJson(), + media: media == null + ? null + : { + ...media!.toJson(), + if (members[swapBy]?.id case final id?) 'swapBy': id, + if (artJson != null) 'art': artJson, + }, members: members.values.map((m) => m.view(stale: isStale(m))).toList(), seekBy: bySeek ? _seekBy : null, seekSeq: bySeek ? _seekSeq : null, diff --git a/packages/core/test/sync_engine_test.dart b/packages/core/test/sync_engine_test.dart index 359f168..25128f5 100644 --- a/packages/core/test/sync_engine_test.dart +++ b/packages/core/test/sync_engine_test.dart @@ -606,4 +606,68 @@ void main() { expect(engine.members['a']!.avatarHash, isEmpty); }); }); + + group('media art', () { + final jpeg = base64Encode([0xFF, 0xD8, 0xFF, ...List.generate(61, (i) => i)]); + final png = base64Encode([0x89, 0x50, 0x4E, 0x47, ...List.generate(60, (i) => 255 - i)]); + const media = HelloMedia(name: 'Show - 01.mkv', size: 1, duration: 0, url: 'http://x/a.mkv'); + Map? art() => engine.broadcast().media?['art'] as Map?; + + test('swapper sends art for the current media; state carries hashes only', () { + join('a', 'alice'); + join('b', 'bob'); + engine.applySwap('a', media); + expect(art(), isNull, reason: 'omitted when empty'); + expect(engine.broadcast().media?['swapBy'], 'm1'); + expect(engine.applyArt('a', engine.mediaId, poster: jpeg, banner: png), isTrue); + final hashes = art()!; + expect(hashes['poster'], avatarHashOf(base64Decode(jpeg))); + expect(hashes['banner'], avatarHashOf(base64Decode(png))); + expect(engine.artFor(hashes['poster'] as String), base64Decode(jpeg)); + expect(engine.artFor('0123456789abcdef'), isNull); + expect(jsonEncode(engine.broadcast().toJson()), isNot(contains(jpeg))); + // A later art replaces it; a field left out is none. + expect(engine.applyArt('a', engine.mediaId, poster: png), isTrue); + expect(art(), {'poster': avatarHashOf(base64Decode(png))}); + }); + + test('rejects other members, stale mediaId; host may send', () { + join('a', 'alice'); + join('b', 'bob'); + join('h', 'host'); + engine.members['h']!.host = true; + expect(engine.applyArt('a', engine.mediaId, poster: jpeg), isFalse, reason: 'no media yet'); + engine.applySwap('a', media); + final id = engine.mediaId; + expect(engine.applyArt('b', id, poster: jpeg), isFalse); + expect(engine.applyArt('a', id - 1, poster: jpeg), isFalse); + expect(art(), isNull); + expect(engine.applyArt('h', id, poster: jpeg), isTrue); + expect(art(), contains('poster')); + }); + + test('bad bytes and oversize fields are dropped; any swap clears', () { + join('a', 'alice'); + engine.applySwap('a', media); + final big = base64Encode([0xFF, 0xD8, 0xFF, ...List.filled(RelayConstants.maxPosterBytes, 1)]); + engine.applyArt('a', engine.mediaId, + poster: big, banner: base64Encode(List.generate(64, (i) => i))); + expect(art(), isNull); + engine.applyArt('a', engine.mediaId, poster: 'not base64!', banner: big); + expect(art(), {'banner': avatarHashOf(base64Decode(big))}, reason: 'banner cap is larger'); + join('b', 'bob'); + engine.applySwap('b', media); + expect(art(), isNull); + expect(engine.artFor(avatarHashOf(base64Decode(big))), isNull); + }); + + test('ArtMessage round trip', () { + final m = ArtMessage.fromJson(Map.from( + jsonDecode(jsonEncode(ArtMessage(mediaId: 3, poster: jpeg).toJson())) as Map)); + expect(m.mediaId, 3); + expect(m.poster, jpeg); + expect(m.banner, isNull); + expect(const ArtMessage(mediaId: 1).toJson(), {'t': 'art', 'mediaId': 1}); + }); + }); } diff --git a/packages/server/lib/src/server_hub.dart b/packages/server/lib/src/server_hub.dart index 88c4dd3..38c7e6e 100644 --- a/packages/server/lib/src/server_hub.dart +++ b/packages/server/lib/src/server_hub.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:relic/relic.dart'; import 'package:sync_relay_core/core.dart'; @@ -162,9 +163,17 @@ class ServerHub { /// `GET /a///`: a current member's picture by its /// content hash, for members (valid, unkicked token) only. The bytes for a /// hash never change, so clients may cache them for good. -void mountAvatarRoute(RelicApp app, ServerHub hub) { +void mountAvatarRoute(RelicApp app, ServerHub hub) => + _mountPictureRoute(app, hub, 'a', hub.engine.avatarFor); + +/// `GET /i///`: the current media's art (`art`) by hash, +/// same rules as [mountAvatarRoute]. +void mountArtRoute(RelicApp app, ServerHub hub) => + _mountPictureRoute(app, hub, 'i', hub.engine.artFor); + +void _mountPictureRoute(RelicApp app, ServerHub hub, String prefix, Uint8List? Function(String hash) lookup) { Response status(int code) => Response(code, body: Body.fromString('status $code')); - app.get('/a/:sid/:token/:hash', (final req) { + app.get('/$prefix/:sid/:token/:hash', (final req) { final sid = req.pathParameters.raw[#sid] ?? ''; final token = req.pathParameters.raw[#token] ?? ''; final hash = req.pathParameters.raw[#hash] ?? ''; @@ -178,7 +187,7 @@ void mountAvatarRoute(RelicApp app, ServerHub hub) { default: break; } - final bytes = hub.engine.avatarFor(hash); + final bytes = lookup(hash); final type = bytes == null ? null : avatarImageType(bytes); if (bytes == null || type == null) return status(404); return Response.ok( @@ -402,6 +411,12 @@ void mountSyncRoute( if (name != null) viewer.name = name; hub.engine.applyProfile(token, name: name, avatar: avatar); hub.changed(); + case 'art': + if (token == null) return; + final art = ArtMessage.fromJson(decoded); + if (hub.engine.applyArt(token, art.mediaId, poster: art.poster, banner: art.banner)) { + hub.broadcastState(); + } case 'report': if (token == null) return; final report = ReportMessage.fromJson(decoded); diff --git a/packages/server/lib/src/sync_server.dart b/packages/server/lib/src/sync_server.dart index 84e5d71..cbd56f0 100644 --- a/packages/server/lib/src/sync_server.dart +++ b/packages/server/lib/src/sync_server.dart @@ -65,6 +65,7 @@ class SyncServer { mountSessionMediaRoute(_app, sessions); mountRelayRoutes(_app, sessions, () => hub.relay); mountAvatarRoute(_app, hub); + mountArtRoute(_app, hub); mountJellyfinRelayRoute( _app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient()); // advertiseHost is only an explicit override; otherwise echo what the client dialled. diff --git a/packages/server/test/server_auth_test.dart b/packages/server/test/server_auth_test.dart index 42e124b..809b0c6 100644 --- a/packages/server/test/server_auth_test.dart +++ b/packages/server/test/server_auth_test.dart @@ -390,6 +390,98 @@ void main() { } catch (_) {} }); + test('art: swapper only, current mediaId, hashes in state, /i serves', () async { + final jpeg = Uint8List.fromList([0xFF, 0xD8, 0xFF, ...List.generate(200, (i) => (i * 3) % 256)]); + final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List.generate(200, (i) => i % 256)]); + final clients = []; + final subs = >[]; + Future<(WebSocketChannel, List>, String)> connect(String name) async { + final ch = WebSocketChannel.connect(Uri.parse('ws://127.0.0.1:${server.boundPort}/sync')); + final messages = >[]; + clients.add(ch); + subs.add(ch.stream.listen((m) => messages.add(Map.from(jsonDecode('$m') as Map)))); + ch.sink.add(jsonEncode(HelloMessage( + proto: RelayConstants.protoVersion, + secret: 'shh', + user: HelloUser(name: name), + media: const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson())); + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (!messages.any((m) => m['t'] == 'welcome')) { + if (DateTime.now().isAfter(deadline)) fail('no welcome'); + await Future.delayed(const Duration(milliseconds: 20)); + } + return (ch, messages, messages.firstWhere((m) => m['t'] == 'welcome')['token'] as String); + } + + Future> waitFor( + List> messages, bool Function(Map) test) async { + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(deadline)) { + for (final m in messages) { + if (test(m)) return m; + } + await Future.delayed(const Duration(milliseconds: 20)); + } + fail('timed out; got $messages'); + } + + Map? artOf(Map state) => + (state['media'] as Map?)?['art'] as Map?; + Future artGet(String token, String hash) async => + (await http.get('127.0.0.1', server.boundPort, '/i/default/$token/$hash')).close(); + + try { + final (a, aMsgs, aToken) = await connect('alice'); + final (b, bMsgs, bToken) = await connect('bob'); + a.sink.add(jsonEncode(const SwapIntent( + media: HelloMedia(name: 'Show - 01.mkv', size: 1, duration: 0, url: 'http://example.com/a.mkv')).toJson())); + final swapped = await waitFor(bMsgs, (m) => m['t'] == 'state' && (m['media'] as Map?)?['name'] == 'Show - 01.mkv'); + final mediaId = swapped['mediaId'] as int; + + // Not the swapper, or a stale mediaId: ignored. + b.sink.add(jsonEncode(ArtMessage(mediaId: mediaId, poster: base64Encode(png)).toJson())); + a.sink.add(jsonEncode(ArtMessage(mediaId: mediaId - 1, poster: base64Encode(png)).toJson())); + await Future.delayed(const Duration(milliseconds: 300)); + expect(server.hub.engine.poster, isNull); + + a.sink.add(jsonEncode(ArtMessage(mediaId: mediaId, poster: base64Encode(jpeg), banner: base64Encode(png)).toJson())); + final withArt = await waitFor(bMsgs, (m) => m['t'] == 'state' && artOf(m) != null); + final art = artOf(withArt)!; + expect(art, {'poster': avatarHashOf(jpeg), 'banner': avatarHashOf(png)}); + + // Any member (a guest without Jellyfin) fetches it. + final ok = await artGet(bToken, art['poster'] as String); + expect(ok.statusCode, 200); + expect(ok.headers.contentType?.mimeType, 'image/jpeg'); + expect(ok.headers.value('cache-control'), contains('immutable')); + expect(await ok.expand((c) => c).toList(), jpeg); + final unknown = await artGet(bToken, '0123456789abcdef'); + expect(unknown.statusCode, 404); + await unknown.drain(); + final noToken = await artGet('nope', art['poster'] as String); + expect(noToken.statusCode, 401); + await noToken.drain(); + + // Any swap clears the art. + b.sink.add(jsonEncode(const SwapIntent( + media: HelloMedia(name: 'Other.mkv', size: 1, duration: 0, url: 'http://example.com/b.mkv')).toJson())); + final cleared = await waitFor(aMsgs, (m) => m['t'] == 'state' && (m['media'] as Map?)?['name'] == 'Other.mkv'); + expect(artOf(cleared), isNull); + final gone = await artGet(aToken, art['poster'] as String); + expect(gone.statusCode, 404); + await gone.drain(); + } finally { + for (final sub in subs) { + await sub.cancel(); + } + for (final ch in clients) { + try { + await ch.sink.close(); + } catch (_) {} + } + } + }); + test('profile: rename + avatar feed, state carries hash, /a gated', () async { final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List.generate(296, (i) => (i * 7) % 256)]); final ch = WebSocketChannel.connect(