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
@@ -16,7 +16,10 @@ enum LinkRelay { direct, server, me }
|
||||
final class LinkMedia extends PickedMedia {
|
||||
final String url;
|
||||
final LinkRelay relay;
|
||||
const LinkMedia(this.url, {this.relay = LinkRelay.direct});
|
||||
/// Put it up even though this computer's probe found a problem
|
||||
/// (`LinkProbe.problem`): the server or a friend may still open it.
|
||||
final bool anyway;
|
||||
const LinkMedia(this.url, {this.relay = LinkRelay.direct, this.anyway = false});
|
||||
|
||||
@override
|
||||
String get label {
|
||||
|
||||
@@ -182,6 +182,10 @@ class SessionCoordinator {
|
||||
final toasts = signal<List<ToastItem>>([]);
|
||||
final reconnecting = signal(false);
|
||||
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).
|
||||
final currentRef = signal<JellyfinRef?>(null);
|
||||
@@ -318,6 +322,12 @@ class SessionCoordinator {
|
||||
int _advanceDoneFor = -1;
|
||||
String _blockedKey = '';
|
||||
bool _jellyfinListening = false;
|
||||
// The mediaId [playerError] belongs to, and the player's duration when
|
||||
// it came: a new duration means the media opened after all.
|
||||
int _playerErrorFor = -1;
|
||||
Duration _playerErrorDuration = Duration.zero;
|
||||
// Members' last `error`, by id, so a new one toasts once.
|
||||
Map<String, String> _memberErrors = const {};
|
||||
|
||||
SessionCoordinator(
|
||||
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
|
||||
@@ -332,8 +342,8 @@ class SessionCoordinator {
|
||||
if (_playerReady) return;
|
||||
try {
|
||||
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
|
||||
playback.onYtdlError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't open this link: $e");
|
||||
playback.onLoadError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't play this: $e");
|
||||
playback.onYtdlError = _setPlayerError;
|
||||
playback.onLoadError = _setPlayerError;
|
||||
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
|
||||
await playback.init();
|
||||
_playerReady = true;
|
||||
@@ -345,6 +355,35 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
void _setPlayerError(String raw) {
|
||||
debugPrint('player cannot open media $_mediaId: $raw');
|
||||
_playerErrorFor = _mediaId;
|
||||
_playerErrorDuration = playback.duration.peek();
|
||||
playerError.value = playErrorText(raw);
|
||||
}
|
||||
|
||||
/// Clears [playerError] once the media opened (a new duration) or the
|
||||
/// server moved to another media.
|
||||
void _checkPlayerError(int mediaId) {
|
||||
if (playerError.peek().isEmpty) return;
|
||||
final d = playback.duration.peek();
|
||||
if (_playerErrorFor != mediaId || (d > Duration.zero && d != _playerErrorDuration)) {
|
||||
playerError.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/// A member's new `error` toasts once (the pill is hidden while playing).
|
||||
void _toastMemberErrors(StateBroadcast r) {
|
||||
final now = {for (final m in r.members) if (m.error.isNotEmpty) m.id: m.error};
|
||||
for (final m in r.members) {
|
||||
final e = now[m.id];
|
||||
if (e != null && m.id != sync.memberId && _memberErrors[m.id] != e) {
|
||||
_pushToast("${m.name} can't play this: $e", actor: m.name);
|
||||
}
|
||||
}
|
||||
_memberErrors = now;
|
||||
}
|
||||
|
||||
bool _reloadingVideo = false;
|
||||
|
||||
/// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh
|
||||
@@ -398,6 +437,8 @@ class SessionCoordinator {
|
||||
diff.reset();
|
||||
_mediaId = -1;
|
||||
_openUrl = null;
|
||||
playerError.value = '';
|
||||
_memberErrors = const {};
|
||||
_lastReady = false;
|
||||
_restoringReady = false;
|
||||
_lastOut = false;
|
||||
@@ -495,8 +536,7 @@ class SessionCoordinator {
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('open media failed: $e');
|
||||
health.value =
|
||||
HealthStatus(HealthSeverity.error, 'cannot open media: $e');
|
||||
_setPlayerError('$e');
|
||||
return false;
|
||||
} finally {
|
||||
_opening = false;
|
||||
@@ -614,6 +654,8 @@ class SessionCoordinator {
|
||||
void _onState(StateBroadcast r) {
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
serverState.value = r;
|
||||
_toastMemberErrors(r);
|
||||
_checkPlayerError(r.mediaId);
|
||||
_maybeSendArt(r);
|
||||
_queuedState = r;
|
||||
if (_applying) return;
|
||||
@@ -917,6 +959,8 @@ class SessionCoordinator {
|
||||
// Duration only once the open media is the server's current one.
|
||||
// Sitting out: still a report (it keeps this member fresh), empty.
|
||||
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
|
||||
_checkPlayerError(r.mediaId);
|
||||
final error = playerError.peek();
|
||||
sync.sendReport(
|
||||
position: _lastOut ? 0 : playback.positionSecs,
|
||||
buffered: _lastOut ? 0 : playback.bufferedSecsAhead,
|
||||
@@ -924,6 +968,7 @@ class SessionCoordinator {
|
||||
duration: known ? playback.durationSecs : 0,
|
||||
mediaId: known ? r.mediaId : 0,
|
||||
jf: _jfAccessFor(r),
|
||||
error: known && _playerErrorFor == r.mediaId ? error : '',
|
||||
);
|
||||
if (!_lastOut) {
|
||||
_checkSeekLanded(r);
|
||||
@@ -1244,10 +1289,13 @@ class SessionCoordinator {
|
||||
/// A pasted link: a recognised Jellyfin item link goes up as a ref
|
||||
/// (see [mediaForLink]) that this member relays for viewers without an
|
||||
/// account (with the link's key, which stays here); anything else as the
|
||||
/// link, or relayed per [relay]. Returns an error text, or null when sent.
|
||||
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async {
|
||||
/// link, or relayed per [relay]. A link this computer cannot open (its
|
||||
/// probe's [LinkProbe.problem], or unreachable) is refused with the
|
||||
/// reason in plain words unless [anyway]. Returns an error text, or null
|
||||
/// when sent.
|
||||
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct, bool anyway = false}) async {
|
||||
markInput();
|
||||
_lastPick = LinkMedia(url, relay: relay);
|
||||
_lastPick = LinkMedia(url, relay: relay, anyway: anyway);
|
||||
final media = await mediaForLink(jellyfin, url);
|
||||
if (media.jellyfin != null) {
|
||||
_pendingOrigin = _JellyfinOrigin(url);
|
||||
@@ -1261,6 +1309,8 @@ class SessionCoordinator {
|
||||
} catch (e) {
|
||||
probeError = '$e';
|
||||
}
|
||||
final problem = probe == null ? playErrorText(probeError ?? '') : probe.problem;
|
||||
if (problem != null && !anyway) return problem;
|
||||
// A page is named by its title, never its URL tail ("1", "watch").
|
||||
final uri = Uri.tryParse(url) ?? Uri();
|
||||
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
|
||||
@@ -1271,9 +1321,9 @@ class SessionCoordinator {
|
||||
if (relay == LinkRelay.me) {
|
||||
// This computer reads it: a file, or a page's formats (one file,
|
||||
// separate video and audio tracks, or segmented playlists).
|
||||
if (probe == null) return 'cannot pass this link on: $probeError';
|
||||
final problem = probe.relayProblem;
|
||||
if (problem != null) return problem;
|
||||
if (probe == null) return problem;
|
||||
final relayProblem = probe.relayProblem;
|
||||
if (relayProblem != null) return relayProblem;
|
||||
final y = await ytDlp.ytDlp;
|
||||
final check = LinkRelayMedia(uri, ytDlp: y, probe: probe);
|
||||
var size = 0;
|
||||
@@ -1352,8 +1402,8 @@ class SessionCoordinator {
|
||||
/// Any picker selection.
|
||||
Future<String?> swapPicked(PickedMedia media) async {
|
||||
switch (media) {
|
||||
case LinkMedia(:final url, :final relay):
|
||||
return swapLink(url, relay: relay);
|
||||
case LinkMedia(:final url, :final relay, :final anyway):
|
||||
return swapLink(url, relay: relay, anyway: anyway);
|
||||
case JellyfinMedia():
|
||||
swapJellyfin(media);
|
||||
case FileMedia(:final path):
|
||||
|
||||
@@ -379,7 +379,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
if (error != null || !mounted) return error;
|
||||
}
|
||||
// Alone, a link plays direct: there is nobody to pass it on to.
|
||||
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url);
|
||||
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url, anyway: media.anyway);
|
||||
final error = await session.coordinator.swapPicked(media);
|
||||
if (error != null || !mounted) return error;
|
||||
final f = session.lastForm;
|
||||
|
||||
@@ -19,6 +19,9 @@ class AvatarInfo {
|
||||
final bool ready, buffering, relaying, stale, host, self;
|
||||
/// Sitting out: dimmed, with an "away" mark.
|
||||
final bool out;
|
||||
/// Why their player cannot open the media (`members[].error`): an error
|
||||
/// mark; empty when it can.
|
||||
final String error;
|
||||
const AvatarInfo({
|
||||
this.id = '',
|
||||
required this.name,
|
||||
@@ -31,6 +34,7 @@ class AvatarInfo {
|
||||
this.host = false,
|
||||
this.self = false,
|
||||
this.out = false,
|
||||
this.error = '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,7 +82,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
|
||||
}
|
||||
|
||||
/// Your own avatar from the profile (also offline).
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false}) {
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) {
|
||||
final p = Profile.instance;
|
||||
final png = p.picture.value;
|
||||
final name = p.name.value;
|
||||
@@ -90,6 +94,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = fal
|
||||
host: host,
|
||||
self: true,
|
||||
out: out,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,7 +109,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
color: self ? Profile.instance.color.value : colorForName(m.name),
|
||||
image: memberAvatarImage(m, coord),
|
||||
ready: m.ready,
|
||||
buffering: !m.stale && m.ready && playing && m.buffered < r!.bufferNeed,
|
||||
buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed,
|
||||
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
||||
stale: m.stale,
|
||||
// The state marks the host (`members[].host`); your own app also
|
||||
@@ -112,6 +117,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
host: m.host || (self && coord.hostFile != null),
|
||||
self: self,
|
||||
out: m.out,
|
||||
error: m.error,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +144,7 @@ class PartyWatch extends StatelessWidget {
|
||||
/// Circle avatar with a status ring outside it and a gap in the background
|
||||
/// colour, so overlapping neighbours never hide the status. Crown = host,
|
||||
/// tower = passing the video on, spinner = buffering, dimmed = stale,
|
||||
/// dimmed with a moon = sitting out.
|
||||
/// dimmed with a moon = sitting out, red "!" = cannot play the media.
|
||||
class StatusAvatar extends StatelessWidget {
|
||||
final AvatarInfo info;
|
||||
final double size;
|
||||
@@ -222,6 +228,17 @@ class StatusAvatar extends StatelessWidget {
|
||||
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
|
||||
),
|
||||
),
|
||||
if (info.error.isNotEmpty)
|
||||
Positioned(
|
||||
key: const ValueKey('error-mark'),
|
||||
right: -3,
|
||||
top: -3,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
child: Icon(Icons.error, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.redAccent.shade200),
|
||||
),
|
||||
),
|
||||
if (badges && info.relaying)
|
||||
Positioned(
|
||||
right: -3,
|
||||
@@ -287,7 +304,9 @@ class _AvatarStackState extends State<AvatarStack> {
|
||||
left: i * step,
|
||||
top: 0,
|
||||
child: Tooltip(
|
||||
message: visible[i].name,
|
||||
message: visible[i].error.isEmpty
|
||||
? visible[i].name
|
||||
: "${visible[i].name} can't play this: ${visible[i].error}",
|
||||
child: StatusAvatar(info: visible[i], size: size),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, notAVideoText;
|
||||
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText;
|
||||
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
@@ -107,7 +107,10 @@ class LinkPreview {
|
||||
/// "youtube.com · Channel", or the host.
|
||||
final String subtitle;
|
||||
final String? thumbnail;
|
||||
const LinkPreview({required this.title, required this.subtitle, this.thumbnail});
|
||||
/// Why this computer cannot open it, in plain words ([LinkProbe.problem]);
|
||||
/// null when it looks playable.
|
||||
final String? problem;
|
||||
const LinkPreview({required this.title, required this.subtitle, this.thumbnail, this.problem});
|
||||
}
|
||||
|
||||
/// What a link is (see [AppYtDlp.probe]).
|
||||
@@ -125,24 +128,30 @@ String clockText(double seconds) {
|
||||
}
|
||||
|
||||
/// The preview of [url]: a web page shows yt-dlp's title, uploader,
|
||||
/// duration and thumbnail (or why it is not a video); a media file (by
|
||||
/// name or by probe) its file name and host. Null for text that is not an http(s) link.
|
||||
/// duration and thumbnail; a media file its file name and host; a link
|
||||
/// that does not open, why ([LinkPreview.problem]). Null for text that is
|
||||
/// not an http(s) link.
|
||||
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
|
||||
final host = _host(uri);
|
||||
// A media file name needs no lookup.
|
||||
if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
|
||||
// A Jellyfin item goes up as the item (see `mediaForLink`), not the page.
|
||||
if (parseJellyfinLink(url.trim()) != null) return LinkPreview(title: host, subtitle: '$host · Jellyfin');
|
||||
final file = hasMediaExtension(uri);
|
||||
final LinkProbe p;
|
||||
try {
|
||||
p = await probe(url.trim());
|
||||
} catch (e) {
|
||||
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e');
|
||||
final problem = playErrorText('$e');
|
||||
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $problem', problem: problem);
|
||||
}
|
||||
final info = p.info;
|
||||
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
|
||||
if (info == null) {
|
||||
return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}');
|
||||
final problem = p.problem;
|
||||
if (info == null || problem != null) {
|
||||
// A media file name that did not answer as one keeps its name.
|
||||
final title = file ? LinkMedia(url.trim()).label : p.title ?? host;
|
||||
return LinkPreview(title: title, subtitle: '$host · ${problem ?? 'Not a video'}', problem: problem);
|
||||
}
|
||||
return LinkPreview(
|
||||
title: p.title ?? host,
|
||||
@@ -221,6 +230,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
Timer? _debounce;
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
/// [_error] is the probe's problem: "Put it up anyway" is offered.
|
||||
bool _canForce = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -242,7 +253,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
if (url == _previewUrl) return;
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
|
||||
if (mounted) setState(() => _error = null);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = null;
|
||||
_canForce = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preview(String url) async {
|
||||
@@ -263,19 +279,27 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _go() async {
|
||||
Future<void> _go({bool anyway = false}) async {
|
||||
final url = _c.link.text.trim();
|
||||
if (url.isEmpty || _busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
_canForce = false;
|
||||
});
|
||||
String? error;
|
||||
try {
|
||||
error = await widget.onPutUp(LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay));
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
// What the probe found refuses it here, with "Put it up anyway": the
|
||||
// server or a friend (with cookies) may still open it.
|
||||
final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: widget.probe))?.problem;
|
||||
String? error = problem;
|
||||
if (problem == null) {
|
||||
try {
|
||||
error = await widget.onPutUp(
|
||||
LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
}
|
||||
final canForce = problem != null;
|
||||
if (error == null) {
|
||||
final p = _previews[url] ?? await previewLink(url, probe: widget.probe);
|
||||
unawaited(widget.recents.add(RecentLink(
|
||||
@@ -289,6 +313,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
_canForce = canForce;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -373,6 +398,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
textAlign: TextAlign.center,
|
||||
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
|
||||
),
|
||||
if (_canForce)
|
||||
Center(
|
||||
child: TextButton(
|
||||
key: const ValueKey('link-put-up-anyway'),
|
||||
onPressed: _busy ? null : () => _go(anyway: true),
|
||||
child: Text(solo ? 'Play it anyway' : 'Put it up anyway'),
|
||||
),
|
||||
),
|
||||
],
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
@@ -475,9 +508,9 @@ class _PreviewCard extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
p?.subtitle ?? 'Paste an http(s) address',
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
style: TextStyle(color: p?.problem != null ? scheme.error : Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -103,6 +103,7 @@ class PartyStrip extends StatelessWidget {
|
||||
info: selfAvatarInfo(
|
||||
ready: self?.ready ?? false,
|
||||
host: online && !solo && coord.hostFile != null,
|
||||
error: online ? coord.playerError.value : '',
|
||||
),
|
||||
size: stripAvatarSize,
|
||||
ring: online,
|
||||
@@ -553,6 +554,8 @@ class _MemberTile extends StatelessWidget {
|
||||
? 'reconnecting…'
|
||||
: m.out
|
||||
? 'sitting out'
|
||||
: m.error.isNotEmpty
|
||||
? "can't play this: ${m.error}"
|
||||
: info.buffering
|
||||
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
|
||||
: m.ready
|
||||
|
||||
@@ -11,11 +11,12 @@ import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// What the player's status pill says, most pressing first.
|
||||
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, waiting }
|
||||
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting }
|
||||
|
||||
/// The pill's button: stop reconnecting, retry, or mark yourself ready
|
||||
/// (the whole pill).
|
||||
enum StatusAction { cancel, retry, ready }
|
||||
/// The pill's button: stop reconnecting, retry (reconnect), reload the
|
||||
/// video (your player could not open it), or mark yourself ready (the
|
||||
/// whole pill).
|
||||
enum StatusAction { cancel, retry, reload, ready }
|
||||
|
||||
/// One status line: [text], a muted [detail], a coloured [tone] for
|
||||
/// problems (warn, error), the members it is about ([who]; [self] for you)
|
||||
@@ -55,18 +56,24 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
|
||||
|
||||
/// The player's status from the session's signals; null when there is
|
||||
/// nothing to say (playing, connected, everyone ready). [slowConnect]:
|
||||
/// connecting has taken over 2 s.
|
||||
/// connecting has taken over 2 s. [playerError]: why your player cannot
|
||||
/// open the media (the coordinator's `playerError`).
|
||||
StatusView? playerStatusFor({
|
||||
required HealthStatus health,
|
||||
bool reconnecting = false,
|
||||
StateBroadcast? state,
|
||||
MemberView? self,
|
||||
bool slowConnect = false,
|
||||
String playerError = '',
|
||||
}) {
|
||||
if (reconnecting) {
|
||||
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
||||
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
||||
}
|
||||
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) {
|
||||
return StatusView(StatusKind.problem, "Can't play this: $playerError",
|
||||
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
|
||||
}
|
||||
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
|
||||
return StatusView(StatusKind.problem, _sentence(health.message),
|
||||
tone: health.severity, action: StatusAction.retry);
|
||||
@@ -81,6 +88,12 @@ StatusView? playerStatusFor({
|
||||
if (r.transport == 'finished') {
|
||||
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
|
||||
}
|
||||
// Members whose player cannot open it: the server does not wait for
|
||||
// them; say why instead of "waiting for".
|
||||
final failed = [
|
||||
for (final m in r.members)
|
||||
if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m,
|
||||
];
|
||||
// As the server holds playback: someone in the ready set short of the
|
||||
// minimum buffer.
|
||||
final need = r.bufferNeed;
|
||||
@@ -92,12 +105,22 @@ StatusView? playerStatusFor({
|
||||
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
|
||||
}
|
||||
}
|
||||
if (failed.isNotEmpty) {
|
||||
final f = failed.first;
|
||||
final who = nameList([for (final m in failed) m.name]);
|
||||
final unready = self != null && !self.ready;
|
||||
return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}",
|
||||
who: failed,
|
||||
short: "$who can't play this",
|
||||
detail: unready ? "Press Space when you're ready" : null,
|
||||
action: unready ? StatusAction.ready : null);
|
||||
}
|
||||
if (self != null && !self.ready) {
|
||||
return StatusView(StatusKind.you, "Press Space when you're ready",
|
||||
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
|
||||
}
|
||||
// Members sitting out are never waited for nor counted.
|
||||
final active = [for (final m in r.members) if (!m.stale && !m.out) m];
|
||||
final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m];
|
||||
final others = [
|
||||
for (final m in active)
|
||||
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||
@@ -117,6 +140,7 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
|
||||
state: c.serverState.value,
|
||||
self: c.selfMember.value,
|
||||
slowConnect: slowConnect,
|
||||
playerError: c.playerError.value,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +188,8 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
||||
coord.cancelReconnect();
|
||||
case StatusAction.retry:
|
||||
coord.retry();
|
||||
case StatusAction.reload:
|
||||
unawaited(coord.reloadVideo());
|
||||
case StatusAction.ready:
|
||||
coord.toggleReady();
|
||||
}
|
||||
@@ -266,6 +292,7 @@ class _StatusLine extends StatelessWidget {
|
||||
return null;
|
||||
case StatusKind.loading:
|
||||
case StatusKind.you:
|
||||
case StatusKind.cannotPlay:
|
||||
case StatusKind.waiting:
|
||||
final r = coord.serverState.value;
|
||||
final infos = [
|
||||
|
||||
@@ -219,6 +219,7 @@ class SyncClient {
|
||||
double duration = 0,
|
||||
int mediaId = 0,
|
||||
Map<String, bool>? jf,
|
||||
String error = '',
|
||||
}) {
|
||||
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
|
||||
_send(
|
||||
@@ -229,6 +230,7 @@ class SyncClient {
|
||||
duration: duration.isFinite && duration > 0 ? duration : 0,
|
||||
mediaId: mediaId,
|
||||
jf: jf,
|
||||
error: error,
|
||||
).toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,8 +202,11 @@ void main() {
|
||||
|
||||
final page = await previewLink('https://example.com/a',
|
||||
probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL'));
|
||||
expect((page!.title, page.subtitle),
|
||||
('Example', "example.com · Not a video (and yt-dlp couldn't read it: Unsupported URL)"));
|
||||
expect((page!.title, page.subtitle, page.problem),
|
||||
('Example', 'example.com · Not a video page yt-dlp knows', 'Not a video page yt-dlp knows'));
|
||||
final refused = await previewLink('https://cdn.example/b.mp4',
|
||||
probe: (url) async => LinkProbe.page(Uri.parse(url), ytDlpError: 'odd', status: 403));
|
||||
expect((refused!.title, refused.problem), ('b.mp4', 'Not allowed (403)'));
|
||||
expect(await previewLink('not a link'), isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -110,7 +110,9 @@ Future<Harness> pumpLibrary(
|
||||
now: () => fixtureNow,
|
||||
probe: (url) async => url.endsWith('.mkv')
|
||||
? LinkProbe.file(Uri.parse(url), 1)
|
||||
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
|
||||
: url.contains('bilibili')
|
||||
? LinkProbe.page(Uri.parse(url), ytDlpError: 'HTTP Error 412: Precondition Failed')
|
||||
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
|
||||
filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) =>
|
||||
FilesStub(solo: solo, relayed: relayed, onPlay: onPlay),
|
||||
onPutUp: (m) async {
|
||||
@@ -296,6 +298,18 @@ void main() {
|
||||
await settle(tester, 10);
|
||||
expect(h.controller.link.text, url);
|
||||
|
||||
// A link the probe cannot open: the reason, not put up; "anyway" is.
|
||||
const bili = 'https://www.bilibili.com/video/BV1';
|
||||
await tester.enterText(find.byKey(const ValueKey('link-field')), bili);
|
||||
await settle(tester, 10);
|
||||
await tester.tap(find.byKey(const ValueKey('link-put-up')));
|
||||
await settle(tester, 10);
|
||||
expect(find.text(needsCookiesText), findsOneWidget);
|
||||
expect(h.put, hasLength(1));
|
||||
await tester.tap(find.byKey(const ValueKey('link-put-up-anyway')));
|
||||
await settle(tester, 10);
|
||||
expect((h.put.last as LinkMedia).anyway, isTrue);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -112,6 +112,19 @@ void main() {
|
||||
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
|
||||
});
|
||||
|
||||
test("can't play: yours with Retry (reload); others' instead of waiting", () {
|
||||
var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!;
|
||||
expect((v.kind, v.text, v.action), (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload));
|
||||
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false,
|
||||
error: 'Not allowed (403)');
|
||||
v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!;
|
||||
expect((v.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null));
|
||||
expect(v.who.single.id, 'b');
|
||||
final notMe = mv('me', 'Me');
|
||||
v = playerStatusFor(health: ok, state: held([notMe, bob]), self: notMe)!;
|
||||
expect((v.kind, v.action), (StatusKind.cannotPlay, StatusAction.ready));
|
||||
});
|
||||
|
||||
test('names read as a sentence', () {
|
||||
expect(nameList(['Bob']), 'Bob');
|
||||
expect(nameList(['Bob', 'Mika']), 'Bob and Mika');
|
||||
|
||||
Reference in new issue
Block a user