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:
1 parent
1b04e002c2
commit
d3be13ecda
21 files changed
+445
-72
No files matched your search
@@ -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):
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user