9 Commits
Author SHA1 Message Date
foxyzandClaude Opus 5.5 6d388483ef CI: run on branch pushes only, not on release tags (release.yml builds those)
ci / core (push) Successful in 30s
ci / server (push) Successful in 57s
ci / updater-windows (push) Successful in 4m46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:00:41 +03:00
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
foxyzandClaude Opus 5.5 e621b392af Dependencies: Flutter upgrade, file_picker 13, flutter_lints 6
ci / core (push) Successful in 28s
ci / server (push) Successful in 57s
ci / updater-windows (push) Successful in 6m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:16:45 +03:00
foxyzandClaude Opus 5.5 884f208330 Schedule: On Jellyfin only when the episode itself is there; Aired · not on Jellyfin yet otherwise
For shows AnimeFlow doesn't track, the app now looks the episode up in the
linked Jellyfin (one /Shows/{id}/Episodes per series, 3 min memo; TVDB
number via anilist_map offsets). No linked account: a neutral Aired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:15:18 +03:00
38 changed files with 1025 additions and 293 deletions

No files matched your search

+3
View File
@@ -1,6 +1,9 @@
name: ci
on:
# Branches only: a release tag points at a commit CI already checked on
# main, and release.yml builds it.
push:
branches: ['**']
pull_request:
jobs:
+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
file name); in a server, pick how the
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
(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:ffi' show Int8;
import 'dart:io';
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/widgets.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).
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
/// page links, at most 1080p, once per player; its cookies
/// (`ytdl-raw-options`) whenever they changed.
@@ -162,6 +180,7 @@ class PlaybackController {
final existing = _video;
if (existing != null) return existing;
final hwdec = _hwdecOverride;
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
// media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC.
final video = _video = VideoController(
@@ -187,8 +206,7 @@ class PlaybackController {
bufferSize: maxBytes,
libass: true,
vo: Platform.environment['SYNC_RELAY_VO'],
// Verbose: the render-mode probe needs mpv's GL init lines.
logLevel: MPVLogLevel.v,
logLevel: MPVLogLevel.warn, // see [logLevel]
))
..onUserSeek = (d) => onUserSeek?.call(d);
_shaders = '';
@@ -243,8 +261,29 @@ class PlaybackController {
if (hwdec != null) {
await _pin('hwdec', hwdec);
}
await _pin('stream-lavf-o',
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4');
await _pin('stream-lavf-o', streamLavfOptions);
}
/// 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
@@ -331,7 +370,27 @@ class PlaybackController {
void _onLog(PlayerLog log) {
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
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;
}
if (log.prefix != 'libmpv_render') return;
@@ -364,10 +423,12 @@ class PlaybackController {
Future<void> _detectRenderMode(Player p, VideoController video) async {
try {
await video.platform.future;
await Future.delayed(const Duration(milliseconds: 1500));
} catch (_) {
return;
} finally {
if (identical(p, _player)) _requestLog(p, logLevel);
}
await Future.delayed(const Duration(milliseconds: 1500));
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
return;
}
@@ -451,6 +512,8 @@ class PlaybackController {
if (!_ready) return;
try {
_chapterJson = '';
_httpError = null;
_loadErrorSent = false;
chapters.value = const [];
tracks.value = const [];
buffered.value = Duration.zero;
@@ -248,7 +248,6 @@ class AnimeFlowScheduleItem {
final int? anidbId;
final int? tvdbId;
final String? jellyfinSeriesId;
final bool followed;
const AnimeFlowScheduleItem({
required this.episode,
@@ -257,7 +256,6 @@ class AnimeFlowScheduleItem {
this.anidbId,
this.tvdbId,
this.jellyfinSeriesId,
this.followed = false,
});
static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem(
@@ -267,7 +265,6 @@ class AnimeFlowScheduleItem {
anidbId: _int(j['anidb_id']),
tvdbId: _int(j['tvdb_id']),
jellyfinSeriesId: _str(j['jellyfin_series_id']),
followed: j['followed'] == true,
);
}
@@ -296,7 +293,6 @@ class AnimeFlowSeries {
final String? jellyfinSeriesId;
final SeriesState state;
final bool monitored;
final bool followed;
final int episodesTotal;
final int episodesAvailable;
final DateTime? nextAirDate;
@@ -312,7 +308,6 @@ class AnimeFlowSeries {
this.jellyfinSeriesId,
this.state = SeriesState.releasing,
this.monitored = false,
this.followed = false,
this.episodesTotal = 0,
this.episodesAvailable = 0,
this.nextAirDate,
@@ -328,7 +323,6 @@ class AnimeFlowSeries {
jellyfinSeriesId: _str(j['jellyfin_series_id']),
state: SeriesState.parse(j['state']),
monitored: j['monitored'] == true,
followed: j['followed'] == true,
episodesTotal: _int(j['episodes_total']) ?? 0,
episodesAvailable: _int(j['episodes_available']) ?? 0,
nextAirDate: _time(j['next_air_date']),
@@ -684,10 +678,6 @@ class AnimeFlowClient {
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].
Future<List<AnimeFlowSearchHit>> search(String q) async => [
for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h),
@@ -17,9 +17,16 @@ enum ScheduleStatus {
unmonitored,
/// AnimeFlow does not track this series (or is unreachable).
notTracked,
/// Not tracked by AnimeFlow, but the series is on Jellyfin (`/library`):
/// the episode may be there; Put it up finds out.
inLibrary;
/// Not tracked by AnimeFlow, the series is on Jellyfin (`/library`) and
/// so is the episode ([confirmOnJellyfin]; [matchSchedule] alone sets
/// it from the series).
inLibrary,
/// Not tracked, the series is on Jellyfin, the episode aired but is not
/// there yet.
notOnJellyfin,
/// Not tracked, the series is on Jellyfin, the episode aired; whether
/// it is there is unknown (no linked account, lookup failed).
aired;
static ScheduleStatus of(EpisodeStatus s) => switch (s) {
EpisodeStatus.available => available,
@@ -41,11 +48,14 @@ class ScheduleEntry {
final String? reason;
/// AnimeFlow tracks the series.
final bool tracked;
final bool followed;
/// AnimeFlow's series id (follow, requests).
/// AnimeFlow's series id (requests).
final int? seriesId;
/// The AnimeFlow season of the matched episode (requests).
/// The AnimeFlow season of the matched episode (requests); untracked
/// on Jellyfin: the TVDB season of the AniList entry.
final int? season;
/// Untracked on Jellyfin: the TVDB episode number in [season] (AniList
/// episode + the `anilist_map` run's offset).
final int? jellyfinEpisode;
/// Play target once available.
final String? jellyfinSeriesId;
/// The series is on Jellyfin (tracked or not).
@@ -57,12 +67,24 @@ class ScheduleEntry {
this.progress,
this.reason,
this.tracked = false,
this.followed = false,
this.seriesId,
this.season,
this.jellyfinEpisode,
this.jellyfinSeriesId,
});
ScheduleEntry withStatus(ScheduleStatus s) => ScheduleEntry(
airing: airing,
status: s,
progress: progress,
reason: reason,
tracked: tracked,
seriesId: seriesId,
season: season,
jellyfinEpisode: jellyfinEpisode,
jellyfinSeriesId: jellyfinSeriesId,
);
@override
String toString() => 'ScheduleEntry(${airing.anilistId}#${airing.episode} $status)';
}
@@ -81,8 +103,7 @@ String _day(DateTime d) {
class _SeriesRef {
final int seriesId;
final String? jellyfinSeriesId;
bool followed;
_SeriesRef(this.seriesId, this.jellyfinSeriesId, this.followed);
_SeriesRef(this.seriesId, this.jellyfinSeriesId);
}
/// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked
@@ -98,33 +119,40 @@ List<ScheduleEntry> matchSchedule(
DateTime? now,
}) {
final at = now ?? DateTime.now();
// AniList id -> (Jellyfin series, its TVDB season) from /library.
final lib = <int, (String, int)>{};
// AniList id -> (Jellyfin series, its TVDB season, TVDB - AniList
// episode offset) from /library.
final lib = <int, (String, int, int)>{};
for (final item in library) {
if (item.jellyfinSeriesId.isEmpty) continue;
for (final r in item.anilistMap) {
lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season);
lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season, r.offset);
}
for (final e in item.anilistIds.entries) {
lib[e.value] ??= (item.jellyfinSeriesId, e.key);
lib[e.value] ??= (item.jellyfinSeriesId, e.key, 0);
}
}
final refs = <int, _SeriesRef>{};
for (final s in series) {
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 byAbs = <String, AnimeFlowScheduleItem>{};
final byDay = <String, AnimeFlowScheduleItem>{};
final numbered = <int>{};
for (final it in schedule) {
final e = it.episode, id = e.anilistId;
if (id == null) continue;
final ref = refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId, it.followed);
if (it.followed) ref.followed = true;
// AnimeFlow's AniList episode number first: TVDB numbering can run on
// across split cours.
if (e.anilistEpisode != null) byEp.putIfAbsent('$id#${e.anilistEpisode}', () => it);
refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId);
if (e.anilistEpisode != null) {
numbered.add(id);
byAni.putIfAbsent('$id#${e.anilistEpisode}', () => it);
}
byEp.putIfAbsent('$id#${e.episode}', () => it);
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it);
@@ -134,7 +162,9 @@ List<ScheduleEntry> matchSchedule(
() {
final id = a.anilistId;
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) {
final e = it.episode;
return ScheduleEntry(
@@ -143,7 +173,6 @@ List<ScheduleEntry> matchSchedule(
progress: e.status == EpisodeStatus.downloading ? e.progress : null,
reason: e.status == EpisodeStatus.failed ? e.reason : null,
tracked: true,
followed: it.followed || (ref?.followed ?? false),
seriesId: it.seriesId,
season: e.season,
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
@@ -156,6 +185,7 @@ List<ScheduleEntry> matchSchedule(
airing: a,
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.inLibrary,
season: onJf.$2,
jellyfinEpisode: a.episode + onJf.$3,
jellyfinSeriesId: onJf.$1,
);
}
@@ -163,7 +193,6 @@ List<ScheduleEntry> matchSchedule(
airing: a,
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing,
tracked: true,
followed: ref.followed,
seriesId: ref.seriesId,
jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1,
);
@@ -171,6 +200,27 @@ List<ScheduleEntry> matchSchedule(
];
}
/// Checks [matchSchedule]'s [ScheduleStatus.inLibrary] entries (series
/// on Jellyfin) against the episodes Jellyfin has: [episodes] maps a
/// Jellyfin series id to its (season, episode) numbers. Found stays
/// inLibrary, absent becomes [ScheduleStatus.notOnJellyfin], a series
/// missing from [episodes] (not looked up) [ScheduleStatus.aired].
List<ScheduleEntry> confirmOnJellyfin(List<ScheduleEntry> entries, Map<String, Set<(int, int)>> episodes) => [
for (final e in entries)
if (e.status != ScheduleStatus.inLibrary)
e
else
switch (episodes[e.jellyfinSeriesId]) {
null => e.withStatus(ScheduleStatus.aired),
final have when have.contains((e.season, e.jellyfinEpisode)) => e,
_ => e.withStatus(ScheduleStatus.notOnJellyfin),
},
];
/// The (season, episode) numbers Jellyfin has of a series; null when it
/// cannot tell (no linked account for that server).
typedef JellyfinEpisodeLookup = Future<Set<(int, int)>?> Function(String jellyfinSeriesId);
/// One loaded window. Errors are display text; [entries] is empty only
/// when AniList failed with nothing cached.
class ScheduleSnapshot {
@@ -206,12 +256,15 @@ class ScheduleSnapshot {
/// Loads AniList and AnimeFlow for a window and merges them.
class ScheduleRepository {
ScheduleRepository({required this.aniList, this.animeFlow, DateTime Function()? now})
ScheduleRepository({required this.aniList, this.animeFlow, this.jellyfinEpisodes, DateTime Function()? now})
: _now = now ?? DateTime.now;
final AniListClient aniList;
/// Null = not configured (AniList only).
final AnimeFlowClient? animeFlow;
/// Confirms "on Jellyfin" per episode ([confirmOnJellyfin]); null =
/// never confirmed (aired entries read [ScheduleStatus.aired]).
final JellyfinEpisodeLookup? jellyfinEpisodes;
final DateTime Function() _now;
/// Last week's Monday 00:00 local .. 4 weeks later.
@@ -236,7 +289,8 @@ class ScheduleRepository {
final flow = await flowF;
final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt));
final entries = flow.error == null
? matchSchedule(airings, schedule: flow.schedule, series: flow.series, library: flow.library, now: now)
? await _confirm(
matchSchedule(airings, schedule: flow.schedule, series: flow.series, library: flow.library, now: now))
: [for (final a in airings) untrackedEntry(a, now)];
return ScheduleSnapshot(
from: start,
@@ -249,6 +303,26 @@ class ScheduleRepository {
);
}
/// Looks up the episodes of every series an inLibrary entry is on, in
/// parallel; a failed or impossible lookup leaves that series out.
Future<List<ScheduleEntry>> _confirm(List<ScheduleEntry> entries) async {
final lookup = jellyfinEpisodes;
final ids = {
for (final e in entries)
if (e.status == ScheduleStatus.inLibrary && e.jellyfinSeriesId != null) e.jellyfinSeriesId!,
};
final found = <String, Set<(int, int)>>{};
if (lookup != null) {
await Future.wait([
for (final id in ids)
lookup(id).then((eps) {
if (eps != null) found[id] = eps;
}, onError: (_) {}),
]);
}
return confirmOnJellyfin(entries, found);
}
Future<({AnimeFlowMe? me, List<AnimeFlowScheduleItem> schedule, List<AnimeFlowSeries> series, List<AnimeFlowLibraryItem> library, Object? error})>
_animeFlow(DateTime from, DateTime to, bool force) async {
final c = animeFlow;
@@ -78,7 +78,7 @@ Future<({int season, int episode})> flowNumbering(AnimeFlowClient? client, Sched
}
}
if (ep != null) return (season: ep.season, episode: ep.episode);
return (season: e.season ?? 1, episode: e.airing.episode);
return (season: e.season ?? 1, episode: e.jellyfinEpisode ?? e.airing.episode);
}
/// The AniDB id of an untracked AniList entry, from AnimeFlow's search:
@@ -112,6 +112,25 @@ JellyfinAccount? jellyfinAccountFor(JellyfinAccounts accounts, AnimeFlowClient?
return accounts.defaultAccount;
}
/// The (season, episode) numbers of the episodes Jellyfin has of
/// [jellyfinSeriesId], seen by the account AnimeFlow's ids belong to
/// ([jellyfinAccountFor]); null without a linked account. One request per
/// series, memoised a few minutes ([JellyfinAccounts.cached]).
Future<Set<(int, int)>?> jellyfinEpisodeNumbers(
JellyfinAccounts accounts,
AnimeFlowClient? flow,
String jellyfinSeriesId,
) async {
await accounts.ensureLoaded(validate: false);
final account = jellyfinAccountFor(accounts, flow);
final client = account == null ? null : accounts.clientFor(account.serverId);
if (account == null || client == null) return null;
return accounts.cached(account.serverId, 'schedule-episodes:$jellyfinSeriesId', () async => {
for (final x in await client.episodes(jellyfinSeriesId))
if (x.parentIndexNumber != null && x.indexNumber != null) (x.parentIndexNumber!, x.indexNumber!),
});
}
/// The Jellyfin episode of an available [e] as a media pick. Throws
/// [ScheduleActionException] (or a client error) when it cannot be found.
Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlowClient? flow, ScheduleEntry e) async {
@@ -49,6 +49,8 @@ String countdownText(DateTime at, DateTime now) {
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined),
ScheduleStatus.notOnJellyfin => ('aired · not on Jellyfin yet', Colors.grey, Icons.hourglass_empty),
ScheduleStatus.aired => ('aired', Colors.grey, Icons.done),
};
class DayColumn extends StatelessWidget {
@@ -125,7 +127,6 @@ class AiringCard extends StatelessWidget {
final ScheduleEntry entry;
final DateTime now;
final bool compact;
final bool followed;
final CardAction action;
/// Why [action] is disabled (no request scope, not on Jellyfin, ...);
@@ -136,20 +137,15 @@ class AiringCard extends StatelessWidget {
final bool busy;
final VoidCallback onAction;
/// Null hides the bookmark (untracked series).
final VoidCallback? onFollow;
const AiringCard({
super.key,
required this.entry,
required this.now,
required this.compact,
required this.followed,
required this.action,
required this.onAction,
this.disabledHint,
this.busy = false,
this.onFollow,
});
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
@@ -179,22 +175,6 @@ class AiringCard extends StatelessWidget {
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
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
@@ -244,7 +224,6 @@ class AiringCard extends StatelessWidget {
],
),
),
if (followed && onFollow == null) Icon(Icons.bookmark, size: 14, color: scheme.primary),
if (requestable)
Tooltip(
message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'),
@@ -268,7 +247,6 @@ class AiringCard extends StatelessWidget {
);
}
final button = _button(context);
final follow = _follow(context, 16);
// The cover poster leads: it is what people recognise at a glance.
return Material(
key: ValueKey('card-$_id'),
@@ -338,25 +316,11 @@ class AiringCard extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25),
),
const SizedBox(height: 2),
Row(
children: [
Flexible(
child: Text(
'Episode ${a.episode}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
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),
),
],
Text(
'Episode ${a.episode}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
const Spacer(),
Row(
@@ -18,7 +18,7 @@ import 'schedule_card.dart';
// one thing to do about it (put it up, request it). Refreshes every
// minute while visible; ETags keep that cheap.
enum ScheduleFilter { jellyfin, following, all }
enum ScheduleFilter { jellyfin, all }
class SchedulePage extends StatefulWidget {
/// 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.
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".
final _requested = <String>{};
@@ -97,18 +95,22 @@ class _SchedulePageState extends State<SchedulePage> {
void initState() {
super.initState();
_ownsRepo = widget.repository == null;
_repo =
widget.repository ??
ScheduleRepository(
aniList: AniListClient(),
animeFlow: AnimeFlowClient.fromAccounts(JellyfinAccounts.instance),
);
_repo = widget.repository ?? _ownRepo();
_load();
_timer = Timer.periodic(SchedulePage.refreshEvery, (_) {
if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load();
});
}
ScheduleRepository _ownRepo() {
final flow = AnimeFlowClient.fromAccounts(_accounts);
return ScheduleRepository(
aniList: AniListClient(),
animeFlow: flow,
jellyfinEpisodes: (id) => jellyfinEpisodeNumbers(_accounts, flow, id),
);
}
@override
void dispose() {
_timer?.cancel();
@@ -186,11 +188,8 @@ class _SchedulePageState extends State<SchedulePage> {
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) {
ScheduleFilter.jellyfin => e.tracked || e.onJellyfin,
ScheduleFilter.following => _followed(e),
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
};
@@ -337,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 {
final c = _flow;
if (c == null || _reqBusy.contains(r.id)) return;
@@ -389,11 +373,6 @@ class _SchedulePageState extends State<SchedulePage> {
icon: Icon(Icons.video_library_outlined),
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')),
],
selected: {_filter},
@@ -537,12 +516,10 @@ class _SchedulePageState extends State<SchedulePage> {
entry: e,
now: now,
compact: _compact,
followed: _followed(e),
action: action,
disabledHint: hint,
busy: busy,
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 String url;
final LinkRelay relay;
const LinkMedia(this.url, {this.relay = LinkRelay.direct});
/// Put it up even though this computer's probe found a problem
/// (`LinkProbe.problem`): the server or a friend may still open it.
final bool anyway;
const LinkMedia(this.url, {this.relay = LinkRelay.direct, this.anyway = false});
@override
String get label {
@@ -182,6 +182,10 @@ class SessionCoordinator {
final toasts = signal<List<ToastItem>>([]);
final reconnecting = signal(false);
final starving = signal<String?>(null);
/// Why this player cannot open the current media, in plain words
/// ([playErrorText]); empty while it can. It rides in `report.error`, so
/// the server stops waiting for this member and the others see it.
final playerError = signal('');
/// The current media's Jellyfin ref (null for links and files).
final currentRef = signal<JellyfinRef?>(null);
@@ -318,6 +322,12 @@ class SessionCoordinator {
int _advanceDoneFor = -1;
String _blockedKey = '';
bool _jellyfinListening = false;
// The mediaId [playerError] belongs to, and the player's duration when
// it came: a new duration means the media opened after all.
int _playerErrorFor = -1;
Duration _playerErrorDuration = Duration.zero;
// Members' last `error`, by id, so a new one toasts once.
Map<String, String> _memberErrors = const {};
SessionCoordinator(
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
@@ -332,7 +342,8 @@ class SessionCoordinator {
if (_playerReady) return;
try {
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
playback.onYtdlError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't open this link: $e");
playback.onYtdlError = _setPlayerError;
playback.onLoadError = _setPlayerError;
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
await playback.init();
_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;
/// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh
@@ -397,6 +437,8 @@ class SessionCoordinator {
diff.reset();
_mediaId = -1;
_openUrl = null;
playerError.value = '';
_memberErrors = const {};
_lastReady = false;
_restoringReady = false;
_lastOut = false;
@@ -494,8 +536,7 @@ class SessionCoordinator {
return true;
} catch (e) {
debugPrint('open media failed: $e');
health.value =
HealthStatus(HealthSeverity.error, 'cannot open media: $e');
_setPlayerError('$e');
return false;
} finally {
_opening = false;
@@ -613,6 +654,8 @@ class SessionCoordinator {
void _onState(StateBroadcast r) {
_lastStateAt = DateTime.now().toUtc();
serverState.value = r;
_toastMemberErrors(r);
_checkPlayerError(r.mediaId);
_maybeSendArt(r);
_queuedState = r;
if (_applying) return;
@@ -916,6 +959,8 @@ class SessionCoordinator {
// Duration only once the open media is the server's current one.
// Sitting out: still a report (it keeps this member fresh), empty.
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
_checkPlayerError(r.mediaId);
final error = playerError.peek();
sync.sendReport(
position: _lastOut ? 0 : playback.positionSecs,
buffered: _lastOut ? 0 : playback.bufferedSecsAhead,
@@ -923,6 +968,7 @@ class SessionCoordinator {
duration: known ? playback.durationSecs : 0,
mediaId: known ? r.mediaId : 0,
jf: _jfAccessFor(r),
error: known && _playerErrorFor == r.mediaId ? error : '',
);
if (!_lastOut) {
_checkSeekLanded(r);
@@ -1243,10 +1289,13 @@ class SessionCoordinator {
/// A pasted link: a recognised Jellyfin item link goes up as a ref
/// (see [mediaForLink]) that this member relays for viewers without an
/// account (with the link's key, which stays here); anything else as the
/// link, or relayed per [relay]. Returns an error text, or null when sent.
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async {
/// link, or relayed per [relay]. A link this computer cannot open (its
/// probe's [LinkProbe.problem], or unreachable) is refused with the
/// reason in plain words unless [anyway]. Returns an error text, or null
/// when sent.
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct, bool anyway = false}) async {
markInput();
_lastPick = LinkMedia(url, relay: relay);
_lastPick = LinkMedia(url, relay: relay, anyway: anyway);
final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url);
@@ -1260,6 +1309,8 @@ class SessionCoordinator {
} catch (e) {
probeError = '$e';
}
final problem = probe == null ? playErrorText(probeError ?? '') : probe.problem;
if (problem != null && !anyway) return problem;
// A page is named by its title, never its URL tail ("1", "watch").
final uri = Uri.tryParse(url) ?? Uri();
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
@@ -1270,9 +1321,9 @@ class SessionCoordinator {
if (relay == LinkRelay.me) {
// This computer reads it: a file, or a page's formats (one file,
// separate video and audio tracks, or segmented playlists).
if (probe == null) return 'cannot pass this link on: $probeError';
final problem = probe.relayProblem;
if (problem != null) return problem;
if (probe == null) return problem;
final relayProblem = probe.relayProblem;
if (relayProblem != null) return relayProblem;
final y = await ytDlp.ytDlp;
final check = LinkRelayMedia(uri, ytDlp: y, probe: probe);
var size = 0;
@@ -1351,8 +1402,8 @@ class SessionCoordinator {
/// Any picker selection.
Future<String?> swapPicked(PickedMedia media) async {
switch (media) {
case LinkMedia(:final url, :final relay):
return swapLink(url, relay: relay);
case LinkMedia(:final url, :final relay, :final anyway):
return swapLink(url, relay: relay, anyway: anyway);
case JellyfinMedia():
swapJellyfin(media);
case FileMedia(:final path):
+1 -1
View File
@@ -379,7 +379,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (error != null || !mounted) return error;
}
// Alone, a link plays direct: there is nobody to pass it on to.
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url);
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url, anyway: media.anyway);
final error = await session.coordinator.swapPicked(media);
if (error != null || !mounted) return error;
final f = session.lastForm;
+23 -4
View File
@@ -19,6 +19,9 @@ class AvatarInfo {
final bool ready, buffering, relaying, stale, host, self;
/// Sitting out: dimmed, with an "away" mark.
final bool out;
/// Why their player cannot open the media (`members[].error`): an error
/// mark; empty when it can.
final String error;
const AvatarInfo({
this.id = '',
required this.name,
@@ -31,6 +34,7 @@ class AvatarInfo {
this.host = false,
this.self = false,
this.out = false,
this.error = '',
});
}
@@ -78,7 +82,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
}
/// Your own avatar from the profile (also offline).
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false}) {
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) {
final p = Profile.instance;
final png = p.picture.value;
final name = p.name.value;
@@ -90,6 +94,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = fal
host: host,
self: true,
out: out,
error: error,
);
}
@@ -104,7 +109,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
color: self ? Profile.instance.color.value : colorForName(m.name),
image: memberAvatarImage(m, coord),
ready: m.ready,
buffering: !m.stale && m.ready && playing && m.buffered < r!.bufferNeed,
buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed,
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
stale: m.stale,
// The state marks the host (`members[].host`); your own app also
@@ -112,6 +117,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
host: m.host || (self && coord.hostFile != null),
self: self,
out: m.out,
error: m.error,
);
}
@@ -138,7 +144,7 @@ class PartyWatch extends StatelessWidget {
/// Circle avatar with a status ring outside it and a gap in the background
/// colour, so overlapping neighbours never hide the status. Crown = host,
/// tower = passing the video on, spinner = buffering, dimmed = stale,
/// dimmed with a moon = sitting out.
/// dimmed with a moon = sitting out, red "!" = cannot play the media.
class StatusAvatar extends StatelessWidget {
final AvatarInfo info;
final double size;
@@ -222,6 +228,17 @@ class StatusAvatar extends StatelessWidget {
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
),
),
if (info.error.isNotEmpty)
Positioned(
key: const ValueKey('error-mark'),
right: -3,
top: -3,
child: Container(
padding: const EdgeInsets.all(1),
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
child: Icon(Icons.error, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.redAccent.shade200),
),
),
if (badges && info.relaying)
Positioned(
right: -3,
@@ -287,7 +304,9 @@ class _AvatarStackState extends State<AvatarStack> {
left: i * step,
top: 0,
child: Tooltip(
message: visible[i].name,
message: visible[i].error.isEmpty
? visible[i].name
: "${visible[i].name} can't play this: ${visible[i].error}",
child: StatusAvatar(info: visible[i], size: size),
),
),
+51 -18
View File
@@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, notAVideoText;
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText;
import '../playback/yt_dlp_app.dart';
import '../screens/home/media_picker.dart';
@@ -107,7 +107,10 @@ class LinkPreview {
/// "youtube.com · Channel", or the host.
final String subtitle;
final String? thumbnail;
const LinkPreview({required this.title, required this.subtitle, this.thumbnail});
/// Why this computer cannot open it, in plain words ([LinkProbe.problem]);
/// null when it looks playable.
final String? problem;
const LinkPreview({required this.title, required this.subtitle, this.thumbnail, this.problem});
}
/// What a link is (see [AppYtDlp.probe]).
@@ -125,24 +128,30 @@ String clockText(double seconds) {
}
/// The preview of [url]: a web page shows yt-dlp's title, uploader,
/// duration and thumbnail (or why it is not a video); a media file (by
/// name or by probe) its file name and host. Null for text that is not an http(s) link.
/// duration and thumbnail; a media file its file name and host; a link
/// that does not open, why ([LinkPreview.problem]). Null for text that is
/// not an http(s) link.
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
final uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
final host = _host(uri);
// A media file name needs no lookup.
if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
// A Jellyfin item goes up as the item (see `mediaForLink`), not the page.
if (parseJellyfinLink(url.trim()) != null) return LinkPreview(title: host, subtitle: '$host · Jellyfin');
final file = hasMediaExtension(uri);
final LinkProbe p;
try {
p = await probe(url.trim());
} catch (e) {
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e');
final problem = playErrorText('$e');
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $problem', problem: problem);
}
final info = p.info;
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
if (info == null) {
return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}');
final problem = p.problem;
if (info == null || problem != null) {
// A media file name that did not answer as one keeps its name.
final title = file ? LinkMedia(url.trim()).label : p.title ?? host;
return LinkPreview(title: title, subtitle: '$host · ${problem ?? 'Not a video'}', problem: problem);
}
return LinkPreview(
title: p.title ?? host,
@@ -221,6 +230,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Timer? _debounce;
bool _busy = false;
String? _error;
/// [_error] is the probe's problem: "Put it up anyway" is offered.
bool _canForce = false;
@override
void initState() {
@@ -242,7 +253,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (url == _previewUrl) return;
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
if (mounted) setState(() => _error = null);
if (mounted) {
setState(() {
_error = null;
_canForce = false;
});
}
}
Future<void> _preview(String url) async {
@@ -263,19 +279,27 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
} catch (_) {}
}
Future<void> _go() async {
Future<void> _go({bool anyway = false}) async {
final url = _c.link.text.trim();
if (url.isEmpty || _busy) return;
setState(() {
_busy = true;
_error = null;
_canForce = false;
});
String? error;
try {
error = await widget.onPutUp(LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay));
} catch (e) {
error = '$e';
// What the probe found refuses it here, with "Put it up anyway": the
// server or a friend (with cookies) may still open it.
final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: widget.probe))?.problem;
String? error = problem;
if (problem == null) {
try {
error = await widget.onPutUp(
LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
} catch (e) {
error = '$e';
}
}
final canForce = problem != null;
if (error == null) {
final p = _previews[url] ?? await previewLink(url, probe: widget.probe);
unawaited(widget.recents.add(RecentLink(
@@ -289,6 +313,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
setState(() {
_busy = false;
_error = error;
_canForce = canForce;
});
}
@@ -373,6 +398,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
textAlign: TextAlign.center,
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
),
if (_canForce)
Center(
child: TextButton(
key: const ValueKey('link-put-up-anyway'),
onPressed: _busy ? null : () => _go(anyway: true),
child: Text(solo ? 'Play it anyway' : 'Put it up anyway'),
),
),
],
AnimatedSize(
duration: easeTime,
@@ -475,9 +508,9 @@ class _PreviewCard extends StatelessWidget {
const SizedBox(height: 4),
Text(
p?.subtitle ?? 'Paste an http(s) address',
maxLines: 1,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey),
style: TextStyle(color: p?.problem != null ? scheme.error : Colors.grey),
),
],
),
@@ -103,6 +103,7 @@ class PartyStrip extends StatelessWidget {
info: selfAvatarInfo(
ready: self?.ready ?? false,
host: online && !solo && coord.hostFile != null,
error: online ? coord.playerError.value : '',
),
size: stripAvatarSize,
ring: online,
@@ -553,6 +554,8 @@ class _MemberTile extends StatelessWidget {
? 'reconnecting…'
: m.out
? 'sitting out'
: m.error.isNotEmpty
? "can't play this: ${m.error}"
: info.buffering
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
: m.ready
+33 -6
View File
@@ -11,11 +11,12 @@ import 'avatar.dart';
import 'motion.dart';
/// What the player's status pill says, most pressing first.
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, waiting }
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting }
/// The pill's button: stop reconnecting, retry, or mark yourself ready
/// (the whole pill).
enum StatusAction { cancel, retry, ready }
/// The pill's button: stop reconnecting, retry (reconnect), reload the
/// video (your player could not open it), or mark yourself ready (the
/// whole pill).
enum StatusAction { cancel, retry, reload, ready }
/// One status line: [text], a muted [detail], a coloured [tone] for
/// problems (warn, error), the members it is about ([who]; [self] for you)
@@ -55,18 +56,24 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
/// The player's status from the session's signals; null when there is
/// nothing to say (playing, connected, everyone ready). [slowConnect]:
/// connecting has taken over 2 s.
/// connecting has taken over 2 s. [playerError]: why your player cannot
/// open the media (the coordinator's `playerError`).
StatusView? playerStatusFor({
required HealthStatus health,
bool reconnecting = false,
StateBroadcast? state,
MemberView? self,
bool slowConnect = false,
String playerError = '',
}) {
if (reconnecting) {
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel);
}
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) {
return StatusView(StatusKind.problem, "Can't play this: $playerError",
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
}
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
return StatusView(StatusKind.problem, _sentence(health.message),
tone: health.severity, action: StatusAction.retry);
@@ -81,6 +88,12 @@ StatusView? playerStatusFor({
if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
}
// Members whose player cannot open it: the server does not wait for
// them; say why instead of "waiting for".
final failed = [
for (final m in r.members)
if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m,
];
// As the server holds playback: someone in the ready set short of the
// minimum buffer.
final need = r.bufferNeed;
@@ -92,12 +105,22 @@ StatusView? playerStatusFor({
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
}
}
if (failed.isNotEmpty) {
final f = failed.first;
final who = nameList([for (final m in failed) m.name]);
final unready = self != null && !self.ready;
return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}",
who: failed,
short: "$who can't play this",
detail: unready ? "Press Space when you're ready" : null,
action: unready ? StatusAction.ready : null);
}
if (self != null && !self.ready) {
return StatusView(StatusKind.you, "Press Space when you're ready",
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
}
// Members sitting out are never waited for nor counted.
final active = [for (final m in r.members) if (!m.stale && !m.out) m];
final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m];
final others = [
for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
@@ -117,6 +140,7 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
state: c.serverState.value,
self: c.selfMember.value,
slowConnect: slowConnect,
playerError: c.playerError.value,
);
}
@@ -164,6 +188,8 @@ class _PlayerStatusState extends State<PlayerStatus> {
coord.cancelReconnect();
case StatusAction.retry:
coord.retry();
case StatusAction.reload:
unawaited(coord.reloadVideo());
case StatusAction.ready:
coord.toggleReady();
}
@@ -266,6 +292,7 @@ class _StatusLine extends StatelessWidget {
return null;
case StatusKind.loading:
case StatusKind.you:
case StatusKind.cannotPlay:
case StatusKind.waiting:
final r = coord.serverState.value;
final infos = [
@@ -219,6 +219,7 @@ class SyncClient {
double duration = 0,
int mediaId = 0,
Map<String, bool>? jf,
String error = '',
}) {
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
_send(
@@ -229,6 +230,7 @@ class SyncClient {
duration: duration.isFinite && duration > 0 ? duration : 0,
mediaId: mediaId,
jf: jf,
error: error,
).toJson(),
);
}
+80 -51
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: android_file_picker
sha256: "014c74ab48d452c3252465682375a7fe6ddf56abb908ec361f207a3c4ffb2444"
sha256: "537bce9a43bc6fc08670cb68498251acc6eb9134b86a391b43e1f7c863588388"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "2.0.1"
archive:
dependency: transitive
description:
name: archive
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
sha256: "3aa20c6b5617b632a1b7198dcca3d88065d37232485b9a6c6c14dc8f5357d279"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
version: "4.4.0"
args:
dependency: transitive
description:
@@ -61,10 +61,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941
sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
version: "2.1.0"
collection:
dependency: transitive
description:
@@ -97,14 +97,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_ui:
dependency: transitive
description:
name: cupertino_ui
sha256: ea0b8170b40a86262b2fc21ac0625303f52df209dbf6fccc2b98b27eff44f6ce
url: "https://pub.dev"
source: hosted
version: "1.1.2"
dbus:
dependency: transitive
description:
name: dbus
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612
url: "https://pub.dev"
source: hosted
version: "0.7.15"
version: "0.8.0"
dio:
dependency: transitive
description:
@@ -130,7 +138,7 @@ packages:
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
dependency: "direct main"
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
@@ -149,42 +157,42 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: "9be6aac79508dbcf8dac80a4fb20f27ce6f910837ca27e3670b9295995cc0110"
sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea"
url: "https://pub.dev"
source: hosted
version: "12.3.0"
version: "13.1.0"
file_picker_darwin:
dependency: transitive
description:
name: file_picker_darwin
sha256: "59fa5394cfa5b6dc8bf491630cb17077b77b844adbb840df371e7be5355d6cf6"
sha256: "3df1dc8e881de9ffd3953c9e482ac5d6e1ffe1e7f7b231b5695543e251ab7918"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "2.1.3"
file_picker_linux:
dependency: transitive
description:
name: file_picker_linux
sha256: bd52ff1e0048f29df95f913c55ad2991c72791d93e6c42241912c4bdee946cb9
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "2.0.2"
file_picker_platform_interface:
dependency: transitive
description:
name: file_picker_platform_interface
sha256: "0355558fd9af6da499d18e333d6b3beb44b0bf19e00933fccd15d18eb8d0e9ca"
sha256: bbdc085a6f168e63f147e9ce8988f79fa0800a58e7f25a48f762c60837107ba5
url: "https://pub.dev"
source: hosted
version: "3.4.0"
version: "4.0.0"
file_picker_web:
dependency: transitive
description:
name: file_picker_web
sha256: "935560a9d29fa6f006f2855addebb88e88d438e13627d4cc24187033c106f227"
sha256: "32af8262aca907b02b8970b32328b0312db0b42ba6f930be6d27fde179d59b7d"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "4.1.0"
fixnum:
dependency: transitive
description:
@@ -210,10 +218,15 @@ packages:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -237,18 +250,18 @@ packages:
dependency: "direct main"
description:
name: hive_ce
sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418"
sha256: "85d43209e2b4903a95d65d054407ea9ad4989d17b4d47e71732070d41b427c87"
url: "https://pub.dev"
source: hosted
version: "2.19.3"
version: "2.20.2"
hive_ce_flutter:
dependency: "direct main"
description:
name: hive_ce_flutter
sha256: "2677e95a333ff15af43ccd06af7eb7abbf1a4f154ea071997f3de4346cae913a"
sha256: "231c5f025a44f4a555b4e7417d1468027cc32d9471b00543bd5bce72464a1009"
url: "https://pub.dev"
source: hosted
version: "2.3.4"
version: "2.4.0"
hooks:
dependency: transitive
description:
@@ -277,10 +290,18 @@ packages:
dependency: "direct main"
description:
name: image
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
sha256: "0be525eb917b032c7c9c980597c508a255814a858bd8d9abfc9c57e04f3ebf89"
url: "https://pub.dev"
source: hosted
version: "4.9.2"
version: "4.10.2"
intl:
dependency: transitive
description:
name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.dev"
source: hosted
version: "0.20.3"
isolate_channel:
dependency: transitive
description:
@@ -293,18 +314,18 @@ packages:
dependency: transitive
description:
name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
version: "1.1.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351
sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
version: "1.0.4+1"
jni_util:
dependency: transitive
description:
@@ -349,10 +370,10 @@ packages:
dependency: transitive
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
version: "6.1.0"
logging:
dependency: transitive
description:
@@ -377,6 +398,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.0"
material_ui:
dependency: transitive
description:
name: material_ui
sha256: "98ab9eda579ecae623e4b376c603035c5def78ce706f735f30bd632af5814695"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
media_kit:
dependency: "direct main"
description:
@@ -461,10 +490,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3
sha256: "5c80c2ad6e2c0397de5db38c6d653e69616a3397e13aa7633bf39eb668f2041b"
url: "https://pub.dev"
source: hosted
version: "9.6.0"
version: "9.6.2"
package_config:
dependency: transitive
description:
@@ -477,10 +506,10 @@ packages:
dependency: transitive
description:
name: package_info_plus
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908"
url: "https://pub.dev"
source: hosted
version: "10.2.1"
version: "10.2.2"
package_info_plus_platform_interface:
dependency: transitive
description:
@@ -549,10 +578,10 @@ packages:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
version: "7.1.0"
platform:
dependency: transitive
description:
@@ -748,14 +777,14 @@ packages:
path: "../../packages/core"
relative: true
source: path
version: "1.1.0"
version: "1.1.1"
sync_relay_server:
dependency: "direct main"
description:
path: "../../packages/server"
relative: true
source: path
version: "1.1.0"
version: "1.1.1"
synchronized:
dependency: transitive
description:
@@ -816,10 +845,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.4.3"
vm_service:
dependency: transitive
description:
@@ -832,10 +861,10 @@ packages:
dependency: transitive
description:
name: wakelock_plus
sha256: "22b3e7e937721de70e63c85e7139f4ac781dc22863b9262431a53ac030eb074b"
sha256: "9a91139ee876c24a9ebe1484e4b9b7923b07d368566a5e0b2301870b0f5029c8"
url: "https://pub.dev"
source: hosted
version: "1.8.0"
version: "1.8.1"
wakelock_plus_platform_interface:
dependency: transitive
description:
@@ -888,10 +917,10 @@ packages:
dependency: transitive
description:
name: windows_file_picker
sha256: "62e6e6e115231d1d1d71c7b5883b4091ce3e0190e49548dfb0ff8cba8d91ad96"
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "2.0.1"
xdg_directories:
dependency: transitive
description:
@@ -904,10 +933,10 @@ packages:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
sha256: "0fe8cc98946def31cbcbc8734aa4067031e6f65ea3a78a508060bfed2200bfeb"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
version: "7.1.0"
yaml:
dependency: transitive
description:
@@ -917,5 +946,5 @@ packages:
source: hosted
version: "3.1.4"
sdks:
dart: ">=3.13.0-0 <4.0.0"
flutter: ">=3.44.0"
dart: ">=3.13.0 <4.0.0"
flutter: ">=3.47.0"
+4 -3
View File
@@ -1,7 +1,7 @@
name: watch_together
description: Watch-together desktop app (working title, repo sync_relay_project). One binary.
publish_to: none
version: 1.1.1
version: 1.1.2
environment:
sdk: '>=3.13.0-0 <4.0.0'
@@ -18,12 +18,13 @@ dependencies:
path: ../../packages/core
sync_relay_server:
path: ../../packages/server
file_picker: ^12.3.0
file_picker: ^13.1.0
hive_ce: ^2.19.3
hive_ce_flutter: ^2.3.4
image: ^4.9.2
window_manager: ^0.5.2
crypto: ^3.0.6
ffi: ^2.1.0
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
fox_utils:
git:
@@ -32,7 +33,7 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter_lints: ^6.0.0
flutter:
uses-material-design: true
assets:
+5 -2
View File
@@ -202,8 +202,11 @@ void main() {
final page = await previewLink('https://example.com/a',
probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL'));
expect((page!.title, page.subtitle),
('Example', "example.com · Not a video (and yt-dlp couldn't read it: Unsupported URL)"));
expect((page!.title, page.subtitle, page.problem),
('Example', 'example.com · Not a video page yt-dlp knows', 'Not a video page yt-dlp knows'));
final refused = await previewLink('https://cdn.example/b.mp4',
probe: (url) async => LinkProbe.page(Uri.parse(url), ytDlpError: 'odd', status: 403));
expect((refused!.title, refused.problem), ('b.mp4', 'Not allowed (403)'));
expect(await previewLink('not a link'), isNull);
});
+15 -1
View File
@@ -110,7 +110,9 @@ Future<Harness> pumpLibrary(
now: () => fixtureNow,
probe: (url) async => url.endsWith('.mkv')
? LinkProbe.file(Uri.parse(url), 1)
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
: url.contains('bilibili')
? LinkProbe.page(Uri.parse(url), ytDlpError: 'HTTP Error 412: Precondition Failed')
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(solo: solo, relayed: relayed, onPlay: onPlay),
onPutUp: (m) async {
@@ -296,6 +298,18 @@ void main() {
await settle(tester, 10);
expect(h.controller.link.text, url);
// A link the probe cannot open: the reason, not put up; "anyway" is.
const bili = 'https://www.bilibili.com/video/BV1';
await tester.enterText(find.byKey(const ValueKey('link-field')), bili);
await settle(tester, 10);
await tester.tap(find.byKey(const ValueKey('link-put-up')));
await settle(tester, 10);
expect(find.text(needsCookiesText), findsOneWidget);
expect(h.put, hasLength(1));
await tester.tap(find.byKey(const ValueKey('link-put-up-anyway')));
await settle(tester, 10);
expect((h.put.last as LinkMedia).anyway, isTrue);
expect(tester.takeException(), isNull);
});
+13
View File
@@ -112,6 +112,19 @@ void main() {
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
});
test("can't play: yours with Retry (reload); others' instead of waiting", () {
var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!;
expect((v.kind, v.text, v.action), (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload));
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false,
error: 'Not allowed (403)');
v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!;
expect((v.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null));
expect(v.who.single.id, 'b');
final notMe = mv('me', 'Me');
v = playerStatusFor(health: ok, state: held([notMe, bob]), self: notMe)!;
expect((v.kind, v.action), (StatusKind.cannotPlay, StatusAction.ready));
});
test('names read as a sentence', () {
expect(nameList(['Bob']), 'Bob');
expect(nameList(['Bob', 'Mika']), 'Bob and Mika');
+3 -11
View File
@@ -29,7 +29,6 @@ final seriesJson = {
'jellyfin_series_id': 'jf7',
'state': 'released',
'monitored': true,
'followed': true,
'episodes_total': 24,
'episodes_available': 20,
'next_air_date': null,
@@ -110,7 +109,7 @@ void main() {
'to': req.uri.queryParameters['to'],
'items': [
{...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"');
},
@@ -126,7 +125,6 @@ void main() {
expect(it.anidbId, 70);
expect(it.tvdbId, 700);
expect(it.jellyfinSeriesId, 'jf7');
expect(it.followed, isTrue);
final e = it.episode;
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));
@@ -146,7 +144,7 @@ void main() {
expect(server.seen, isEmpty);
});
test('/series, /series/{id} with episodes, follow/unfollow', () async {
test('/series, /series/{id} with episodes', () async {
await serve({
'GET /series': (req, res) async => json(res, {'items': [seriesJson]}, etag: 'W/"a"'),
'GET /series/7': (req, res) async => json(res, {
@@ -156,24 +154,18 @@ void main() {
{'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 s = (await c.series()).single;
expect(s.seriesId, 7);
expect(s.anilistId, 111);
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));
final d = await c.seriesDetail(7);
expect(d.episodes.map((e) => e.season), [0, 1]);
expect(d.episodes.last.status, EpisodeStatus.failed);
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 {
+50 -7
View File
@@ -20,7 +20,7 @@ Airing airing(int id, int ep, DateTime local) => Airing(
);
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}) =>
AnimeFlowScheduleItem(
episode: AnimeFlowEpisode(
@@ -36,7 +36,6 @@ AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
seriesId: seriesId,
title: 'Show',
jellyfinSeriesId: 'jf$seriesId',
followed: followed,
);
void main() {
@@ -80,6 +79,36 @@ void main() {
expect(out.single.season, 6);
});
test('confirmOnJellyfin: the episode (with the run offset) must be on Jellyfin', () {
final lib = AnimeFlowLibraryItem.fromJson({
'jellyfin_series_id': 'jfJ',
'name': 'JoJo',
'anilist_map': [
{'season': 6, 'anilist_id': 210482, 'first_episode': 13, 'offset': 12},
],
});
final matched = matchSchedule(
[
airing(210482, 1, DateTime(2026, 10, 1, 18)), // S6E13: there
airing(210482, 2, DateTime(2026, 10, 8, 11)), // S6E14: not yet
airing(172192, 3, DateTime(2026, 10, 6, 18)), // series not looked up
],
schedule: const [],
library: [
lib,
const AnimeFlowLibraryItem(jellyfinSeriesId: 'jfR', name: 'Returner', anilistIds: {2: 172192}),
],
now: now,
);
expect(matched.map((e) => e.jellyfinEpisode), [13, 14, 3]);
final out = confirmOnJellyfin(matched, {
'jfJ': {(6, 12), (6, 13), (1, 14)},
});
expect(out.map((e) => e.status),
[ScheduleStatus.inLibrary, ScheduleStatus.notOnJellyfin, ScheduleStatus.aired]);
expect(out[1].jellyfinSeriesId, 'jfJ');
});
test("AnimeFlow's anilist_episode wins over TVDB numbering (split cour)", () {
// S2E13 in TVDB is part 2 (AniList 300) episode 1.
final out = matchSchedule(
@@ -99,13 +128,28 @@ void main() {
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', () {
// One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200.
final out = matchSchedule(
[airing(100, 3, DateTime(2026, 10, 1, 20)), airing(200, 3, DateTime(2026, 10, 9, 20))],
schedule: [
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')],
now: now,
@@ -113,7 +157,6 @@ void main() {
expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.downloading]);
expect(out.map((e) => e.season), [1, 2]);
expect(out[1].progress, 0.4);
expect(out[1].followed, isTrue);
expect(out.every((e) => e.tracked && e.seriesId == 7 && e.jellyfinSeriesId == 'jf7'), isTrue);
});
@@ -160,11 +203,11 @@ void main() {
final out = matchSchedule(
[airing(500, 2, DateTime(2026, 10, 7, 20)), airing(500, 3, DateTime(2026, 10, 14, 20))],
schedule: const [],
series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500, followed: true)],
series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500)],
now: now,
);
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', () {
@@ -226,7 +269,7 @@ void main() {
return json(res, {
'items': [
{'season': 1, 'episode': 3, 'anilist_id': 100, 'status': 'available', 'series_id': 7,
'title': 'Show', 'followed': false},
'title': 'Show'},
],
});
case '/flow/series':
+48 -18
View File
@@ -8,6 +8,7 @@ import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/schedule/anilist.dart';
import 'package:watch_together/src/schedule/animeflow.dart';
import 'package:watch_together/src/schedule/schedule_model.dart';
import 'package:watch_together/src/schedule/ui/schedule_actions.dart';
import 'package:watch_together/src/schedule/ui/schedule_page.dart';
import 'package:watch_together/src/screens/home/picked_media.dart';
@@ -87,7 +88,6 @@ List<ScheduleEntry> entries() => [
airing: airing(111, 'Avail Show', 3, day(0, 9)),
status: ScheduleStatus.available,
tracked: true,
followed: true,
seriesId: 7,
season: 1,
jellyfinSeriesId: 'jf7',
@@ -200,8 +200,6 @@ void main() {
});
case ('GET', '/search'):
json(res, {'items': searchHits});
case ('PUT' || 'DELETE', '/series/7/follow'):
res.statusCode = 204;
case ('POST', '/requests/1/approve' || '/requests/2/reject'):
json(res, {});
default:
@@ -297,13 +295,9 @@ void main() {
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 tester.tap(find.text('Following'));
await tester.pump();
expect(card(111, 3), findsOneWidget);
expect(card(222, 5), findsNothing);
expect(find.text('Following'), findsNothing);
await tester.tap(find.text('All airing'));
await tester.pump();
expect(card(444, 1), findsOneWidget);
@@ -397,15 +391,6 @@ void main() {
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 {
final jf = FakeJf();
final accounts = JellyfinAccounts(
@@ -434,6 +419,51 @@ void main() {
await done(tester);
});
testWidgets('untracked on Jellyfin: episode lookup, aired-not-there has no Play', (tester) async {
final jf = FakeJf();
final accounts = JellyfinAccounts(
store: MemoryJellyfinStore({
'accounts': jsonEncode([
{
'serverId': 'srv',
'serverName': 'Home',
'urls': ['http://jf.test'],
'userId': 'uid',
'userName': 'me',
'token': 'tok',
},
]),
}),
httpFactory: () => jf,
deviceName: 'Test',
);
final eps = await tester.runAsync(() => jellyfinEpisodeNumbers(accounts, flow, 'jf7'));
expect(eps, {(1, 2), (1, 3)});
await tester.runAsync(() => jellyfinEpisodeNumbers(accounts, flow, 'jf7'));
expect(jf.seen.where((u) => u.path.endsWith('/Episodes')), hasLength(1), reason: 'memoised');
final none = JellyfinAccounts(store: MemoryJellyfinStore(), deviceName: 'Test');
expect(await tester.runAsync(() => jellyfinEpisodeNumbers(none, flow, 'jf7')), isNull);
await pumpPage(
tester,
snap: () => snapshot(
list: [
ScheduleEntry(
airing: airing(777, 'Clover', 2, day(0, 9)),
status: ScheduleStatus.notOnJellyfin,
season: 1,
jellyfinEpisode: 2,
jellyfinSeriesId: 'jfC',
),
],
),
);
expect(card(777, 2), findsOneWidget);
expect(find.text('aired · not on Jellyfin yet'), findsOneWidget);
expect(action(777, 2), findsNothing);
await done(tester);
});
testWidgets('solo: the button says Play and works', (tester) async {
await pumpPage(tester, solo: true);
expect(find.text('Put it up'), findsNothing);
@@ -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',
);
}
+73 -14
View File
@@ -113,8 +113,17 @@ Loosely modeled on Syncplay, with a different readiness design.
setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
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
a swap meanwhile would have dropped). Stale, leave and sitting out
remove from the set.
a swap meanwhile would have dropped). Stale, leave, sitting out and a
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
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
@@ -256,8 +265,25 @@ mpv settings (`PlaybackController`): `Player` with `bufferSize` 256 MiB
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
file is ~8), `demuxer-max-back-bytes=67108864`,
`stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4`
(comma form; this mpv build rejects colon-joined). The cache is in RAM:
`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): 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
ignores invalid values silently, so every pin is read back and a mismatch is
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
context (OpenGL, else its software renderer) when the `VideoController` is
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,
`softwareGl`); none within 1.5 s of the controller being ready means 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
traffic (measured: 0 messages over 5 s of playback). The hardware path is
menu and Settings say so). Otherwise the session player logs at `warn`
(`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
thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change
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
(`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
reason ("Not a video (and yt-dlp couldn't read it: ...)"). The app
probes on paste (`AppYtDlp.probe`, remembered 10 minutes) 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).
reason and the GET's HTTP status. `LinkProbe.problem`: why it will not
play, in plain words (`playErrorText` of yt-dlp's reason, else of a 4xx
status), null for a file or a page yt-dlp read. The app probes on paste
(`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
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`,
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
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
@@ -819,7 +866,13 @@ always scrolls the page). Space never presses a focused button
against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token;
`/library` marks shows on Jellyfin it doesn't track, through every AniList
entry of a season (`anilist_map`: a season AniList splits has several)) into a status per
episode (`schedule_model.dart`); requests and the owner's inbox are in
episode (`schedule_model.dart`). An aired episode of such a show reads
"on Jellyfin" only when Jellyfin has it: the season plus AniList
episode + the run's offset, checked against the series' episodes seen
by the linked account (`jellyfinEpisodeNumbers`, one request per series
memoised 3 min; `confirmOnJellyfin` is the pure step); absent reads
"aired · not on Jellyfin yet", unknown (no linked account, lookup
failed) "aired", neither with Put it up. Requests and the owner's inbox are in
`requests_sheet.dart`. It refreshes every minute; mouse drag and
Shift+wheel scroll the board sideways, the plain wheel scrolls a column.
Settings (`settings_tab.dart`) has a left nav with icons
@@ -897,7 +950,13 @@ always scrolls the page). Space never presses a focused button
`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
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
between kinds. The mini shows its short line in its hover bar. ⚙ in both
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":123,"duration":0,"url":"...","relay":{}}}
{"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":"chat","text":"..."}
{"t":"out","target":"<memberId>","out":true}
@@ -72,6 +72,17 @@ Field notes:
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
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`.
- `user.avatar`: base64 PNG, at most 256 KiB of characters (else
`rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32
@@ -113,7 +124,7 @@ Field notes:
"serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
"host"?,"out"?}],
"host"?,"out"?,"error"?}],
"seekBy":"m1","seekSeq":3,"relay":true}
{"t":"pong","c":1767225600000,"s":1767225600012}
{"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
"Avatars"). `members[].host` (omitted when false): the member sent the
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
(the clock `serverTime` uses). With `t3` the local receive time, the offset
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.
4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
filter on the Schedule; today's "Following" (AnimeFlow's monitored
shows) becomes "On the server".
filter on the Schedule from the user's AniList lists. This is the
Schedule's personal tracking; AnimeFlow has no per-user follow.
5. **Ops**: check shaders on Windows; the relay e2e "source drops
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.
- HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater)
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.
- Playlist: `queue-add`, `queue-skip`, auto-advance like a swap.
- Sidecar subtitles.
+2
View File
@@ -50,6 +50,8 @@ abstract class RelayConstants {
static const int maxBannerBytes = 768 * 1024;
static const int maxNameLength = 32;
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
/// minute; the server drops the rest silently.
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.
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 {
final String message;
const LinkProbeException(this.message);
@@ -94,15 +126,30 @@ class LinkProbe {
final String? pageTitle;
/// Why yt-dlp could not read the page.
final String? ytDlpError;
/// The HTTP status of the probe's GET; 0 when unknown.
final int status;
const LinkProbe.file(this.url, this.size)
: isPage = false,
info = null,
pageTitle = null,
ytDlpError = null;
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError})
ytDlpError = null,
status = 0;
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.status = 0})
: isPage = true,
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
/// for a file (named by its file name).
String? get title {
@@ -134,12 +181,14 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
try {
String? body;
Object? getError;
var status = 0;
try {
final req = await c.getUrl(url).timeout(const Duration(seconds: 15));
req.headers
..set(HttpHeaders.rangeHeader, 'bytes=0-0')
..set(HttpHeaders.acceptEncodingHeader, 'identity');
final res = await req.close().timeout(const Duration(seconds: 15));
status = res.statusCode;
final type = res.headers.contentType?.mimeType ?? '';
if (looksLikeMediaFile(status: res.statusCode, contentType: type, url: url)) {
await res.drain<void>().catchError((_) {});
@@ -155,7 +204,7 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
String? ytErr;
if (ytDlp != null) {
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) {
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)));
} 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 {
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,
/// false = through the server's relay. Null leaves the last one in place.
final Map<String, bool>? jf;
/// Why the player cannot open [mediaId] (short plain text); empty while
/// it can.
final String error;
const ReportMessage(
{required this.position,
required this.buffered,
required this.eventId,
this.duration = 0,
this.mediaId = 0,
this.jf});
this.jf,
this.error = ''});
Map<String, Object?> toJson() => {
't': 'report',
@@ -451,6 +455,7 @@ class ReportMessage {
if (duration > 0) 'duration': duration,
if (mediaId != 0) 'mediaId': mediaId,
if (jf != null) 'jf': jf,
if (error.isNotEmpty) 'error': error,
};
factory ReportMessage.fromJson(Map<String, Object?> json) {
@@ -462,10 +467,20 @@ class ReportMessage {
duration: finiteDouble(json['duration'], 'duration'),
mediaId: finiteInt(json['mediaId'], 'mediaId'),
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
/// keys over 64 characters, at most 8 entries (it rides in every `state`);
/// null when absent or not a map.
@@ -582,6 +597,9 @@ class MemberView {
final bool host;
/// Sitting out: connected, outside the ready gate, not playing.
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({
required this.id,
required this.name,
@@ -593,6 +611,7 @@ class MemberView {
this.jf = const {},
this.host = false,
this.out = false,
this.error = '',
});
Map<String, Object?> toJson() => {
@@ -606,6 +625,7 @@ class MemberView {
if (jf.isNotEmpty) 'jf': jf,
if (host) 'host': true,
if (out) 'out': true,
if (error.isNotEmpty) 'error': error,
};
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
@@ -619,6 +639,7 @@ class MemberView {
jf: jfAccessFromJson(json['jf']) ?? const {},
host: json['host'] == true,
out: json['out'] == true,
error: memberErrorFromJson(json['error']),
);
}
+28 -6
View File
@@ -56,6 +56,9 @@ class EngineMember {
bool host = false;
/// Sitting out: never in the set, never ready, its buffer ignored.
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;
EngineMember({
required this.id,
@@ -97,6 +100,7 @@ class EngineMember {
jf: jf,
host: host,
out: out,
error: error,
);
}
@@ -197,6 +201,7 @@ class SyncEngine {
final wasStale = isStale(member);
member.lastSeen = now;
if (!member.out &&
member.error.isEmpty &&
!member.inSet &&
(member.ready || (wasStale && transport != Transport.playing))) {
member.inSet = true;
@@ -307,12 +312,12 @@ class SyncEngine {
_seen(member);
// Sitting out: back first ([applyOut]), then ready.
if (member.out) return;
if (member.ready == ready && member.inSet) {
if (member.ready == ready && (member.inSet || member.error.isNotEmpty)) {
evaluate();
return;
}
member.ready = ready;
if (ready) member.inSet = true;
if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage(
actor: member.name,
action: ready ? 'ready' : 'unready',
@@ -328,13 +333,13 @@ class SyncEngine {
for (final member in members.values) {
if (member.out) continue;
member.ready = ready;
if (ready) member.inSet = true;
if (ready && member.error.isEmpty) member.inSet = true;
}
} else {
final member = byId(target);
if (member == null || member.out) return;
member.ready = ready;
if (ready) member.inSet = true;
if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage(
actor: actorName,
action: ready ? 'force-ready' : 'force-unready',
@@ -366,7 +371,7 @@ class SyncEngine {
member.out = out;
member.ready = false;
member.buffered = 0;
member.inSet = !out && transport != Transport.playing;
member.inSet = !out && member.error.isEmpty && transport != Transport.playing;
feed.add(FeedMessage(
actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now));
evaluate();
@@ -408,6 +413,7 @@ class SyncEngine {
// (a stale member rejoins the set with its next frame if still ready).
final t = now;
for (final member in members.values) {
member.error = '';
member.inSet = !member.out && !isStale(member, t);
member.buffered = 0;
}
@@ -416,10 +422,11 @@ class SyncEngine {
}
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];
if (member == null) return;
if (!reportPosition.isFinite) return;
_setError(member, mediaId == this.mediaId ? error : '');
_seen(member);
if (jf != null) member.jf = Map.unmodifiable(jf);
member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered;
@@ -433,6 +440,21 @@ class SyncEngine {
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() {
var changed = false;
for (final member in members.values) {
+1 -1
View File
@@ -1,6 +1,6 @@
name: sync_relay_core
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
version: 1.1.1
version: 1.1.2
environment:
sdk: ^3.0.0
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', () {
test('join while held lands in the set, playback stays held', () {
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', () {
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';
+1 -1
View File
@@ -428,7 +428,7 @@ void mountSyncRoute(
final report = ReportMessage.fromJson(decoded);
hub.engine.applyReport(
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();
case 'ping':
final ping = PingMessage.fromJson(decoded);
+1 -1
View File
@@ -1,7 +1,7 @@
name: sync_relay_server
description: Pure-Dart sync + media server. Embeddable, no Flutter.
publish_to: none
version: 1.1.1
version: 1.1.2
environment:
sdk: ^3.0.0
dependencies:
+5 -5
View File
@@ -1,10 +1,10 @@
{
"version": "1.1.1",
"version": "1.1.2",
"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",
"notes": "- CI (Windows): fetch git dependencies fresh; the host's cached fox_utils clone was stale",
"url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.2/watch-together-1.1.2-linux-x64.tar.gz",
"notes": "- Roadmap: drop the link-token item (what a user pastes is theirs)",
"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",
"windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.1/watch-together-1.1.1-windows-x64.zip"
"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.2/watch-together-1.1.2-windows-x64.zip"
}
}