6 Commits
Author SHA1 Message Date
foxyz bbecdcc4d8 Release v1.1.2
ci / core (push) Successful in 33s
release / linux (push) Successful in 4m28s
ci / updater-windows (push) Successful in 4m49s
ci / server (push) Successful in 58s
release / windows (push) Successful in 4m30s
2026-10-10 21:45:57 +03:00
foxyzandClaude Opus 5.5 f1ccf88b06 Roadmap: drop the link-token item (what a user pastes is theirs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:45:57 +03:00
foxyzandClaude Opus 5.5 d3be13ecda 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>
2026-10-10 21:29:41 +03:00
foxyzandClaude Opus 5.5 1b04e002c2 Player: mpv logs at warn (v only while probing the render mode), 4xx fails at once, load errors show
The session player asked libmpv for every v-level message all session and
media_kit drains them on the UI thread: a retry storm could freeze the
window. ffmpeg reconnects only on 5xx, bounded. A load that fails before
a duration is known reports once ('can't play this: HTTP error 403 ...').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:12:00 +03:00
foxyzandClaude Opus 5.5 f23cc7b311 Schedule: remove the per-user follow (bookmark, Following filter, AnimeFlow follow calls)
Personal tracking will come from the user's AniList lists; AnimeFlow keeps
downloads, availability and requests only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:51:42 +03:00
foxyzandClaude Opus 5.5 473c82ec0a Schedule: AnimeFlow's AniList episode number wins outright (JoJo S6E4 no longer claims next week's ep 4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:42:28 +03:00
36 changed files with 737 additions and 216 deletions

No files matched your search

+4 -1
View File
@@ -145,7 +145,10 @@ folder to Files, or fill Link).
what it is (a page's title, uploader, length and thumbnail, else the what it is (a page's title, uploader, length and thumbnail, else the
file name); in a server, pick how the file name); in a server, pick how the
others get it (everyone fetches it, the server fetches it, or through others get it (everyone fetches it, the server fetches it, or through
you). Your last 12 links are listed under it. you). Your last 12 links are listed under it. A link that does not
open here (needs cookies, 403, 404, not a video) says why and goes up
only with "Put it up anyway"; in the player, whoever cannot play it is
named ("Bob can't play this: Not allowed (403)") and nobody waits for them.
- **Files**: video folders you add on this computer, grouped by series - **Files**: video folders you add on this computer, grouped by series
(click a series to list its episodes), or any file through Browse. The others stream it from you. (click a series to list its episodes), or any file through Browse. The others stream it from you.
@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:ffi' show Int8;
import 'dart:io'; import 'dart:io';
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
@@ -64,6 +66,22 @@ class PlaybackController {
/// mpv's yt-dlp hook could not open the current link (its error text). /// mpv's yt-dlp hook could not open the current link (its error text).
void Function(String error)? onYtdlError; void Function(String error)? onYtdlError;
/// The current media failed to load (the HTTP error when there was one,
/// e.g. a CDN's 403), at most once per [openUrl].
void Function(String error)? onLoadError;
// Per opened media: the last HTTP error ffmpeg logged, and whether a
// load error was reported.
String? _httpError;
bool _loadErrorSent = false;
/// mpv's log level for this app. media_kit drains mpv's log on the UI
/// thread one line at a time, so a chatty level (ffmpeg retrying a
/// refused request logs every attempt) can starve the UI: warnings and
/// errors only, except while the video output starts ([video]), when
/// the render-mode probe needs mpv's verbose GL init lines.
static const logLevel = 'warn';
/// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web /// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web
/// page links, at most 1080p, once per player; its cookies /// page links, at most 1080p, once per player; its cookies
/// (`ytdl-raw-options`) whenever they changed. /// (`ytdl-raw-options`) whenever they changed.
@@ -162,6 +180,7 @@ class PlaybackController {
final existing = _video; final existing = _video;
if (existing != null) return existing; if (existing != null) return existing;
final hwdec = _hwdecOverride; final hwdec = _hwdecOverride;
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
// media_kit's video output sets hwdec itself on attach ('auto' unless // media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC. // configured), which used to override SYNC_RELAY_HWDEC.
final video = _video = VideoController( final video = _video = VideoController(
@@ -187,8 +206,7 @@ class PlaybackController {
bufferSize: maxBytes, bufferSize: maxBytes,
libass: true, libass: true,
vo: Platform.environment['SYNC_RELAY_VO'], vo: Platform.environment['SYNC_RELAY_VO'],
// Verbose: the render-mode probe needs mpv's GL init lines. logLevel: MPVLogLevel.warn, // see [logLevel]
logLevel: MPVLogLevel.v,
)) ))
..onUserSeek = (d) => onUserSeek?.call(d); ..onUserSeek = (d) => onUserSeek?.call(d);
_shaders = ''; _shaders = '';
@@ -243,8 +261,29 @@ class PlaybackController {
if (hwdec != null) { if (hwdec != null) {
await _pin('hwdec', hwdec); await _pin('hwdec', hwdec);
} }
await _pin('stream-lavf-o', await _pin('stream-lavf-o', streamLavfOptions);
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4'); }
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect
/// after a dropped connection, also on a streamed (growing) relay
/// response, and a server error (5xx) while connecting; a refused request
/// (4xx) fails at once. At most 3 tries per read, 4 s apart at most.
static const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
'reconnect_delay_max=4,reconnect_max_retries=3';
// mpv_request_log_messages on [p]'s handle (media_kit has no API for it).
static void _requestLog(Player p, String level) {
try {
final dynamic platform = p.platform;
final name = level.toNativeUtf8();
try {
platform.mpv.mpv_request_log_messages(platform.ctx, name.cast<Int8>());
} finally {
malloc.free(name);
}
} catch (e) {
debugPrint('mpv log level $level not set: $e');
}
} }
/// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on /// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on
@@ -331,7 +370,27 @@ class PlaybackController {
void _onLog(PlayerLog log) { void _onLog(PlayerLog log) {
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) { if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), ''); final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
if (text.isNotEmpty) onYtdlError?.call(text); if (text.isNotEmpty) {
_loadErrorSent = true;
onYtdlError?.call(text);
}
return;
}
// ffmpeg logs through one mpv instance per process (the first one
// alive), so the HTTP status is a bonus, not a given.
if (log.prefix == 'ffmpeg') {
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
if (http != null) _httpError = http[0]!.trim();
return;
}
// Nothing playable opened (a track of mpv's yt-dlp EDL refused, ...).
if (!_loadErrorSent &&
duration.peek() == Duration.zero &&
((log.prefix == 'cplayer' && log.level == 'fatal') ||
(log.prefix == 'demux' && log.level == 'error'))) {
_loadErrorSent = true;
final http = _httpError;
onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http');
return; return;
} }
if (log.prefix != 'libmpv_render') return; if (log.prefix != 'libmpv_render') return;
@@ -364,10 +423,12 @@ class PlaybackController {
Future<void> _detectRenderMode(Player p, VideoController video) async { Future<void> _detectRenderMode(Player p, VideoController video) async {
try { try {
await video.platform.future; await video.platform.future;
await Future.delayed(const Duration(milliseconds: 1500));
} catch (_) { } catch (_) {
return; return;
} finally {
if (identical(p, _player)) _requestLog(p, logLevel);
} }
await Future.delayed(const Duration(milliseconds: 1500));
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) { if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
return; return;
} }
@@ -451,6 +512,8 @@ class PlaybackController {
if (!_ready) return; if (!_ready) return;
try { try {
_chapterJson = ''; _chapterJson = '';
_httpError = null;
_loadErrorSent = false;
chapters.value = const []; chapters.value = const [];
tracks.value = const []; tracks.value = const [];
buffered.value = Duration.zero; buffered.value = Duration.zero;
@@ -248,7 +248,6 @@ class AnimeFlowScheduleItem {
final int? anidbId; final int? anidbId;
final int? tvdbId; final int? tvdbId;
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
final bool followed;
const AnimeFlowScheduleItem({ const AnimeFlowScheduleItem({
required this.episode, required this.episode,
@@ -257,7 +256,6 @@ class AnimeFlowScheduleItem {
this.anidbId, this.anidbId,
this.tvdbId, this.tvdbId,
this.jellyfinSeriesId, this.jellyfinSeriesId,
this.followed = false,
}); });
static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem( static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem(
@@ -267,7 +265,6 @@ class AnimeFlowScheduleItem {
anidbId: _int(j['anidb_id']), anidbId: _int(j['anidb_id']),
tvdbId: _int(j['tvdb_id']), tvdbId: _int(j['tvdb_id']),
jellyfinSeriesId: _str(j['jellyfin_series_id']), jellyfinSeriesId: _str(j['jellyfin_series_id']),
followed: j['followed'] == true,
); );
} }
@@ -296,7 +293,6 @@ class AnimeFlowSeries {
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
final SeriesState state; final SeriesState state;
final bool monitored; final bool monitored;
final bool followed;
final int episodesTotal; final int episodesTotal;
final int episodesAvailable; final int episodesAvailable;
final DateTime? nextAirDate; final DateTime? nextAirDate;
@@ -312,7 +308,6 @@ class AnimeFlowSeries {
this.jellyfinSeriesId, this.jellyfinSeriesId,
this.state = SeriesState.releasing, this.state = SeriesState.releasing,
this.monitored = false, this.monitored = false,
this.followed = false,
this.episodesTotal = 0, this.episodesTotal = 0,
this.episodesAvailable = 0, this.episodesAvailable = 0,
this.nextAirDate, this.nextAirDate,
@@ -328,7 +323,6 @@ class AnimeFlowSeries {
jellyfinSeriesId: _str(j['jellyfin_series_id']), jellyfinSeriesId: _str(j['jellyfin_series_id']),
state: SeriesState.parse(j['state']), state: SeriesState.parse(j['state']),
monitored: j['monitored'] == true, monitored: j['monitored'] == true,
followed: j['followed'] == true,
episodesTotal: _int(j['episodes_total']) ?? 0, episodesTotal: _int(j['episodes_total']) ?? 0,
episodesAvailable: _int(j['episodes_available']) ?? 0, episodesAvailable: _int(j['episodes_available']) ?? 0,
nextAirDate: _time(j['next_air_date']), nextAirDate: _time(j['next_air_date']),
@@ -684,10 +678,6 @@ class AnimeFlowClient {
return AnimeFlowSeries.fromJson(j is Map ? j : const {}); return AnimeFlowSeries.fromJson(j is Map ? j : const {});
} }
/// Follow (or unfollow) a series [view].
Future<void> setFollowed(int seriesId, bool followed) =>
_call(followed ? 'PUT' : 'DELETE', '/series/$seriesId/follow');
/// Titles that can be requested [request]. /// Titles that can be requested [request].
Future<List<AnimeFlowSearchHit>> search(String q) async => [ Future<List<AnimeFlowSearchHit>> search(String q) async => [
for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h), for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h),
@@ -48,8 +48,7 @@ class ScheduleEntry {
final String? reason; final String? reason;
/// AnimeFlow tracks the series. /// AnimeFlow tracks the series.
final bool tracked; final bool tracked;
final bool followed; /// AnimeFlow's series id (requests).
/// AnimeFlow's series id (follow, requests).
final int? seriesId; final int? seriesId;
/// The AnimeFlow season of the matched episode (requests); untracked /// The AnimeFlow season of the matched episode (requests); untracked
/// on Jellyfin: the TVDB season of the AniList entry. /// on Jellyfin: the TVDB season of the AniList entry.
@@ -68,7 +67,6 @@ class ScheduleEntry {
this.progress, this.progress,
this.reason, this.reason,
this.tracked = false, this.tracked = false,
this.followed = false,
this.seriesId, this.seriesId,
this.season, this.season,
this.jellyfinEpisode, this.jellyfinEpisode,
@@ -81,7 +79,6 @@ class ScheduleEntry {
progress: progress, progress: progress,
reason: reason, reason: reason,
tracked: tracked, tracked: tracked,
followed: followed,
seriesId: seriesId, seriesId: seriesId,
season: season, season: season,
jellyfinEpisode: jellyfinEpisode, jellyfinEpisode: jellyfinEpisode,
@@ -106,8 +103,7 @@ String _day(DateTime d) {
class _SeriesRef { class _SeriesRef {
final int seriesId; final int seriesId;
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
bool followed; _SeriesRef(this.seriesId, this.jellyfinSeriesId);
_SeriesRef(this.seriesId, this.jellyfinSeriesId, this.followed);
} }
/// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked /// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked
@@ -138,19 +134,25 @@ List<ScheduleEntry> matchSchedule(
final refs = <int, _SeriesRef>{}; final refs = <int, _SeriesRef>{};
for (final s in series) { for (final s in series) {
final id = s.anilistId; final id = s.anilistId;
if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId, s.followed); if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId);
} }
// AnimeFlow's AniList episode number wins outright: TVDB numbering runs
// on across split entries (JoJo S6E4 is "2nd & 3rd STAGE" ep 3), so a TVDB
// or absolute number, or an air day, is only a guess for AniList entries
// AnimeFlow gives no AniList numbers for.
final byAni = <String, AnimeFlowScheduleItem>{};
final byEp = <String, AnimeFlowScheduleItem>{}; final byEp = <String, AnimeFlowScheduleItem>{};
final byAbs = <String, AnimeFlowScheduleItem>{}; final byAbs = <String, AnimeFlowScheduleItem>{};
final byDay = <String, AnimeFlowScheduleItem>{}; final byDay = <String, AnimeFlowScheduleItem>{};
final numbered = <int>{};
for (final it in schedule) { for (final it in schedule) {
final e = it.episode, id = e.anilistId; final e = it.episode, id = e.anilistId;
if (id == null) continue; if (id == null) continue;
final ref = refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId, it.followed); refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId);
if (it.followed) ref.followed = true; if (e.anilistEpisode != null) {
// AnimeFlow's AniList episode number first: TVDB numbering can run on numbered.add(id);
// across split cours. byAni.putIfAbsent('$id#${e.anilistEpisode}', () => it);
if (e.anilistEpisode != null) byEp.putIfAbsent('$id#${e.anilistEpisode}', () => it); }
byEp.putIfAbsent('$id#${e.episode}', () => it); byEp.putIfAbsent('$id#${e.episode}', () => it);
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it); if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it); if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it);
@@ -160,7 +162,9 @@ List<ScheduleEntry> matchSchedule(
() { () {
final id = a.anilistId; final id = a.anilistId;
final ref = refs[id]; final ref = refs[id];
final it = byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}']; final it = numbered.contains(id)
? byAni['$id#${a.episode}']
: byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}'];
if (it != null) { if (it != null) {
final e = it.episode; final e = it.episode;
return ScheduleEntry( return ScheduleEntry(
@@ -169,7 +173,6 @@ List<ScheduleEntry> matchSchedule(
progress: e.status == EpisodeStatus.downloading ? e.progress : null, progress: e.status == EpisodeStatus.downloading ? e.progress : null,
reason: e.status == EpisodeStatus.failed ? e.reason : null, reason: e.status == EpisodeStatus.failed ? e.reason : null,
tracked: true, tracked: true,
followed: it.followed || (ref?.followed ?? false),
seriesId: it.seriesId, seriesId: it.seriesId,
season: e.season, season: e.season,
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1, jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
@@ -190,7 +193,6 @@ List<ScheduleEntry> matchSchedule(
airing: a, airing: a,
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing, status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing,
tracked: true, tracked: true,
followed: ref.followed,
seriesId: ref.seriesId, seriesId: ref.seriesId,
jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1, jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1,
); );
@@ -127,7 +127,6 @@ class AiringCard extends StatelessWidget {
final ScheduleEntry entry; final ScheduleEntry entry;
final DateTime now; final DateTime now;
final bool compact; final bool compact;
final bool followed;
final CardAction action; final CardAction action;
/// Why [action] is disabled (no request scope, not on Jellyfin, ...); /// Why [action] is disabled (no request scope, not on Jellyfin, ...);
@@ -138,20 +137,15 @@ class AiringCard extends StatelessWidget {
final bool busy; final bool busy;
final VoidCallback onAction; final VoidCallback onAction;
/// Null hides the bookmark (untracked series).
final VoidCallback? onFollow;
const AiringCard({ const AiringCard({
super.key, super.key,
required this.entry, required this.entry,
required this.now, required this.now,
required this.compact, required this.compact,
required this.followed,
required this.action, required this.action,
required this.onAction, required this.onAction,
this.disabledHint, this.disabledHint,
this.busy = false, this.busy = false,
this.onFollow,
}); });
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}'; String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
@@ -181,22 +175,6 @@ class AiringCard extends StatelessWidget {
return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b); return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b);
} }
Widget? _follow(BuildContext context, double size) {
final f = onFollow;
if (f == null) return null;
return IconButton(
key: ValueKey('follow-$_id'),
onPressed: f,
tooltip: followed ? 'Stop following' : 'Follow',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: BoxConstraints.tightFor(width: size + 10, height: size + 10),
iconSize: size,
color: followed ? Theme.of(context).colorScheme.primary : null,
icon: Icon(followed ? Icons.bookmark : Icons.bookmark_outline),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
@@ -246,7 +224,6 @@ class AiringCard extends StatelessWidget {
], ],
), ),
), ),
if (followed && onFollow == null) Icon(Icons.bookmark, size: 14, color: scheme.primary),
if (requestable) if (requestable)
Tooltip( Tooltip(
message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'), message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'),
@@ -270,7 +247,6 @@ class AiringCard extends StatelessWidget {
); );
} }
final button = _button(context); final button = _button(context);
final follow = _follow(context, 16);
// The cover poster leads: it is what people recognise at a glance. // The cover poster leads: it is what people recognise at a glance.
return Material( return Material(
key: ValueKey('card-$_id'), key: ValueKey('card-$_id'),
@@ -340,26 +316,12 @@ class AiringCard extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25), style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Row( Text(
children: [
Flexible(
child: Text(
'Episode ${a.episode}', 'Episode ${a.episode}',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
), ),
),
const SizedBox(width: 2),
if (follow != null)
follow
else if (followed)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(Icons.bookmark, size: 14, color: scheme.primary),
),
],
),
const Spacer(), const Spacer(),
Row( Row(
children: [ children: [
@@ -18,7 +18,7 @@ import 'schedule_card.dart';
// one thing to do about it (put it up, request it). Refreshes every // one thing to do about it (put it up, request it). Refreshes every
// minute while visible; ETags keep that cheap. // minute while visible; ETags keep that cheap.
enum ScheduleFilter { jellyfin, following, all } enum ScheduleFilter { jellyfin, all }
class SchedulePage extends StatefulWidget { class SchedulePage extends StatefulWidget {
/// Nobody else watches (offline or solo): available episodes say Play /// Nobody else watches (offline or solo): available episodes say Play
@@ -75,8 +75,6 @@ class _SchedulePageState extends State<SchedulePage> {
/// The sheet stays built while it slides out. /// The sheet stays built while it slides out.
bool _sheetBuilt = false; bool _sheetBuilt = false;
/// Follow toggles not yet confirmed by a reload (series id -> followed).
final _followOverride = <int, bool>{};
/// Requested in this run: "anilistId#episode" or "series:anilistId". /// Requested in this run: "anilistId#episode" or "series:anilistId".
final _requested = <String>{}; final _requested = <String>{};
@@ -190,11 +188,8 @@ class _SchedulePageState extends State<SchedulePage> {
animate ? _scroll.animateTo(x, duration: easeTime, curve: ease) : _scroll.jumpTo(x); animate ? _scroll.animateTo(x, duration: easeTime, curve: ease) : _scroll.jumpTo(x);
} }
bool _followed(ScheduleEntry e) => (e.seriesId == null ? null : _followOverride[e.seriesId]) ?? e.followed;
bool _shows(ScheduleEntry e) => switch (_filter) { bool _shows(ScheduleEntry e) => switch (_filter) {
ScheduleFilter.jellyfin => e.tracked || e.onJellyfin, ScheduleFilter.jellyfin => e.tracked || e.onJellyfin,
ScheduleFilter.following => _followed(e),
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow, ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
}; };
@@ -341,21 +336,6 @@ class _SchedulePageState extends State<SchedulePage> {
}, },
); );
Future<void> _toggleFollow(ScheduleEntry e) async {
final c = _flow, id = e.seriesId;
if (c == null || id == null) return;
final next = !_followed(e);
setState(() => _followOverride[id] = next);
try {
await c.setFollowed(id, next);
await _load();
} catch (err) {
_toast(friendlyError(err));
} finally {
if (mounted) setState(() => _followOverride.remove(id));
}
}
Future<void> _decide(AnimeFlowRequest r, {required bool approve}) async { Future<void> _decide(AnimeFlowRequest r, {required bool approve}) async {
final c = _flow; final c = _flow;
if (c == null || _reqBusy.contains(r.id)) return; if (c == null || _reqBusy.contains(r.id)) return;
@@ -393,11 +373,6 @@ class _SchedulePageState extends State<SchedulePage> {
icon: Icon(Icons.video_library_outlined), icon: Icon(Icons.video_library_outlined),
label: Text('On Jellyfin'), label: Text('On Jellyfin'),
), ),
ButtonSegment(
value: ScheduleFilter.following,
icon: Icon(Icons.bookmark_outline),
label: Text('Following'),
),
ButtonSegment(value: ScheduleFilter.all, icon: Icon(Icons.public), label: Text('All airing')), ButtonSegment(value: ScheduleFilter.all, icon: Icon(Icons.public), label: Text('All airing')),
], ],
selected: {_filter}, selected: {_filter},
@@ -541,12 +516,10 @@ class _SchedulePageState extends State<SchedulePage> {
entry: e, entry: e,
now: now, now: now,
compact: _compact, compact: _compact,
followed: _followed(e),
action: action, action: action,
disabledHint: hint, disabledHint: hint,
busy: busy, busy: busy,
onAction: () => _onAction(e, action), onAction: () => _onAction(e, action),
onFollow: e.tracked && e.seriesId != null && _flow != null ? () => _toggleFollow(e) : null,
); );
} }
@@ -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,7 +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 = _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;
@@ -344,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
@@ -397,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;
@@ -494,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;
@@ -613,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;
@@ -916,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,
@@ -923,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);
@@ -1243,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);
@@ -1260,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);
@@ -1270,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;
@@ -1351,8 +1402,8 @@ class SessionCoordinator {
/// Any picker selection. /// Any picker selection.
Future<String?> swapPicked(PickedMedia media) async { Future<String?> swapPicked(PickedMedia media) async {
switch (media) { switch (media) {
case LinkMedia(:final url, :final relay): case LinkMedia(:final url, :final relay, :final anyway):
return swapLink(url, relay: relay); return swapLink(url, relay: relay, anyway: anyway);
case JellyfinMedia(): case JellyfinMedia():
swapJellyfin(media); swapJellyfin(media);
case FileMedia(:final path): case FileMedia(:final path):
+1 -1
View File
@@ -379,7 +379,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (error != null || !mounted) return error; if (error != null || !mounted) return error;
} }
// Alone, a link plays direct: there is nobody to pass it on to. // Alone, a link plays direct: there is nobody to pass it on to.
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url); if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url, anyway: media.anyway);
final error = await session.coordinator.swapPicked(media); final error = await session.coordinator.swapPicked(media);
if (error != null || !mounted) return error; if (error != null || !mounted) return error;
final f = session.lastForm; final f = session.lastForm;
+23 -4
View File
@@ -19,6 +19,9 @@ class AvatarInfo {
final bool ready, buffering, relaying, stale, host, self; final bool ready, buffering, relaying, stale, host, self;
/// Sitting out: dimmed, with an "away" mark. /// Sitting out: dimmed, with an "away" mark.
final bool out; final bool out;
/// Why their player cannot open the media (`members[].error`): an error
/// mark; empty when it can.
final String error;
const AvatarInfo({ const AvatarInfo({
this.id = '', this.id = '',
required this.name, required this.name,
@@ -31,6 +34,7 @@ class AvatarInfo {
this.host = false, this.host = false,
this.self = false, this.self = false,
this.out = false, this.out = false,
this.error = '',
}); });
} }
@@ -78,7 +82,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
} }
/// Your own avatar from the profile (also offline). /// Your own avatar from the profile (also offline).
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false}) { AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) {
final p = Profile.instance; final p = Profile.instance;
final png = p.picture.value; final png = p.picture.value;
final name = p.name.value; final name = p.name.value;
@@ -90,6 +94,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = fal
host: host, host: host,
self: true, self: true,
out: out, out: out,
error: error,
); );
} }
@@ -104,7 +109,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
color: self ? Profile.instance.color.value : colorForName(m.name), color: self ? Profile.instance.color.value : colorForName(m.name),
image: memberAvatarImage(m, coord), image: memberAvatarImage(m, coord),
ready: m.ready, ready: m.ready,
buffering: !m.stale && m.ready && playing && m.buffered < r!.bufferNeed, buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed,
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id, relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
stale: m.stale, stale: m.stale,
// The state marks the host (`members[].host`); your own app also // The state marks the host (`members[].host`); your own app also
@@ -112,6 +117,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
host: m.host || (self && coord.hostFile != null), host: m.host || (self && coord.hostFile != null),
self: self, self: self,
out: m.out, out: m.out,
error: m.error,
); );
} }
@@ -138,7 +144,7 @@ class PartyWatch extends StatelessWidget {
/// Circle avatar with a status ring outside it and a gap in the background /// Circle avatar with a status ring outside it and a gap in the background
/// colour, so overlapping neighbours never hide the status. Crown = host, /// colour, so overlapping neighbours never hide the status. Crown = host,
/// tower = passing the video on, spinner = buffering, dimmed = stale, /// tower = passing the video on, spinner = buffering, dimmed = stale,
/// dimmed with a moon = sitting out. /// dimmed with a moon = sitting out, red "!" = cannot play the media.
class StatusAvatar extends StatelessWidget { class StatusAvatar extends StatelessWidget {
final AvatarInfo info; final AvatarInfo info;
final double size; final double size;
@@ -222,6 +228,17 @@ class StatusAvatar extends StatelessWidget {
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200), child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
), ),
), ),
if (info.error.isNotEmpty)
Positioned(
key: const ValueKey('error-mark'),
right: -3,
top: -3,
child: Container(
padding: const EdgeInsets.all(1),
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
child: Icon(Icons.error, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.redAccent.shade200),
),
),
if (badges && info.relaying) if (badges && info.relaying)
Positioned( Positioned(
right: -3, right: -3,
@@ -287,7 +304,9 @@ class _AvatarStackState extends State<AvatarStack> {
left: i * step, left: i * step,
top: 0, top: 0,
child: Tooltip( child: Tooltip(
message: visible[i].name, message: visible[i].error.isEmpty
? visible[i].name
: "${visible[i].name} can't play this: ${visible[i].error}",
child: StatusAvatar(info: visible[i], size: size), child: StatusAvatar(info: visible[i], size: size),
), ),
), ),
+48 -15
View File
@@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, notAVideoText; import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText;
import '../playback/yt_dlp_app.dart'; import '../playback/yt_dlp_app.dart';
import '../screens/home/media_picker.dart'; import '../screens/home/media_picker.dart';
@@ -107,7 +107,10 @@ class LinkPreview {
/// "youtube.com · Channel", or the host. /// "youtube.com · Channel", or the host.
final String subtitle; final String subtitle;
final String? thumbnail; final String? thumbnail;
const LinkPreview({required this.title, required this.subtitle, this.thumbnail}); /// Why this computer cannot open it, in plain words ([LinkProbe.problem]);
/// null when it looks playable.
final String? problem;
const LinkPreview({required this.title, required this.subtitle, this.thumbnail, this.problem});
} }
/// What a link is (see [AppYtDlp.probe]). /// What a link is (see [AppYtDlp.probe]).
@@ -125,24 +128,30 @@ String clockText(double seconds) {
} }
/// The preview of [url]: a web page shows yt-dlp's title, uploader, /// The preview of [url]: a web page shows yt-dlp's title, uploader,
/// duration and thumbnail (or why it is not a video); a media file (by /// duration and thumbnail; a media file its file name and host; a link
/// name or by probe) its file name and host. Null for text that is not an http(s) link. /// that does not open, why ([LinkPreview.problem]). Null for text that is
/// not an http(s) link.
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async { Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
final uri = Uri.tryParse(url.trim()); final uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null; if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
final host = _host(uri); final host = _host(uri);
// A media file name needs no lookup. // A Jellyfin item goes up as the item (see `mediaForLink`), not the page.
if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host); if (parseJellyfinLink(url.trim()) != null) return LinkPreview(title: host, subtitle: '$host · Jellyfin');
final file = hasMediaExtension(uri);
final LinkProbe p; final LinkProbe p;
try { try {
p = await probe(url.trim()); p = await probe(url.trim());
} catch (e) { } catch (e) {
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e'); final problem = playErrorText('$e');
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $problem', problem: problem);
} }
final info = p.info; final info = p.info;
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host); if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
if (info == null) { final problem = p.problem;
return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}'); if (info == null || problem != null) {
// A media file name that did not answer as one keeps its name.
final title = file ? LinkMedia(url.trim()).label : p.title ?? host;
return LinkPreview(title: title, subtitle: '$host · ${problem ?? 'Not a video'}', problem: problem);
} }
return LinkPreview( return LinkPreview(
title: p.title ?? host, title: p.title ?? host,
@@ -221,6 +230,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Timer? _debounce; Timer? _debounce;
bool _busy = false; bool _busy = false;
String? _error; String? _error;
/// [_error] is the probe's problem: "Put it up anyway" is offered.
bool _canForce = false;
@override @override
void initState() { void initState() {
@@ -242,7 +253,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (url == _previewUrl) return; if (url == _previewUrl) return;
_debounce?.cancel(); _debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url)); _debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
if (mounted) setState(() => _error = null); if (mounted) {
setState(() {
_error = null;
_canForce = false;
});
}
} }
Future<void> _preview(String url) async { Future<void> _preview(String url) async {
@@ -263,19 +279,27 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
} catch (_) {} } catch (_) {}
} }
Future<void> _go() async { Future<void> _go({bool anyway = false}) async {
final url = _c.link.text.trim(); final url = _c.link.text.trim();
if (url.isEmpty || _busy) return; if (url.isEmpty || _busy) return;
setState(() { setState(() {
_busy = true; _busy = true;
_error = null; _error = null;
_canForce = false;
}); });
String? error; // What the probe found refuses it here, with "Put it up anyway": the
// 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 { try {
error = await widget.onPutUp(LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay)); error = await widget.onPutUp(
LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
} catch (e) { } catch (e) {
error = '$e'; error = '$e';
} }
}
final canForce = problem != null;
if (error == null) { if (error == null) {
final p = _previews[url] ?? await previewLink(url, probe: widget.probe); final p = _previews[url] ?? await previewLink(url, probe: widget.probe);
unawaited(widget.recents.add(RecentLink( unawaited(widget.recents.add(RecentLink(
@@ -289,6 +313,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
setState(() { setState(() {
_busy = false; _busy = false;
_error = error; _error = error;
_canForce = canForce;
}); });
} }
@@ -373,6 +398,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey), style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
), ),
if (_canForce)
Center(
child: TextButton(
key: const ValueKey('link-put-up-anyway'),
onPressed: _busy ? null : () => _go(anyway: true),
child: Text(solo ? 'Play it anyway' : 'Put it up anyway'),
),
),
], ],
AnimatedSize( AnimatedSize(
duration: easeTime, duration: easeTime,
@@ -475,9 +508,9 @@ class _PreviewCard extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
p?.subtitle ?? 'Paste an http(s) address', p?.subtitle ?? 'Paste an http(s) address',
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey), style: TextStyle(color: p?.problem != null ? scheme.error : Colors.grey),
), ),
], ],
), ),
@@ -103,6 +103,7 @@ class PartyStrip extends StatelessWidget {
info: selfAvatarInfo( info: selfAvatarInfo(
ready: self?.ready ?? false, ready: self?.ready ?? false,
host: online && !solo && coord.hostFile != null, host: online && !solo && coord.hostFile != null,
error: online ? coord.playerError.value : '',
), ),
size: stripAvatarSize, size: stripAvatarSize,
ring: online, ring: online,
@@ -553,6 +554,8 @@ class _MemberTile extends StatelessWidget {
? 'reconnecting…' ? 'reconnecting…'
: m.out : m.out
? 'sitting out' ? 'sitting out'
: m.error.isNotEmpty
? "can't play this: ${m.error}"
: info.buffering : info.buffering
? 'buffering ${m.buffered.toStringAsFixed(1)} s' ? 'buffering ${m.buffered.toStringAsFixed(1)} s'
: m.ready : m.ready
+33 -6
View File
@@ -11,11 +11,12 @@ import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
/// What the player's status pill says, most pressing first. /// What the player's status pill says, most pressing first.
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, waiting } enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting }
/// The pill's button: stop reconnecting, retry, or mark yourself ready /// The pill's button: stop reconnecting, retry (reconnect), reload the
/// (the whole pill). /// video (your player could not open it), or mark yourself ready (the
enum StatusAction { cancel, retry, ready } /// whole pill).
enum StatusAction { cancel, retry, reload, ready }
/// One status line: [text], a muted [detail], a coloured [tone] for /// One status line: [text], a muted [detail], a coloured [tone] for
/// problems (warn, error), the members it is about ([who]; [self] for you) /// problems (warn, error), the members it is about ([who]; [self] for you)
@@ -55,18 +56,24 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
/// The player's status from the session's signals; null when there is /// The player's status from the session's signals; null when there is
/// nothing to say (playing, connected, everyone ready). [slowConnect]: /// nothing to say (playing, connected, everyone ready). [slowConnect]:
/// connecting has taken over 2 s. /// connecting has taken over 2 s. [playerError]: why your player cannot
/// open the media (the coordinator's `playerError`).
StatusView? playerStatusFor({ StatusView? playerStatusFor({
required HealthStatus health, required HealthStatus health,
bool reconnecting = false, bool reconnecting = false,
StateBroadcast? state, StateBroadcast? state,
MemberView? self, MemberView? self,
bool slowConnect = false, bool slowConnect = false,
String playerError = '',
}) { }) {
if (reconnecting) { if (reconnecting) {
return const StatusView(StatusKind.reconnecting, 'Reconnecting…', return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel); tone: HealthSeverity.warn, action: StatusAction.cancel);
} }
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) {
return StatusView(StatusKind.problem, "Can't play this: $playerError",
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
}
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) { if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
return StatusView(StatusKind.problem, _sentence(health.message), return StatusView(StatusKind.problem, _sentence(health.message),
tone: health.severity, action: StatusAction.retry); tone: health.severity, action: StatusAction.retry);
@@ -81,6 +88,12 @@ StatusView? playerStatusFor({
if (r.transport == 'finished') { if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished'); return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
} }
// Members whose player cannot open it: the server does not wait for
// them; say why instead of "waiting for".
final failed = [
for (final m in r.members)
if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m,
];
// As the server holds playback: someone in the ready set short of the // As the server holds playback: someone in the ready set short of the
// minimum buffer. // minimum buffer.
final need = r.bufferNeed; final need = r.bufferNeed;
@@ -92,12 +105,22 @@ StatusView? playerStatusFor({
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%'); who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
} }
} }
if (failed.isNotEmpty) {
final f = failed.first;
final who = nameList([for (final m in failed) m.name]);
final unready = self != null && !self.ready;
return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}",
who: failed,
short: "$who can't play this",
detail: unready ? "Press Space when you're ready" : null,
action: unready ? StatusAction.ready : null);
}
if (self != null && !self.ready) { if (self != null && !self.ready) {
return StatusView(StatusKind.you, "Press Space when you're ready", return StatusView(StatusKind.you, "Press Space when you're ready",
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready'); who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
} }
// Members sitting out are never waited for nor counted. // Members sitting out are never waited for nor counted.
final active = [for (final m in r.members) if (!m.stale && !m.out) m]; final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m];
final others = [ final others = [
for (final m in active) for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m, if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
@@ -117,6 +140,7 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
state: c.serverState.value, state: c.serverState.value,
self: c.selfMember.value, self: c.selfMember.value,
slowConnect: slowConnect, slowConnect: slowConnect,
playerError: c.playerError.value,
); );
} }
@@ -164,6 +188,8 @@ class _PlayerStatusState extends State<PlayerStatus> {
coord.cancelReconnect(); coord.cancelReconnect();
case StatusAction.retry: case StatusAction.retry:
coord.retry(); coord.retry();
case StatusAction.reload:
unawaited(coord.reloadVideo());
case StatusAction.ready: case StatusAction.ready:
coord.toggleReady(); coord.toggleReady();
} }
@@ -266,6 +292,7 @@ class _StatusLine extends StatelessWidget {
return null; return null;
case StatusKind.loading: case StatusKind.loading:
case StatusKind.you: case StatusKind.you:
case StatusKind.cannotPlay:
case StatusKind.waiting: case StatusKind.waiting:
final r = coord.serverState.value; final r = coord.serverState.value;
final infos = [ final infos = [
@@ -219,6 +219,7 @@ class SyncClient {
double duration = 0, double duration = 0,
int mediaId = 0, int mediaId = 0,
Map<String, bool>? jf, Map<String, bool>? jf,
String error = '',
}) { }) {
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered; final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
_send( _send(
@@ -229,6 +230,7 @@ class SyncClient {
duration: duration.isFinite && duration > 0 ? duration : 0, duration: duration.isFinite && duration > 0 ? duration : 0,
mediaId: mediaId, mediaId: mediaId,
jf: jf, jf: jf,
error: error,
).toJson(), ).toJson(),
); );
} }
+1 -1
View File
@@ -138,7 +138,7 @@ packages:
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi: ffi:
dependency: transitive dependency: "direct main"
description: description:
name: ffi name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
+2 -1
View File
@@ -1,7 +1,7 @@
name: watch_together name: watch_together
description: Watch-together desktop app (working title, repo sync_relay_project). One binary. description: Watch-together desktop app (working title, repo sync_relay_project). One binary.
publish_to: none publish_to: none
version: 1.1.1 version: 1.1.2
environment: environment:
sdk: '>=3.13.0-0 <4.0.0' sdk: '>=3.13.0-0 <4.0.0'
@@ -24,6 +24,7 @@ dependencies:
image: ^4.9.2 image: ^4.9.2
window_manager: ^0.5.2 window_manager: ^0.5.2
crypto: ^3.0.6 crypto: ^3.0.6
ffi: ^2.1.0
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard). # Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
fox_utils: fox_utils:
git: git:
+5 -2
View File
@@ -202,8 +202,11 @@ void main() {
final page = await previewLink('https://example.com/a', final page = await previewLink('https://example.com/a',
probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL')); probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL'));
expect((page!.title, page.subtitle), expect((page!.title, page.subtitle, page.problem),
('Example', "example.com · Not a video (and yt-dlp couldn't read it: Unsupported URL)")); ('Example', 'example.com · Not a video page yt-dlp knows', 'Not a video page yt-dlp knows'));
final refused = await previewLink('https://cdn.example/b.mp4',
probe: (url) async => LinkProbe.page(Uri.parse(url), ytDlpError: 'odd', status: 403));
expect((refused!.title, refused.problem), ('b.mp4', 'Not allowed (403)'));
expect(await previewLink('not a link'), isNull); expect(await previewLink('not a link'), isNull);
}); });
+14
View File
@@ -110,6 +110,8 @@ 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)
: 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')), : 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),
@@ -296,6 +298,18 @@ void main() {
await settle(tester, 10); await settle(tester, 10);
expect(h.controller.link.text, url); expect(h.controller.link.text, url);
// A link the probe cannot open: the reason, not put up; "anyway" is.
const bili = 'https://www.bilibili.com/video/BV1';
await tester.enterText(find.byKey(const ValueKey('link-field')), bili);
await settle(tester, 10);
await tester.tap(find.byKey(const ValueKey('link-put-up')));
await settle(tester, 10);
expect(find.text(needsCookiesText), findsOneWidget);
expect(h.put, hasLength(1));
await tester.tap(find.byKey(const ValueKey('link-put-up-anyway')));
await settle(tester, 10);
expect((h.put.last as LinkMedia).anyway, isTrue);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
+13
View File
@@ -112,6 +112,19 @@ void main() {
expect(w.who.map((m) => m.name), ['Bob', 'Mika']); expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
}); });
test("can't play: yours with Retry (reload); others' instead of waiting", () {
var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!;
expect((v.kind, v.text, v.action), (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload));
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false,
error: 'Not allowed (403)');
v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!;
expect((v.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null));
expect(v.who.single.id, 'b');
final notMe = mv('me', 'Me');
v = playerStatusFor(health: ok, state: held([notMe, bob]), self: notMe)!;
expect((v.kind, v.action), (StatusKind.cannotPlay, StatusAction.ready));
});
test('names read as a sentence', () { test('names read as a sentence', () {
expect(nameList(['Bob']), 'Bob'); expect(nameList(['Bob']), 'Bob');
expect(nameList(['Bob', 'Mika']), 'Bob and Mika'); expect(nameList(['Bob', 'Mika']), 'Bob and Mika');
+3 -11
View File
@@ -29,7 +29,6 @@ final seriesJson = {
'jellyfin_series_id': 'jf7', 'jellyfin_series_id': 'jf7',
'state': 'released', 'state': 'released',
'monitored': true, 'monitored': true,
'followed': true,
'episodes_total': 24, 'episodes_total': 24,
'episodes_available': 20, 'episodes_available': 20,
'next_air_date': null, 'next_air_date': null,
@@ -110,7 +109,7 @@ void main() {
'to': req.uri.queryParameters['to'], 'to': req.uri.queryParameters['to'],
'items': [ 'items': [
{...episodeJson, 'series_id': 7, 'title': 'Show', 'anidb_id': 70, 'tvdb_id': 700, {...episodeJson, 'series_id': 7, 'title': 'Show', 'anidb_id': 70, 'tvdb_id': 700,
'jellyfin_series_id': 'jf7', 'followed': true}, 'jellyfin_series_id': 'jf7'},
], ],
}, etag: 'W/"s1"'); }, etag: 'W/"s1"');
}, },
@@ -126,7 +125,6 @@ void main() {
expect(it.anidbId, 70); expect(it.anidbId, 70);
expect(it.tvdbId, 700); expect(it.tvdbId, 700);
expect(it.jellyfinSeriesId, 'jf7'); expect(it.jellyfinSeriesId, 'jf7');
expect(it.followed, isTrue);
final e = it.episode; final e = it.episode;
expect((e.season, e.episode, e.absolute, e.title, e.anilistId), (2, 3, 15, 'Three', 222)); expect((e.season, e.episode, e.absolute, e.title, e.anilistId), (2, 3, 15, 'Three', 222));
expect(e.airDate, DateTime.utc(2026, 10, 8, 19, 36, 32)); expect(e.airDate, DateTime.utc(2026, 10, 8, 19, 36, 32));
@@ -146,7 +144,7 @@ void main() {
expect(server.seen, isEmpty); expect(server.seen, isEmpty);
}); });
test('/series, /series/{id} with episodes, follow/unfollow', () async { test('/series, /series/{id} with episodes', () async {
await serve({ await serve({
'GET /series': (req, res) async => json(res, {'items': [seriesJson]}, etag: 'W/"a"'), 'GET /series': (req, res) async => json(res, {'items': [seriesJson]}, etag: 'W/"a"'),
'GET /series/7': (req, res) async => json(res, { 'GET /series/7': (req, res) async => json(res, {
@@ -156,24 +154,18 @@ void main() {
{'season': 1, 'episode': 1, 'status': 'failed', 'reason': 'No release'}, {'season': 1, 'episode': 1, 'status': 'failed', 'reason': 'No release'},
], ],
}), }),
'PUT /series/7/follow': (req, res) async => res.statusCode = 204,
'DELETE /series/7/follow': (req, res) async => res.statusCode = 204,
}); });
final c = client(); final c = client();
final s = (await c.series()).single; final s = (await c.series()).single;
expect(s.seriesId, 7); expect(s.seriesId, 7);
expect(s.anilistId, 111); expect(s.anilistId, 111);
expect(s.state, SeriesState.released); expect(s.state, SeriesState.released);
expect(s.monitored && s.followed, isTrue); expect(s.monitored, isTrue);
expect((s.episodesTotal, s.episodesAvailable, s.nextAirDate), (24, 20, null)); expect((s.episodesTotal, s.episodesAvailable, s.nextAirDate), (24, 20, null));
final d = await c.seriesDetail(7); final d = await c.seriesDetail(7);
expect(d.episodes.map((e) => e.season), [0, 1]); expect(d.episodes.map((e) => e.season), [0, 1]);
expect(d.episodes.last.status, EpisodeStatus.failed); expect(d.episodes.last.status, EpisodeStatus.failed);
expect(d.episodes.last.reason, 'No release'); expect(d.episodes.last.reason, 'No release');
await c.setFollowed(7, true);
await c.setFollowed(7, false);
expect(server.seen.skip(2).map((r) => '${r.method} ${r.uri.path}'),
['PUT /api/app/v1/series/7/follow', 'DELETE /api/app/v1/series/7/follow']);
}); });
test('/search and the three request bodies', () async { test('/search and the three request bodies', () async {
+20 -7
View File
@@ -20,7 +20,7 @@ Airing airing(int id, int ep, DateTime local) => Airing(
); );
AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status, AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
{int season = 1, int? absolute, DateTime? air, int seriesId = 7, bool followed = false, {int season = 1, int? absolute, DateTime? air, int seriesId = 7,
double? progress, String? reason}) => double? progress, String? reason}) =>
AnimeFlowScheduleItem( AnimeFlowScheduleItem(
episode: AnimeFlowEpisode( episode: AnimeFlowEpisode(
@@ -36,7 +36,6 @@ AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
seriesId: seriesId, seriesId: seriesId,
title: 'Show', title: 'Show',
jellyfinSeriesId: 'jf$seriesId', jellyfinSeriesId: 'jf$seriesId',
followed: followed,
); );
void main() { void main() {
@@ -129,13 +128,28 @@ void main() {
expect(out.single.season, 2); expect(out.single.season, 2);
}); });
test('a TVDB number never claims another AniList episode (JoJo S6E4 = 2nd STAGE ep 3)', () {
AnimeFlowScheduleItem row(int tvdbEp, int aniEp, EpisodeStatus st) => AnimeFlowScheduleItem(
episode: AnimeFlowEpisode(season: 6, episode: tvdbEp, anilistId: 210482, anilistEpisode: aniEp, status: st),
seriesId: 40,
title: 'JoJo',
jellyfinSeriesId: 'jfJ',
);
final out = matchSchedule(
[airing(210482, 3, DateTime(2026, 10, 9, 11)), airing(210482, 4, DateTime(2026, 10, 16, 11))],
schedule: [row(4, 3, EpisodeStatus.available), row(5, 4, EpisodeStatus.upcoming)],
now: now,
);
expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.upcoming]);
});
test('by AniList id + episode, per season ids', () { test('by AniList id + episode, per season ids', () {
// One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200. // One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200.
final out = matchSchedule( final out = matchSchedule(
[airing(100, 3, DateTime(2026, 10, 1, 20)), airing(200, 3, DateTime(2026, 10, 9, 20))], [airing(100, 3, DateTime(2026, 10, 1, 20)), airing(200, 3, DateTime(2026, 10, 9, 20))],
schedule: [ schedule: [
item(100, 3, EpisodeStatus.available, season: 1), item(100, 3, EpisodeStatus.available, season: 1),
item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4, followed: true), item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4),
], ],
series: [const AnimeFlowSeries(seriesId: 7, title: 'Show', anilistId: 100, jellyfinSeriesId: 'jf7')], series: [const AnimeFlowSeries(seriesId: 7, title: 'Show', anilistId: 100, jellyfinSeriesId: 'jf7')],
now: now, now: now,
@@ -143,7 +157,6 @@ void main() {
expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.downloading]); expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.downloading]);
expect(out.map((e) => e.season), [1, 2]); expect(out.map((e) => e.season), [1, 2]);
expect(out[1].progress, 0.4); expect(out[1].progress, 0.4);
expect(out[1].followed, isTrue);
expect(out.every((e) => e.tracked && e.seriesId == 7 && e.jellyfinSeriesId == 'jf7'), isTrue); expect(out.every((e) => e.tracked && e.seriesId == 7 && e.jellyfinSeriesId == 'jf7'), isTrue);
}); });
@@ -190,11 +203,11 @@ void main() {
final out = matchSchedule( final out = matchSchedule(
[airing(500, 2, DateTime(2026, 10, 7, 20)), airing(500, 3, DateTime(2026, 10, 14, 20))], [airing(500, 2, DateTime(2026, 10, 7, 20)), airing(500, 3, DateTime(2026, 10, 14, 20))],
schedule: const [], schedule: const [],
series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500, followed: true)], series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500)],
now: now, now: now,
); );
expect(out.map((e) => e.status), [ScheduleStatus.missing, ScheduleStatus.upcoming]); expect(out.map((e) => e.status), [ScheduleStatus.missing, ScheduleStatus.upcoming]);
expect(out.every((e) => e.followed && e.seriesId == 9), isTrue); expect(out.every((e) => e.seriesId == 9), isTrue);
}); });
test('progress only while downloading, reason only when failed', () { test('progress only while downloading, reason only when failed', () {
@@ -256,7 +269,7 @@ void main() {
return json(res, { return json(res, {
'items': [ 'items': [
{'season': 1, 'episode': 3, 'anilist_id': 100, 'status': 'available', 'series_id': 7, {'season': 1, 'episode': 3, 'anilist_id': 100, 'status': 'available', 'series_id': 7,
'title': 'Show', 'followed': false}, 'title': 'Show'},
], ],
}); });
case '/flow/series': case '/flow/series':
+2 -18
View File
@@ -88,7 +88,6 @@ List<ScheduleEntry> entries() => [
airing: airing(111, 'Avail Show', 3, day(0, 9)), airing: airing(111, 'Avail Show', 3, day(0, 9)),
status: ScheduleStatus.available, status: ScheduleStatus.available,
tracked: true, tracked: true,
followed: true,
seriesId: 7, seriesId: 7,
season: 1, season: 1,
jellyfinSeriesId: 'jf7', jellyfinSeriesId: 'jf7',
@@ -201,8 +200,6 @@ void main() {
}); });
case ('GET', '/search'): case ('GET', '/search'):
json(res, {'items': searchHits}); json(res, {'items': searchHits});
case ('PUT' || 'DELETE', '/series/7/follow'):
res.statusCode = 204;
case ('POST', '/requests/1/approve' || '/requests/2/reject'): case ('POST', '/requests/1/approve' || '/requests/2/reject'):
json(res, {}); json(res, {});
default: default:
@@ -298,13 +295,9 @@ void main() {
await done(tester); await done(tester);
}); });
testWidgets('filters: following, all airing, niche too, compact', (tester) async { testWidgets('filters: all airing, niche too, compact', (tester) async {
await pumpPage(tester); await pumpPage(tester);
await tester.tap(find.text('Following')); expect(find.text('Following'), findsNothing);
await tester.pump();
expect(card(111, 3), findsOneWidget);
expect(card(222, 5), findsNothing);
await tester.tap(find.text('All airing')); await tester.tap(find.text('All airing'));
await tester.pump(); await tester.pump();
expect(card(444, 1), findsOneWidget); expect(card(444, 1), findsOneWidget);
@@ -398,15 +391,6 @@ void main() {
await done(tester); await done(tester);
}); });
testWidgets('follow toggles through AnimeFlow', (tester) async {
final repo = await pumpPage(tester);
await tester.tap(find.byKey(const ValueKey('follow-111-3')));
await io(tester);
expect(server.seen.where((r) => r.method == 'DELETE' && r.uri.path.endsWith('/series/7/follow')), hasLength(1));
expect(repo.forced.length, greaterThanOrEqualTo(2)); // reloaded
await done(tester);
});
testWidgets('put it up resolves the Jellyfin episode', (tester) async { testWidgets('put it up resolves the Jellyfin episode', (tester) async {
final jf = FakeJf(); final jf = FakeJf();
final accounts = JellyfinAccounts( final accounts = JellyfinAccounts(
@@ -0,0 +1,148 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:watch_together/src/playback/playback_controller.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
const _referer = 'https://www.bilibili.com/';
/// Separate video-only and audio-only files, like Bilibili's DASH .m4s.
Future<(String, String)> _media() async {
final v = '$_tmp/ytdl_headers_v.mp4', a = '$_tmp/ytdl_headers_a.m4a';
for (final (path, args) in [
(v, ['-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24', '-c:v', 'libx264', '-preset', 'ultrafast']),
(a, ['-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', '-c:a', 'aac']),
]) {
if (await File(path).exists()) continue;
final r = await Process.run('ffmpeg', ['-y', '-v', 'error', ...args, '-t', '30', path]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
}
return (v, a);
}
/// A CDN like Bilibili's: 403 without the page's Referer.
class _Cdn {
_Cdn(this._server, this._files) {
_server.listen(_serve);
}
final HttpServer _server;
final Map<String, String> _files;
int ok = 0, forbidden = 0;
String url(String name) => 'http://127.0.0.1:${_server.port}/$name';
Future<void> _serve(HttpRequest req) async {
final res = req.response;
final file = _files[req.uri.pathSegments.lastOrNull];
if (file == null || req.headers.value('referer') != _referer) {
forbidden++;
res.statusCode = HttpStatus.forbidden;
await res.close();
return;
}
ok++;
final bytes = await File(file).readAsBytes();
var start = 0, end = bytes.length - 1;
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
res.headers.set('accept-ranges', 'bytes');
if (range != null) {
start = int.tryParse(range[1]!) ?? 0;
end = int.tryParse(range[2]!) ?? end;
res.statusCode = HttpStatus.partialContent;
res.headers.set('content-range', 'bytes $start-$end/${bytes.length}');
}
res.contentLength = end - start + 1;
res.add(bytes.sublist(start, end + 1));
await res.close();
}
}
/// A stand-in yt-dlp printing what yt-dlp gives for a split-track page:
/// `requested_formats` with per-format `http_headers`, none at the top.
Future<String> _fakeYtDlp(_Cdn cdn, {required bool headers}) async {
final dir = await Directory('$_tmp/ytdl_headers_fake').create(recursive: true);
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
final json = jsonEncode({
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
'format_id': 'v+a', 'ext': 'mp4',
'requested_formats': [
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h},
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h},
],
});
final f = File('${dir.path}/yt-dlp-${headers ? 'h' : 'n'}');
await f.writeAsString("#!/bin/sh\ncat <<'EOF'\n$json\nEOF\n");
await Process.run('chmod', ['+x', f.path]);
return f.path;
}
void main() {
late _Cdn cdn;
late HttpServer server;
setUpAll(() async {
MediaKit.ensureInitialized();
final (v, a) = await _media();
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
cdn = _Cdn(server, {'v.m4s': v, 'a.m4s': a});
});
tearDownAll(() => server.close(force: true));
test(
"mpv's yt-dlp hook sends each format's Referer to the CDN",
timeout: const Timeout(Duration(minutes: 1)),
() async {
final p = PlaybackController();
try {
await p.init();
expect(p.pinMismatches, isEmpty, reason: 'stream-lavf-o and the rest took effect');
await p.useYtDlp(await _fakeYtDlp(cdn, headers: true));
await p.openUrl('https://www.bilibili.com/video/BV1fake');
final end = DateTime.now().add(const Duration(seconds: 20));
while (DateTime.now().isBefore(end) && p.duration.peek() == Duration.zero) {
await Future.delayed(const Duration(milliseconds: 100));
}
expect(p.duration.peek(), greaterThan(Duration.zero), reason: 'both tracks opened');
expect(cdn.forbidden, 0, reason: 'every request had the Referer');
expect(cdn.ok, greaterThanOrEqualTo(2));
} finally {
await p.dispose();
}
},
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
);
test(
'a 403 is a load error, not a reconnect loop',
timeout: const Timeout(Duration(minutes: 1)),
() async {
final p = PlaybackController();
final errors = <String>[];
p.onLoadError = errors.add;
try {
await p.init();
var logs = 0;
final sub = p.player.stream.log.listen((_) => logs++);
await p.useYtDlp(await _fakeYtDlp(cdn, headers: false));
final before = cdn.forbidden;
await p.openUrl('https://www.bilibili.com/video/BV2fake');
await Future.delayed(const Duration(seconds: 8));
await sub.cancel();
// ignore: avoid_print
print('403s: ${cdn.forbidden - before}, log lines: $logs, errors: $errors');
expect(cdn.forbidden - before, lessThan(10), reason: 'no retry loop on 403');
expect(errors, hasLength(1));
// The status only when ffmpeg logs through this mpv (see _onLog).
expect(errors.single, anyOf(contains('403'), contains('could not be opened')));
expect(logs, lessThan(30), reason: 'warnings and errors only');
} finally {
await p.dispose();
}
},
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
);
}
+66 -13
View File
@@ -113,8 +113,17 @@ Loosely modeled on Syncplay, with a different readiness design.
setting "Unready when the video changes", `AppSettings.unreadyOnSwap`, setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
default off, sends `ready=false` itself on a new `mediaId`; not for the default off, sends `ready=false` itself on a new `mediaId`; not for the
first state of a session, and a reconnect does not restore a ready that first state of a session, and a reconnect does not restore a ready that
a swap meanwhile would have dropped). Stale, leave and sitting out a swap meanwhile would have dropped). Stale, leave, sitting out and a
remove from the set. player error remove from the set.
- **Player error** (`EngineMember.error`, `report.error`,
`state.members[].error`): a member whose player cannot open the current
media says why in its reports; the server takes it out of the set at
once (like sitting out, but automatic: `ready`/`force` do not put it
back, its ready flag is kept), so the others start without it. A report
without it (the media opened, after a reload too) or a swap clears it:
back in the set like a join while not PLAYING; while PLAYING from its
next frame if it is ready (the others then hold for its buffer, as for a
stale member coming back), else from its `ready`.
- **Sitting out** (`EngineMember.out`, `state.members[].out`): a member - **Sitting out** (`EngineMember.out`, `state.members[].out`): a member
stays connected (chat, feed, party, nudges) but is never in the set and stays connected (chat, feed, party, nudges) but is never in the set and
never ready: no `ready` or `force` reaches it, a swap leaves it out, its never ready: no `ready` or `force` reaches it, a swap leaves it out, its
@@ -256,8 +265,25 @@ mpv settings (`PlaybackController`): `Player` with `bufferSize` 256 MiB
read-ahead: rides out short drops, intro skips are instant), read-ahead: rides out short drops, intro skips are instant),
`demuxer-max-bytes=268435456` (150 s up to ~14 Mbit/s; a 1.4 GB / 24 min `demuxer-max-bytes=268435456` (150 s up to ~14 Mbit/s; a 1.4 GB / 24 min
file is ~8), `demuxer-max-back-bytes=67108864`, file is ~8), `demuxer-max-back-bytes=67108864`,
`stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4` `stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=5xx,reconnect_delay_max=4,reconnect_max_retries=3`
(comma form; this mpv build rejects colon-joined). The cache is in RAM: (comma form; this mpv build rejects colon-joined): a dropped connection
or a 5xx is retried, at most 3 times per read and 4 s apart at most; a
4xx (a CDN refusing a request) fails at once instead of being retried
(older ffmpeg ignores `reconnect_max_retries`). A load that fails (mpv's
fatal "No video or audio streams selected", or an EDL track that cannot
be opened) before a duration is known is reported once per open
(`onLoadError`), with ffmpeg's HTTP status when this player gets
ffmpeg's log (ffmpeg logs through one mpv instance per process, the
first one alive). That, a yt-dlp hook error and a failed `openUrl` become
the coordinator's `playerError` in plain words (`playErrorText`, core
`link_probe.dart`: 412/precondition/login/cookies/sign in → "This site
needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed
(403)", 404/410 → "Not found (404)", "Unsupported URL" → "Not a video
page yt-dlp knows", network errors → "Couldn't reach the site", anything
else as is). It rides in `report.error` (see "Player error" in the sync
model) until the media opens (a new duration) or the server's `mediaId`
moves on; the pill shows "Can't play this: ..." with Retry (Reload
video). The cache is in RAM:
media_kit sets `cache-on-disk` without a `cache-dir`, and mpv falls back. mpv 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
@@ -288,12 +314,18 @@ turns shaders off (saved) with a toast.
Render mode: media_kit's Linux/Windows video output creates mpv's render Render mode: media_kit's Linux/Windows video output creates mpv's render
context (OpenGL, else its software renderer) when the `VideoController` is context (OpenGL, else its software renderer) when the `VideoController` is
created, and nothing in its API says which. The player therefore requests created, and nothing in its API says which. The player therefore requests
mpv logs at `v`: OpenGL init logs `GL_VERSION` under `libmpv_render` mpv logs at `v` while the controller is created: OpenGL init logs
`GL_VERSION` under `libmpv_render`
(`RenderMode.gpu`; "suspected software renderer" means llvmpipe, (`RenderMode.gpu`; "suspected software renderer" means llvmpipe,
`softwareGl`); none within 1.5 s of the controller being ready means the `softwareGl`); none within 1.5 s of the controller being ready means the
software path (`software`, where shaders do nothing; the shader toast, the software path (`software`, where shaders do nothing; the shader toast, the
menu and Settings say so). After startup the `v` level adds no steady menu and Settings say so). Otherwise the session player logs at `warn`
traffic (measured: 0 messages over 5 s of playback). The hardware path is (`PlaybackController.logLevel`, switched with `mpv_request_log_messages`
on media_kit's handle): media_kit drains mpv's log on the UI thread, one
message at a time, so a chatty level, or ffmpeg logging every retry of a
refused request, could starve the UI. Shader and yt-dlp hook errors are
`error` lines and still arrive. The thumbnail and sound players keep
media_kit's default (`error`) and nothing listens to their log. The hardware path is
fragile: media_kit borrows whatever EGL context GDK left current on the main fragile: media_kit borrows whatever EGL context GDK left current on the main
thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change
can silently fall back to software. can silently fall back to software.
@@ -633,15 +665,30 @@ 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: ...". file. A hook error is a player error (see the mpv settings under "Media"). The hook (mpv
0.41) gives mpv the first requested format's `http_headers` (User-Agent
as `user-agent`; Referer, Cookie, X-Forwarded-For as file-local
`http-header-fields`, which win over the empty list media_kit sets on
load) and its `cookies` (`stream-lavf-o`), for every track of its EDL:
Bilibili's CDN answers 403 without the Referer. The app never opens a
resolved format URL in mpv itself (the putter of "Through me" plays the
page through the hook too); relays read them with yt-dlp's headers.
- **Through me**: the putter's probe must give tracks, and a pieces - **Through me**: the putter's probe must give tracks, and a pieces
track's playlist must be readable, else the swap is refused ("Live track's playlist must be readable, else the swap is refused ("Live
streams can't be passed on", DRM, or nothing a relay can read) with a streams can't be passed on", DRM, or nothing a relay can read) with a
@@ -903,7 +950,13 @@ always scrolls the page). Space never presses a focused button
`StateBroadcast.bufferNeed`, the gate's own need at the current `StateBroadcast.bufferNeed`, the gate's own need at the current
position: less near the end, none at finished; their avatar with the progress round it), you not position: less near the end, none at finished; their avatar with the progress round it), you not
ready (a click marks you ready) and who everyone waits for (avatars, ready (a click marks you ready) and who everyone waits for (avatars,
"2/3 ready"); nothing otherwise, info never. 34 px avatars, a main line "2/3 ready"); nothing otherwise, info never. Your own `playerError`
comes right after reconnecting ("Can't play this: ...", Retry reloads
the video); members with `members[].error` show as "Bob can't play
this: ..." (before your ready prompt, which then rides in the detail
line) and are never in "waiting for". A member's new error also toasts
once (the pill is hidden while playing); avatars carry a red error mark
and the members card says "can't play this: ...". 34 px avatars, a main line
and a muted detail line. Frosted glass, colour only for problems; it fades through and a muted detail line. Frosted glass, colour only for problems; it fades through
between kinds. The mini shows its short line in its hover bar. ⚙ in both between kinds. The mini shows its short line in its hover bar. ⚙ in both
control designs opens the player settings card (`controls/ control designs opens the player settings card (`controls/
+16 -3
View File
@@ -40,7 +40,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
{"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}} {"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}}
{"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}} {"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}}
{"t":"report","position":123.1,"buffered":6.2,"eventId":41, {"t":"report","position":123.1,"buffered":6.2,"eventId":41,
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}} "duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"}
{"t":"ping","c":1767225600000} {"t":"ping","c":1767225600000}
{"t":"chat","text":"..."} {"t":"chat","text":"..."}
{"t":"out","target":"<memberId>","out":true} {"t":"out","target":"<memberId>","out":true}
@@ -72,6 +72,17 @@ Field notes:
client plays the item with its own account, `false` = through the relay. client plays the item with its own account, `false` = through the relay.
Sticky: a report without `jf` keeps the last one. At most 8 entries, keys Sticky: a report without `jf` keeps the last one. At most 8 entries, keys
up to 64 characters; others are dropped. up to 64 characters; others are dropped.
- `report.error` (optional, omitted when empty): short plain text saying
why this client's player cannot open the media `report.mediaId` (e.g.
`Not allowed (403)`). The client sends it in every report while that
lasts and drops it once the media opens (a reload included). The server
keeps it per member (one line, cut to 120 characters) only for the
current `mediaId`: a report without it, or for another `mediaId`,
clears it, and so does a swap. An errored member is out of the ready
gate: out of the set, a `ready` or `force` does not put it back; when
the error clears it is in the set again like a join (unless `playing`,
then from its `ready`). Optional on both sides: older servers ignore it,
older clients ignore `members[].error`.
- `ping.c`: the client's clock in epoch ms; answered with `pong`. - `ping.c`: the client's clock in epoch ms; answered with `pong`.
- `user.avatar`: base64 PNG, at most 256 KiB of characters (else - `user.avatar`: base64 PNG, at most 256 KiB of characters (else
`rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32 `rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32
@@ -113,7 +124,7 @@ Field notes:
"serverTime":"<ISO-8601 UTC>","mediaId":1, "serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent, "media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?, "members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
"host"?,"out"?}], "host"?,"out"?,"error"?}],
"seekBy":"m1","seekSeq":3,"relay":true} "seekBy":"m1","seekSeq":3,"relay":true}
{"t":"pong","c":1767225600000,"s":1767225600012} {"t":"pong","c":1767225600000,"s":1767225600012}
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"} {"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
@@ -145,7 +156,9 @@ Field notes:
(omitted when the member has no picture) names the picture's bytes (see (omitted when the member has no picture) names the picture's bytes (see
"Avatars"). `members[].host` (omitted when false): the member sent the "Avatars"). `members[].host` (omitted when false): the member sent the
server's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when server's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when
false): the member sits out (see `out`). false): the member sits out (see `out`). `members[].error` (omitted when
empty): the member's player cannot open the current media (see
`report.error`).
- `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms - `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms
(the clock `serverTime` uses). With `t3` the local receive time, the offset (the clock `serverTime` uses). With `t3` the local receive time, the offset
is `s - (c + t3) / 2`, accurate to half the round trip. is `s - (c + t3) / 2`, accurate to half the round trip.
+2 -4
View File
@@ -33,8 +33,8 @@ off by deleting it in the same commit as the code. Wire changes also edit
should open the video track's URL alone, not the EDL. should open the video track's URL alone, not the EDL.
4. **AniList account**: sign in (OAuth via a localhost callback), "My list" 4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
filter on the Schedule; today's "Following" (AnimeFlow's monitored filter on the Schedule from the user's AniList lists. This is the
shows) becomes "On the server". Schedule's personal tracking; AnimeFlow has no per-user follow.
5. **Ops**: check shaders on Windows; the relay e2e "source drops 5. **Ops**: check shaders on Windows; the relay e2e "source drops
uncleanly and resumes" fails about half the time under the full suite. uncleanly and resumes" fails about half the time under the full suite.
@@ -76,8 +76,6 @@ off by deleting it in the same commit as the code. Wire changes also edit
not wanted yet. not wanted yet.
- HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater) - HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater)
to dio from `fox_utils` when touching them, for retries/interceptors. to dio from `fox_utils` when touching them, for retries/interceptors.
- The `state` broadcast carries the full media URL, which can include an
`api_key` (recognised Jellyfin links become refs; other links do not).
- TLS: document a reverse proxy in front of the server. - TLS: document a reverse proxy in front of the server.
- Playlist: `queue-add`, `queue-skip`, auto-advance like a swap. - Playlist: `queue-add`, `queue-skip`, auto-advance like a swap.
- Sidecar subtitles. - Sidecar subtitles.
+2
View File
@@ -50,6 +50,8 @@ abstract class RelayConstants {
static const int maxBannerBytes = 768 * 1024; static const int maxBannerBytes = 768 * 1024;
static const int maxNameLength = 32; static const int maxNameLength = 32;
static const int maxChatLength = 500; static const int maxChatLength = 500;
/// Characters of `report.error` the server keeps.
static const int maxMemberErrorLength = 120;
/// Nudges one member may send: at most this many per second and per /// Nudges one member may send: at most this many per second and per
/// minute; the server drops the rest silently. /// minute; the server drops the rest silently.
static const int nudgesPerSec = 3; static const int nudgesPerSec = 3;
+53 -4
View File
@@ -75,6 +75,38 @@ String notAVideoText(String? ytDlpError) =>
/// The text for a page with no video a relay can read. /// The text for a page with no video a relay can read.
const noRelayFormatText = "This page has no video a relay can read. Choose 'Everyone fetches it' instead."; const noRelayFormatText = "This page has no video a relay can read. Choose 'Everyone fetches it' instead.";
/// The plain-words texts of [playErrorText].
const needsCookiesText = 'This site needs cookies — Settings → Player → yt-dlp → Cookies';
const notAllowedText = 'Not allowed (403)';
const notFoundText = 'Not found (404)';
const unsupportedUrlText = 'Not a video page yt-dlp knows';
const unreachableText = "Couldn't reach the site";
const _plainTexts = {needsCookiesText, notAllowedText, notFoundText, unsupportedUrlText, unreachableText};
final _cookiesHint = RegExp(r'\b412\b|precondition|log ?in\b|logged|cookies|sign ?in\b', caseSensitive: false);
final _forbidden = RegExp(r'(^|[\s(:])403\b|forbidden', caseSensitive: false);
final _notFound = RegExp(r'(^|[\s(:])404\b|\b410\b|not found', caseSensitive: false);
final _unreachable = RegExp(
r'SocketException|HandshakeException|TimeoutException|timed out|failed host lookup|connection (refused|reset|closed)|'
r'network is unreachable|no route to host|name or service not known|getaddrinfo|unable to download webpage',
caseSensitive: false);
/// Why a link or media cannot be played, in plain words, from the raw
/// error of yt-dlp, ffmpeg/mpv or the link probe; anything it does not
/// know comes back as is (one line, at most 100 characters).
String playErrorText(String raw) {
final s = raw.trim();
// First: its message quotes the URL, which may say anything.
if (s.toLowerCase().contains('unsupported url')) return unsupportedUrlText;
if (_cookiesHint.hasMatch(s)) return needsCookiesText;
if (_forbidden.hasMatch(s)) return notAllowedText;
if (_notFound.hasMatch(s)) return notFoundText;
if (_unreachable.hasMatch(s)) return unreachableText;
final line = s.replaceAll(RegExp(r'\s+'), ' ');
if (line.isEmpty) return "Couldn't open it";
return line.length > 100 ? '${line.substring(0, 99)}…' : line;
}
class LinkProbeException implements Exception { class LinkProbeException implements Exception {
final String message; final String message;
const LinkProbeException(this.message); const LinkProbeException(this.message);
@@ -94,15 +126,30 @@ class LinkProbe {
final String? pageTitle; final String? pageTitle;
/// Why yt-dlp could not read the page. /// Why yt-dlp could not read the page.
final String? ytDlpError; final String? ytDlpError;
/// The HTTP status of the probe's GET; 0 when unknown.
final int status;
const LinkProbe.file(this.url, this.size) const LinkProbe.file(this.url, this.size)
: isPage = false, : isPage = false,
info = null, info = null,
pageTitle = null, pageTitle = null,
ytDlpError = null; ytDlpError = null,
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError}) status = 0;
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.status = 0})
: isPage = true, : isPage = true,
size = -1; size = -1;
/// Why nobody may be able to play this link ([playErrorText]), or null
/// when it looks playable: a page yt-dlp could not read, or a link
/// answering 4xx.
String? get problem {
if (!isPage || info != null) return null;
final err = ytDlpError == null ? null : playErrorText(ytDlpError!);
// A 4xx says more than yt-dlp's words it has no plain text for.
final known = err != null && _plainTexts.contains(err);
if (!known && status >= 400 && status < 500) return playErrorText('HTTP Error $status');
return err;
}
/// The name to show: yt-dlp's title, the page's, else the host; null /// The name to show: yt-dlp's title, the page's, else the host; null
/// for a file (named by its file name). /// for a file (named by its file name).
String? get title { String? get title {
@@ -134,12 +181,14 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
try { try {
String? body; String? body;
Object? getError; Object? getError;
var status = 0;
try { try {
final req = await c.getUrl(url).timeout(const Duration(seconds: 15)); final req = await c.getUrl(url).timeout(const Duration(seconds: 15));
req.headers req.headers
..set(HttpHeaders.rangeHeader, 'bytes=0-0') ..set(HttpHeaders.rangeHeader, 'bytes=0-0')
..set(HttpHeaders.acceptEncodingHeader, 'identity'); ..set(HttpHeaders.acceptEncodingHeader, 'identity');
final res = await req.close().timeout(const Duration(seconds: 15)); final res = await req.close().timeout(const Duration(seconds: 15));
status = res.statusCode;
final type = res.headers.contentType?.mimeType ?? ''; final type = res.headers.contentType?.mimeType ?? '';
if (looksLikeMediaFile(status: res.statusCode, contentType: type, url: url)) { if (looksLikeMediaFile(status: res.statusCode, contentType: type, url: url)) {
await res.drain<void>().catchError((_) {}); await res.drain<void>().catchError((_) {});
@@ -155,7 +204,7 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
String? ytErr; String? ytErr;
if (ytDlp != null) { if (ytDlp != null) {
try { try {
return LinkProbe.page(url, info: await ytDlp.resolve(url.toString())); return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()), status: status);
} catch (e) { } catch (e) {
ytErr = '$e'; ytErr = '$e';
} }
@@ -170,7 +219,7 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
body = await _readText(await req.close().timeout(const Duration(seconds: 15))); body = await _readText(await req.close().timeout(const Duration(seconds: 15)));
} catch (_) {} } catch (_) {}
} }
return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr); return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr, status: status);
} finally { } finally {
if (client == null) c.close(force: true); if (client == null) c.close(force: true);
} }
+22 -1
View File
@@ -435,13 +435,17 @@ class ReportMessage {
/// Jellyfin access by `ServerId`: true = plays with its own account, /// Jellyfin access by `ServerId`: true = plays with its own account,
/// false = through the server's relay. Null leaves the last one in place. /// false = through the server's relay. Null leaves the last one in place.
final Map<String, bool>? jf; final Map<String, bool>? jf;
/// Why the player cannot open [mediaId] (short plain text); empty while
/// it can.
final String error;
const ReportMessage( const ReportMessage(
{required this.position, {required this.position,
required this.buffered, required this.buffered,
required this.eventId, required this.eventId,
this.duration = 0, this.duration = 0,
this.mediaId = 0, this.mediaId = 0,
this.jf}); this.jf,
this.error = ''});
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
't': 'report', 't': 'report',
@@ -451,6 +455,7 @@ class ReportMessage {
if (duration > 0) 'duration': duration, if (duration > 0) 'duration': duration,
if (mediaId != 0) 'mediaId': mediaId, if (mediaId != 0) 'mediaId': mediaId,
if (jf != null) 'jf': jf, if (jf != null) 'jf': jf,
if (error.isNotEmpty) 'error': error,
}; };
factory ReportMessage.fromJson(Map<String, Object?> json) { factory ReportMessage.fromJson(Map<String, Object?> json) {
@@ -462,10 +467,20 @@ class ReportMessage {
duration: finiteDouble(json['duration'], 'duration'), duration: finiteDouble(json['duration'], 'duration'),
mediaId: finiteInt(json['mediaId'], 'mediaId'), mediaId: finiteInt(json['mediaId'], 'mediaId'),
jf: jfAccessFromJson(json['jf']), jf: jfAccessFromJson(json['jf']),
error: memberErrorFromJson(json['error']),
); );
} }
} }
/// Reads a `report.error` / `members[].error`: trimmed, single line, at
/// most [RelayConstants.maxMemberErrorLength] characters; empty otherwise.
String memberErrorFromJson(Object? value) {
if (value is! String) return '';
final line = value.replaceAll(RegExp(r'\s+'), ' ').trim();
const max = RelayConstants.maxMemberErrorLength;
return line.length > max ? line.substring(0, max) : line;
}
/// Reads a `jf` map (`{serverId: bool}`), dropping non-bool entries and /// Reads a `jf` map (`{serverId: bool}`), dropping non-bool entries and
/// keys over 64 characters, at most 8 entries (it rides in every `state`); /// keys over 64 characters, at most 8 entries (it rides in every `state`);
/// null when absent or not a map. /// null when absent or not a map.
@@ -582,6 +597,9 @@ class MemberView {
final bool host; final bool host;
/// Sitting out: connected, outside the ready gate, not playing. /// Sitting out: connected, outside the ready gate, not playing.
final bool out; final bool out;
/// Why its player cannot open the current media (`report.error`); empty
/// while it can. Errored: outside the ready gate.
final String error;
const MemberView({ const MemberView({
required this.id, required this.id,
required this.name, required this.name,
@@ -593,6 +611,7 @@ class MemberView {
this.jf = const {}, this.jf = const {},
this.host = false, this.host = false,
this.out = false, this.out = false,
this.error = '',
}); });
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
@@ -606,6 +625,7 @@ class MemberView {
if (jf.isNotEmpty) 'jf': jf, if (jf.isNotEmpty) 'jf': jf,
if (host) 'host': true, if (host) 'host': true,
if (out) 'out': true, if (out) 'out': true,
if (error.isNotEmpty) 'error': error,
}; };
factory MemberView.fromJson(Map<String, Object?> json) => MemberView( factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
@@ -619,6 +639,7 @@ class MemberView {
jf: jfAccessFromJson(json['jf']) ?? const {}, jf: jfAccessFromJson(json['jf']) ?? const {},
host: json['host'] == true, host: json['host'] == true,
out: json['out'] == true, out: json['out'] == true,
error: memberErrorFromJson(json['error']),
); );
} }
+28 -6
View File
@@ -56,6 +56,9 @@ class EngineMember {
bool host = false; bool host = false;
/// Sitting out: never in the set, never ready, its buffer ignored. /// Sitting out: never in the set, never ready, its buffer ignored.
bool out = false; bool out = false;
/// Its player cannot open the current media (`report.error`): outside
/// the ready gate until a report clears it or the media changes.
String error = '';
DateTime lastSeen; DateTime lastSeen;
EngineMember({ EngineMember({
required this.id, required this.id,
@@ -97,6 +100,7 @@ class EngineMember {
jf: jf, jf: jf,
host: host, host: host,
out: out, out: out,
error: error,
); );
} }
@@ -197,6 +201,7 @@ class SyncEngine {
final wasStale = isStale(member); final wasStale = isStale(member);
member.lastSeen = now; member.lastSeen = now;
if (!member.out && if (!member.out &&
member.error.isEmpty &&
!member.inSet && !member.inSet &&
(member.ready || (wasStale && transport != Transport.playing))) { (member.ready || (wasStale && transport != Transport.playing))) {
member.inSet = true; member.inSet = true;
@@ -307,12 +312,12 @@ class SyncEngine {
_seen(member); _seen(member);
// Sitting out: back first ([applyOut]), then ready. // Sitting out: back first ([applyOut]), then ready.
if (member.out) return; if (member.out) return;
if (member.ready == ready && member.inSet) { if (member.ready == ready && (member.inSet || member.error.isNotEmpty)) {
evaluate(); evaluate();
return; return;
} }
member.ready = ready; member.ready = ready;
if (ready) member.inSet = true; if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage( feed.add(FeedMessage(
actor: member.name, actor: member.name,
action: ready ? 'ready' : 'unready', action: ready ? 'ready' : 'unready',
@@ -328,13 +333,13 @@ class SyncEngine {
for (final member in members.values) { for (final member in members.values) {
if (member.out) continue; if (member.out) continue;
member.ready = ready; member.ready = ready;
if (ready) member.inSet = true; if (ready && member.error.isEmpty) member.inSet = true;
} }
} else { } else {
final member = byId(target); final member = byId(target);
if (member == null || member.out) return; if (member == null || member.out) return;
member.ready = ready; member.ready = ready;
if (ready) member.inSet = true; if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage( feed.add(FeedMessage(
actor: actorName, actor: actorName,
action: ready ? 'force-ready' : 'force-unready', action: ready ? 'force-ready' : 'force-unready',
@@ -366,7 +371,7 @@ class SyncEngine {
member.out = out; member.out = out;
member.ready = false; member.ready = false;
member.buffered = 0; member.buffered = 0;
member.inSet = !out && transport != Transport.playing; member.inSet = !out && member.error.isEmpty && transport != Transport.playing;
feed.add(FeedMessage( feed.add(FeedMessage(
actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now)); actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now));
evaluate(); evaluate();
@@ -408,6 +413,7 @@ class SyncEngine {
// (a stale member rejoins the set with its next frame if still ready). // (a stale member rejoins the set with its next frame if still ready).
final t = now; final t = now;
for (final member in members.values) { for (final member in members.values) {
member.error = '';
member.inSet = !member.out && !isStale(member, t); member.inSet = !member.out && !isStale(member, t);
member.buffered = 0; member.buffered = 0;
} }
@@ -416,10 +422,11 @@ class SyncEngine {
} }
void applyReport(String token, double reportPosition, double buffered, int reportEventId, void applyReport(String token, double reportPosition, double buffered, int reportEventId,
{double duration = 0, int mediaId = 0, Map<String, bool>? jf}) { {double duration = 0, int mediaId = 0, Map<String, bool>? jf, String error = ''}) {
final member = members[token]; final member = members[token];
if (member == null) return; if (member == null) return;
if (!reportPosition.isFinite) return; if (!reportPosition.isFinite) return;
_setError(member, mediaId == this.mediaId ? error : '');
_seen(member); _seen(member);
if (jf != null) member.jf = Map.unmodifiable(jf); if (jf != null) member.jf = Map.unmodifiable(jf);
member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered; member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered;
@@ -433,6 +440,21 @@ class SyncEngine {
evaluate(); evaluate();
} }
/// An error takes the member out of the set (the gate stops waiting for
/// it); clearing it puts it back like a join: in the set unless playing
/// (then from its next `ready`).
void _setError(EngineMember member, String error) {
if (member.error == error) return;
member.error = error;
if (error.isNotEmpty) {
member.inSet = false;
} else if (!member.out) {
member.inSet = transport != Transport.playing;
}
// Leaving the set may let the others start.
evaluate();
}
void sweepStale() { void sweepStale() {
var changed = false; var changed = false;
for (final member in members.values) { for (final member in members.values) {
+1 -1
View File
@@ -1,6 +1,6 @@
name: sync_relay_core name: sync_relay_core
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter. description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
version: 1.1.1 version: 1.1.2
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
dependencies: dependencies:
+46
View File
@@ -85,6 +85,52 @@ void main() {
}); });
}); });
group('player errors', () {
void fail(String token, String error, {int? mediaId}) => engine.applyReport(
token, engine.positionAt(), 0, engine.eventId,
mediaId: mediaId ?? engine.mediaId, error: error);
test('an errored member leaves the gate; clearing puts it back', () {
join('a', 'alice');
join('b', 'bob');
engine.applyReady('a', true);
engine.applyReady('b', true);
buffer('a', 4.0);
expect(engine.transport, Transport.held, reason: 'bob has no buffer');
fail('b', "Not allowed (403)");
final b = engine.members['b']!;
expect([b.error, b.inSet], ['Not allowed (403)', false]);
expect(engine.transport, Transport.playing, reason: 'alice plays alone');
final view = engine.broadcast().members;
expect(view.last.toJson()['error'], 'Not allowed (403)');
expect(view.first.toJson().containsKey('error'), isFalse);
engine.applyReady('b', true);
expect(b.inSet, isFalse, reason: 'ready does not bring it back while errored');
// A report of another media does not set it; a clean one clears it.
fail('b', 'x', mediaId: engine.mediaId - 1);
expect(b.error, isEmpty);
expect(b.inSet, isTrue, reason: 'ready and playing: back like a stale return');
expect(engine.transport, Transport.held, reason: 'bob is buffering');
});
test('a swap clears it; held, clearing puts it in the set', () {
join('a', 'alice');
join('b', 'bob');
fail('b', 'Not found (404)');
engine.applySwap('a', const HelloMedia(name: 'n', size: 1, duration: 0, url: 'u'));
final b = engine.members['b']!;
expect([b.error, b.inSet], ['', true]);
fail('b', 'Not found (404)');
expect(b.inSet, isFalse);
buffer('b', 4.0);
expect([b.error, b.inSet], ['', true]);
expect(ReportMessage.fromJson({
't': 'report', 'position': 0, 'buffered': 0, 'eventId': 0,
'error': ' a\nb ${'x' * 200}'}).error.length,
RelayConstants.maxMemberErrorLength);
});
});
group('membership + gate', () { group('membership + gate', () {
test('join while held lands in the set, playback stays held', () { test('join while held lands in the set, playback stays held', () {
join('a', 'alice'); join('a', 'alice');
+18
View File
@@ -115,6 +115,24 @@ void main() {
}); });
}); });
test('play errors in plain words; a probe refuses unread pages and 4xx links', () {
expect(playErrorText('[BiliBili] BV1x: Unable to download webpage: HTTP Error 412: Precondition Failed'),
needsCookiesText);
expect(playErrorText('Sign in to confirm your age'), needsCookiesText);
expect(playErrorText('the video server answered HTTP error 403 Forbidden'), notAllowedText);
expect(playErrorText('HTTP Error 404: Not Found'), notFoundText);
expect(playErrorText('Unsupported URL: https://example.com/login/403'), unsupportedUrlText);
expect(playErrorText('cannot open the link: SocketException: Failed host lookup'), unreachableText);
expect(playErrorText('the video stream could not be opened'), 'the video stream could not be opened');
expect(playErrorText(notAllowedText), notAllowedText);
final u = Uri.parse('https://x/v.mp4');
expect(LinkProbe.page(u, ytDlpError: 'HTTP Error 412: Precondition Failed').problem, needsCookiesText);
expect(LinkProbe.page(u, ytDlpError: 'odd failure', status: 403).problem, notAllowedText);
expect(LinkProbe.page(u, ytDlpError: 'odd failure').problem, 'odd failure');
expect(LinkProbe.page(u, info: YtDlpInfo.parse({'title': 'x', 'formats': []}), status: 404).problem, isNull);
expect(LinkProbe.file(u, 10).problem, isNull);
});
test('viewer URL: one track plain, several an mpv EDL with %length% framing', () { test('viewer URL: one track plain, several an mpv EDL with %length% framing', () {
expect(relayViewerUrl('http://h:1/r/default/t/3', 'a/b c'), 'http://h:1/r/default/t/3/a_b%20c'); expect(relayViewerUrl('http://h:1/r/default/t/3', 'a/b c'), 'http://h:1/r/default/t/3/a_b%20c');
const v = 'http://h:1/r/default/t/3/0/x', a = 'http://h:1/r/default/t/3/1/x'; const v = 'http://h:1/r/default/t/3/0/x', a = 'http://h:1/r/default/t/3/1/x';
+1 -1
View File
@@ -428,7 +428,7 @@ void mountSyncRoute(
final report = ReportMessage.fromJson(decoded); final report = ReportMessage.fromJson(decoded);
hub.engine.applyReport( hub.engine.applyReport(
token, report.position, report.buffered, report.eventId, token, report.position, report.buffered, report.eventId,
duration: report.duration, mediaId: report.mediaId, jf: report.jf); duration: report.duration, mediaId: report.mediaId, jf: report.jf, error: report.error);
hub.changed(); hub.changed();
case 'ping': case 'ping':
final ping = PingMessage.fromJson(decoded); final ping = PingMessage.fromJson(decoded);
+1 -1
View File
@@ -1,7 +1,7 @@
name: sync_relay_server name: sync_relay_server
description: Pure-Dart sync + media server. Embeddable, no Flutter. description: Pure-Dart sync + media server. Embeddable, no Flutter.
publish_to: none publish_to: none
version: 1.1.1 version: 1.1.2
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
dependencies: dependencies:
+5 -5
View File
@@ -1,10 +1,10 @@
{ {
"version": "1.1.1", "version": "1.1.2",
"protocol": 11, "protocol": 11,
"url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.1/watch-together-1.1.1-linux-x64.tar.gz", "url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.2/watch-together-1.1.2-linux-x64.tar.gz",
"notes": "- CI (Windows): fetch git dependencies fresh; the host's cached fox_utils clone was stale", "notes": "- Roadmap: drop the link-token item (what a user pastes is theirs)",
"assets": { "assets": {
"linux-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.1/watch-together-1.1.1-linux-x64.tar.gz", "linux-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.2/watch-together-1.1.2-linux-x64.tar.gz",
"windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.1/watch-together-1.1.1-windows-x64.zip" "windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.2/watch-together-1.1.2-windows-x64.zip"
} }
} }