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 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-10 21:29:41 +03:00
1 parent 1b04e002c2
commit d3be13ecda
21 files changed
+445 -72

No files matched your search

+4 -1
View File
@@ -145,7 +145,10 @@ folder to Files, or fill Link).
what it is (a page's title, uploader, length and thumbnail, else the what it is (a page's title, uploader, length and thumbnail, else the
file name); in a server, pick how the file name); in a server, pick how the
others get it (everyone fetches it, the server fetches it, or through 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 - **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. (click a series to list its episodes), or any file through Browse. The others stream it from you.
@@ -16,7 +16,10 @@ enum LinkRelay { direct, server, me }
final class LinkMedia extends PickedMedia { final class LinkMedia extends PickedMedia {
final String url; final String url;
final LinkRelay relay; 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 @override
String get label { String get label {
@@ -182,6 +182,10 @@ class SessionCoordinator {
final toasts = signal<List<ToastItem>>([]); final toasts = signal<List<ToastItem>>([]);
final reconnecting = signal(false); final reconnecting = signal(false);
final starving = signal<String?>(null); final starving = signal<String?>(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). /// The current media's Jellyfin ref (null for links and files).
final currentRef = signal<JellyfinRef?>(null); final currentRef = signal<JellyfinRef?>(null);
@@ -318,6 +322,12 @@ class SessionCoordinator {
int _advanceDoneFor = -1; int _advanceDoneFor = -1;
String _blockedKey = ''; String _blockedKey = '';
bool _jellyfinListening = false; 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<String, String> _memberErrors = const {};
SessionCoordinator( SessionCoordinator(
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp}) {this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
@@ -332,8 +342,8 @@ class SessionCoordinator {
if (_playerReady) return; if (_playerReady) return;
try { try {
playback.onFrozen = () => unawaited(reloadVideo(auto: true)); playback.onFrozen = () => unawaited(reloadVideo(auto: true));
playback.onYtdlError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't open this link: $e"); playback.onYtdlError = _setPlayerError;
playback.onLoadError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't play this: $e"); playback.onLoadError = _setPlayerError;
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6); playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
await playback.init(); await playback.init();
_playerReady = true; _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; bool _reloadingVideo = false;
/// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh /// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh
@@ -398,6 +437,8 @@ class SessionCoordinator {
diff.reset(); diff.reset();
_mediaId = -1; _mediaId = -1;
_openUrl = null; _openUrl = null;
playerError.value = '';
_memberErrors = const {};
_lastReady = false; _lastReady = false;
_restoringReady = false; _restoringReady = false;
_lastOut = false; _lastOut = false;
@@ -495,8 +536,7 @@ class SessionCoordinator {
return true; return true;
} catch (e) { } catch (e) {
debugPrint('open media failed: $e'); debugPrint('open media failed: $e');
health.value = _setPlayerError('$e');
HealthStatus(HealthSeverity.error, 'cannot open media: $e');
return false; return false;
} finally { } finally {
_opening = false; _opening = false;
@@ -614,6 +654,8 @@ class SessionCoordinator {
void _onState(StateBroadcast r) { void _onState(StateBroadcast r) {
_lastStateAt = DateTime.now().toUtc(); _lastStateAt = DateTime.now().toUtc();
serverState.value = r; serverState.value = r;
_toastMemberErrors(r);
_checkPlayerError(r.mediaId);
_maybeSendArt(r); _maybeSendArt(r);
_queuedState = r; _queuedState = r;
if (_applying) return; if (_applying) return;
@@ -917,6 +959,8 @@ class SessionCoordinator {
// Duration only once the open media is the server's current one. // Duration only once the open media is the server's current one.
// Sitting out: still a report (it keeps this member fresh), empty. // Sitting out: still a report (it keeps this member fresh), empty.
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId; final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
_checkPlayerError(r.mediaId);
final error = playerError.peek();
sync.sendReport( sync.sendReport(
position: _lastOut ? 0 : playback.positionSecs, position: _lastOut ? 0 : playback.positionSecs,
buffered: _lastOut ? 0 : playback.bufferedSecsAhead, buffered: _lastOut ? 0 : playback.bufferedSecsAhead,
@@ -924,6 +968,7 @@ class SessionCoordinator {
duration: known ? playback.durationSecs : 0, duration: known ? playback.durationSecs : 0,
mediaId: known ? r.mediaId : 0, mediaId: known ? r.mediaId : 0,
jf: _jfAccessFor(r), jf: _jfAccessFor(r),
error: known && _playerErrorFor == r.mediaId ? error : '',
); );
if (!_lastOut) { if (!_lastOut) {
_checkSeekLanded(r); _checkSeekLanded(r);
@@ -1244,10 +1289,13 @@ class SessionCoordinator {
/// A pasted link: a recognised Jellyfin item link goes up as a ref /// A pasted link: a recognised Jellyfin item link goes up as a ref
/// (see [mediaForLink]) that this member relays for viewers without an /// (see [mediaForLink]) that this member relays for viewers without an
/// account (with the link's key, which stays here); anything else as the /// account (with the link's key, which stays here); anything else as the
/// link, or relayed per [relay]. Returns an error text, or null when sent. /// link, or relayed per [relay]. A link this computer cannot open (its
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async { /// 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<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct, bool anyway = false}) async {
markInput(); markInput();
_lastPick = LinkMedia(url, relay: relay); _lastPick = LinkMedia(url, relay: relay, anyway: anyway);
final media = await mediaForLink(jellyfin, url); final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) { if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url); _pendingOrigin = _JellyfinOrigin(url);
@@ -1261,6 +1309,8 @@ class SessionCoordinator {
} catch (e) { } catch (e) {
probeError = '$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"). // A page is named by its title, never its URL tail ("1", "watch").
final uri = Uri.tryParse(url) ?? Uri(); final uri = Uri.tryParse(url) ?? Uri();
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host); 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) { if (relay == LinkRelay.me) {
// This computer reads it: a file, or a page's formats (one file, // This computer reads it: a file, or a page's formats (one file,
// separate video and audio tracks, or segmented playlists). // separate video and audio tracks, or segmented playlists).
if (probe == null) return 'cannot pass this link on: $probeError'; if (probe == null) return problem;
final problem = probe.relayProblem; final relayProblem = probe.relayProblem;
if (problem != null) return problem; if (relayProblem != null) return relayProblem;
final y = await ytDlp.ytDlp; final y = await ytDlp.ytDlp;
final check = LinkRelayMedia(uri, ytDlp: y, probe: probe); final check = LinkRelayMedia(uri, ytDlp: y, probe: probe);
var size = 0; var size = 0;
@@ -1352,8 +1402,8 @@ class SessionCoordinator {
/// Any picker selection. /// Any picker selection.
Future<String?> swapPicked(PickedMedia media) async { Future<String?> swapPicked(PickedMedia media) async {
switch (media) { switch (media) {
case LinkMedia(:final url, :final relay): case LinkMedia(:final url, :final relay, :final anyway):
return swapLink(url, relay: relay); return swapLink(url, relay: relay, anyway: anyway);
case JellyfinMedia(): case JellyfinMedia():
swapJellyfin(media); swapJellyfin(media);
case FileMedia(:final path): case FileMedia(:final path):
+1 -1
View File
@@ -379,7 +379,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (error != null || !mounted) return error; if (error != null || !mounted) return error;
} }
// Alone, a link plays direct: there is nobody to pass it on to. // 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); final error = await session.coordinator.swapPicked(media);
if (error != null || !mounted) return error; if (error != null || !mounted) return error;
final f = session.lastForm; final f = session.lastForm;
+23 -4
View File
@@ -19,6 +19,9 @@ class AvatarInfo {
final bool ready, buffering, relaying, stale, host, self; final bool ready, buffering, relaying, stale, host, self;
/// Sitting out: dimmed, with an "away" mark. /// Sitting out: dimmed, with an "away" mark.
final bool out; final bool out;
/// Why their player cannot open the media (`members[].error`): an error
/// mark; empty when it can.
final String error;
const AvatarInfo({ const AvatarInfo({
this.id = '', this.id = '',
required this.name, required this.name,
@@ -31,6 +34,7 @@ class AvatarInfo {
this.host = false, this.host = false,
this.self = false, this.self = false,
this.out = false, this.out = false,
this.error = '',
}); });
} }
@@ -78,7 +82,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
} }
/// Your own avatar from the profile (also offline). /// 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 p = Profile.instance;
final png = p.picture.value; final png = p.picture.value;
final name = p.name.value; final name = p.name.value;
@@ -90,6 +94,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = fal
host: host, host: host,
self: true, self: true,
out: out, out: out,
error: error,
); );
} }
@@ -104,7 +109,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
color: self ? Profile.instance.color.value : colorForName(m.name), color: self ? Profile.instance.color.value : colorForName(m.name),
image: memberAvatarImage(m, coord), image: memberAvatarImage(m, coord),
ready: m.ready, 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, relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
stale: m.stale, stale: m.stale,
// The state marks the host (`members[].host`); your own app also // 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), host: m.host || (self && coord.hostFile != null),
self: self, self: self,
out: m.out, 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 /// Circle avatar with a status ring outside it and a gap in the background
/// colour, so overlapping neighbours never hide the status. Crown = host, /// colour, so overlapping neighbours never hide the status. Crown = host,
/// tower = passing the video on, spinner = buffering, dimmed = stale, /// 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 { class StatusAvatar extends StatelessWidget {
final AvatarInfo info; final AvatarInfo info;
final double size; 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), 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) if (badges && info.relaying)
Positioned( Positioned(
right: -3, right: -3,
@@ -287,7 +304,9 @@ class _AvatarStackState extends State<AvatarStack> {
left: i * step, left: i * step,
top: 0, top: 0,
child: Tooltip( 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), child: StatusAvatar(info: visible[i], size: size),
), ),
), ),
+51 -18
View File
@@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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 '../playback/yt_dlp_app.dart';
import '../screens/home/media_picker.dart'; import '../screens/home/media_picker.dart';
@@ -107,7 +107,10 @@ class LinkPreview {
/// "youtube.com · Channel", or the host. /// "youtube.com · Channel", or the host.
final String subtitle; final String subtitle;
final String? thumbnail; 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]). /// 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, /// 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 /// duration and thumbnail; a media file its file name and host; a link
/// name or by probe) its file name and host. Null for text that is not an http(s) link. /// that does not open, why ([LinkPreview.problem]). Null for text that is
/// not an http(s) link.
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async { Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
final uri = Uri.tryParse(url.trim()); final uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null; if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
final host = _host(uri); final host = _host(uri);
// A media file name needs no lookup. // A Jellyfin item goes up as the item (see `mediaForLink`), not the page.
if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host); if (parseJellyfinLink(url.trim()) != null) return LinkPreview(title: host, subtitle: '$host · Jellyfin');
final file = hasMediaExtension(uri);
final LinkProbe p; final LinkProbe p;
try { try {
p = await probe(url.trim()); p = await probe(url.trim());
} catch (e) { } 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; final info = p.info;
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host); if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
if (info == null) { final problem = p.problem;
return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}'); 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( return LinkPreview(
title: p.title ?? host, title: p.title ?? host,
@@ -221,6 +230,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Timer? _debounce; Timer? _debounce;
bool _busy = false; bool _busy = false;
String? _error; String? _error;
/// [_error] is the probe's problem: "Put it up anyway" is offered.
bool _canForce = false;
@override @override
void initState() { void initState() {
@@ -242,7 +253,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (url == _previewUrl) return; if (url == _previewUrl) return;
_debounce?.cancel(); _debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url)); _debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
if (mounted) setState(() => _error = null); if (mounted) {
setState(() {
_error = null;
_canForce = false;
});
}
} }
Future<void> _preview(String url) async { Future<void> _preview(String url) async {
@@ -263,19 +279,27 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
} catch (_) {} } catch (_) {}
} }
Future<void> _go() async { Future<void> _go({bool anyway = false}) async {
final url = _c.link.text.trim(); final url = _c.link.text.trim();
if (url.isEmpty || _busy) return; if (url.isEmpty || _busy) return;
setState(() { setState(() {
_busy = true; _busy = true;
_error = null; _error = null;
_canForce = false;
}); });
String? error; // What the probe found refuses it here, with "Put it up anyway": the
try { // server or a friend (with cookies) may still open it.
error = await widget.onPutUp(LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay)); final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: widget.probe))?.problem;
} catch (e) { String? error = problem;
error = '$e'; 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) { if (error == null) {
final p = _previews[url] ?? await previewLink(url, probe: widget.probe); final p = _previews[url] ?? await previewLink(url, probe: widget.probe);
unawaited(widget.recents.add(RecentLink( unawaited(widget.recents.add(RecentLink(
@@ -289,6 +313,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
setState(() { setState(() {
_busy = false; _busy = false;
_error = error; _error = error;
_canForce = canForce;
}); });
} }
@@ -373,6 +398,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey), 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( AnimatedSize(
duration: easeTime, duration: easeTime,
@@ -475,9 +508,9 @@ class _PreviewCard extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
p?.subtitle ?? 'Paste an http(s) address', p?.subtitle ?? 'Paste an http(s) address',
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey), style: TextStyle(color: p?.problem != null ? scheme.error : Colors.grey),
), ),
], ],
), ),
@@ -103,6 +103,7 @@ class PartyStrip extends StatelessWidget {
info: selfAvatarInfo( info: selfAvatarInfo(
ready: self?.ready ?? false, ready: self?.ready ?? false,
host: online && !solo && coord.hostFile != null, host: online && !solo && coord.hostFile != null,
error: online ? coord.playerError.value : '',
), ),
size: stripAvatarSize, size: stripAvatarSize,
ring: online, ring: online,
@@ -553,6 +554,8 @@ class _MemberTile extends StatelessWidget {
? 'reconnecting…' ? 'reconnecting…'
: m.out : m.out
? 'sitting out' ? 'sitting out'
: m.error.isNotEmpty
? "can't play this: ${m.error}"
: info.buffering : info.buffering
? 'buffering ${m.buffered.toStringAsFixed(1)} s' ? 'buffering ${m.buffered.toStringAsFixed(1)} s'
: m.ready : m.ready
+33 -6
View File
@@ -11,11 +11,12 @@ import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
/// What the player's status pill says, most pressing first. /// 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 pill's button: stop reconnecting, retry (reconnect), reload the
/// (the whole pill). /// video (your player could not open it), or mark yourself ready (the
enum StatusAction { cancel, retry, ready } /// whole pill).
enum StatusAction { cancel, retry, reload, ready }
/// One status line: [text], a muted [detail], a coloured [tone] for /// One status line: [text], a muted [detail], a coloured [tone] for
/// problems (warn, error), the members it is about ([who]; [self] for you) /// 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 /// The player's status from the session's signals; null when there is
/// nothing to say (playing, connected, everyone ready). [slowConnect]: /// 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({ StatusView? playerStatusFor({
required HealthStatus health, required HealthStatus health,
bool reconnecting = false, bool reconnecting = false,
StateBroadcast? state, StateBroadcast? state,
MemberView? self, MemberView? self,
bool slowConnect = false, bool slowConnect = false,
String playerError = '',
}) { }) {
if (reconnecting) { if (reconnecting) {
return const StatusView(StatusKind.reconnecting, 'Reconnecting…', return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel); 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) { if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
return StatusView(StatusKind.problem, _sentence(health.message), return StatusView(StatusKind.problem, _sentence(health.message),
tone: health.severity, action: StatusAction.retry); tone: health.severity, action: StatusAction.retry);
@@ -81,6 +88,12 @@ StatusView? playerStatusFor({
if (r.transport == 'finished') { if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: '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 // As the server holds playback: someone in the ready set short of the
// minimum buffer. // minimum buffer.
final need = r.bufferNeed; final need = r.bufferNeed;
@@ -92,12 +105,22 @@ StatusView? playerStatusFor({
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%'); 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) { if (self != null && !self.ready) {
return StatusView(StatusKind.you, "Press Space when you're 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'); 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. // 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 = [ final others = [
for (final m in active) for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m, 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, state: c.serverState.value,
self: c.selfMember.value, self: c.selfMember.value,
slowConnect: slowConnect, slowConnect: slowConnect,
playerError: c.playerError.value,
); );
} }
@@ -164,6 +188,8 @@ class _PlayerStatusState extends State<PlayerStatus> {
coord.cancelReconnect(); coord.cancelReconnect();
case StatusAction.retry: case StatusAction.retry:
coord.retry(); coord.retry();
case StatusAction.reload:
unawaited(coord.reloadVideo());
case StatusAction.ready: case StatusAction.ready:
coord.toggleReady(); coord.toggleReady();
} }
@@ -266,6 +292,7 @@ class _StatusLine extends StatelessWidget {
return null; return null;
case StatusKind.loading: case StatusKind.loading:
case StatusKind.you: case StatusKind.you:
case StatusKind.cannotPlay:
case StatusKind.waiting: case StatusKind.waiting:
final r = coord.serverState.value; final r = coord.serverState.value;
final infos = [ final infos = [
@@ -219,6 +219,7 @@ class SyncClient {
double duration = 0, double duration = 0,
int mediaId = 0, int mediaId = 0,
Map<String, bool>? jf, Map<String, bool>? jf,
String error = '',
}) { }) {
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered; final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
_send( _send(
@@ -229,6 +230,7 @@ class SyncClient {
duration: duration.isFinite && duration > 0 ? duration : 0, duration: duration.isFinite && duration > 0 ? duration : 0,
mediaId: mediaId, mediaId: mediaId,
jf: jf, jf: jf,
error: error,
).toJson(), ).toJson(),
); );
} }
+5 -2
View File
@@ -202,8 +202,11 @@ void main() {
final page = await previewLink('https://example.com/a', final page = await previewLink('https://example.com/a',
probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL')); probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL'));
expect((page!.title, page.subtitle), expect((page!.title, page.subtitle, page.problem),
('Example', "example.com · Not a video (and yt-dlp couldn't read it: Unsupported URL)")); ('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); expect(await previewLink('not a link'), isNull);
}); });
+15 -1
View File
@@ -110,7 +110,9 @@ Future<Harness> pumpLibrary(
now: () => fixtureNow, now: () => fixtureNow,
probe: (url) async => url.endsWith('.mkv') probe: (url) async => url.endsWith('.mkv')
? LinkProbe.file(Uri.parse(url), 1) ? 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}) => filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(solo: solo, relayed: relayed, onPlay: onPlay), FilesStub(solo: solo, relayed: relayed, onPlay: onPlay),
onPutUp: (m) async { onPutUp: (m) async {
@@ -296,6 +298,18 @@ void main() {
await settle(tester, 10); await settle(tester, 10);
expect(h.controller.link.text, url); 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); expect(tester.takeException(), isNull);
}); });
+13
View File
@@ -112,6 +112,19 @@ void main() {
expect(w.who.map((m) => m.name), ['Bob', 'Mika']); 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', () { test('names read as a sentence', () {
expect(nameList(['Bob']), 'Bob'); expect(nameList(['Bob']), 'Bob');
expect(nameList(['Bob', 'Mika']), 'Bob and Mika'); expect(nameList(['Bob', 'Mika']), 'Bob and Mika');
+43 -11
View File
@@ -113,8 +113,17 @@ Loosely modeled on Syncplay, with a different readiness design.
setting "Unready when the video changes", `AppSettings.unreadyOnSwap`, setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
default off, sends `ready=false` itself on a new `mediaId`; not for the 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 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 a swap meanwhile would have dropped). Stale, leave, sitting out and a
remove from the set. 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 - **Sitting out** (`EngineMember.out`, `state.members[].out`): a member
stays connected (chat, feed, party, nudges) but is never in the set and 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 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 (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 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 be opened) before a duration is known is reported once per open
(`onLoadError`) and shows as "can't play this: ..." with ffmpeg's HTTP (`onLoadError`), with ffmpeg's HTTP status when this player gets
status when this player gets ffmpeg's log (ffmpeg logs through one mpv ffmpeg's log (ffmpeg logs through one mpv instance per process, the
instance per process, the first one alive). The cache is in RAM: 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 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 ignores invalid values silently, so every pin is read back and a mismatch is
logged and kept in `pinMismatches`. Measured against the `/m` endpoint 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 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 (`looksLikeMediaFile`); anything else goes to yt-dlp, and when yt-dlp
cannot read it the page's `og:title` / `<title>` is kept with yt-dlp's cannot read it the page's `og:title` / `<title>` is kept with yt-dlp's
reason ("Not a video (and yt-dlp couldn't read it: ...)"). The app reason and the GET's HTTP status. `LinkProbe.problem`: why it will not
probes on paste (`AppYtDlp.probe`, remembered 10 minutes) for the Link play, in plain words (`playErrorText` of yt-dlp's reason, else of a 4xx
preview and the swap: a page is named by its title, never its URL tail, status), null for a file or a page yt-dlp read. The app probes on paste
and its thumbnail goes up as the banner (no poster). (`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 - **Everyone fetches it**: the swap sends the page URL. Before opening an
external link every app points mpv's hook at its managed copy 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`, (`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 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 0.41) gives mpv the first requested format's `http_headers` (User-Agent
as `user-agent`; Referer, Cookie, X-Forwarded-For as file-local as `user-agent`; Referer, Cookie, X-Forwarded-For as file-local
`http-header-fields`, which win over the empty list media_kit sets on `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 `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 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, 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 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 between kinds. The mini shows its short line in its hover bar. ⚙ in both
control designs opens the player settings card (`controls/ control designs opens the player settings card (`controls/
+16 -3
View File
@@ -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":0,"duration":0,"jellyfin":{<ref>}}}
{"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}} {"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}}
{"t":"report","position":123.1,"buffered":6.2,"eventId":41, {"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":"ping","c":1767225600000}
{"t":"chat","text":"..."} {"t":"chat","text":"..."}
{"t":"out","target":"<memberId>","out":true} {"t":"out","target":"<memberId>","out":true}
@@ -72,6 +72,17 @@ Field notes:
client plays the item with its own account, `false` = through the relay. 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 Sticky: a report without `jf` keeps the last one. At most 8 entries, keys
up to 64 characters; others are dropped. 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`. - `ping.c`: the client's clock in epoch ms; answered with `pong`.
- `user.avatar`: base64 PNG, at most 256 KiB of characters (else - `user.avatar`: base64 PNG, at most 256 KiB of characters (else
`rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32 `rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32
@@ -113,7 +124,7 @@ Field notes:
"serverTime":"<ISO-8601 UTC>","mediaId":1, "serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent, "media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?, "members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
"host"?,"out"?}], "host"?,"out"?,"error"?}],
"seekBy":"m1","seekSeq":3,"relay":true} "seekBy":"m1","seekSeq":3,"relay":true}
{"t":"pong","c":1767225600000,"s":1767225600012} {"t":"pong","c":1767225600000,"s":1767225600012}
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"} {"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 (omitted when the member has no picture) names the picture's bytes (see
"Avatars"). `members[].host` (omitted when false): the member sent the "Avatars"). `members[].host` (omitted when false): the member sent the
server's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when 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 - `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 (the clock `serverTime` uses). With `t3` the local receive time, the offset
is `s - (c + t3) / 2`, accurate to half the round trip. is `s - (c + t3) / 2`, accurate to half the round trip.
+2
View File
@@ -50,6 +50,8 @@ abstract class RelayConstants {
static const int maxBannerBytes = 768 * 1024; static const int maxBannerBytes = 768 * 1024;
static const int maxNameLength = 32; static const int maxNameLength = 32;
static const int maxChatLength = 500; static const int maxChatLength = 500;
/// 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 /// Nudges one member may send: at most this many per second and per
/// minute; the server drops the rest silently. /// minute; the server drops the rest silently.
static const int nudgesPerSec = 3; static const int nudgesPerSec = 3;
+53 -4
View File
@@ -75,6 +75,38 @@ String notAVideoText(String? ytDlpError) =>
/// The text for a page with no video a relay can read. /// 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."; 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 { class LinkProbeException implements Exception {
final String message; final String message;
const LinkProbeException(this.message); const LinkProbeException(this.message);
@@ -94,15 +126,30 @@ class LinkProbe {
final String? pageTitle; final String? pageTitle;
/// Why yt-dlp could not read the page. /// Why yt-dlp could not read the page.
final String? ytDlpError; final String? ytDlpError;
/// The HTTP status of the probe's GET; 0 when unknown.
final int status;
const LinkProbe.file(this.url, this.size) const LinkProbe.file(this.url, this.size)
: isPage = false, : isPage = false,
info = null, info = null,
pageTitle = null, pageTitle = null,
ytDlpError = null; ytDlpError = null,
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError}) status = 0;
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.status = 0})
: isPage = true, : isPage = true,
size = -1; 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 /// The name to show: yt-dlp's title, the page's, else the host; null
/// for a file (named by its file name). /// for a file (named by its file name).
String? get title { String? get title {
@@ -134,12 +181,14 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
try { try {
String? body; String? body;
Object? getError; Object? getError;
var status = 0;
try { try {
final req = await c.getUrl(url).timeout(const Duration(seconds: 15)); final req = await c.getUrl(url).timeout(const Duration(seconds: 15));
req.headers req.headers
..set(HttpHeaders.rangeHeader, 'bytes=0-0') ..set(HttpHeaders.rangeHeader, 'bytes=0-0')
..set(HttpHeaders.acceptEncodingHeader, 'identity'); ..set(HttpHeaders.acceptEncodingHeader, 'identity');
final res = await req.close().timeout(const Duration(seconds: 15)); final res = await req.close().timeout(const Duration(seconds: 15));
status = res.statusCode;
final type = res.headers.contentType?.mimeType ?? ''; final type = res.headers.contentType?.mimeType ?? '';
if (looksLikeMediaFile(status: res.statusCode, contentType: type, url: url)) { if (looksLikeMediaFile(status: res.statusCode, contentType: type, url: url)) {
await res.drain<void>().catchError((_) {}); await res.drain<void>().catchError((_) {});
@@ -155,7 +204,7 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
String? ytErr; String? ytErr;
if (ytDlp != null) { if (ytDlp != null) {
try { 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) { } catch (e) {
ytErr = '$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))); body = await _readText(await req.close().timeout(const Duration(seconds: 15)));
} catch (_) {} } 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 { } finally {
if (client == null) c.close(force: true); if (client == null) c.close(force: true);
} }
+22 -1
View File
@@ -435,13 +435,17 @@ class ReportMessage {
/// Jellyfin access by `ServerId`: true = plays with its own account, /// Jellyfin access by `ServerId`: true = plays with its own account,
/// false = through the server's relay. Null leaves the last one in place. /// false = through the server's relay. Null leaves the last one in place.
final Map<String, bool>? jf; final Map<String, bool>? jf;
/// Why the player cannot open [mediaId] (short plain text); empty while
/// it can.
final String error;
const ReportMessage( const ReportMessage(
{required this.position, {required this.position,
required this.buffered, required this.buffered,
required this.eventId, required this.eventId,
this.duration = 0, this.duration = 0,
this.mediaId = 0, this.mediaId = 0,
this.jf}); this.jf,
this.error = ''});
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
't': 'report', 't': 'report',
@@ -451,6 +455,7 @@ class ReportMessage {
if (duration > 0) 'duration': duration, if (duration > 0) 'duration': duration,
if (mediaId != 0) 'mediaId': mediaId, if (mediaId != 0) 'mediaId': mediaId,
if (jf != null) 'jf': jf, if (jf != null) 'jf': jf,
if (error.isNotEmpty) 'error': error,
}; };
factory ReportMessage.fromJson(Map<String, Object?> json) { factory ReportMessage.fromJson(Map<String, Object?> json) {
@@ -462,10 +467,20 @@ class ReportMessage {
duration: finiteDouble(json['duration'], 'duration'), duration: finiteDouble(json['duration'], 'duration'),
mediaId: finiteInt(json['mediaId'], 'mediaId'), mediaId: finiteInt(json['mediaId'], 'mediaId'),
jf: jfAccessFromJson(json['jf']), 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 /// Reads a `jf` map (`{serverId: bool}`), dropping non-bool entries and
/// keys over 64 characters, at most 8 entries (it rides in every `state`); /// keys over 64 characters, at most 8 entries (it rides in every `state`);
/// null when absent or not a map. /// null when absent or not a map.
@@ -582,6 +597,9 @@ class MemberView {
final bool host; final bool host;
/// Sitting out: connected, outside the ready gate, not playing. /// Sitting out: connected, outside the ready gate, not playing.
final bool out; 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({ const MemberView({
required this.id, required this.id,
required this.name, required this.name,
@@ -593,6 +611,7 @@ class MemberView {
this.jf = const {}, this.jf = const {},
this.host = false, this.host = false,
this.out = false, this.out = false,
this.error = '',
}); });
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
@@ -606,6 +625,7 @@ class MemberView {
if (jf.isNotEmpty) 'jf': jf, if (jf.isNotEmpty) 'jf': jf,
if (host) 'host': true, if (host) 'host': true,
if (out) 'out': true, if (out) 'out': true,
if (error.isNotEmpty) 'error': error,
}; };
factory MemberView.fromJson(Map<String, Object?> json) => MemberView( factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
@@ -619,6 +639,7 @@ class MemberView {
jf: jfAccessFromJson(json['jf']) ?? const {}, jf: jfAccessFromJson(json['jf']) ?? const {},
host: json['host'] == true, host: json['host'] == true,
out: json['out'] == true, out: json['out'] == true,
error: memberErrorFromJson(json['error']),
); );
} }
+28 -6
View File
@@ -56,6 +56,9 @@ class EngineMember {
bool host = false; bool host = false;
/// Sitting out: never in the set, never ready, its buffer ignored. /// Sitting out: never in the set, never ready, its buffer ignored.
bool out = false; 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; DateTime lastSeen;
EngineMember({ EngineMember({
required this.id, required this.id,
@@ -97,6 +100,7 @@ class EngineMember {
jf: jf, jf: jf,
host: host, host: host,
out: out, out: out,
error: error,
); );
} }
@@ -197,6 +201,7 @@ class SyncEngine {
final wasStale = isStale(member); final wasStale = isStale(member);
member.lastSeen = now; member.lastSeen = now;
if (!member.out && if (!member.out &&
member.error.isEmpty &&
!member.inSet && !member.inSet &&
(member.ready || (wasStale && transport != Transport.playing))) { (member.ready || (wasStale && transport != Transport.playing))) {
member.inSet = true; member.inSet = true;
@@ -307,12 +312,12 @@ class SyncEngine {
_seen(member); _seen(member);
// Sitting out: back first ([applyOut]), then ready. // Sitting out: back first ([applyOut]), then ready.
if (member.out) return; if (member.out) return;
if (member.ready == ready && member.inSet) { if (member.ready == ready && (member.inSet || member.error.isNotEmpty)) {
evaluate(); evaluate();
return; return;
} }
member.ready = ready; member.ready = ready;
if (ready) member.inSet = true; if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage( feed.add(FeedMessage(
actor: member.name, actor: member.name,
action: ready ? 'ready' : 'unready', action: ready ? 'ready' : 'unready',
@@ -328,13 +333,13 @@ class SyncEngine {
for (final member in members.values) { for (final member in members.values) {
if (member.out) continue; if (member.out) continue;
member.ready = ready; member.ready = ready;
if (ready) member.inSet = true; if (ready && member.error.isEmpty) member.inSet = true;
} }
} else { } else {
final member = byId(target); final member = byId(target);
if (member == null || member.out) return; if (member == null || member.out) return;
member.ready = ready; member.ready = ready;
if (ready) member.inSet = true; if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage( feed.add(FeedMessage(
actor: actorName, actor: actorName,
action: ready ? 'force-ready' : 'force-unready', action: ready ? 'force-ready' : 'force-unready',
@@ -366,7 +371,7 @@ class SyncEngine {
member.out = out; member.out = out;
member.ready = false; member.ready = false;
member.buffered = 0; member.buffered = 0;
member.inSet = !out && transport != Transport.playing; member.inSet = !out && member.error.isEmpty && transport != Transport.playing;
feed.add(FeedMessage( feed.add(FeedMessage(
actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now)); actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now));
evaluate(); evaluate();
@@ -408,6 +413,7 @@ class SyncEngine {
// (a stale member rejoins the set with its next frame if still ready). // (a stale member rejoins the set with its next frame if still ready).
final t = now; final t = now;
for (final member in members.values) { for (final member in members.values) {
member.error = '';
member.inSet = !member.out && !isStale(member, t); member.inSet = !member.out && !isStale(member, t);
member.buffered = 0; member.buffered = 0;
} }
@@ -416,10 +422,11 @@ class SyncEngine {
} }
void applyReport(String token, double reportPosition, double buffered, int reportEventId, 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]; final member = members[token];
if (member == null) return; if (member == null) return;
if (!reportPosition.isFinite) return; if (!reportPosition.isFinite) return;
_setError(member, mediaId == this.mediaId ? error : '');
_seen(member); _seen(member);
if (jf != null) member.jf = Map.unmodifiable(jf); if (jf != null) member.jf = Map.unmodifiable(jf);
member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered; member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered;
@@ -433,6 +440,21 @@ class SyncEngine {
evaluate(); 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() { void sweepStale() {
var changed = false; var changed = false;
for (final member in members.values) { for (final member in members.values) {
+46
View File
@@ -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', () { group('membership + gate', () {
test('join while held lands in the set, playback stays held', () { test('join while held lands in the set, playback stays held', () {
join('a', 'alice'); join('a', 'alice');
+18
View File
@@ -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', () { 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'); 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'; const v = 'http://h:1/r/default/t/3/0/x', a = 'http://h:1/r/default/t/3/1/x';
+1 -1
View File
@@ -428,7 +428,7 @@ void mountSyncRoute(
final report = ReportMessage.fromJson(decoded); final report = ReportMessage.fromJson(decoded);
hub.engine.applyReport( hub.engine.applyReport(
token, report.position, report.buffered, report.eventId, 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(); hub.changed();
case 'ping': case 'ping':
final ping = PingMessage.fromJson(decoded); final ping = PingMessage.fromJson(decoded);