Errors: refuse links the probe can't play (with Put it up anyway), plain error texts, and others see who can't play

report.error / state.members[].error (optional, no protocol bump): an
errored member is out of the ready gate until its player opens the media
or the media changes; others see "Bob can't play this: ..." and a mark
on the avatar; your own pill offers Retry (reload).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-10 21:29:41 +03:00
1 parent 1b04e002c2
commit d3be13ecda
21 files changed
+445 -72

No files matched your search

@@ -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):
+1 -1
View File
@@ -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;
+23 -4
View File
@@ -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),
),
),
+51 -18
View File
@@ -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
+33 -6
View File
@@ -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(),
);
}
+5 -2
View File
@@ -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);
});
+15 -1
View File
@@ -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);
});
+13
View File
@@ -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');