From 27b4ec2c9649fabae20cd6f534360a5b8e7f7112 Mon Sep 17 00:00:00 2001 From: foxyz Date: Sun, 11 Oct 2026 04:31:14 +0300 Subject: [PATCH] Swap shows only the new media, no guessed endings, quieter screen - A swap stops the previous media at once; buffer counts only for the media the player holds (client) and the current mediaId (server), so the old video can no longer start the new one. - The automatic frozen-picture reload is gone (false alarms on still scenes); Reload video stays. - The "Next episode" pill shows only from a known ending. - A web link's thumbnail is its cover, under the video's own name. - While playing, the unread chip stays off the picture; chat pop-ups can be All / Only when paused / Off. - Server fetch failures say when the server has no cookies. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 ++- .../lib/src/jellyfin/session_media.dart | 9 +- .../lib/src/playback/frame_watchdog.dart | 120 ------------------ .../lib/src/playback/playback_controller.dart | 82 ------------ apps/desktop/lib/src/session/media_art.dart | 32 ++++- apps/desktop/lib/src/session/now_playing.dart | 4 + .../lib/src/session/now_playing_info.dart | 4 + .../lib/src/session/session_coordinator.dart | 74 +++++++---- .../lib/src/shell/now_playing_title.dart | 10 +- .../desktop/lib/src/shell/session_player.dart | 22 ++-- apps/desktop/lib/src/shell/settings_tab.dart | 36 +++++- apps/desktop/lib/src/shell/start_banner.dart | 3 +- apps/desktop/lib/src/shell/toasts.dart | 10 +- apps/desktop/lib/src/state/settings.dart | 27 ++++ .../lib/src/widgets/shown_frame_probe.dart | 65 ---------- apps/desktop/test/frame_watchdog_test.dart | 108 ---------------- apps/desktop/test/jellyfin_e2e_test.dart | 6 +- apps/desktop/test/jellyfin_session_test.dart | 6 +- apps/desktop/test/media_marks_test.dart | 14 +- apps/desktop/test/shell_test.dart | 10 +- docs/ARCHITECTURE.md | 47 ++++--- docs/PROTOCOL.md | 9 +- packages/core/lib/src/sync_engine.dart | 6 +- packages/core/test/sync_engine_test.dart | 18 +++ packages/server/lib/src/server_hub.dart | 19 ++- 25 files changed, 274 insertions(+), 485 deletions(-) delete mode 100644 apps/desktop/lib/src/playback/frame_watchdog.dart delete mode 100644 apps/desktop/lib/src/widgets/shown_frame_probe.dart delete mode 100644 apps/desktop/test/frame_watchdog_test.dart diff --git a/README.md b/README.md index c2de3b0..c9e85b7 100644 --- a/README.md +++ b/README.md @@ -81,16 +81,19 @@ corner; double-click or ⤢ makes it big again, × sits you out (below) and double-click on the picture goes fullscreen (the strip hides, the party shows in a corner while paused, the window buttons sit in the player's top bar while its controls are up), Esc leaves it. The controls show the -show's cover, title and episode top left (guests get the cover too); before +show's cover, title and episode top left (a web video: its thumbnail and +name; guests get the art too); before a video first plays, its banner fills the picture until playback starts. One pill above the controls says what playback waits for ("Press Space when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or Tab), ready to type; it shows messages, and its "Activity" toggle adds joins, ready, seeks and the like between them. Joins, leaves, chat and -other news also pop up bottom left with the person's avatar; click a message to open the chat. Unread chat -shows as a count on the chat buttons and, in fullscreen with the controls -hidden, as a small chip top right. +other news also pop up bottom left with the person's avatar; click a message to open the chat +(Settings → Notifications → Chat messages: All, Only when paused or Off; +messages always reach the chat). Unread chat shows as a count on the chat +buttons and, in fullscreen with the controls hidden and the video paused, +as a small chip top right. Your name and picture change for everyone in the server right away; the colour is yours only. @@ -308,8 +311,9 @@ Watching: Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`, Shift+P / Shift+N) put up the previous or next episode for everyone. From -the ending (when it is known, see Skipping above; else in the last two -minutes) a "Next episode" button floats in the corner. When an +the ending, when it is known (see Skipping above), a "Next episode" +button floats in the corner; when it is not, nothing shows before the +end. When an episode ends, a card shows what is next with Play next; with "Play the next episode automatically" (Settings, on by default) it counts down 10 s first, and Cancel stops it on your screen (seeking back stops it for @@ -379,7 +383,7 @@ fails to load shows a toast and switches shaders off. - "host offline - check address and port": wrong address, or the port is not open. - "wrong server password": password mismatch. - "host runs a different version": update the app on both sides. -- Picture frozen but sound fine: Ctrl+R reloads the video; the app also does it automatically. +- Picture frozen but sound fine: Ctrl+R reloads the video. - Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space. - Windows: the build is new; if something is off there, say so. diff --git a/apps/desktop/lib/src/jellyfin/session_media.dart b/apps/desktop/lib/src/jellyfin/session_media.dart index bd6cf72..84e4f44 100644 --- a/apps/desktop/lib/src/jellyfin/session_media.dart +++ b/apps/desktop/lib/src/jellyfin/session_media.dart @@ -320,14 +320,13 @@ Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPla /// When the floating "Next episode" pill appears: at [endingStart] (the /// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`) -/// when one is known inside [runtime], else the last two minutes of -/// [runtime] (all of it for shorter media). -Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) { +/// when one is known inside [runtime]. Null otherwise: no guessed ending, +/// the end-of-episode card at the real end is the only prompt then. +Duration? nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) { if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) { return endingStart; } - const lead = Duration(minutes: 2); - return runtime > lead ? runtime - lead : Duration.zero; + return null; } /// Who performs the auto-advance swap, so several counting clients do not diff --git a/apps/desktop/lib/src/playback/frame_watchdog.dart b/apps/desktop/lib/src/playback/frame_watchdog.dart deleted file mode 100644 index 2c80f07..0000000 --- a/apps/desktop/lib/src/playback/frame_watchdog.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'dart:typed_data'; - -/// FNV-1a over a 15x15 grid of RGB(A) pixels in the central half of an -/// image. Used on a small capture of what the compositor shows: the -/// centre is video, the edges carry the (changing) control overlays. -int hashCenterPixels(ByteData rgba, int width, int height) { - var x = 0x811c9dc5; - if (width <= 0 || height <= 0) return x; - for (var gy = 1; gy < 16; gy++) { - for (var gx = 1; gx < 16; gx++) { - final px = (width * (0.25 + 0.5 * gx / 16)).floor(); - final py = (height * (0.25 + 0.5 * gy / 16)).floor(); - final o = (py * width + px) * 4; - if (o + 3 > rgba.lengthInBytes) continue; - for (var c = 0; c < 3; c++) { - x = ((x ^ rgba.getUint8(o + c)) * 0x01000193) & 0xffffffff; - } - } - } - return x; -} - -/// FNV-1a over ~4k bytes spread evenly across a raw frame (mpv -/// `screenshot-raw`); dimension-agnostic, enough to tell frames apart. -int hashSpread(Uint8List bytes) { - var x = 0x811c9dc5; - if (bytes.isEmpty) return x; - final step = bytes.length <= 4096 ? 1 : bytes.length ~/ 4096; - for (var i = 0; i < bytes.length; i += step) { - x = ((x ^ bytes[i]) * 0x01000193) & 0xffffffff; - } - return x; -} - -/// One sample of player state, taken about once a second. -class FrameSample { - final DateTime at; - final double position; - - /// Fingerprint of the picture currently shown on screen; null when it - /// cannot be captured (no view, hidden window, headless). - final int? frames; - - /// Playing, not buffering, rate > 0, video track present, window - /// visible. Anything else disarms the watchdog. - final bool eligible; - const FrameSample({ - required this.at, - required this.position, - required this.frames, - required this.eligible, - }); -} - -/// Detects a frozen picture: position advances normally but the shown -/// picture stands still for [stallAfter]. Seeks (position jumps), pauses, -/// buffering and missing captures all reset the stall clock. A hit is a -/// suspicion only — a still scene looks the same — so the caller confirms -/// it against the decoder and calls [dismiss] when it was a still scene. -/// Fires at most once until the picture is seen moving again for -/// [rearmAfter], so a player that stays (or soon gets) frozen after a -/// reload does not reload in a loop. [reset] keeps that latch. -class FrameWatchdog { - static const stallAfter = Duration(seconds: 3); - static const rearmAfter = Duration(seconds: 10); - - FrameSample? _last; - DateTime? _stallSince; - DateTime? _healthySince; - bool _fired = false; - - void reset() { - _last = null; - _stallSince = null; - _healthySince = null; - } - - /// The last hit was a still scene: re-arm, but wait a full - /// [stallAfter] before suspecting again. - void dismiss() { - _fired = false; - _stallSince = null; - } - - /// Returns true exactly when a freeze is suspected. - bool feed(FrameSample s) { - final last = _last; - _last = s; - if (!s.eligible || s.frames == null || last == null || - !last.eligible || last.frames == null) { - _stallSince = null; - _healthySince = null; - return false; - } - if (s.frames != last.frames) { - _stallSince = null; - _healthySince ??= last.at; - if (s.at.difference(_healthySince!) >= rearmAfter) _fired = false; - return false; - } - _healthySince = null; - final wall = s.at.difference(last.at).inMilliseconds / 1000.0; - final moved = s.position - last.position; - // Normal playback only: a jump (seek) or a standstill (stall, the - // position itself stuck) is not a frozen picture. - final normal = wall > 0 && moved > wall * 0.5 && moved < wall * 2 + 0.5; - if (!normal) { - _stallSince = null; - return false; - } - _stallSince ??= last.at; - if (_fired) return false; - if (s.at.difference(_stallSince!) >= stallAfter) { - _fired = true; - _stallSince = null; - return true; - } - return false; - } -} diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index 289bf55..6d06c33 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -16,7 +16,6 @@ import '../shaders/shader_presets.dart'; import '../state/settings.dart'; import '../sync/state_diff.dart'; import '../sync/user_seek.dart'; -import 'frame_watchdog.dart'; import 'media_marks.dart'; import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat; @@ -41,9 +40,6 @@ class PlaybackController { SyncPlayer? _player; VideoController? _video; - Timer? _watchTimer; - bool _probing = false; - final FrameWatchdog _watchdog = FrameWatchdog(); StreamSubscription? _logSub; final List> _timeSubs = []; String _chapterJson = ''; @@ -60,9 +56,6 @@ class PlaybackController { /// otherwise, so no texture outlives its player. final alive = signal(false); - /// Called once when the watchdog sees a frozen picture. - void Function()? onFrozen; - /// mpv's yt-dlp hook could not open the current link (its error text). void Function(String error)? onYtdlError; @@ -109,10 +102,6 @@ class PlaybackController { Player? _ytdlSetOn; String _ytdlRaw = ''; - /// Fingerprint of the picture on screen, installed by the video view - /// (`ShownFrameProbe.sample`). Without it the watchdog stays idle. - Future Function()? shownFrame; - Player get player => _player!; /// Seeks from the video controls (seek bar) go here instead of to mpv. @@ -217,8 +206,6 @@ class PlaybackController { await _pinBufferProps(); await _pin('volume-max', '$maxVolume'); await applyShaders(); - _watchdog.reset(); - _watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch()); generation.value++; alive.value = true; } @@ -439,73 +426,6 @@ class PlaybackController { } } - /// Fingerprint of the decoder's current frame (mpv `screenshot-raw`, - /// off-thread); null without video. - Future _sourceFrame(Player p) async { - try { - final raw = await p.screenshot(format: null); - return raw == null || raw.isEmpty ? null : hashSpread(raw); - } catch (_) { - return null; - } - } - - /// A suspected freeze is real only if mpv's own frames still move: a - /// still scene looks frozen on screen too. - Future _sourceMoving(Player p) async { - final a = await _sourceFrame(p); - await Future.delayed(const Duration(milliseconds: 500)); - if (!identical(p, _player) || !p.state.playing) return false; - final b = await _sourceFrame(p); - return a != null && b != null && a != b; - } - - static bool get _windowVisible { - try { - final s = WidgetsBinding.instance.lifecycleState; - return s == null || - s == AppLifecycleState.resumed || - s == AppLifecycleState.inactive; - } catch (_) { - return false; - } - } - - Future _watch() async { - final p = _player; - final probe = shownFrame; - // Only meaningful once the video view is up. - if (p == null || _video == null || probe == null || _probing) return; - _probing = true; - try { - final frames = await probe(); - if (!identical(p, _player)) return; - final s = p.state; - final eligible = s.playing && - !s.buffering && - !s.completed && - s.rate > 0 && - (s.width ?? 0) > 0 && - _windowVisible; - final fired = _watchdog.feed(FrameSample( - at: DateTime.now(), - position: positionSecs, - frames: frames, - eligible: eligible, - )); - if (fired) { - if (await _sourceMoving(p) && identical(p, _player)) { - debugPrint('video watchdog: picture stuck while mpv frames move'); - onFrozen?.call(); - } else { - _watchdog.dismiss(); - } - } - } finally { - _probing = false; - } - } - /// Opens [url] paused. [headers] go with every request for it (mpv /// `http-header-fields`): a Jellyfin token travels there, not in the URL. Future openUrl(String url, {Map? headers}) async { @@ -689,8 +609,6 @@ class PlaybackController { Future dispose() async { final p = _player; if (p == null) return; - _watchTimer?.cancel(); - _watchTimer = null; await _logSub?.cancel(); _logSub = null; for (final sub in _timeSubs) { diff --git a/apps/desktop/lib/src/session/media_art.dart b/apps/desktop/lib/src/session/media_art.dart index ea123ed..27d70da 100644 --- a/apps/desktop/lib/src/session/media_art.dart +++ b/apps/desktop/lib/src/session/media_art.dart @@ -1,16 +1,19 @@ import 'dart:async'; import 'dart:io'; +import 'dart:isolate'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; 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". +// art"): Jellyfin images with this install's account, a web link's +// thumbnail, 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}); @@ -68,6 +71,31 @@ Future aniListArt(AniListClient aniList, String name, return (poster: poster, banner: banner); } +/// A web video's thumbnail at [url] as both cover and banner (the same +/// bytes, so the same hash: what tells clients it is a thumbnail, see +/// `NowPlaying.thumbnail`); shrunk to a JPEG under the cover cap when +/// larger; null when it cannot be had. +Future thumbnailArt(Uri url, + {Future Function(Uri url, Map headers, int maxBytes) fetch = fetchImage}) async { + var bytes = await fetch(url, const {}, 8 * 1024 * 1024); + if (bytes != null && bytes.length > RelayConstants.maxPosterBytes) bytes = await _shrinkApart(bytes); + return bytes == null ? null : (poster: bytes, banner: bytes); +} + +// Its own function, so the isolate's closure holds nothing but [raw]. +Future _shrinkApart(Uint8List raw) => Isolate.run(() => _shrinkThumbnail(raw)); + +Uint8List? _shrinkThumbnail(Uint8List raw) { + final decoded = img.decodeImage(raw); + if (decoded == null) return null; + for (final width in const [1280, 960, 640]) { + final small = decoded.width > width ? img.copyResize(decoded, width: width, interpolation: img.Interpolation.average) : decoded; + final jpg = img.encodeJpg(small, quality: 82); + if (jpg.length <= RelayConstants.maxPosterBytes) return jpg; + } + return null; +} + /// GET [url]: the bytes when 200, at most [maxBytes] and a known image /// type (see `avatarImageType`); else null. Future fetchImage(Uri url, Map headers, int maxBytes) async { diff --git a/apps/desktop/lib/src/session/now_playing.dart b/apps/desktop/lib/src/session/now_playing.dart index 4601a25..cd63dca 100644 --- a/apps/desktop/lib/src/session/now_playing.dart +++ b/apps/desktop/lib/src/session/now_playing.dart @@ -11,6 +11,10 @@ class NowPlaying { final String bannerHash; const NowPlaying({required this.title, this.episode, this.posterHash = '', this.bannerHash = ''}); + /// The art is a web video's thumbnail (the same picture as cover and + /// banner): shown wide, under the video's own name. + bool get thumbnail => posterHash.isNotEmpty && posterHash == bannerHash; + @override bool operator ==(Object other) => other is NowPlaying && diff --git a/apps/desktop/lib/src/session/now_playing_info.dart b/apps/desktop/lib/src/session/now_playing_info.dart index 64715fa..454ac14 100644 --- a/apps/desktop/lib/src/session/now_playing_info.dart +++ b/apps/desktop/lib/src/session/now_playing_info.dart @@ -16,6 +16,10 @@ NowPlaying? nowPlayingOf(Map? media) { 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 : ''; + // A web video's thumbnail: its name as is, no episode guessing. + if (poster.isNotEmpty && poster == banner) { + return NowPlaying(title: name, posterHash: poster, bannerHash: banner); + } final ref = refOfStateMedia(media); final (title, episode) = ref != null ? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle) diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index a1aed17..355abb6 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -200,7 +200,8 @@ class SessionCoordinator { /// Neighbouring episodes this viewer can put up (own account only). final previousEpisode = signal(null); final nextEpisode = signal(null); - /// The floating "Next episode" pill near the end of an episode. + /// The floating "Next episode" pill from a known ending (Outro segment + /// or ED chapter, [nextPromptStart]) on. final nextPromptVisible = signal(false); /// End-of-episode countdown; null when none runs here. final autoAdvance = signal(null); @@ -269,13 +270,18 @@ class SessionCoordinator { int _epoch = 0; Timer? _reconnectTimer; int _mediaId = -1; + // The mediaId whose media the player holds (or is opening); -1 for none. + // Unlike [_mediaId] it survives a lost connection, so a reconnect to the + // same media keeps the player, and a swap stops the previous media. + int _playerMediaId = -1; String? _openUrl; String _wsHost = ''; int _wsPort = 0; _RelayOrigin? _pendingOrigin; // This client's last swap, until a state shows it landed (a newer // mediaId than [after] with this member as `swapBy`): its art goes up then. - ({HelloMedia media, int after})? _pendingArt; + // [link]: the web link it came from (a relayed one has no `url`). + ({HelloMedia media, int after, String? link})? _pendingArt; AniListClient? _aniList; // What this member serves for media [$1]; survives a reconnect (the // state then looks new again). @@ -341,7 +347,6 @@ class SessionCoordinator { Future initPlayer() async { if (_playerReady) return; try { - playback.onFrozen = () => unawaited(reloadVideo(auto: true)); playback.onYtdlError = _setPlayerError; playback.onLoadError = _setPlayerError; playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6); @@ -386,12 +391,12 @@ class SessionCoordinator { bool _reloadingVideo = false; - /// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh + /// "Reload video" (button, Ctrl+R, status menu): fresh /// player + video output, current media reopened. While it runs the /// player counts as not ready, so reports/seek detection pause and the /// next broadcast resyncs position and play/pause (diff reset + play /// edge). The ready flag is never touched. - Future reloadVideo({bool auto = false}) async { + Future reloadVideo() async { // Also allowed after a failed reload (player gone, session still on). if (_params == null || _reloadingVideo) return; _reloadingVideo = true; @@ -411,16 +416,6 @@ class SessionCoordinator { _playerReady = playback.alive.peek(); _reloadingVideo = false; } - if (auto) { - _toastId++; - toasts.value = [ - ...toasts.peek(), - ToastItem( - id: _toastId, - text: 'Video froze — reloaded', - at: DateTime.now().toUtc()) - ]; - } } void markInput() => _idle.activity(DateTime.now().toUtc()); @@ -436,6 +431,7 @@ class SessionCoordinator { _wsPort = params.port; diff.reset(); _mediaId = -1; + _playerMediaId = -1; _openUrl = null; playerError.value = ''; _memberErrors = const {}; @@ -713,6 +709,7 @@ class SessionCoordinator { _seekGate.reset(); _clearPendingSeek(); _leaveJellyfinItem(); + await _leavePreviousMedia(r.mediaId); final name = (r.media?['name'] as String?) ?? ''; if (ref != null) { await _playJellyfin(ref, r, @@ -837,6 +834,7 @@ class SessionCoordinator { starving.value = null; if (_mediaId == -1 && _openUrl == null) return; _mediaId = -1; + _playerMediaId = -1; _openUrl = null; _openHeaders = null; _seekGate.reset(); @@ -849,6 +847,22 @@ class SessionCoordinator { } catch (_) {} } + /// A new media [mediaId]: the previous one leaves the player at once, so + /// nobody sees or hears it while the new one resolves, is fetched by the + /// server or opens. A reconnect to the media the player holds keeps it. + Future _leavePreviousMedia(int mediaId) async { + final held = _playerMediaId; + _playerMediaId = mediaId; + if (held == -1 || held == mediaId) return; + _openUrl = null; + _openHeaders = null; + diff.reset(); + if (!_playerReady) return; + try { + await playback.stop(); + } catch (_) {} + } + void _updateStarving(StateBroadcast r, double bufferedAhead) { if (r.transport == 'playing') { starving.value = null; @@ -959,11 +973,15 @@ class SessionCoordinator { // Duration only once the open media is the server's current one. // Sitting out: still a report (it keeps this member fresh), empty. final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId; + // The buffer counts only while the player holds the server's current + // media: before a swap's media is open it is the previous one's (or + // none), and the start must wait for this member. + final holds = !_lastOut && !_opening && _openUrl != null && _playerMediaId == r.mediaId; _checkPlayerError(r.mediaId); final error = playerError.peek(); sync.sendReport( position: _lastOut ? 0 : playback.positionSecs, - buffered: _lastOut ? 0 : playback.bufferedSecsAhead, + buffered: holds ? playback.bufferedSecsAhead : 0, eventId: r.eventId, duration: known ? playback.durationSecs : 0, mediaId: known ? r.mediaId : 0, @@ -1240,11 +1258,11 @@ class SessionCoordinator { sync.sendNudge(memberId); } - void _swap(HelloMedia media) { + void _swap(HelloMedia media, {String? link}) { // Putting something up means watching it: back first. final self = selfMember.peek(); if (self != null && self.out) sync.sendOut(self.id, false); - _pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0); + _pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url); sync.sendSwap(media); } @@ -1253,24 +1271,24 @@ class SessionCoordinator { 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)); + unawaited(_sendArt(r.mediaId, pending.media, pending.link)); } - /// Fetches the art of [media] (this client's swap, now [mediaId]) and - /// sends it; quietly nothing on any failure. - Future _sendArt(int mediaId, HelloMedia media) async { + /// Fetches the art of [media] (this client's swap, now [mediaId], from + /// web [link] if any) and sends it; quietly nothing on any failure. + Future _sendArt(int mediaId, HelloMedia media, String? link) 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 if (ref == null && (media.url ?? '').isNotEmpty) { + } else if (ref == null && (link ?? '').isNotEmpty) { // A web link is not a show: never guess. A page's thumbnail is - // its banner. - final thumb = (await _probeFor(media.url!))?.info?.thumbnail; + // its cover and banner both. + final thumb = (await _probeFor(link!))?.info?.thumbnail; final uri = thumb == null ? null : Uri.tryParse(thumb); - bytes = uri == null ? null : (banner: await fetchImage(uri, const {}, RelayConstants.maxBannerBytes), poster: null); + bytes = uri == null ? null : await thumbnailArt(uri); } else { final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1; bytes = await aniListArt(_aniList ??= AniListClient(), name); @@ -1347,7 +1365,7 @@ class SessionCoordinator { } _pendingOrigin = _LinkOrigin(url, probe, y); _swap(HelloMedia( - name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs))); + name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)), link: url); return null; } // The server reads it with its own yt-dlp; only a page this app knows @@ -1672,7 +1690,7 @@ class SessionCoordinator { final item = (jellyfinPlan.peek() as OwnPlay?)?.item; final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()), endingStart: playback.ending.peek()?.start); - show = snap.position * 1000 >= at.inMilliseconds; + show = at != null && snap.position * 1000 >= at.inMilliseconds; } nextPromptVisible.value = show; } diff --git a/apps/desktop/lib/src/shell/now_playing_title.dart b/apps/desktop/lib/src/shell/now_playing_title.dart index 52df6ad..54824c6 100644 --- a/apps/desktop/lib/src/shell/now_playing_title.dart +++ b/apps/desktop/lib/src/shell/now_playing_title.dart @@ -47,18 +47,20 @@ Widget fadeInImage(ImageProvider image, {BoxFit fit = BoxFit.cover}) => Image( ); /// The portrait cover (2:3) of the current media, a quiet tile with a -/// film icon until (or unless) its art arrives. +/// film icon until (or unless) its art arrives; [wide] (a video +/// thumbnail): 16:9 at the same height. class ArtCover extends StatelessWidget { final String hash; final double width; - const ArtCover({super.key, required this.hash, this.width = 40}); + final bool wide; + const ArtCover({super.key, required this.hash, this.width = 40, this.wide = false}); @override Widget build(BuildContext context) { final image = artImage(hash); final r = BorderRadius.circular(width >= 80 ? 10 : 6); return Container( - width: width, + width: wide ? width * 1.5 * 16 / 9 : width, height: width * 1.5, decoration: BoxDecoration( borderRadius: r, @@ -116,7 +118,7 @@ class NowPlayingBlock extends StatelessWidget { key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'), mainAxisSize: MainAxisSize.min, children: [ - if (cover) ...[ArtCover(hash: n.posterHash), const SizedBox(width: 12)], + if (cover) ...[ArtCover(hash: n.posterHash, wide: n.thumbnail), const SizedBox(width: 12)], Flexible( child: Column( mainAxisSize: MainAxisSize.min, diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 1e4e9f0..adc48c0 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -23,7 +23,6 @@ import '../shaders/shader_presets.dart'; import '../state/session_state.dart'; import '../state/settings.dart'; import '../widgets/app_keys.dart'; -import '../widgets/shown_frame_probe.dart'; import '../widgets/text_focus.dart'; import '../widgets/window_buttons.dart'; import 'avatar.dart'; @@ -93,7 +92,6 @@ class _SessionPlayerState extends State { // `Video` (it never swaps controllers in place). var _videoGen = -1; var _videoKey = GlobalKey(); - final _frameProbe = ShownFrameProbe(); final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false); DateTime? _lastKeyAt; late final void Function() _stopShaderWatch; @@ -114,7 +112,6 @@ class _SessionPlayerState extends State { void initState() { super.initState(); _coord.jellyfin.ensureLoaded(); - _coord.playback.shownFrame = _frameProbe.sample; HardwareKeyboard.instance.addHandler(_globalKey); // Tab (AppKeys) reaches the chat through here in fullscreen. AppKeys.addChatToggle(_toggleChat); @@ -132,8 +129,6 @@ class _SessionPlayerState extends State { void dispose() { HardwareKeyboard.instance.removeHandler(_globalKey); AppKeys.removeChatToggle(_toggleChat); - final playback = _coord.playback; - if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null; _stopShaderWatch(); _noFocus.dispose(); _controlsShown.dispose(); @@ -287,11 +282,7 @@ class _SessionPlayerState extends State { return Stack( fit: StackFit.expand, children: [ - // The frozen-picture watchdog captures this boundary: the - // video (and its controls), not the window around it. - RepaintBoundary( - child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]), - ), + video, if (_full) ...[ _status(), _progressLine(), @@ -755,15 +746,18 @@ class _SessionPlayerState extends State { ); /// Fullscreen hides the strip and its chat badge: while there is unread - /// chat and the controls are down, a small glass chip says so top right. + /// chat, the controls are down and the video is not playing, a small + /// glass chip says so top right (while it plays, only the controls' chat + /// button shows the count: nothing sits on the picture). Widget _unreadChip() => Positioned( right: 16, top: 16, child: IgnorePointer( child: ValueListenableBuilder( valueListenable: _controlsShown, - builder: (context, shown, _) { - final on = widget.unread > 0 && !widget.chatOpen && !shown; + builder: (context, shown, _) => SignalBuilder(builder: (context) { + final playing = _coord.serverState.value?.transport == 'playing'; + final on = widget.unread > 0 && !widget.chatOpen && !shown && !playing; return AnimatedOpacity( duration: easeTime, curve: ease, @@ -783,7 +777,7 @@ class _SessionPlayerState extends State { ), ), ); - }, + }), ), ), ); diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index 339ba1f..f26ded7 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -723,8 +723,8 @@ class _AnimeFlowCardState extends State { } } -/// What pops up and what makes a sound. No switches: there is nothing to -/// choose yet. +/// What pops up (chat pop-ups: [AppSettings.chatPopups]) and what makes +/// a sound. class NotificationsInfo extends StatelessWidget { const NotificationsInfo({super.key}); @@ -746,11 +746,39 @@ class NotificationsInfo extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - line('Joins, leaves, video changes and chat', + line('Joins, leaves and video changes', 'Pop up bottom left for 5 s, with the person\'s picture'), + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Chat messages'), + Text('Pop up the same way; always in Chat & activity (Tab)', style: muted), + ], + ), + ), + SignalBuilder( + builder: (context) => DropdownButton( + key: const ValueKey('chat-popups'), + value: AppSettings.instance.chatPopups.value, + onChanged: (v) { + if (v != null) AppSettings.instance.setChatPopups(v); + }, + items: [ + for (final m in ChatPopups.values) DropdownMenuItem(value: m, child: Text(m.label)), + ], + ), + ), + ], + ), + ), line('Sat out or nudged by someone', 'Only on your end, with their picture'), line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'), - line('App notices', 'Shader changes, a frozen video reloaded, the idle warning'), + line('App notices', 'Shader changes, the idle warning'), ], ), ), diff --git a/apps/desktop/lib/src/shell/start_banner.dart b/apps/desktop/lib/src/shell/start_banner.dart index aa01c9a..675cd2d 100644 --- a/apps/desktop/lib/src/shell/start_banner.dart +++ b/apps/desktop/lib/src/shell/start_banner.dart @@ -82,7 +82,8 @@ class StartCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (!small) ...[ + // A thumbnail already fills the card as the banner. + if (!small && !now.thumbnail) ...[ ArtCover(hash: now.posterHash, width: coverWidth), const SizedBox(width: 24), ], diff --git a/apps/desktop/lib/src/shell/toasts.dart b/apps/desktop/lib/src/shell/toasts.dart index 034af95..59a4693 100644 --- a/apps/desktop/lib/src/shell/toasts.dart +++ b/apps/desktop/lib/src/shell/toasts.dart @@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart'; import '../session/session_coordinator.dart' show ToastItem; import '../state/session_state.dart'; +import '../state/settings.dart'; import 'avatar.dart'; import 'motion.dart'; @@ -47,7 +48,7 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) { /// The coordinator's toasts as cards with avatars, bottom-left: each /// slides in, stays [life], then slides out and collapses its gap. A click /// on a chat toast opens the chat ([onOpenChat]); the rest let clicks -/// through. +/// through. Chat toasts follow [AppSettings.chatPopups]. class PartyToasts extends StatefulWidget { final Duration life; final VoidCallback? onOpenChat; @@ -76,8 +77,13 @@ class _PartyToastsState extends State { if (fresh.isEmpty) return; // Only new toasts re-run this, not the party they mention. untracked(() { + final playing = coord.serverState.value?.transport == 'playing'; + final popups = AppSettings.instance.chatPopups.value; for (final t in fresh) { - _add(shellToastFor(t, (n) => avatarForName(n, coord))); + final toast = shellToastFor(t, (n) => avatarForName(n, coord)); + // Chat per Settings → Notifications (it is in the chat either way). + if (toast.chat && !popups.shows(playing: playing)) continue; + _add(toast); } }); }); diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index d9f2b7e..f1ea5e8 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -27,9 +27,27 @@ const _keyWelcomeDismissed = 'welcomeDismissed'; const _keySoundPresetFor = 'soundPresetFor'; const _keyUnreadyOnSwap = 'unreadyOnSwap'; const _keyIdleSitsOut = 'idleSitsOut'; +const _keyChatPopups = 'chatPopups'; enum HwdecMode { auto, off, custom } +/// When chat messages pop up as toasts (they always reach the chat). +enum ChatPopups { + all('All'), + paused('Only when paused'), + off('Off'); + + final String label; + const ChatPopups(this.label); + + /// Whether a chat message pops up now ([playing]: the video plays). + bool shows({required bool playing}) => switch (this) { + ChatPopups.all => true, + ChatPopups.paused => !playing, + ChatPopups.off => false, + }; +} + class AppSettings { static final AppSettings instance = AppSettings(); @@ -52,6 +70,8 @@ class AppSettings { /// A 2 px progress line at the bottom of the video while the controls /// are hidden (both designs). Applies live. final progressLine = signal(true); + /// Chat message toasts. Applies live. + final chatPopups = signal(ChatPopups.all); /// App sounds (`sfx/`): 0..1, 0 = silent. final soundVolume = signal(1.0); /// `SfxKind` names switched off one by one. @@ -109,6 +129,8 @@ class AppSettings { if (hideRe is bool) hideReReleases.value = hideRe; final line = box.get(_keyProgressLine); if (line is bool) progressLine.value = line; + final popups = box.get(_keyChatPopups); + chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.all; final vol = box.get(_keySoundVolume); if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1); final off = box.get(_keySoundsOff); @@ -181,6 +203,11 @@ class AppSettings { _put(_keyProgressLine, on); } + void setChatPopups(ChatPopups mode) { + chatPopups.value = mode; + _put(_keyChatPopups, mode.name); + } + void setSoundVolume(double v) { soundVolume.value = v.clamp(0, 1); _put(_keySoundVolume, soundVolume.value); diff --git a/apps/desktop/lib/src/widgets/shown_frame_probe.dart b/apps/desktop/lib/src/widgets/shown_frame_probe.dart deleted file mode 100644 index 21d9f37..0000000 --- a/apps/desktop/lib/src/widgets/shown_frame_probe.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'dart:ui' as ui; - -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; - -import '../playback/frame_watchdog.dart'; - -/// Fingerprints what the compositor really shows of the video. Place -/// [anchor] under a `RepaintBoundary` around the video (the session player -/// does); [sample] captures that boundary around the newest mounted anchor -/// at ~200 px wide — the texture included — and hashes its centre. This is the only per-presented-frame signal: mpv's own -/// counters keep ticking when the Flutter texture is stuck. -class ShownFrameProbe { - final _anchors = <_AnchorState>[]; - - Widget anchor() => _Anchor(probe: this); - - Future sample() async { - for (final a in _anchors.reversed) { - if (!a.mounted) continue; - final boundary = a.context.findAncestorRenderObjectOfType(); - if (boundary == null || !boundary.attached || !boundary.hasSize) return null; - final width = boundary.size.width; - if (width <= 0) return null; - ui.Image? image; - try { - image = await boundary.toImage(pixelRatio: (200 / width).clamp(0.02, 1.0)); - final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); - if (data == null) return null; - return hashCenterPixels(data, image.width, image.height); - } catch (_) { - // Route not painted (covered) or mid-teardown: no reading. - return null; - } finally { - image?.dispose(); - } - } - return null; - } -} - -class _Anchor extends StatefulWidget { - final ShownFrameProbe probe; - const _Anchor({required this.probe}); - - @override - State<_Anchor> createState() => _AnchorState(); -} - -class _AnchorState extends State<_Anchor> { - @override - void initState() { - super.initState(); - widget.probe._anchors.add(this); - } - - @override - void dispose() { - widget.probe._anchors.remove(this); - super.dispose(); - } - - @override - Widget build(BuildContext context) => const SizedBox.shrink(); -} diff --git a/apps/desktop/test/frame_watchdog_test.dart b/apps/desktop/test/frame_watchdog_test.dart deleted file mode 100644 index b247056..0000000 --- a/apps/desktop/test/frame_watchdog_test.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:watch_together/src/playback/frame_watchdog.dart'; - -final _t0 = DateTime.utc(2026, 1, 1); - -/// Feeds one sample per second; [frames]/[pos] give the value at second i. -List _run( - FrameWatchdog w, - int seconds, { - required int? Function(int i) frames, - double Function(int i)? pos, - bool Function(int i)? eligible, - int start = 0, -}) { - return [ - for (var i = start; i < start + seconds; i++) - w.feed(FrameSample( - at: _t0.add(Duration(seconds: i)), - position: pos?.call(i) ?? i.toDouble(), - frames: frames(i), - eligible: eligible?.call(i) ?? true, - )), - ]; -} - -void main() { - test('hashes tell frames apart and ignore the edges', () { - final a = ByteData(100 * 60 * 4); - final b = ByteData(100 * 60 * 4); - expect(hashCenterPixels(a, 100, 60), hashCenterPixels(b, 100, 60)); - b.setUint8((2 * 100 + 2) * 4, 255); // corner: overlay area - expect(hashCenterPixels(a, 100, 60), hashCenterPixels(b, 100, 60)); - b.setUint8((30 * 100 + 50) * 4, 255); // centre: video - expect(hashCenterPixels(a, 100, 60), isNot(hashCenterPixels(b, 100, 60))); - final raw = Uint8List(1 << 20); - final h = hashSpread(raw); - raw[raw.length ~/ 2 - (raw.length ~/ 2) % (raw.length ~/ 4096)] = 1; - expect(hashSpread(raw), isNot(h)); - }); - - test('frames advancing never fires', () { - final w = FrameWatchdog(); - expect(_run(w, 30, frames: (i) => i * 30), everyElement(isFalse)); - }); - - test('frames stuck while position advances fires once after ~3 s', () { - final w = FrameWatchdog(); - final fired = _run(w, 20, frames: (i) => i < 5 ? i * 30 : 150); - expect(fired.where((f) => f).length, 1); - expect(fired.indexOf(true), 8, reason: 'stuck from t=5, 3 s later'); - }); - - test('pause, buffering, missing data and seeks do not fire', () { - final w = FrameWatchdog(); - // Paused / buffering: not eligible. - expect(_run(w, 10, frames: (_) => 100, eligible: (_) => false), - everyElement(isFalse)); - // No video output (headless / audio-only). - expect(_run(w, 10, frames: (_) => null, start: 10), everyElement(isFalse)); - // Position stuck too (network stall): not a frozen picture. - expect(_run(w, 10, frames: (_) => 100, pos: (_) => 50, start: 20), - everyElement(isFalse)); - // Seeking every 2 s with frames stuck in between: jumps reset it. - expect( - _run(w, 12, - frames: (_) => 100, - pos: (i) => i.isEven ? i * 10.0 : i * 10.0 - 9, - start: 30), - everyElement(isFalse)); - }); - - test('no reload loop: re-arms only after 10 s of healthy frames', () { - final w = FrameWatchdog(); - expect(_run(w, 10, frames: (_) => 7).where((f) => f).length, 1); - w.reset(); // new player - // Brief healthy spell, then stuck again: must not fire. - expect( - _run(w, 15, frames: (i) => i < 13 ? i : 13, start: 10) - .where((f) => f) - .length, - 0); - // Healthy for 10 s+, then stuck: fires again. - w.reset(); - expect( - _run(w, 25, frames: (i) => i < 42 ? i : 42, start: 30) - .where((f) => f) - .length, - 1); - }); - - test('still scene: dismiss re-arms after another full stall', () { - final w = FrameWatchdog(); - final fired = []; - for (var i = 0; i < 20; i++) { - if (w.feed(FrameSample( - at: _t0.add(Duration(seconds: i)), - position: i.toDouble(), - frames: 5, - eligible: true))) { - fired.add(i); - w.dismiss(); - } - } - expect(fired, [3, 6, 9, 12, 15, 18]); - }); -} diff --git a/apps/desktop/test/jellyfin_e2e_test.dart b/apps/desktop/test/jellyfin_e2e_test.dart index 3471390..f75ac25 100644 --- a/apps/desktop/test/jellyfin_e2e_test.dart +++ b/apps/desktop/test/jellyfin_e2e_test.dart @@ -301,9 +301,9 @@ void main() { a.toggleReady(); await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again'); - // Account holders see the "Next episode" prompt (this item is - // shorter than its 2 min lead); the guest cannot put it up. - await _poll(() => a.nextPromptVisible.peek(), 'next prompt'); + // No known ending (no Outro segment, no ED chapter): no floating + // "Next episode" prompt mid-episode, only the card at the end. + expect(a.nextPromptVisible.peek(), isFalse); expect(b.nextPromptVisible.peek(), isFalse); // Alice and the guest pick English; it carries to the next episode. diff --git a/apps/desktop/test/jellyfin_session_test.dart b/apps/desktop/test/jellyfin_session_test.dart index e857a07..a9bd33a 100644 --- a/apps/desktop/test/jellyfin_session_test.dart +++ b/apps/desktop/test/jellyfin_session_test.dart @@ -321,9 +321,9 @@ void main() { expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7'); }); - test('the floating prompt starts two minutes before the end', () { - expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22)); - expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero); + test('no floating prompt without a known ending', () { + expect(nextPromptStart(null, const Duration(minutes: 24)), isNull); + expect(nextPromptStart(null, const Duration(seconds: 90)), isNull); }); test('one auto-advance swapper: the lowest id among members with their own account', () { diff --git a/apps/desktop/test/media_marks_test.dart b/apps/desktop/test/media_marks_test.dart index 9a47769..17259a1 100644 --- a/apps/desktop/test/media_marks_test.dart +++ b/apps/desktop/test/media_marks_test.dart @@ -154,14 +154,14 @@ void main() { }); group('next episode prompt', () { - test('at the ED start when known, else the last two minutes', () { + test('at the ED start when known, else none (never a guessed time)', () { expect(nextPromptStart(null, m(24), endingStart: m(20)), m(20)); - expect(nextPromptStart(null, m(24)), m(22)); - expect(nextPromptStart(null, m(24), endingStart: null), m(22)); - // Nonsense ending starts fall back. - expect(nextPromptStart(null, m(24), endingStart: m(25)), m(22)); - expect(nextPromptStart(null, m(24), endingStart: Duration.zero), m(22)); - expect(nextPromptStart(null, s(90)), Duration.zero); + expect(nextPromptStart(null, m(24)), isNull); + expect(nextPromptStart(null, m(24), endingStart: null), isNull); + // Nonsense ending starts count as unknown. + expect(nextPromptStart(null, m(24), endingStart: m(25)), isNull); + expect(nextPromptStart(null, m(24), endingStart: Duration.zero), isNull); + expect(nextPromptStart(null, s(90)), isNull); }); }); diff --git a/apps/desktop/test/shell_test.dart b/apps/desktop/test/shell_test.dart index a570838..ba2d1f7 100644 --- a/apps/desktop/test/shell_test.dart +++ b/apps/desktop/test/shell_test.dart @@ -374,19 +374,19 @@ void main() { final app = shellToastFor(ToastItem(id: 3, text: 'Bob joined', at: at), who); expect(app.who, isNull); expect(app.text, 'Bob joined'); - final froze = shellToastFor(ToastItem(id: 4, text: 'Video froze — reloaded', at: at), who); - expect(froze.who, isNull); + final notice = shellToastFor(ToastItem(id: 4, text: 'Shaders off', at: at), who); + expect(notice.who, isNull); }); testWidgets('toasts slide in with an avatar and leave after their time', (tester) async { await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts(life: Duration(seconds: 1))))); - coord.toasts.value = [ToastItem(id: 1, text: 'Video froze — reloaded', at: DateTime.now())]; + coord.toasts.value = [ToastItem(id: 1, text: 'Shaders off', at: DateTime.now())]; await tester.pump(); await tester.pump(); - expect(find.text('Video froze — reloaded'), findsOneWidget); + expect(find.text('Shaders off'), findsOneWidget); await tester.pump(const Duration(seconds: 1)); await tester.pumpAndSettle(); - expect(find.text('Video froze — reloaded'), findsNothing); + expect(find.text('Shaders off'), findsNothing); }); testWidgets('status avatar fills its box at any size', (tester) async { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9714645..00ba273 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -98,7 +98,11 @@ Loosely modeled on Syncplay, with a different readiness design. the media `duration` and its `mediaId` once the player knows it. The server stores `buffered` and liveness, and takes the first reported duration for the current media (swap resets it); a report with `eventId` - older than current has its position ignored. + older than current has its position ignored. `buffered` is the current + media's only: the client sends 0 until the player holds it (a swap's + media still resolving, being fetched by the server or opening), and the + server counts a report naming an earlier `mediaId` as 0, so the old + media's buffer never passes the gate for the new one. - **Ready gate** (HELD to PLAYING): the in-set is non-empty and every in-set member is ready, fresh and has `buffered >= need`. `need` is 4 s (`minBufferSecs`), or near the end what is left minus 2 s @@ -336,17 +340,10 @@ session: created on join, disposed on leave, replaced by `recreate()` for `StateDiff` actions, the play gate (`localPlayAllowed`: self in-set and ready) or the user's own seeks (see Sync model). A server seek within 0.5 s of the current position is skipped. `leave()` stops the player (otherwise audio keeps playing). On media change -(`mediaId` bump) the coordinator opens the new URL and `StateDiff` seeks to 0, -holds and resets rate. - -Frozen picture: `FrameWatchdog` samples about once a second. It hashes a -small capture of the compositor's centre (`ShownFrameProbe`) and suspects a -freeze when position advances while that hash stays constant for 3 s. Two -differing mpv `screenshot-raw` frames show the decoder is still producing -pictures, which confirms the freeze (a still scene is dismissed). A confirmed -freeze reloads the video with a toast. It fires once until the picture moves -again for 10 s, so it cannot loop. Seeks, pause, buffering and a hidden window -disarm it. +(`mediaId` bump) the coordinator stops the previous media at once (no old +picture or sound while the new one resolves, is fetched or opens; a +reconnect to the media the player holds keeps it), then opens the new URL +and `StateDiff` seeks to 0, holds and resets rate. Chapters, segments, tracks (`PlaybackController` signals, values from `playback/media_marks.dart`, no widgets; any controls UI reads them): @@ -673,7 +670,7 @@ all three link modes. Nothing is bundled or installed by hand. (`AppYtDlp.probe`, remembered 10 minutes; media file names too, not a Jellyfin item link) for the Link preview and the swap: a page is named by its title, never its URL - tail, and its thumbnail goes up as the banner (no poster). A link with a + tail, and its thumbnail goes up as its art (see "Media art"). A link with a problem, or one the probe cannot reach ("Couldn't reach the site"), is not put up in any mode: the Link page shows the reason (preview and under the button) and offers "Put it up anyway" (`LinkMedia.anyway`), @@ -747,9 +744,9 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, `/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the previous and next episode. The controls show previous / next buttons (also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (the ending's - start when one is known, see "Skip rules", else the last 2 min of the - runtime) a floating "Next episode" pill shows outside the auto-hiding - controls. Auto-advance still waits for `finished`. Any + start, see "Skip rules") a floating "Next episode" pill shows outside + the auto-hiding controls. Without a known ending there is no pill (never + a guessed time); the end card at `finished` is the only prompt. Auto-advance still waits for `finished`. Any of them swaps to that episode's ref with the clicker's account. Guests (relay, blocked) see none of it. - **Auto-advance** (Settings, on by default): at `finished` with a known @@ -978,12 +975,16 @@ always scrolls the page). Space never presses a focused button feed lines between them. Opening focuses the field; Tab or Esc in it close the panel and focus goes back where it was. Unread chat badges the strip's and both player designs' chat buttons; in fullscreen with - the controls down a glass chip (`PlayerGlass`) top right shows it. + the controls down and the video not playing a glass chip + (`PlayerGlass`) top right shows it (while it plays nothing sits on the + picture). **Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with the avatar of their `ToastItem.actor` (feed and chat toasts; the app's own have none); each slides in, stays 5 s, then slides out and collapses its gap. A click on - a chat toast opens the chat; the others let clicks through. + a chat toast opens the chat; the others let clicks through. Chat toasts + follow `AppSettings.chatPopups` (Settings → Notifications: All, Only + when paused, Off); the message is in the chat either way. Settings persist in two Hive boxes: `connect_form` (last form, recent servers and links, the profile picture and colour; saved even when a join @@ -1187,7 +1188,11 @@ testers land back on stable. `0.0.0-dev` (a dev run) checks nothing. - 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. + its own account; a web link's yt-dlp thumbnail, the one the Library's + Recent grid shows, as poster and banner both, shrunk to a JPEG under the + poster cap when larger; 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`). + `state.media` (`now_playing_info.dart`); poster and banner of one hash + mark a thumbnail (`NowPlaying.thumbnail`): the media name as is, no + episode line, a 16:9 cover in the controls and none on the start card. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index b8e3bb8..e67af1f 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -68,6 +68,9 @@ Field notes: - `report.duration` / `report.mediaId` (optional, omitted when 0): the open media's length in seconds and the `mediaId` it belongs to. The server takes the first one whose `mediaId` is current while its media duration is 0. + `report.buffered` is the current media's: a client sends 0 while its + player does not hold it yet, and the server counts a report whose + `mediaId` names an earlier media as `buffered` 0. - `report.jf` (optional): Jellyfin access per `ServerId`, `true` = this client plays the item with its own account, `false` = through the relay. Sticky: a report without `jf` keeps the last one. At most 8 entries, keys @@ -202,7 +205,8 @@ state media.art = {"poster":"","banner":""} 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. + picture is absent, `art` when both are. The same picture as poster and + banner (one hash) is a web video's thumbnail. - `GET /i///`: as `/a/` (see "Avatars"); 404 when the current media has no such art. @@ -273,7 +277,8 @@ anything else means one file. server password`: an open server would fetch any address it can reach for anyone). A web page `url` is read through the server's yt-dlp; when the server cannot read the link, the swapper later gets `The server - can't fetch this link: `. Any later swap ends the relay. + can't fetch this link: `; when yt-dlp failed and the server has + no cookies, the reason is `it has no cookies (...)` instead. Any later swap ends the relay. `tracks` on a member's swap names the tracks its source will serve; the server ignores it with `fetch` and with `jellyfin`. - `state.media.relay.source`: member id of the source (absent with diff --git a/packages/core/lib/src/sync_engine.dart b/packages/core/lib/src/sync_engine.dart index e1252c1..afcfce7 100644 --- a/packages/core/lib/src/sync_engine.dart +++ b/packages/core/lib/src/sync_engine.dart @@ -429,7 +429,11 @@ class SyncEngine { _setError(member, mediaId == this.mediaId ? error : ''); _seen(member); if (jf != null) member.jf = Map.unmodifiable(jf); - member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered; + // A report for an earlier media (sent before the swap reached the + // client) says nothing about this one's buffer. + final earlier = mediaId != 0 && mediaId != this.mediaId; + member.buffered = + member.out || earlier || !buffered.isFinite || buffered < 0 ? 0 : buffered; final m = media; // First report of the length wins; only for the media the client has open. if (m != null && m.duration <= 0 && mediaId == this.mediaId && diff --git a/packages/core/test/sync_engine_test.dart b/packages/core/test/sync_engine_test.dart index 45b9829..a7b5b7f 100644 --- a/packages/core/test/sync_engine_test.dart +++ b/packages/core/test/sync_engine_test.dart @@ -429,6 +429,24 @@ void main() { expect(engine.members['b']!.inSet, isTrue); expect(engine.transport, Transport.held); }); + + test("the previous media's buffer never starts the new one", () { + join('a', 'alice'); + join('b', 'bob'); + engine.applyReady('a', true); + engine.applyReady('b', true); + engine.applySwap('a', ep2); + final oldEvent = engine.eventId - 1; + // Sent before the swap reached the clients, landing after it. + engine.applyReport('a', 30, 20.0, oldEvent, mediaId: 1); + engine.applyReport('b', 30, 20.0, oldEvent, mediaId: 1); + expect(engine.members['a']!.buffered, 0); + engine.tick(); + expect(engine.transport, Transport.held); + engine.applyReport('a', 0, 6.0, engine.eventId, mediaId: 2); + engine.applyReport('b', 0, 6.0, engine.eventId, mediaId: 2); + expect(engine.transport, Transport.playing); + }); }); group('force with actor feed', () { diff --git a/packages/server/lib/src/server_hub.dart b/packages/server/lib/src/server_hub.dart index a09a1a5..31982b1 100644 --- a/packages/server/lib/src/server_hub.dart +++ b/packages/server/lib/src/server_hub.dart @@ -34,6 +34,23 @@ class ServerHub { return ref != null && cred != null && cred.serverId.isNotEmpty && cred.serverId == ref.serverId; } + /// What the swapper reads when the server cannot fetch a link ([why] + /// from the relay). A yt-dlp failure on a server without cookies says + /// so, unless cookies would not help (unknown site, gone, unreachable). + String fetchFailText(String why) { + const head = "The server can't fetch this link"; + final y = ytDlp; + final plain = playErrorText(why); + final cookiesMightHelp = y != null && + y.enabled && + why.contains('yt-dlp') && + !const {unsupportedUrlText, notFoundText, unreachableText}.contains(plain) && + y.cookies.read(count: false) == null; + return cookiesMightHelp + ? '$head: it has no cookies (set them in Settings → Player → yt-dlp on the hosting app, or --yt-dlp-cookies)' + : '$head: $why'; + } + StateBroadcast stateBroadcast() => engine.broadcast(relay: relayAvailable); /// The relay of the current media (`/r/...`), if it is relayed. @@ -362,7 +379,7 @@ void mountSyncRoute( ? ActiveRelay.fetch(hub.engine.mediaId, name, link!, ytDlp: hub.ytDlp, onLiveChanged: () => hub.relayLiveChanged(active), - onFail: (why) => wsErr(ws, "The server can't fetch this link: $why")) + onFail: (why) => wsErr(ws, hub.fetchFailText(why))) : _memberRelay(hub, token, name, media.relay!.tracks); hub.setRelay(active); hub.changed();