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 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 04:31:14 +03:00
1 parent ab133dd82f
commit 27b4ec2c96
25 files changed
+274 -485

No files matched your search

+30 -2
View File
@@ -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<ArtBytes?> 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<ArtBytes?> thumbnailArt(Uri url,
{Future<Uint8List?> Function(Uri url, Map<String, String> 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<Uint8List?> _shrinkApart(Uint8List raw) => Isolate.run<Uint8List?>(() => _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<Uint8List?> fetchImage(Uri url, Map<String, String> headers, int maxBytes) async {
@@ -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 &&
@@ -16,6 +16,10 @@ NowPlaying? nowPlayingOf(Map<String, Object?>? 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)
@@ -200,7 +200,8 @@ class SessionCoordinator {
/// Neighbouring episodes this viewer can put up (own account only).
final previousEpisode = signal<EpisodeLink?>(null);
final nextEpisode = signal<EpisodeLink?>(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<AutoAdvance?>(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<void> 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<void> reloadVideo({bool auto = false}) async {
Future<void> 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<void> _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<void> _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<void> _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;
}