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:
1 parent
ab133dd82f
commit
27b4ec2c96
25 files changed
+274
-485
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user