Player: now-playing title with cover, start banner, one status pill; media art for everyone

The swapper sends the media's poster and banner once (`art`, Jellyfin
images or AniList by parsed name); state carries their hashes and every
member fetches them from /i/ like avatars. state.media.swapBy names who
put the media up. uosc and the standard bar show cover, title and
episode; a banner card shows before playback starts. The top-left health
pill and the waiting pill become one frosted status pill in plain words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 01:19:34 +03:00
1 parent d092ea371a
commit b534c8c7ea
29 files changed
+1892 -236

No files matched your search

+7 -4
View File
@@ -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 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 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 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 bar while its controls are up), Esc leaves it. The controls show the
pill under the picture says for whom ("Waiting for you · press Space when show's cover, title and episode top left (guests get the cover too); before
ready"). Chat and activity open in a panel on the right (chat button or 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 Tab); joins, leaves, chat and other news also pop up bottom left with the
person's avatar. 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 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"), track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙ 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. the same.
Skipping: during the opening a "Skip OP" button floats in the corner (it Skipping: during the opening a "Skip OP" button floats in the corner (it
@@ -6,7 +6,8 @@ import 'package:signals/signals_flutter.dart';
import '../../state/settings.dart'; import '../../state/settings.dart';
// The mpv-style (uosc) controls frame: a full-width timeline at the // 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 // Mouse movement shows them; after [UoscControls.hideAfter] without any
// they fade out, the timeline shrinks to a 2 px line (gone when "Progress // 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 // line when hidden" is off) and the cursor hides. Our own frame: no
@@ -20,6 +21,9 @@ class UoscControls extends StatefulWidget {
final List<Widget> right; final List<Widget> right;
/// Top-right corner (chat, back to the library), shown with the controls. /// Top-right corner (chat, back to the library), shown with the controls.
final Widget? top; final Widget? top;
/// Top left (what is playing), shown with the controls; gets the width
/// [top] leaves.
final Widget? topLeft;
final VoidCallback? onDoubleTap; final VoidCallback? onDoubleTap;
final Duration hideAfter; final Duration hideAfter;
const UoscControls({ const UoscControls({
@@ -28,6 +32,7 @@ class UoscControls extends StatefulWidget {
this.left = const [], this.left = const [],
this.right = const [], this.right = const [],
this.top, this.top,
this.topLeft,
this.onDoubleTap, this.onDoubleTap,
this.hideAfter = const Duration(seconds: 3), this.hideAfter = const Duration(seconds: 3),
}); });
@@ -136,13 +141,54 @@ class UoscControlsState extends State<UoscControls> {
), ),
), ),
), ),
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( Positioned(
top: 0, top: 0,
left: 0,
right: 0, right: 0,
child: IgnorePointer( child: IgnorePointer(
key: const ValueKey('uosc-top'),
ignoring: !visible, 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( Positioned(
+74 -10
View File
@@ -9,7 +9,8 @@ import 'package:sync_relay_core/core.dart';
import '../update/updater.dart' show appVersion; import '../update/updater.dart' show appVersion;
// AniList airing schedule (public GraphQL, no login): every episode airing // 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 // 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 // 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)'; 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. /// "#e4a15d" -> opaque [Color]; anything else -> null.
Color? parseHexColor(Object? s) { Color? parseHexColor(Object? s) {
if (s is! String || s.length != 7 || !s.startsWith('#')) return null; 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<Map> _page(int from, int to, int page) async { Future<Map> _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<String, Object?> variables) async {
for (var attempt = 0;; attempt++) { for (var attempt = 0;; attempt++) {
final (status, body, retryAfter) = await _post(jsonEncode({ final (status, body, retryAfter) = await _post(jsonEncode({'query': query, 'variables': variables}));
'query': query,
'variables': {'from': from - 1, 'to': to, 'page': page},
}));
if (status == 429 && attempt == 0) { if (status == 429 && attempt == 0) {
final wait = retryAfter ?? 60; final wait = retryAfter ?? 60;
_log.info('AniList rate limited, retrying in ${wait}s'); _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) { if (status != 200) {
throw AniListException('AniList HTTP $status${_errorText(j)}', statusCode: status); throw AniListException('AniList HTTP $status${_errorText(j)}', statusCode: status);
} }
final data = j is Map && j['data'] is Map ? (j['data'] as Map)['Page'] : null; final data = j is Map && j['data'] is Map ? j['data'] as Map : null;
if (data is! Map) { return (data, status, j);
throw AniListException('AniList: bad response${_errorText(j)}', statusCode: status); }
} }
return data;
static const searchQuery = r'''
query($search:String){Media(search:$search,type:ANIME,isAdult:false){id title{romaji english}
coverImage{extraLarge large} bannerImage}}''';
final _search = <String, Future<AniListMedia?>>{};
/// 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<AniListMedia?> 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<AniListMedia?> _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;
} }
} }
@@ -0,0 +1,95 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import '../files/name_parser.dart';
import '../schedule/anilist.dart';
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
// art"): Jellyfin images with this install's account, else an AniList
// title search. Everything here fails quietly to "no art".
/// Poster and banner bytes; either may be null.
typedef ArtBytes = ({Uint8List? poster, Uint8List? banner});
/// Series poster (the item's own for movies) and series/item backdrop,
/// else its Banner image, scaled by Jellyfin to fit the caps.
Future<ArtBytes> jellyfinArt(JellyfinClient client, String itemId,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final item = await client.item(itemId).timeout(const Duration(seconds: 10));
final seriesPoster = item.seriesId != null && item.seriesPrimaryImageTag != null
? JellyfinImageRef(item.seriesId!, JellyfinImageType.primary, item.seriesPrimaryImageTag!)
: null;
final poster = seriesPoster ?? item.image(JellyfinImageType.primary);
var banner = item.image(JellyfinImageType.backdrop);
if (banner == null) {
var owner = item;
if (item.seriesId != null && item.ownImage(JellyfinImageType.banner) == null) {
try {
owner = await client.item(item.seriesId!).timeout(const Duration(seconds: 10));
} catch (_) {}
}
banner = owner.ownImage(JellyfinImageType.banner);
}
final headers = client.authHeaders;
final (p, b) = await (
poster == null
? Future<Uint8List?>.value()
: fetch(client.imageUrl(poster, maxHeight: 450, quality: 85), headers, RelayConstants.maxPosterBytes),
banner == null
? Future<Uint8List?>.value()
: fetch(client.imageUrl(banner, maxWidth: 1280, quality: 85), headers, RelayConstants.maxBannerBytes),
).wait;
return (poster: p, banner: b);
}
/// The series of release name [name] on AniList: cover and banner; null
/// when the name says nothing or AniList knows no match.
Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final series = parseReleaseName(name).series.trim();
if (series.isEmpty) return null;
final match = await aniList.searchMedia(series);
if (match == null) return null;
Future<Uint8List?> get(String? url, int maxBytes) async {
final uri = url == null ? null : Uri.tryParse(url);
return uri == null ? null : fetch(uri, const {}, maxBytes);
}
final (cover, banner) = await (
get(match.cover, RelayConstants.maxPosterBytes),
get(match.banner, RelayConstants.maxBannerBytes),
).wait;
final poster = cover ?? (match.coverSmall == match.cover ? null : await get(match.coverSmall, RelayConstants.maxPosterBytes));
return (poster: poster, banner: banner);
}
/// GET [url]: the bytes when 200, at most [maxBytes] and a known image
/// type (see `avatarImageType`); else null.
Future<Uint8List?> fetchImage(Uri url, Map<String, String> headers, int maxBytes) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final req = await client.getUrl(url);
headers.forEach(req.headers.set);
final resp = await req.close().timeout(const Duration(seconds: 15));
if (resp.statusCode != 200 || resp.contentLength > maxBytes) {
await resp.drain<void>().catchError((_) {});
return null;
}
final builder = BytesBuilder(copy: false);
await for (final chunk in resp.timeout(const Duration(seconds: 15))) {
builder.add(chunk);
if (builder.length > maxBytes) return null;
}
final bytes = builder.takeBytes();
return avatarImageType(bytes) == null ? null : bytes;
} catch (e) {
debugPrint('art: fetch failed: $e');
return null;
} finally {
client.close(force: true);
}
}
@@ -0,0 +1,24 @@
/// What the player shows about the current media: a title, an episode line
/// and art. Art is by content hash (`state.media.art`), fetched by everyone
/// (guests included) from the server they dialled; '' when there is none.
class NowPlaying {
/// Series name for episodes, else the movie, file or link name.
final String title;
/// "S2 E4 · Episode title", "E12", or null (movies, links).
final String? episode;
/// Portrait cover (2:3) and wide banner; '' when none.
final String posterHash;
final String bannerHash;
const NowPlaying({required this.title, this.episode, this.posterHash = '', this.bannerHash = ''});
@override
bool operator ==(Object other) =>
other is NowPlaying &&
other.title == title &&
other.episode == episode &&
other.posterHash == posterHash &&
other.bannerHash == bannerHash;
@override
int get hashCode => Object.hash(title, episode, posterHash, bannerHash);
}
@@ -0,0 +1,56 @@
import '../files/name_parser.dart';
import '../jellyfin/session_media.dart';
import 'now_playing.dart';
// Title and episode line of `state.media` (pure; see [NowPlaying]).
// "S1E6" (pickFor) or "S01E02".
final _episodeLabel = RegExp(r'^S(\d{1,3})\s?E(\d{1,4})$', caseSensitive: false);
final _year = RegExp(r'^(?:19|20)\d{2}$');
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
/// What the player shows for [media] (`state.media`); null with nothing up.
NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
if (media == null) return null;
final name = media['name'] is String ? media['name'] as String : '';
final art = media['art'] is Map ? media['art'] as Map : const {};
final poster = art['poster'] is String ? art['poster'] as String : '';
final banner = art['banner'] is String ? art['banner'] as String : '';
final ref = refOfStateMedia(media);
final (title, episode) = ref != null
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
: fileTitle(name);
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
}
/// Jellyfin: [name] is the item's, [subtitle] "Series · S1E6" (or
/// "Series - S01E02") for episodes, the year for movies. Episodes give the
/// series and "S1 E6 · Name"; anything else the name alone.
(String, String?) jellyfinTitle(String name, String? subtitle) {
final sub = subtitle?.trim() ?? '';
if (sub.isEmpty || _year.hasMatch(sub)) return (name, null);
// The label is after the last separator (or the whole subtitle).
final cut = RegExp(r' [·-] ').allMatches(sub).lastOrNull;
final m = _episodeLabel.firstMatch(cut == null ? sub : sub.substring(cut.end).trim());
final series = m == null ? sub : (cut == null ? '' : sub.substring(0, cut.start).trim());
final label = m == null ? null : 'S${int.parse(m.group(1)!)} E${int.parse(m.group(2)!)}';
if (series.isEmpty) return (name, label);
final episode = [?label, if (name.isNotEmpty) name].join(' · ');
return (series, episode.isEmpty ? null : episode);
}
/// Files and links: the release name's series and "S1 E3", "E25" or its
/// extra marker (NCOP1, OVA 2); the name minus extension when it says
/// nothing better.
(String, String?) fileTitle(String name) {
final p = parseReleaseName(name);
final series = p.series.trim();
if (series.isEmpty) return (name.replaceFirst(_ext, ''), null);
final String? episode;
if (p.episode != null) {
episode = p.season != null ? 'S${p.season} E${p.episode}' : 'E${p.episode}';
} else {
episode = p.extra;
}
return (series, episode);
}
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -11,6 +12,7 @@ import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart'; import '../jellyfin/track_prefs.dart';
import '../playback/media_marks.dart'; import '../playback/media_marks.dart';
import '../playback/playback_controller.dart'; import '../playback/playback_controller.dart';
import '../schedule/anilist.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../state/settings.dart'; import '../state/settings.dart';
@@ -20,6 +22,9 @@ import '../sync/sync_client.dart';
import '../sync/user_seek.dart'; import '../sync/user_seek.dart';
import 'health.dart'; import 'health.dart';
import 'idle.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. /// Thrown when a connect is superseded by cancel/leave before it finished.
class ConnectCancelled implements Exception { class ConnectCancelled implements Exception {
@@ -146,6 +151,8 @@ class SessionCoordinator {
final SyncClient sync = SyncClient(); final SyncClient sync = SyncClient();
/// Member pictures by `avatarHash`, fetched from the server. /// Member pictures by `avatarHash`, fetched from the server.
late final AvatarCache avatars = AvatarCache(sync); 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 PlaybackController playback = PlaybackController();
final StateDiff diff = StateDiff(); final StateDiff diff = StateDiff();
@@ -160,6 +167,9 @@ class SessionCoordinator {
/// The current media's Jellyfin ref (null for links and files). /// The current media's Jellyfin ref (null for links and files).
final currentRef = signal<JellyfinRef?>(null); final currentRef = signal<JellyfinRef?>(null);
/// Title, episode line and art hashes of the current media; null with
/// nothing up.
late final nowPlaying = computed<NowPlaying?>(() => nowPlayingOf(serverState.value?.media));
/// How this viewer plays [currentRef]; null while resolving. /// How this viewer plays [currentRef]; null while resolving.
final jellyfinPlan = signal<JellyfinPlan?>(null); final jellyfinPlan = signal<JellyfinPlan?>(null);
/// Set when this viewer cannot play the Jellyfin media: no account and /// Set when this viewer cannot play the Jellyfin media: no account and
@@ -221,6 +231,10 @@ class SessionCoordinator {
String _wsHost = ''; String _wsHost = '';
int _wsPort = 0; int _wsPort = 0;
_RelayOrigin? _pendingOrigin; _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 // What this member serves for media [$1]; survives a reconnect (the
// state then looks new again). // state then looks new again).
(int, _RelayOrigin)? _activeOrigin; (int, _RelayOrigin)? _activeOrigin;
@@ -386,7 +400,7 @@ class SessionCoordinator {
final media = params.media ?? final media = params.media ??
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl)); (params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
if (epoch != _epoch) throw const ConnectCancelled(); if (epoch != _epoch) throw const ConnectCancelled();
if (media != null) sync.sendSwap(media); if (media != null) _swap(media);
_startTicker(); _startTicker();
} catch (e) { } catch (e) {
if (epoch != _epoch) throw const ConnectCancelled(); if (epoch != _epoch) throw const ConnectCancelled();
@@ -522,6 +536,7 @@ class SessionCoordinator {
void _onState(StateBroadcast r) { void _onState(StateBroadcast r) {
_lastStateAt = DateTime.now().toUtc(); _lastStateAt = DateTime.now().toUtc();
serverState.value = r; serverState.value = r;
_maybeSendArt(r);
_queuedState = r; _queuedState = r;
if (_applying) return; if (_applying) return;
_applying = true; _applying = true;
@@ -984,6 +999,43 @@ class SessionCoordinator {
sync.sendKick(memberId, reason: reason); sync.sendKick(memberId, reason: reason);
} }
void _swap(HelloMedia media) {
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0);
sync.sendSwap(media);
}
void _maybeSendArt(StateBroadcast r) {
final pending = _pendingArt;
if (pending == null || r.mediaId <= pending.after) return;
if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return;
_pendingArt = null;
unawaited(_sendArt(r.mediaId, pending.media));
}
/// Fetches the art of [media] (this client's swap, now [mediaId]) and
/// sends it; quietly nothing on any failure.
Future<void> _sendArt(int mediaId, HelloMedia media) async {
try {
final ref = media.jellyfin;
final client = ref == null ? null : jellyfin.clientFor(ref.serverId);
final ArtBytes? bytes;
if (ref != null && client != null) {
bytes = await jellyfinArt(client, ref.itemId);
} else {
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
bytes = await aniListArt(_aniList ??= AniListClient(), name);
}
final poster = bytes?.poster, banner = bytes?.banner;
if (poster == null && banner == null) return;
if (serverState.peek()?.mediaId != mediaId) return;
sync.sendArt(mediaId,
poster: poster == null ? null : base64Encode(poster),
banner: banner == null ? null : base64Encode(banner));
} catch (e) {
debugPrint('art for media $mediaId: $e');
}
}
/// A pasted link: a recognised Jellyfin item link goes up as a ref /// A pasted link: a recognised Jellyfin item link goes up as a ref
/// (see [mediaForLink]) that this member relays for viewers without an /// (see [mediaForLink]) that this member relays for viewers without an
/// account (with the link's key, which stays here); anything else as the /// 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); final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) { if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url); _pendingOrigin = _JellyfinOrigin(url);
sync.sendSwap(media.copyWith(relay: const MediaRelay())); _swap(media.copyWith(relay: const MediaRelay()));
return null; return null;
} }
if (relay == LinkRelay.direct) { if (relay == LinkRelay.direct) {
sync.sendSwap(media); _swap(media);
return null; return null;
} }
// Relayed: it must be a single file the relay can range-read (not a // Relayed: it must be a single file the relay can range-read (not a
@@ -1015,9 +1067,9 @@ class SessionCoordinator {
} }
if (relay == LinkRelay.me) { if (relay == LinkRelay.me) {
_pendingOrigin = _LinkOrigin(url); _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 { } else {
sync.sendSwap(HelloMedia( _swap(HelloMedia(
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true))); name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true)));
} }
return null; return null;
@@ -1028,7 +1080,7 @@ class SessionCoordinator {
void swapJellyfin(JellyfinMedia media) { void swapJellyfin(JellyfinMedia media) {
markInput(); markInput();
_pendingOrigin = const _JellyfinOrigin(null); _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 /// A file on this computer. The host's app serves it itself; anyone
@@ -1040,13 +1092,13 @@ class SessionCoordinator {
if (host != null) { if (host != null) {
final file = await host(path); final file = await host(path);
if (file == null) return 'file not found — check the 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; return null;
} }
final file = File(path); final file = File(path);
if (!await file.exists()) return 'file not found — check the path'; if (!await file.exists()) return 'file not found — check the path';
_pendingOrigin = _FileOrigin(path); _pendingOrigin = _FileOrigin(path);
sync.sendSwap(HelloMedia( _swap(HelloMedia(
name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay())); name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()));
return null; return null;
} }
@@ -1541,6 +1593,7 @@ class SessionCoordinator {
await _stopJellyfin(); await _stopJellyfin();
_stopRelaySource(); _stopRelaySource();
_pendingOrigin = null; _pendingOrigin = null;
_pendingArt = null;
_activeOrigin = null; _activeOrigin = null;
_playerReady = false; _playerReady = false;
try { try {
@@ -1567,6 +1620,7 @@ class SessionCoordinator {
await _kickSub?.cancel(); await _kickSub?.cancel();
await _errSub?.cancel(); await _errSub?.cancel();
await _statusSub?.cancel(); await _statusSub?.cancel();
_aniList?.close();
await sync.dispose(); await sync.dispose();
await playback.dispose(); await playback.dispose();
} }
+7 -2
View File
@@ -15,11 +15,16 @@ const fadeOutPart = 0.35;
/// Fade through (Material motion): the old view is gone after the first /// 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 /// 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. /// [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, key: key,
duration: fadeThroughTime, duration: fadeThroughTime,
layoutBuilder: (current, previous) => Stack( layoutBuilder: (current, previous) => Stack(
fit: StackFit.expand, fit: expand ? StackFit.expand : StackFit.loose,
alignment: alignment,
children: [...previous, ?current], children: [...previous, ?current],
), ),
transitionBuilder: (c, anim) { transitionBuilder: (c, anim) {
@@ -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 = <String, MemoryImage>{};
/// [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,
),
),
);
}
@@ -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<MemberView> 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<String> 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<PlayerStatus> createState() => _PlayerStatusState();
}
class _PlayerStatusState extends State<PlayerStatus> {
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<StatusAction> 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),
);
}
+22 -182
View File
@@ -13,7 +13,6 @@ import '../controls/uosc/timeline.dart';
import '../playback/media_marks.dart' show SkipOffer, formatClock; import '../playback/media_marks.dart' show SkipOffer, formatClock;
import '../screens/session/jellyfin_widgets.dart'; import '../screens/session/jellyfin_widgets.dart';
import '../screens/session/player_controls.dart'; import '../screens/session/player_controls.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../shaders/shader_menu.dart'; import '../shaders/shader_menu.dart';
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
@@ -24,6 +23,9 @@ import '../widgets/text_focus.dart';
import '../widgets/window_buttons.dart'; import '../widgets/window_buttons.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'motion.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, /// Where the session's video is shown: nowhere, the floating mini player,
/// or filling the body (and, with fullscreen, the window). /// 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 /// 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 /// everything over it. Full: our bars on media_kit's controls or the uosc
/// set (Settings), health / waiting / Jellyfin overlays and the player /// set (Settings), the start card, the status pill, Jellyfin overlays and
/// keys. Mini: hover controls and a progress line. The shell keeps this /// 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 /// widget mounted at one place in the tree while the mode changes, so the
/// `Video` is never mounted twice nor rebuilt for a move. /// `Video` is never mounted twice nor rebuilt for a move.
class SessionPlayer extends StatefulWidget { class SessionPlayer extends StatefulWidget {
@@ -277,8 +279,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]), child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]),
), ),
if (_full) ...[ if (_full) ...[
_healthOverlay(), _status(),
_waitingPill(bottom: 124),
_progressLine(), _progressLine(),
_jellyfinOverlay(), _jellyfinOverlay(),
if (widget.fullscreen) _pausedParty(), if (widget.fullscreen) _pausedParty(),
@@ -306,6 +307,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
} }
static const _barHeight = 56.0; 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); static const _controlsHide = Duration(seconds: 3);
/// Fullscreen hides the strip and its window buttons: the top bar /// Fullscreen hides the strip and its window buttons: the top bar
@@ -346,14 +349,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
), ),
); );
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() { MaterialDesktopVideoControlsThemeData _controlsTheme() {
final coord = _coord; final coord = _coord;
return MaterialDesktopVideoControlsThemeData( return MaterialDesktopVideoControlsThemeData(
@@ -375,19 +370,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: Align( child: Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: SizedBox( child: SizedBox(
height: _barHeight, height: _topBarHeight,
child: Row( child: Row(
children: [ children: [
Expanded( const Expanded(child: NowPlayingTitle()),
child: SignalBuilder(
builder: (context) => Text(
_title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 16),
),
),
),
MaterialDesktopCustomButton( MaterialDesktopCustomButton(
icon: const Icon(Icons.video_library_outlined), icon: const Icon(Icons.video_library_outlined),
onPressed: widget.onChangeVideo, onPressed: widget.onChangeVideo,
@@ -510,7 +496,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
} }
Widget _sessionControls(VideoState state) { Widget _sessionControls(VideoState state) {
// The start card sits under the controls (they stay usable on it).
// The "Player design" setting swaps them live. // The "Player design" setting swaps them live.
return Stack(fit: StackFit.expand, children: [const StartBanner(), _controlsSwitch(state)]);
}
Widget _controlsSwitch(VideoState state) {
return SessionControlsSwitch( return SessionControlsSwitch(
material: (_) => ControlsHoverProbe( material: (_) => ControlsHoverProbe(
shown: _standardShown, shown: _standardShown,
@@ -518,7 +509,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: DoubleClickListener( child: DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen, onDoubleClick: widget.onToggleFullscreen,
// Clicks on the bars are button clicks, not the video. // Clicks on the bars are button clicks, not the video.
skipTop: _barHeight, skipTop: _topBarHeight,
skipBottom: _barHeight + ChapterSeekBar.height, skipBottom: _barHeight + ChapterSeekBar.height,
child: MaterialDesktopVideoControls(state), child: MaterialDesktopVideoControls(state),
), ),
@@ -546,6 +537,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
}); });
return UoscControls( return UoscControls(
onDoubleTap: widget.onToggleFullscreen, onDoubleTap: widget.onToggleFullscreen,
topLeft: const NowPlayingTitle(),
top: Row( top: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -655,80 +647,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
); );
} }
Widget _healthOverlay() { /// The one status pill, bottom centre above the controls.
final coord = _coord; Widget _status() =>
return Positioned( const Positioned(left: 16, right: 16, bottom: 124, child: Center(child: PlayerStatus()));
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()));
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and /// Jellyfin: the blocked card, the end-of-episode card (countdown) and
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED / /// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
@@ -807,7 +728,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
Widget _miniOverlay() { Widget _miniOverlay() {
final coord = _coord; final coord = _coord;
final playback = coord.playback; final playback = coord.playback;
const white = TextStyle(color: Colors.white, fontWeight: FontWeight.w600);
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _miniHover = true), onEnter: (_) => setState(() => _miniHover = true),
onExit: (_) => setState(() => _miniHover = false), onExit: (_) => setState(() => _miniHover = false),
@@ -832,12 +752,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
Row( Row(
children: [ children: [
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( const Expanded(child: NowPlayingTitle(cover: false, compact: true)),
child: SignalBuilder(
builder: (context) =>
Text(_title, maxLines: 1, overflow: TextOverflow.ellipsis, style: white),
),
),
IconButton( IconButton(
focusNode: _noFocus, focusNode: _noFocus,
onPressed: widget.onExpand, onPressed: widget.onExpand,
@@ -871,7 +786,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
style: const TextStyle(color: Colors.white70, fontSize: 12)), style: const TextStyle(color: Colors.white70, fontSize: 12)),
const Spacer(), const Spacer(),
Flexible( Flexible(
child: Text(waitingText(compact: true), child: Text(playerStatusNow()?.short ?? '',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.amber, fontSize: 12)), style: const TextStyle(color: Colors.amber, fontSize: 12)),
@@ -904,81 +819,6 @@ const _shaderKeys = [
LogicalKeyboardKey.digit6, 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 <MemberView>[])
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. /// Mini player progress: a 2 px line, or (hovered) a 4 px bar that seeks.
class _MiniSeekBar extends StatelessWidget { class _MiniSeekBar extends StatelessWidget {
final bool thin; final bool thin;
@@ -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<StartBanner> createState() => _StartBannerState();
}
class _StartBannerState extends State<StartBanner> {
int? _mediaId;
bool _started = false;
// Faded out: the card leaves the tree (and its images with it).
bool _gone = true;
NowPlaying? _last;
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
return IgnorePointer(
child: ListenableBuilder(
listenable: artChanges(),
builder: (context, _) => SignalBuilder(builder: (context) {
final r = coord.serverState.value;
final now = coord.nowPlaying.value;
if (r != null && r.mediaId != _mediaId) {
_mediaId = r.mediaId;
_started = false;
}
if (r != null && (r.transport == 'playing' || r.position >= 1)) _started = true;
final show = now != null && r?.media != null && !_started;
if (show) {
_last = now;
_gone = false;
}
final last = _last;
if (last == null || _gone) return const SizedBox.shrink();
return AnimatedOpacity(
key: const ValueKey('start-banner'),
opacity: show ? 1 : 0,
duration: easeTime,
curve: ease,
onEnd: () {
if (!show && mounted) setState(() => _gone = true);
},
child: StartCard(now: last),
);
}),
),
);
}
}
+37 -12
View File
@@ -3,15 +3,38 @@ import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import 'sync_client.dart'; import 'sync_client.dart';
/// Member pictures by content hash (`state.members[].avatarHash`), fetched /// Member pictures by content hash (`state.members[].avatarHash`), from `/a/`.
/// once from `/a/<sid>/<token>/<hash>` on the host the client dialled and class AvatarCache extends PictureCache {
/// kept in memory. A hash names fixed bytes, so entries never go stale; AvatarCache(super.sync, {super.fetch})
/// listeners are notified when one arrives. : super(route: 'a', keep: 32, live: (s) => [for (final m in s.members) m.avatarHash]);
class AvatarCache extends ChangeNotifier { }
/// 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<String> 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 `/<route>/<sid>/<token>/<hash>`
/// 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; 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. /// Fetches one picture; null on any failure. Injectable for tests.
final Future<Uint8List?> Function(Uri url) _fetch; final Future<Uint8List?> Function(Uri url) _fetch;
final Map<String, Uint8List> _bytes = {}; final Map<String, Uint8List> _bytes = {};
@@ -21,15 +44,17 @@ class AvatarCache extends ChangeNotifier {
bool _disposed = false; bool _disposed = false;
static const _retryAfter = Duration(seconds: 10); 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<Uint8List?> Function(Uri url)? fetch}) PictureCache(this.sync,
{required this.route,
required this.keep,
required Iterable<String> Function(StateBroadcast state) live,
Future<Uint8List?> Function(Uri url)? fetch})
: _fetch = fetch ?? _httpGet { : _fetch = fetch ?? _httpGet {
_sub = sync.states.listen((s) { _sub = sync.states.listen((s) {
final live = {for (final m in s.members) if (m.avatarHash.isNotEmpty) m.avatarHash}; final hashes = {for (final h in live(s)) if (h.isNotEmpty) h};
if (_bytes.length > _keep) _bytes.removeWhere((h, _) => !live.contains(h)); if (_bytes.length > keep) _bytes.removeWhere((h, _) => !hashes.contains(h));
live.forEach(_want); hashes.forEach(_want);
}); });
} }
@@ -53,7 +78,7 @@ class AvatarCache extends ChangeNotifier {
scheme: ws.scheme == 'wss' ? 'https' : 'http', scheme: ws.scheme == 'wss' ? 'https' : 'http',
host: ws.host, host: ws.host,
port: ws.port, port: ws.port,
pathSegments: ['a', 'default', token, hash]); pathSegments: [route, 'default', token, hash]);
_inFlight.add(hash); _inFlight.add(hash);
unawaited(_fetch(url).then((bytes) { unawaited(_fetch(url).then((bytes) {
_inFlight.remove(hash); _inFlight.remove(hash);
@@ -250,6 +250,11 @@ class SyncClient {
_send(ProfileIntent(name: name, avatar: avatar).toJson()); _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 = ''}) { void sendKick(String target, {String reason = ''}) {
_send(KickRequest(target: target, reason: reason).toJson()); _send(KickRequest(target: target, reason: reason).toJson());
} }
+31
View File
@@ -56,4 +56,35 @@ void main() {
await server.stop(); 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<int>.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();
}
});
} }
@@ -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'));
});
}
+283
View File
@@ -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<MemberView> 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<Image>(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<AnimatedOpacity>(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<AnimatedOpacity>(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<void> 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)),
]),
),
),
);
});
}
});
}
@@ -1,8 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/schedule/anilist.dart'; import 'package:watch_together/src/schedule/anilist.dart';
import 'package:watch_together/src/session/media_art.dart';
import 'schedule_fake_server.dart'; import 'schedule_fake_server.dart';
@@ -163,6 +165,52 @@ void main() {
await expectLater(client().schedule(from, to), throwsA(isA<AniListException>())); await expectLater(client().schedule(from, to), throwsA(isA<AniListException>()));
}); });
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 = <String>[];
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', () { test('parseHexColor', () {
expect(parseHexColor('#000000')?.toARGB32(), 0xff000000); expect(parseHexColor('#000000')?.toARGB32(), 0xff000000);
expect(parseHexColor('e4a15d'), isNull); expect(parseHexColor('e4a15d'), isNull);
+3 -2
View File
@@ -290,12 +290,13 @@ void main() {
expect(tester.takeException(), isNull); 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; coord.playback.alive.value = true;
await pumpShell(tester, const Size(1280, 800)); await pumpShell(tester, const Size(1280, 800));
online([member('a', 'Alex', ready: true), member('b', 'Bob'), member('c', 'Cleo')]); online([member('a', 'Alex', ready: true), member('b', 'Bob'), member('c', 'Cleo')]);
await tester.pumpAndSettle(); 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', () { test('toasts: feed toasts carry the actor, the rest are the app\'s', () {
+26 -5
View File
@@ -604,9 +604,22 @@ sideways until an end, then the page).
The player keys (Settings → Keyboard shortcuts) work while the player is 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 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 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 here, else mini. Both control designs show `NowPlayingTitle` top left
everyone waits for, or says "Waiting for you · press Space" (a click (`now_playing_title.dart`: 2:3 cover, title, episode line from
marks you ready); the mini shows it in its hover bar. ⚙ in both `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/ control designs opens the player settings card (`controls/
player_settings.dart`, `showPopover`): player design, progress line player_settings.dart`, `showPopover`): player design, progress line
when hidden, upscaling shader, hardware decoding (set live on mpv's 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 `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 off (its double-click pushes a fullscreen route with a second `Video`), the
picture's double-click is a raw pointer observer (`DoubleClickListener`). 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 (fullscreen: exit, then the window buttons); the bottom bar ends with the
shader button, ⚙ and fullscreen. Its seek bar is off; ours shader button, ⚙ and fullscreen. Its seek bar is off; ours
(`ChapterSeekBar`: chapter diamonds, hover time with the chapter title) (`ChapterSeekBar`: chapter diamonds, hover time with the chapter title)
@@ -714,7 +727,8 @@ change video, chat & activity, reload, mark everyone ready, fullscreen,
leave), leave),
subtitles and audio with track counts, a shader badge ("A HQ", opening the subtitles and audio with track counts, a shader badge ("A HQ", opening the
shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30, 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 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 current row highlighted) list tracks and chapters with start times. All
actions are the coordinator's (seeks through `player.seek`, the user path; 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 the bytes of current members in memory and serves them at `/a/...`, which
clients fetch once per hash (`AvatarCache`). Uplink per broadcast stays clients fetch once per hash (`AvatarCache`). Uplink per broadcast stays
small whatever the picture size (cap 256 KiB base64 per avatar). 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`).
+28 -3
View File
@@ -24,7 +24,8 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
`GET /j/<sid>/<token>/<itemId>` relays the current Jellyfin media (see `GET /j/<sid>/<token>/<itemId>` relays the current Jellyfin media (see
"Jellyfin media" below). `/r/<sid>/<token>/...` is the relay of a "Jellyfin media" below). `/r/<sid>/<token>/...` is the relay of a
member's media (see "Relay" below). `GET /a/<sid>/<token>/<hash>` serves member's media (see "Relay" below). `GET /a/<sid>/<token>/<hash>` serves
a member's picture (see "Avatars" below). a member's picture (see "Avatars" below), `GET /i/<sid>/<token>/<hash>`
the current media's art (see "Media art" below).
## Client to server ## Client to server
@@ -44,6 +45,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
{"t":"chat","text":"..."} {"t":"chat","text":"..."}
{"t":"kick","target":"<memberId>","reason":""} {"t":"kick","target":"<memberId>","reason":""}
{"t":"profile","name":"...","avatar":"<base64>"} {"t":"profile","name":"...","avatar":"<base64>"}
{"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
``` ```
Field notes: Field notes:
@@ -77,6 +79,7 @@ Field notes:
that is absent (or null) stays as it is; `avatar: ""` removes the picture. that is absent (or null) stays as it is; `avatar: ""` removes the picture.
Same rules as `hello.user`; a too large avatar gets `err` Same rules as `hello.user`; a too large avatar gets `err`
`rejected: avatar too large` (the socket stays open, nothing changes). `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. - `chat.text` is cut to 500 characters.
- `swap.media.url` is the link; `name`, `size`, `duration` are informational. - `swap.media.url` is the link; `name`, `size`, `duration` are informational.
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is `swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
@@ -91,7 +94,7 @@ Field notes:
"state":{<state>}} "state":{<state>}}
{"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3, {"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
"serverTime":"<ISO-8601 UTC>","mediaId":1, "serverTime":"<ISO-8601 UTC>","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"?, "members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
"host"?}], "host"?}],
"seekBy":"m1","seekSeq":3,"relay":true} "seekBy":"m1","seekSeq":3,"relay":true}
@@ -116,7 +119,9 @@ Field notes:
`eventId` increases on seek, swap and transport changes. `eventId` increases on seek, swap and transport changes.
`mediaId` increases on swap. `media.relay` is present for relayed media `mediaId` increases on swap. `media.relay` is present for relayed media
(see "Relay"). `media.duration` is 0 until a client reports (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 `eventId` came from a seek. `relay` (omitted when false): the server can
relay the current Jellyfin media. `members[].jf` is the member's last relay the current Jellyfin media. `members[].jf` is the member's last
`report.jf`, omitted when it never sent one. `members[].avatarHash` `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, picture, 410 unknown session. Pictures go with their member (leave,
change, removal). change, removal).
## Media art
```
client {"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
state media.art = {"poster":"<hash>","banner":"<hash>"}
```
- 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/<sid>/<token>/<hash>`: as `/a/` (see "Avatars"); 404 when the
current media has no such art.
## Jellyfin media ## Jellyfin media
``` ```
+3
View File
@@ -38,6 +38,9 @@ abstract class RelayConstants {
// over-size for private-server use. PNG keeps transparency; JPEG did // over-size for private-server use. PNG keeps transparency; JPEG did
// not. Only the hash rides in `state`; the bytes are fetched once. // not. Only the hash rides in `state`; the bytes are fetched once.
static const int maxAvatarBytes = 256 * 1024; 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 maxNameLength = 32;
static const int maxChatLength = 500; static const int maxChatLength = 500;
} }
+4 -1
View File
@@ -132,7 +132,8 @@ enum JellyfinImageType {
primary('Primary'), primary('Primary'),
backdrop('Backdrop'), backdrop('Backdrop'),
thumb('Thumb'), thumb('Thumb'),
logo('Logo'); logo('Logo'),
banner('Banner');
final String apiName; final String apiName;
const JellyfinImageType(this.apiName); const JellyfinImageType(this.apiName);
@@ -361,6 +362,8 @@ class JellyfinItem {
if (parentLogoItemId != null && parentLogoImageTag != null) { if (parentLogoItemId != null && parentLogoImageTag != null) {
return JellyfinImageRef(parentLogoItemId!, type, parentLogoImageTag!); return JellyfinImageRef(parentLogoItemId!, type, parentLogoImageTag!);
} }
case JellyfinImageType.banner:
break;
} }
return null; return null;
} }
+26
View File
@@ -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<String, Object?> toJson() => {
't': 'art',
'mediaId': mediaId,
if (poster != null) 'poster': poster,
if (banner != null) 'banner': banner,
};
factory ArtMessage.fromJson(Map<String, Object?> 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 { class MemberView {
final String id; final String id;
final String name; final String name;
+69 -1
View File
@@ -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 { class EngineFeed {
final List<FeedMessage> pending = []; final List<FeedMessage> pending = [];
void add(FeedMessage message) => pending.add(message); void add(FeedMessage message) => pending.add(message);
@@ -120,6 +141,12 @@ class SyncEngine {
DateTime serverTime; DateTime serverTime;
int mediaId = 1; int mediaId = 1;
HelloMedia? media; 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; int _nextId = 1;
// Who sent the seek that produced [_seekEventId], for the echo check. // Who sent the seek that produced [_seekEventId], for the echo check.
String? _seekBy; String? _seekBy;
@@ -207,6 +234,38 @@ class SyncEngine {
return null; 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<String, Object?>? 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`). /// A reconnect took over [token]'s member (see `hello.resume`).
void applyResume(String token) { void applyResume(String token) {
final member = members[token]; final member = members[token];
@@ -301,6 +360,9 @@ class SyncEngine {
final actor = members[actorToken]; final actor = members[actorToken];
mediaId++; mediaId++;
media = next; media = next;
swapBy = actorToken;
poster = null;
banner = null;
position = 0; position = 0;
serverTime = now; serverTime = now;
eventId++; eventId++;
@@ -409,7 +471,13 @@ class SyncEngine {
position: position, position: position,
serverTime: serverTime, serverTime: serverTime,
mediaId: mediaId, 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(), members: members.values.map((m) => m.view(stale: isStale(m))).toList(),
seekBy: bySeek ? _seekBy : null, seekBy: bySeek ? _seekBy : null,
seekSeq: bySeek ? _seekSeq : null, seekSeq: bySeek ? _seekSeq : null,
+64
View File
@@ -606,4 +606,68 @@ void main() {
expect(engine.members['a']!.avatarHash, isEmpty); expect(engine.members['a']!.avatarHash, isEmpty);
}); });
}); });
group('media art', () {
final jpeg = base64Encode([0xFF, 0xD8, 0xFF, ...List<int>.generate(61, (i) => i)]);
final png = base64Encode([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(60, (i) => 255 - i)]);
const media = HelloMedia(name: 'Show - 01.mkv', size: 1, duration: 0, url: 'http://x/a.mkv');
Map<String, Object?>? art() => engine.broadcast().media?['art'] as Map<String, Object?>?;
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<int>.filled(RelayConstants.maxPosterBytes, 1)]);
engine.applyArt('a', engine.mediaId,
poster: big, banner: base64Encode(List<int>.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<String, Object?>.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});
});
});
} }
+18 -3
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:relic/relic.dart'; import 'package:relic/relic.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
@@ -162,9 +163,17 @@ class ServerHub {
/// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its /// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its
/// content hash, for members (valid, unkicked token) only. The bytes for a /// content hash, for members (valid, unkicked token) only. The bytes for a
/// hash never change, so clients may cache them for good. /// 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/<sid>/<token>/<hash>`: 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')); 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 sid = req.pathParameters.raw[#sid] ?? '';
final token = req.pathParameters.raw[#token] ?? ''; final token = req.pathParameters.raw[#token] ?? '';
final hash = req.pathParameters.raw[#hash] ?? ''; final hash = req.pathParameters.raw[#hash] ?? '';
@@ -178,7 +187,7 @@ void mountAvatarRoute(RelicApp app, ServerHub hub) {
default: default:
break; break;
} }
final bytes = hub.engine.avatarFor(hash); final bytes = lookup(hash);
final type = bytes == null ? null : avatarImageType(bytes); final type = bytes == null ? null : avatarImageType(bytes);
if (bytes == null || type == null) return status(404); if (bytes == null || type == null) return status(404);
return Response.ok( return Response.ok(
@@ -402,6 +411,12 @@ void mountSyncRoute(
if (name != null) viewer.name = name; if (name != null) viewer.name = name;
hub.engine.applyProfile(token, name: name, avatar: avatar); hub.engine.applyProfile(token, name: name, avatar: avatar);
hub.changed(); 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': case 'report':
if (token == null) return; if (token == null) return;
final report = ReportMessage.fromJson(decoded); final report = ReportMessage.fromJson(decoded);
+1
View File
@@ -65,6 +65,7 @@ class SyncServer {
mountSessionMediaRoute(_app, sessions); mountSessionMediaRoute(_app, sessions);
mountRelayRoutes(_app, sessions, () => hub.relay); mountRelayRoutes(_app, sessions, () => hub.relay);
mountAvatarRoute(_app, hub); mountAvatarRoute(_app, hub);
mountArtRoute(_app, hub);
mountJellyfinRelayRoute( mountJellyfinRelayRoute(
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient()); _app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
// advertiseHost is only an explicit override; otherwise echo what the client dialled. // advertiseHost is only an explicit override; otherwise echo what the client dialled.
@@ -390,6 +390,98 @@ void main() {
} catch (_) {} } catch (_) {}
}); });
test('art: swapper only, current mediaId, hashes in state, /i serves', () async {
final jpeg = Uint8List.fromList([0xFF, 0xD8, 0xFF, ...List<int>.generate(200, (i) => (i * 3) % 256)]);
final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(200, (i) => i % 256)]);
final clients = <WebSocketChannel>[];
final subs = <StreamSubscription<Object?>>[];
Future<(WebSocketChannel, List<Map<String, Object?>>, String)> connect(String name) async {
final ch = WebSocketChannel.connect(Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
final messages = <Map<String, Object?>>[];
clients.add(ch);
subs.add(ch.stream.listen((m) => messages.add(Map<String, Object?>.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<Map<String, Object?>> waitFor(
List<Map<String, Object?>> messages, bool Function(Map<String, Object?>) 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<String, Object?>? artOf(Map<String, Object?> state) =>
(state['media'] as Map?)?['art'] as Map<String, Object?>?;
Future<HttpClientResponse> 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 { test('profile: rename + avatar feed, state carries hash, /a gated', () async {
final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(296, (i) => (i * 7) % 256)]); final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(296, (i) => (i * 7) % 256)]);
final ch = WebSocketChannel.connect( final ch = WebSocketChannel.connect(