From d3be13ecda5abf93aa18801886950ce7bb1b1f8a Mon Sep 17 00:00:00 2001 From: foxyz Date: Sat, 10 Oct 2026 21:29:41 +0300 Subject: [PATCH] Errors: refuse links the probe can't play (with Put it up anyway), plain error texts, and others see who can't play report.error / state.members[].error (optional, no protocol bump): an errored member is out of the ready gate until its player opens the media or the media changes; others see "Bob can't play this: ..." and a mark on the avatar; your own pill offers Retry (reload). Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- .../lib/src/screens/home/picked_media.dart | 5 +- .../lib/src/session/session_coordinator.dart | 74 ++++++++++++++++--- apps/desktop/lib/src/shell/app_shell.dart | 2 +- apps/desktop/lib/src/shell/avatar.dart | 27 ++++++- apps/desktop/lib/src/shell/library_link.dart | 69 ++++++++++++----- apps/desktop/lib/src/shell/party_strip.dart | 3 + apps/desktop/lib/src/shell/player_status.dart | 39 ++++++++-- apps/desktop/lib/src/sync/sync_client.dart | 2 + apps/desktop/test/library_rows_test.dart | 7 +- apps/desktop/test/library_tab_test.dart | 16 +++- apps/desktop/test/player_status_test.dart | 13 ++++ docs/ARCHITECTURE.md | 54 +++++++++++--- docs/PROTOCOL.md | 19 ++++- packages/core/lib/src/constants.dart | 2 + packages/core/lib/src/link_probe.dart | 57 +++++++++++++- packages/core/lib/src/protocol.dart | 23 +++++- packages/core/lib/src/sync_engine.dart | 34 +++++++-- packages/core/test/sync_engine_test.dart | 46 ++++++++++++ packages/core/test/yt_dlp_test.dart | 18 +++++ packages/server/lib/src/server_hub.dart | 2 +- 21 files changed, 445 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 521c508..c2de3b0 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,10 @@ folder to Files, or fill Link). what it is (a page's title, uploader, length and thumbnail, else the file name); in a server, pick how the others get it (everyone fetches it, the server fetches it, or through - you). Your last 12 links are listed under it. + you). Your last 12 links are listed under it. A link that does not + open here (needs cookies, 403, 404, not a video) says why and goes up + only with "Put it up anyway"; in the player, whoever cannot play it is + named ("Bob can't play this: Not allowed (403)") and nobody waits for them. - **Files**: video folders you add on this computer, grouped by series (click a series to list its episodes), or any file through Browse. The others stream it from you. diff --git a/apps/desktop/lib/src/screens/home/picked_media.dart b/apps/desktop/lib/src/screens/home/picked_media.dart index 39e4a40..619f8ff 100644 --- a/apps/desktop/lib/src/screens/home/picked_media.dart +++ b/apps/desktop/lib/src/screens/home/picked_media.dart @@ -16,7 +16,10 @@ enum LinkRelay { direct, server, me } final class LinkMedia extends PickedMedia { final String url; final LinkRelay relay; - const LinkMedia(this.url, {this.relay = LinkRelay.direct}); + /// Put it up even though this computer's probe found a problem + /// (`LinkProbe.problem`): the server or a friend may still open it. + final bool anyway; + const LinkMedia(this.url, {this.relay = LinkRelay.direct, this.anyway = false}); @override String get label { diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 46aba32..a1aed17 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -182,6 +182,10 @@ class SessionCoordinator { final toasts = signal>([]); final reconnecting = signal(false); final starving = signal(null); + /// Why this player cannot open the current media, in plain words + /// ([playErrorText]); empty while it can. It rides in `report.error`, so + /// the server stops waiting for this member and the others see it. + final playerError = signal(''); /// The current media's Jellyfin ref (null for links and files). final currentRef = signal(null); @@ -318,6 +322,12 @@ class SessionCoordinator { int _advanceDoneFor = -1; String _blockedKey = ''; bool _jellyfinListening = false; + // The mediaId [playerError] belongs to, and the player's duration when + // it came: a new duration means the media opened after all. + int _playerErrorFor = -1; + Duration _playerErrorDuration = Duration.zero; + // Members' last `error`, by id, so a new one toasts once. + Map _memberErrors = const {}; SessionCoordinator( {this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp}) @@ -332,8 +342,8 @@ class SessionCoordinator { if (_playerReady) return; try { playback.onFrozen = () => unawaited(reloadVideo(auto: true)); - playback.onYtdlError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't open this link: $e"); - playback.onLoadError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't play this: $e"); + playback.onYtdlError = _setPlayerError; + playback.onLoadError = _setPlayerError; playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6); await playback.init(); _playerReady = true; @@ -345,6 +355,35 @@ class SessionCoordinator { } } + void _setPlayerError(String raw) { + debugPrint('player cannot open media $_mediaId: $raw'); + _playerErrorFor = _mediaId; + _playerErrorDuration = playback.duration.peek(); + playerError.value = playErrorText(raw); + } + + /// Clears [playerError] once the media opened (a new duration) or the + /// server moved to another media. + void _checkPlayerError(int mediaId) { + if (playerError.peek().isEmpty) return; + final d = playback.duration.peek(); + if (_playerErrorFor != mediaId || (d > Duration.zero && d != _playerErrorDuration)) { + playerError.value = ''; + } + } + + /// A member's new `error` toasts once (the pill is hidden while playing). + void _toastMemberErrors(StateBroadcast r) { + final now = {for (final m in r.members) if (m.error.isNotEmpty) m.id: m.error}; + for (final m in r.members) { + final e = now[m.id]; + if (e != null && m.id != sync.memberId && _memberErrors[m.id] != e) { + _pushToast("${m.name} can't play this: $e", actor: m.name); + } + } + _memberErrors = now; + } + bool _reloadingVideo = false; /// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh @@ -398,6 +437,8 @@ class SessionCoordinator { diff.reset(); _mediaId = -1; _openUrl = null; + playerError.value = ''; + _memberErrors = const {}; _lastReady = false; _restoringReady = false; _lastOut = false; @@ -495,8 +536,7 @@ class SessionCoordinator { return true; } catch (e) { debugPrint('open media failed: $e'); - health.value = - HealthStatus(HealthSeverity.error, 'cannot open media: $e'); + _setPlayerError('$e'); return false; } finally { _opening = false; @@ -614,6 +654,8 @@ class SessionCoordinator { void _onState(StateBroadcast r) { _lastStateAt = DateTime.now().toUtc(); serverState.value = r; + _toastMemberErrors(r); + _checkPlayerError(r.mediaId); _maybeSendArt(r); _queuedState = r; if (_applying) return; @@ -917,6 +959,8 @@ 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; + _checkPlayerError(r.mediaId); + final error = playerError.peek(); sync.sendReport( position: _lastOut ? 0 : playback.positionSecs, buffered: _lastOut ? 0 : playback.bufferedSecsAhead, @@ -924,6 +968,7 @@ class SessionCoordinator { duration: known ? playback.durationSecs : 0, mediaId: known ? r.mediaId : 0, jf: _jfAccessFor(r), + error: known && _playerErrorFor == r.mediaId ? error : '', ); if (!_lastOut) { _checkSeekLanded(r); @@ -1244,10 +1289,13 @@ class SessionCoordinator { /// A pasted link: a recognised Jellyfin item link goes up as a ref /// (see [mediaForLink]) that this member relays for viewers without an /// account (with the link's key, which stays here); anything else as the - /// link, or relayed per [relay]. Returns an error text, or null when sent. - Future swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async { + /// link, or relayed per [relay]. A link this computer cannot open (its + /// probe's [LinkProbe.problem], or unreachable) is refused with the + /// reason in plain words unless [anyway]. Returns an error text, or null + /// when sent. + Future swapLink(String url, {LinkRelay relay = LinkRelay.direct, bool anyway = false}) async { markInput(); - _lastPick = LinkMedia(url, relay: relay); + _lastPick = LinkMedia(url, relay: relay, anyway: anyway); final media = await mediaForLink(jellyfin, url); if (media.jellyfin != null) { _pendingOrigin = _JellyfinOrigin(url); @@ -1261,6 +1309,8 @@ class SessionCoordinator { } catch (e) { probeError = '$e'; } + final problem = probe == null ? playErrorText(probeError ?? '') : probe.problem; + if (problem != null && !anyway) return problem; // A page is named by its title, never its URL tail ("1", "watch"). final uri = Uri.tryParse(url) ?? Uri(); final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host); @@ -1271,9 +1321,9 @@ class SessionCoordinator { if (relay == LinkRelay.me) { // This computer reads it: a file, or a page's formats (one file, // separate video and audio tracks, or segmented playlists). - if (probe == null) return 'cannot pass this link on: $probeError'; - final problem = probe.relayProblem; - if (problem != null) return problem; + if (probe == null) return problem; + final relayProblem = probe.relayProblem; + if (relayProblem != null) return relayProblem; final y = await ytDlp.ytDlp; final check = LinkRelayMedia(uri, ytDlp: y, probe: probe); var size = 0; @@ -1352,8 +1402,8 @@ class SessionCoordinator { /// Any picker selection. Future swapPicked(PickedMedia media) async { switch (media) { - case LinkMedia(:final url, :final relay): - return swapLink(url, relay: relay); + case LinkMedia(:final url, :final relay, :final anyway): + return swapLink(url, relay: relay, anyway: anyway); case JellyfinMedia(): swapJellyfin(media); case FileMedia(:final path): diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index 60faff9..3b3ca58 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -379,7 +379,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin if (error != null || !mounted) return error; } // Alone, a link plays direct: there is nobody to pass it on to. - if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url); + if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url, anyway: media.anyway); final error = await session.coordinator.swapPicked(media); if (error != null || !mounted) return error; final f = session.lastForm; diff --git a/apps/desktop/lib/src/shell/avatar.dart b/apps/desktop/lib/src/shell/avatar.dart index efccff7..0d8b08d 100644 --- a/apps/desktop/lib/src/shell/avatar.dart +++ b/apps/desktop/lib/src/shell/avatar.dart @@ -19,6 +19,9 @@ class AvatarInfo { final bool ready, buffering, relaying, stale, host, self; /// Sitting out: dimmed, with an "away" mark. final bool out; + /// Why their player cannot open the media (`members[].error`): an error + /// mark; empty when it can. + final String error; const AvatarInfo({ this.id = '', required this.name, @@ -31,6 +34,7 @@ class AvatarInfo { this.host = false, this.self = false, this.out = false, + this.error = '', }); } @@ -78,7 +82,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) { } /// Your own avatar from the profile (also offline). -AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false}) { +AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) { final p = Profile.instance; final png = p.picture.value; final name = p.name.value; @@ -90,6 +94,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = fal host: host, self: true, out: out, + error: error, ); } @@ -104,7 +109,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator color: self ? Profile.instance.color.value : colorForName(m.name), image: memberAvatarImage(m, coord), ready: m.ready, - buffering: !m.stale && m.ready && playing && m.buffered < r!.bufferNeed, + buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed, relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id, stale: m.stale, // The state marks the host (`members[].host`); your own app also @@ -112,6 +117,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator host: m.host || (self && coord.hostFile != null), self: self, out: m.out, + error: m.error, ); } @@ -138,7 +144,7 @@ class PartyWatch extends StatelessWidget { /// Circle avatar with a status ring outside it and a gap in the background /// colour, so overlapping neighbours never hide the status. Crown = host, /// tower = passing the video on, spinner = buffering, dimmed = stale, -/// dimmed with a moon = sitting out. +/// dimmed with a moon = sitting out, red "!" = cannot play the media. class StatusAvatar extends StatelessWidget { final AvatarInfo info; final double size; @@ -222,6 +228,17 @@ class StatusAvatar extends StatelessWidget { child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200), ), ), + if (info.error.isNotEmpty) + Positioned( + key: const ValueKey('error-mark'), + right: -3, + top: -3, + child: Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration(shape: BoxShape.circle, color: bg), + child: Icon(Icons.error, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.redAccent.shade200), + ), + ), if (badges && info.relaying) Positioned( right: -3, @@ -287,7 +304,9 @@ class _AvatarStackState extends State { left: i * step, top: 0, child: Tooltip( - message: visible[i].name, + message: visible[i].error.isEmpty + ? visible[i].name + : "${visible[i].name} can't play this: ${visible[i].error}", child: StatusAvatar(info: visible[i], size: size), ), ), diff --git a/apps/desktop/lib/src/shell/library_link.dart b/apps/desktop/lib/src/shell/library_link.dart index 065cea1..bfb1845 100644 --- a/apps/desktop/lib/src/shell/library_link.dart +++ b/apps/desktop/lib/src/shell/library_link.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, notAVideoText; +import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText; import '../playback/yt_dlp_app.dart'; import '../screens/home/media_picker.dart'; @@ -107,7 +107,10 @@ class LinkPreview { /// "youtube.com · Channel", or the host. final String subtitle; final String? thumbnail; - const LinkPreview({required this.title, required this.subtitle, this.thumbnail}); + /// Why this computer cannot open it, in plain words ([LinkProbe.problem]); + /// null when it looks playable. + final String? problem; + const LinkPreview({required this.title, required this.subtitle, this.thumbnail, this.problem}); } /// What a link is (see [AppYtDlp.probe]). @@ -125,24 +128,30 @@ String clockText(double seconds) { } /// The preview of [url]: a web page shows yt-dlp's title, uploader, -/// duration and thumbnail (or why it is not a video); a media file (by -/// name or by probe) its file name and host. Null for text that is not an http(s) link. +/// duration and thumbnail; a media file its file name and host; a link +/// that does not open, why ([LinkPreview.problem]). Null for text that is +/// not an http(s) link. Future previewLink(String url, {LinkProber probe = defaultLinkProber}) async { final uri = Uri.tryParse(url.trim()); if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null; final host = _host(uri); - // A media file name needs no lookup. - if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host); + // A Jellyfin item goes up as the item (see `mediaForLink`), not the page. + if (parseJellyfinLink(url.trim()) != null) return LinkPreview(title: host, subtitle: '$host · Jellyfin'); + final file = hasMediaExtension(uri); final LinkProbe p; try { p = await probe(url.trim()); } catch (e) { - return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e'); + final problem = playErrorText('$e'); + return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $problem', problem: problem); } final info = p.info; if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host); - if (info == null) { - return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}'); + final problem = p.problem; + if (info == null || problem != null) { + // A media file name that did not answer as one keeps its name. + final title = file ? LinkMedia(url.trim()).label : p.title ?? host; + return LinkPreview(title: title, subtitle: '$host · ${problem ?? 'Not a video'}', problem: problem); } return LinkPreview( title: p.title ?? host, @@ -221,6 +230,8 @@ class _LibraryLinkSourceState extends State { Timer? _debounce; bool _busy = false; String? _error; + /// [_error] is the probe's problem: "Put it up anyway" is offered. + bool _canForce = false; @override void initState() { @@ -242,7 +253,12 @@ class _LibraryLinkSourceState extends State { if (url == _previewUrl) return; _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 350), () => _preview(url)); - if (mounted) setState(() => _error = null); + if (mounted) { + setState(() { + _error = null; + _canForce = false; + }); + } } Future _preview(String url) async { @@ -263,19 +279,27 @@ class _LibraryLinkSourceState extends State { } catch (_) {} } - Future _go() async { + Future _go({bool anyway = false}) async { final url = _c.link.text.trim(); if (url.isEmpty || _busy) return; setState(() { _busy = true; _error = null; + _canForce = false; }); - String? error; - try { - error = await widget.onPutUp(LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay)); - } catch (e) { - error = '$e'; + // What the probe found refuses it here, with "Put it up anyway": the + // server or a friend (with cookies) may still open it. + final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: widget.probe))?.problem; + String? error = problem; + if (problem == null) { + try { + error = await widget.onPutUp( + LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay, anyway: anyway)); + } catch (e) { + error = '$e'; + } } + final canForce = problem != null; if (error == null) { final p = _previews[url] ?? await previewLink(url, probe: widget.probe); unawaited(widget.recents.add(RecentLink( @@ -289,6 +313,7 @@ class _LibraryLinkSourceState extends State { setState(() { _busy = false; _error = error; + _canForce = canForce; }); } @@ -373,6 +398,14 @@ class _LibraryLinkSourceState extends State { textAlign: TextAlign.center, style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey), ), + if (_canForce) + Center( + child: TextButton( + key: const ValueKey('link-put-up-anyway'), + onPressed: _busy ? null : () => _go(anyway: true), + child: Text(solo ? 'Play it anyway' : 'Put it up anyway'), + ), + ), ], AnimatedSize( duration: easeTime, @@ -475,9 +508,9 @@ class _PreviewCard extends StatelessWidget { const SizedBox(height: 4), Text( p?.subtitle ?? 'Paste an http(s) address', - maxLines: 1, + maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.grey), + style: TextStyle(color: p?.problem != null ? scheme.error : Colors.grey), ), ], ), diff --git a/apps/desktop/lib/src/shell/party_strip.dart b/apps/desktop/lib/src/shell/party_strip.dart index ce96b33..6f10725 100644 --- a/apps/desktop/lib/src/shell/party_strip.dart +++ b/apps/desktop/lib/src/shell/party_strip.dart @@ -103,6 +103,7 @@ class PartyStrip extends StatelessWidget { info: selfAvatarInfo( ready: self?.ready ?? false, host: online && !solo && coord.hostFile != null, + error: online ? coord.playerError.value : '', ), size: stripAvatarSize, ring: online, @@ -553,6 +554,8 @@ class _MemberTile extends StatelessWidget { ? 'reconnecting…' : m.out ? 'sitting out' + : m.error.isNotEmpty + ? "can't play this: ${m.error}" : info.buffering ? 'buffering ${m.buffered.toStringAsFixed(1)} s' : m.ready diff --git a/apps/desktop/lib/src/shell/player_status.dart b/apps/desktop/lib/src/shell/player_status.dart index cbc70d7..70f8c54 100644 --- a/apps/desktop/lib/src/shell/player_status.dart +++ b/apps/desktop/lib/src/shell/player_status.dart @@ -11,11 +11,12 @@ import 'avatar.dart'; import 'motion.dart'; /// What the player's status pill says, most pressing first. -enum StatusKind { reconnecting, problem, connecting, finished, loading, you, waiting } +enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting } -/// The pill's button: stop reconnecting, retry, or mark yourself ready -/// (the whole pill). -enum StatusAction { cancel, retry, ready } +/// The pill's button: stop reconnecting, retry (reconnect), reload the +/// video (your player could not open it), or mark yourself ready (the +/// whole pill). +enum StatusAction { cancel, retry, reload, ready } /// One status line: [text], a muted [detail], a coloured [tone] for /// problems (warn, error), the members it is about ([who]; [self] for you) @@ -55,18 +56,24 @@ 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. +/// connecting has taken over 2 s. [playerError]: why your player cannot +/// open the media (the coordinator's `playerError`). StatusView? playerStatusFor({ required HealthStatus health, bool reconnecting = false, StateBroadcast? state, MemberView? self, bool slowConnect = false, + String playerError = '', }) { if (reconnecting) { return const StatusView(StatusKind.reconnecting, 'Reconnecting…', tone: HealthSeverity.warn, action: StatusAction.cancel); } + if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) { + return StatusView(StatusKind.problem, "Can't play this: $playerError", + tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this"); + } if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) { return StatusView(StatusKind.problem, _sentence(health.message), tone: health.severity, action: StatusAction.retry); @@ -81,6 +88,12 @@ StatusView? playerStatusFor({ if (r.transport == 'finished') { return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished'); } + // Members whose player cannot open it: the server does not wait for + // them; say why instead of "waiting for". + final failed = [ + for (final m in r.members) + if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m, + ]; // As the server holds playback: someone in the ready set short of the // minimum buffer. final need = r.bufferNeed; @@ -92,12 +105,22 @@ StatusView? playerStatusFor({ who: [m], self: you, progress: p, detail: '${(p * 100).round()}%'); } } + if (failed.isNotEmpty) { + final f = failed.first; + final who = nameList([for (final m in failed) m.name]); + final unready = self != null && !self.ready; + return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}", + who: failed, + short: "$who can't play this", + detail: unready ? "Press Space when you're ready" : null, + action: unready ? StatusAction.ready : null); + } if (self != null && !self.ready) { return StatusView(StatusKind.you, "Press Space when you're ready", who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready'); } // Members sitting out are never waited for nor counted. - final active = [for (final m in r.members) if (!m.stale && !m.out) m]; + final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m]; final others = [ for (final m in active) if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m, @@ -117,6 +140,7 @@ StatusView? playerStatusNow({bool slowConnect = false}) { state: c.serverState.value, self: c.selfMember.value, slowConnect: slowConnect, + playerError: c.playerError.value, ); } @@ -164,6 +188,8 @@ class _PlayerStatusState extends State { coord.cancelReconnect(); case StatusAction.retry: coord.retry(); + case StatusAction.reload: + unawaited(coord.reloadVideo()); case StatusAction.ready: coord.toggleReady(); } @@ -266,6 +292,7 @@ class _StatusLine extends StatelessWidget { return null; case StatusKind.loading: case StatusKind.you: + case StatusKind.cannotPlay: case StatusKind.waiting: final r = coord.serverState.value; final infos = [ diff --git a/apps/desktop/lib/src/sync/sync_client.dart b/apps/desktop/lib/src/sync/sync_client.dart index 0014b79..b640f2e 100644 --- a/apps/desktop/lib/src/sync/sync_client.dart +++ b/apps/desktop/lib/src/sync/sync_client.dart @@ -219,6 +219,7 @@ class SyncClient { double duration = 0, int mediaId = 0, Map? jf, + String error = '', }) { final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered; _send( @@ -229,6 +230,7 @@ class SyncClient { duration: duration.isFinite && duration > 0 ? duration : 0, mediaId: mediaId, jf: jf, + error: error, ).toJson(), ); } diff --git a/apps/desktop/test/library_rows_test.dart b/apps/desktop/test/library_rows_test.dart index 3b03cc4..b512577 100644 --- a/apps/desktop/test/library_rows_test.dart +++ b/apps/desktop/test/library_rows_test.dart @@ -202,8 +202,11 @@ void main() { final page = await previewLink('https://example.com/a', probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL')); - expect((page!.title, page.subtitle), - ('Example', "example.com · Not a video (and yt-dlp couldn't read it: Unsupported URL)")); + expect((page!.title, page.subtitle, page.problem), + ('Example', 'example.com · Not a video page yt-dlp knows', 'Not a video page yt-dlp knows')); + final refused = await previewLink('https://cdn.example/b.mp4', + probe: (url) async => LinkProbe.page(Uri.parse(url), ytDlpError: 'odd', status: 403)); + expect((refused!.title, refused.problem), ('b.mp4', 'Not allowed (403)')); expect(await previewLink('not a link'), isNull); }); diff --git a/apps/desktop/test/library_tab_test.dart b/apps/desktop/test/library_tab_test.dart index 163758e..6d74345 100644 --- a/apps/desktop/test/library_tab_test.dart +++ b/apps/desktop/test/library_tab_test.dart @@ -110,7 +110,9 @@ Future pumpLibrary( now: () => fixtureNow, probe: (url) async => url.endsWith('.mkv') ? LinkProbe.file(Uri.parse(url), 1) - : LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')), + : url.contains('bilibili') + ? LinkProbe.page(Uri.parse(url), ytDlpError: 'HTTP Error 412: Precondition Failed') + : LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')), filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) => FilesStub(solo: solo, relayed: relayed, onPlay: onPlay), onPutUp: (m) async { @@ -296,6 +298,18 @@ void main() { await settle(tester, 10); expect(h.controller.link.text, url); + // A link the probe cannot open: the reason, not put up; "anyway" is. + const bili = 'https://www.bilibili.com/video/BV1'; + await tester.enterText(find.byKey(const ValueKey('link-field')), bili); + await settle(tester, 10); + await tester.tap(find.byKey(const ValueKey('link-put-up'))); + await settle(tester, 10); + expect(find.text(needsCookiesText), findsOneWidget); + expect(h.put, hasLength(1)); + await tester.tap(find.byKey(const ValueKey('link-put-up-anyway'))); + await settle(tester, 10); + expect((h.put.last as LinkMedia).anyway, isTrue); + expect(tester.takeException(), isNull); }); diff --git a/apps/desktop/test/player_status_test.dart b/apps/desktop/test/player_status_test.dart index aa03a6e..a42ed7b 100644 --- a/apps/desktop/test/player_status_test.dart +++ b/apps/desktop/test/player_status_test.dart @@ -112,6 +112,19 @@ void main() { expect(w.who.map((m) => m.name), ['Bob', 'Mika']); }); + test("can't play: yours with Retry (reload); others' instead of waiting", () { + var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!; + expect((v.kind, v.text, v.action), (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload)); + final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false, + error: 'Not allowed (403)'); + v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!; + expect((v.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null)); + expect(v.who.single.id, 'b'); + final notMe = mv('me', 'Me'); + v = playerStatusFor(health: ok, state: held([notMe, bob]), self: notMe)!; + expect((v.kind, v.action), (StatusKind.cannotPlay, StatusAction.ready)); + }); + test('names read as a sentence', () { expect(nameList(['Bob']), 'Bob'); expect(nameList(['Bob', 'Mika']), 'Bob and Mika'); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d09588b..ad617a6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -113,8 +113,17 @@ Loosely modeled on Syncplay, with a different readiness design. setting "Unready when the video changes", `AppSettings.unreadyOnSwap`, default off, sends `ready=false` itself on a new `mediaId`; not for the first state of a session, and a reconnect does not restore a ready that - a swap meanwhile would have dropped). Stale, leave and sitting out - remove from the set. + a swap meanwhile would have dropped). Stale, leave, sitting out and a + player error remove from the set. +- **Player error** (`EngineMember.error`, `report.error`, + `state.members[].error`): a member whose player cannot open the current + media says why in its reports; the server takes it out of the set at + once (like sitting out, but automatic: `ready`/`force` do not put it + back, its ready flag is kept), so the others start without it. A report + without it (the media opened, after a reload too) or a swap clears it: + back in the set like a join while not PLAYING; while PLAYING from its + next frame if it is ready (the others then hold for its buffer, as for a + stale member coming back), else from its `ready`. - **Sitting out** (`EngineMember.out`, `state.members[].out`): a member stays connected (chat, feed, party, nudges) but is never in the set and never ready: no `ready` or `force` reaches it, a swap leaves it out, its @@ -263,9 +272,18 @@ or a 5xx is retried, at most 3 times per read and 4 s apart at most; a (older ffmpeg ignores `reconnect_max_retries`). A load that fails (mpv's fatal "No video or audio streams selected", or an EDL track that cannot be opened) before a duration is known is reported once per open -(`onLoadError`) and shows as "can't play this: ..." with ffmpeg's HTTP -status when this player gets ffmpeg's log (ffmpeg logs through one mpv -instance per process, the first one alive). The cache is in RAM: +(`onLoadError`), with ffmpeg's HTTP status when this player gets +ffmpeg's log (ffmpeg logs through one mpv instance per process, the +first one alive). That, a yt-dlp hook error and a failed `openUrl` become +the coordinator's `playerError` in plain words (`playErrorText`, core +`link_probe.dart`: 412/precondition/login/cookies/sign in → "This site +needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed +(403)", 404/410 → "Not found (404)", "Unsupported URL" → "Not a video +page yt-dlp knows", network errors → "Couldn't reach the site", anything +else as is). It rides in `report.error` (see "Player error" in the sync +model) until the media opens (a new duration) or the server's `mediaId` +moves on; the pill shows "Can't play this: ..." with Retry (Reload +video). The cache is in RAM: media_kit sets `cache-on-disk` without a `cache-dir`, and mpv falls back. mpv ignores invalid values silently, so every pin is read back and a mismatch is logged and kept in `pinMismatches`. Measured against the `/m` endpoint @@ -647,15 +665,23 @@ all three link modes. Nothing is bundled or installed by hand. video/audio type, or a generic one on a media file name, is a file (`looksLikeMediaFile`); anything else goes to yt-dlp, and when yt-dlp cannot read it the page's `og:title` / `` is kept with yt-dlp's - reason ("Not a video (and yt-dlp couldn't read it: ...)"). The app - probes on paste (`AppYtDlp.probe`, remembered 10 minutes) 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). + reason and the GET's HTTP status. `LinkProbe.problem`: why it will not + play, in plain words (`playErrorText` of yt-dlp's reason, else of a 4xx + status), null for a file or a page yt-dlp read. The app probes on paste + (`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 + 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`), + for when the server or a friend with cookies may still open it; + `swapLink` refuses it the same way for any other caller. - **Everyone fetches it**: the swap sends the page URL. Before opening an external link every app points mpv's hook at its managed copy (`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`, once per player), waiting for the download unless the URL names a media - file. A hook error shows as "can't open this link: ...". The hook (mpv + file. A hook error is a player error (see the mpv settings under "Media"). The hook (mpv 0.41) gives mpv the first requested format's `http_headers` (User-Agent as `user-agent`; Referer, Cookie, X-Forwarded-For as file-local `http-header-fields`, which win over the empty list media_kit sets on @@ -924,7 +950,13 @@ always scrolls the page). Space never presses a focused button `StateBroadcast.bufferNeed`, the gate's own need at the current position: less near the end, none at finished; their avatar with the progress round it), you not ready (a click marks you ready) and who everyone waits for (avatars, - "2/3 ready"); nothing otherwise, info never. 34 px avatars, a main line + "2/3 ready"); nothing otherwise, info never. Your own `playerError` + comes right after reconnecting ("Can't play this: ...", Retry reloads + the video); members with `members[].error` show as "Bob can't play + this: ..." (before your ready prompt, which then rides in the detail + line) and are never in "waiting for". A member's new error also toasts + once (the pill is hidden while playing); avatars carry a red error mark + and the members card says "can't play this: ...". 34 px avatars, a main line and a muted detail line. 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/ diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index f7ca0ea..b8e3bb8 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -40,7 +40,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`). {"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}} {"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}} {"t":"report","position":123.1,"buffered":6.2,"eventId":41, - "duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}} + "duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"} {"t":"ping","c":1767225600000} {"t":"chat","text":"..."} {"t":"out","target":"<memberId>","out":true} @@ -72,6 +72,17 @@ Field notes: 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 up to 64 characters; others are dropped. +- `report.error` (optional, omitted when empty): short plain text saying + why this client's player cannot open the media `report.mediaId` (e.g. + `Not allowed (403)`). The client sends it in every report while that + lasts and drops it once the media opens (a reload included). The server + keeps it per member (one line, cut to 120 characters) only for the + current `mediaId`: a report without it, or for another `mediaId`, + clears it, and so does a swap. An errored member is out of the ready + gate: out of the set, a `ready` or `force` does not put it back; when + the error clears it is in the set again like a join (unless `playing`, + then from its `ready`). Optional on both sides: older servers ignore it, + older clients ignore `members[].error`. - `ping.c`: the client's clock in epoch ms; answered with `pong`. - `user.avatar`: base64 PNG, at most 256 KiB of characters (else `rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32 @@ -113,7 +124,7 @@ Field notes: "serverTime":"<ISO-8601 UTC>","mediaId":1, "media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent, "members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?, - "host"?,"out"?}], + "host"?,"out"?,"error"?}], "seekBy":"m1","seekSeq":3,"relay":true} {"t":"pong","c":1767225600000,"s":1767225600012} {"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"} @@ -145,7 +156,9 @@ Field notes: (omitted when the member has no picture) names the picture's bytes (see "Avatars"). `members[].host` (omitted when false): the member sent the server's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when - false): the member sits out (see `out`). + false): the member sits out (see `out`). `members[].error` (omitted when + empty): the member's player cannot open the current media (see + `report.error`). - `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms (the clock `serverTime` uses). With `t3` the local receive time, the offset is `s - (c + t3) / 2`, accurate to half the round trip. diff --git a/packages/core/lib/src/constants.dart b/packages/core/lib/src/constants.dart index 898e00d..833e695 100644 --- a/packages/core/lib/src/constants.dart +++ b/packages/core/lib/src/constants.dart @@ -50,6 +50,8 @@ abstract class RelayConstants { static const int maxBannerBytes = 768 * 1024; static const int maxNameLength = 32; static const int maxChatLength = 500; + /// Characters of `report.error` the server keeps. + static const int maxMemberErrorLength = 120; /// Nudges one member may send: at most this many per second and per /// minute; the server drops the rest silently. static const int nudgesPerSec = 3; diff --git a/packages/core/lib/src/link_probe.dart b/packages/core/lib/src/link_probe.dart index c334bb5..4395b66 100644 --- a/packages/core/lib/src/link_probe.dart +++ b/packages/core/lib/src/link_probe.dart @@ -75,6 +75,38 @@ String notAVideoText(String? ytDlpError) => /// The text for a page with no video a relay can read. const noRelayFormatText = "This page has no video a relay can read. Choose 'Everyone fetches it' instead."; +/// The plain-words texts of [playErrorText]. +const needsCookiesText = 'This site needs cookies — Settings → Player → yt-dlp → Cookies'; +const notAllowedText = 'Not allowed (403)'; +const notFoundText = 'Not found (404)'; +const unsupportedUrlText = 'Not a video page yt-dlp knows'; +const unreachableText = "Couldn't reach the site"; +const _plainTexts = {needsCookiesText, notAllowedText, notFoundText, unsupportedUrlText, unreachableText}; + +final _cookiesHint = RegExp(r'\b412\b|precondition|log ?in\b|logged|cookies|sign ?in\b', caseSensitive: false); +final _forbidden = RegExp(r'(^|[\s(:])403\b|forbidden', caseSensitive: false); +final _notFound = RegExp(r'(^|[\s(:])404\b|\b410\b|not found', caseSensitive: false); +final _unreachable = RegExp( + r'SocketException|HandshakeException|TimeoutException|timed out|failed host lookup|connection (refused|reset|closed)|' + r'network is unreachable|no route to host|name or service not known|getaddrinfo|unable to download webpage', + caseSensitive: false); + +/// Why a link or media cannot be played, in plain words, from the raw +/// error of yt-dlp, ffmpeg/mpv or the link probe; anything it does not +/// know comes back as is (one line, at most 100 characters). +String playErrorText(String raw) { + final s = raw.trim(); + // First: its message quotes the URL, which may say anything. + if (s.toLowerCase().contains('unsupported url')) return unsupportedUrlText; + if (_cookiesHint.hasMatch(s)) return needsCookiesText; + if (_forbidden.hasMatch(s)) return notAllowedText; + if (_notFound.hasMatch(s)) return notFoundText; + if (_unreachable.hasMatch(s)) return unreachableText; + final line = s.replaceAll(RegExp(r'\s+'), ' '); + if (line.isEmpty) return "Couldn't open it"; + return line.length > 100 ? '${line.substring(0, 99)}…' : line; +} + class LinkProbeException implements Exception { final String message; const LinkProbeException(this.message); @@ -94,15 +126,30 @@ class LinkProbe { final String? pageTitle; /// Why yt-dlp could not read the page. final String? ytDlpError; + /// The HTTP status of the probe's GET; 0 when unknown. + final int status; const LinkProbe.file(this.url, this.size) : isPage = false, info = null, pageTitle = null, - ytDlpError = null; - const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError}) + ytDlpError = null, + status = 0; + const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.status = 0}) : isPage = true, size = -1; + /// Why nobody may be able to play this link ([playErrorText]), or null + /// when it looks playable: a page yt-dlp could not read, or a link + /// answering 4xx. + String? get problem { + if (!isPage || info != null) return null; + final err = ytDlpError == null ? null : playErrorText(ytDlpError!); + // A 4xx says more than yt-dlp's words it has no plain text for. + final known = err != null && _plainTexts.contains(err); + if (!known && status >= 400 && status < 500) return playErrorText('HTTP Error $status'); + return err; + } + /// The name to show: yt-dlp's title, the page's, else the host; null /// for a file (named by its file name). String? get title { @@ -134,12 +181,14 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async { try { String? body; Object? getError; + var status = 0; try { final req = await c.getUrl(url).timeout(const Duration(seconds: 15)); req.headers ..set(HttpHeaders.rangeHeader, 'bytes=0-0') ..set(HttpHeaders.acceptEncodingHeader, 'identity'); final res = await req.close().timeout(const Duration(seconds: 15)); + status = res.statusCode; final type = res.headers.contentType?.mimeType ?? ''; if (looksLikeMediaFile(status: res.statusCode, contentType: type, url: url)) { await res.drain<void>().catchError((_) {}); @@ -155,7 +204,7 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async { String? ytErr; if (ytDlp != null) { try { - return LinkProbe.page(url, info: await ytDlp.resolve(url.toString())); + return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()), status: status); } catch (e) { ytErr = '$e'; } @@ -170,7 +219,7 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async { body = await _readText(await req.close().timeout(const Duration(seconds: 15))); } catch (_) {} } - return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr); + return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr, status: status); } finally { if (client == null) c.close(force: true); } diff --git a/packages/core/lib/src/protocol.dart b/packages/core/lib/src/protocol.dart index b4ec910..5435ea6 100644 --- a/packages/core/lib/src/protocol.dart +++ b/packages/core/lib/src/protocol.dart @@ -435,13 +435,17 @@ class ReportMessage { /// Jellyfin access by `ServerId`: true = plays with its own account, /// false = through the server's relay. Null leaves the last one in place. final Map<String, bool>? jf; + /// Why the player cannot open [mediaId] (short plain text); empty while + /// it can. + final String error; const ReportMessage( {required this.position, required this.buffered, required this.eventId, this.duration = 0, this.mediaId = 0, - this.jf}); + this.jf, + this.error = ''}); Map<String, Object?> toJson() => { 't': 'report', @@ -451,6 +455,7 @@ class ReportMessage { if (duration > 0) 'duration': duration, if (mediaId != 0) 'mediaId': mediaId, if (jf != null) 'jf': jf, + if (error.isNotEmpty) 'error': error, }; factory ReportMessage.fromJson(Map<String, Object?> json) { @@ -462,10 +467,20 @@ class ReportMessage { duration: finiteDouble(json['duration'], 'duration'), mediaId: finiteInt(json['mediaId'], 'mediaId'), jf: jfAccessFromJson(json['jf']), + error: memberErrorFromJson(json['error']), ); } } +/// Reads a `report.error` / `members[].error`: trimmed, single line, at +/// most [RelayConstants.maxMemberErrorLength] characters; empty otherwise. +String memberErrorFromJson(Object? value) { + if (value is! String) return ''; + final line = value.replaceAll(RegExp(r'\s+'), ' ').trim(); + const max = RelayConstants.maxMemberErrorLength; + return line.length > max ? line.substring(0, max) : line; +} + /// Reads a `jf` map (`{serverId: bool}`), dropping non-bool entries and /// keys over 64 characters, at most 8 entries (it rides in every `state`); /// null when absent or not a map. @@ -582,6 +597,9 @@ class MemberView { final bool host; /// Sitting out: connected, outside the ready gate, not playing. final bool out; + /// Why its player cannot open the current media (`report.error`); empty + /// while it can. Errored: outside the ready gate. + final String error; const MemberView({ required this.id, required this.name, @@ -593,6 +611,7 @@ class MemberView { this.jf = const {}, this.host = false, this.out = false, + this.error = '', }); Map<String, Object?> toJson() => { @@ -606,6 +625,7 @@ class MemberView { if (jf.isNotEmpty) 'jf': jf, if (host) 'host': true, if (out) 'out': true, + if (error.isNotEmpty) 'error': error, }; factory MemberView.fromJson(Map<String, Object?> json) => MemberView( @@ -619,6 +639,7 @@ class MemberView { jf: jfAccessFromJson(json['jf']) ?? const {}, host: json['host'] == true, out: json['out'] == true, + error: memberErrorFromJson(json['error']), ); } diff --git a/packages/core/lib/src/sync_engine.dart b/packages/core/lib/src/sync_engine.dart index e40e7ce..e1252c1 100644 --- a/packages/core/lib/src/sync_engine.dart +++ b/packages/core/lib/src/sync_engine.dart @@ -56,6 +56,9 @@ class EngineMember { bool host = false; /// Sitting out: never in the set, never ready, its buffer ignored. bool out = false; + /// Its player cannot open the current media (`report.error`): outside + /// the ready gate until a report clears it or the media changes. + String error = ''; DateTime lastSeen; EngineMember({ required this.id, @@ -97,6 +100,7 @@ class EngineMember { jf: jf, host: host, out: out, + error: error, ); } @@ -197,6 +201,7 @@ class SyncEngine { final wasStale = isStale(member); member.lastSeen = now; if (!member.out && + member.error.isEmpty && !member.inSet && (member.ready || (wasStale && transport != Transport.playing))) { member.inSet = true; @@ -307,12 +312,12 @@ class SyncEngine { _seen(member); // Sitting out: back first ([applyOut]), then ready. if (member.out) return; - if (member.ready == ready && member.inSet) { + if (member.ready == ready && (member.inSet || member.error.isNotEmpty)) { evaluate(); return; } member.ready = ready; - if (ready) member.inSet = true; + if (ready && member.error.isEmpty) member.inSet = true; feed.add(FeedMessage( actor: member.name, action: ready ? 'ready' : 'unready', @@ -328,13 +333,13 @@ class SyncEngine { for (final member in members.values) { if (member.out) continue; member.ready = ready; - if (ready) member.inSet = true; + if (ready && member.error.isEmpty) member.inSet = true; } } else { final member = byId(target); if (member == null || member.out) return; member.ready = ready; - if (ready) member.inSet = true; + if (ready && member.error.isEmpty) member.inSet = true; feed.add(FeedMessage( actor: actorName, action: ready ? 'force-ready' : 'force-unready', @@ -366,7 +371,7 @@ class SyncEngine { member.out = out; member.ready = false; member.buffered = 0; - member.inSet = !out && transport != Transport.playing; + member.inSet = !out && member.error.isEmpty && transport != Transport.playing; feed.add(FeedMessage( actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now)); evaluate(); @@ -408,6 +413,7 @@ class SyncEngine { // (a stale member rejoins the set with its next frame if still ready). final t = now; for (final member in members.values) { + member.error = ''; member.inSet = !member.out && !isStale(member, t); member.buffered = 0; } @@ -416,10 +422,11 @@ class SyncEngine { } void applyReport(String token, double reportPosition, double buffered, int reportEventId, - {double duration = 0, int mediaId = 0, Map<String, bool>? jf}) { + {double duration = 0, int mediaId = 0, Map<String, bool>? jf, String error = ''}) { final member = members[token]; if (member == null) return; if (!reportPosition.isFinite) return; + _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; @@ -433,6 +440,21 @@ class SyncEngine { evaluate(); } + /// An error takes the member out of the set (the gate stops waiting for + /// it); clearing it puts it back like a join: in the set unless playing + /// (then from its next `ready`). + void _setError(EngineMember member, String error) { + if (member.error == error) return; + member.error = error; + if (error.isNotEmpty) { + member.inSet = false; + } else if (!member.out) { + member.inSet = transport != Transport.playing; + } + // Leaving the set may let the others start. + evaluate(); + } + void sweepStale() { var changed = false; for (final member in members.values) { diff --git a/packages/core/test/sync_engine_test.dart b/packages/core/test/sync_engine_test.dart index b7dbae1..45b9829 100644 --- a/packages/core/test/sync_engine_test.dart +++ b/packages/core/test/sync_engine_test.dart @@ -85,6 +85,52 @@ void main() { }); }); + group('player errors', () { + void fail(String token, String error, {int? mediaId}) => engine.applyReport( + token, engine.positionAt(), 0, engine.eventId, + mediaId: mediaId ?? engine.mediaId, error: error); + + test('an errored member leaves the gate; clearing puts it back', () { + join('a', 'alice'); + join('b', 'bob'); + engine.applyReady('a', true); + engine.applyReady('b', true); + buffer('a', 4.0); + expect(engine.transport, Transport.held, reason: 'bob has no buffer'); + fail('b', "Not allowed (403)"); + final b = engine.members['b']!; + expect([b.error, b.inSet], ['Not allowed (403)', false]); + expect(engine.transport, Transport.playing, reason: 'alice plays alone'); + final view = engine.broadcast().members; + expect(view.last.toJson()['error'], 'Not allowed (403)'); + expect(view.first.toJson().containsKey('error'), isFalse); + engine.applyReady('b', true); + expect(b.inSet, isFalse, reason: 'ready does not bring it back while errored'); + // A report of another media does not set it; a clean one clears it. + fail('b', 'x', mediaId: engine.mediaId - 1); + expect(b.error, isEmpty); + expect(b.inSet, isTrue, reason: 'ready and playing: back like a stale return'); + expect(engine.transport, Transport.held, reason: 'bob is buffering'); + }); + + test('a swap clears it; held, clearing puts it in the set', () { + join('a', 'alice'); + join('b', 'bob'); + fail('b', 'Not found (404)'); + engine.applySwap('a', const HelloMedia(name: 'n', size: 1, duration: 0, url: 'u')); + final b = engine.members['b']!; + expect([b.error, b.inSet], ['', true]); + fail('b', 'Not found (404)'); + expect(b.inSet, isFalse); + buffer('b', 4.0); + expect([b.error, b.inSet], ['', true]); + expect(ReportMessage.fromJson({ + 't': 'report', 'position': 0, 'buffered': 0, 'eventId': 0, + 'error': ' a\nb ${'x' * 200}'}).error.length, + RelayConstants.maxMemberErrorLength); + }); + }); + group('membership + gate', () { test('join while held lands in the set, playback stays held', () { join('a', 'alice'); diff --git a/packages/core/test/yt_dlp_test.dart b/packages/core/test/yt_dlp_test.dart index 1c198fb..825658d 100644 --- a/packages/core/test/yt_dlp_test.dart +++ b/packages/core/test/yt_dlp_test.dart @@ -115,6 +115,24 @@ void main() { }); }); + test('play errors in plain words; a probe refuses unread pages and 4xx links', () { + expect(playErrorText('[BiliBili] BV1x: Unable to download webpage: HTTP Error 412: Precondition Failed'), + needsCookiesText); + expect(playErrorText('Sign in to confirm your age'), needsCookiesText); + expect(playErrorText('the video server answered HTTP error 403 Forbidden'), notAllowedText); + expect(playErrorText('HTTP Error 404: Not Found'), notFoundText); + expect(playErrorText('Unsupported URL: https://example.com/login/403'), unsupportedUrlText); + expect(playErrorText('cannot open the link: SocketException: Failed host lookup'), unreachableText); + expect(playErrorText('the video stream could not be opened'), 'the video stream could not be opened'); + expect(playErrorText(notAllowedText), notAllowedText); + final u = Uri.parse('https://x/v.mp4'); + expect(LinkProbe.page(u, ytDlpError: 'HTTP Error 412: Precondition Failed').problem, needsCookiesText); + expect(LinkProbe.page(u, ytDlpError: 'odd failure', status: 403).problem, notAllowedText); + expect(LinkProbe.page(u, ytDlpError: 'odd failure').problem, 'odd failure'); + expect(LinkProbe.page(u, info: YtDlpInfo.parse({'title': 'x', 'formats': []}), status: 404).problem, isNull); + expect(LinkProbe.file(u, 10).problem, isNull); + }); + test('viewer URL: one track plain, several an mpv EDL with %length% framing', () { expect(relayViewerUrl('http://h:1/r/default/t/3', 'a/b c'), 'http://h:1/r/default/t/3/a_b%20c'); const v = 'http://h:1/r/default/t/3/0/x', a = 'http://h:1/r/default/t/3/1/x'; diff --git a/packages/server/lib/src/server_hub.dart b/packages/server/lib/src/server_hub.dart index dffe568..a09a1a5 100644 --- a/packages/server/lib/src/server_hub.dart +++ b/packages/server/lib/src/server_hub.dart @@ -428,7 +428,7 @@ void mountSyncRoute( final report = ReportMessage.fromJson(decoded); hub.engine.applyReport( token, report.position, report.buffered, report.eventId, - duration: report.duration, mediaId: report.mediaId, jf: report.jf); + duration: report.duration, mediaId: report.mediaId, jf: report.jf, error: report.error); hub.changed(); case 'ping': final ping = PingMessage.fromJson(decoded);