15 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
foxyz cc45b86271 Release v1.1.1
ci / core (push) Successful in 28s
ci / server (push) Successful in 56s
release / windows (push) Successful in 4m33s
release / linux (push) Successful in 3m44s
ci / updater-windows (push) Successful in 4m25s
2026-10-10 01:01:28 +03:00
foxyzandClaude Opus 5.5 84c5cda668 CI (Windows): fetch git dependencies fresh; the host's cached fox_utils clone was stale
ci / core (push) Successful in 42s
ci / server (push) Successful in 1m17s
ci / updater-windows (push) Successful in 4m26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:41:17 +03:00
foxyzandClaude Opus 5.5 bc96cb13d9 fox_utils 0.1.9: fixes the Windows build (broken since 1.0.1)
ci / core (push) Successful in 28s
ci / updater-windows (push) Failing after 43s
ci / server (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:32:29 +03:00
foxyzandClaude Opus 5.5 f7f2f0cddd CI: show the verbose Windows build's error lines and tail
ci / core (push) Successful in 28s
ci / server (push) Successful in 55s
ci / updater-windows (push) Failing after 3m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:23:11 +03:00
foxyzandClaude Opus 5.5 05b06b1fc1 CI: capture the verbose Windows build through cmd (PowerShell 'Stop' aborted on stderr)
ci / core (push) Successful in 28s
ci / server (push) Successful in 58s
ci / updater-windows (push) Failing after 4m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:13:23 +03:00
foxyzandClaude Opus 5.5 748d58f036 CI: build Windows on every push; a failed Windows build prints the compiler errors
ci / core (push) Successful in 28s
ci / server (push) Successful in 57s
ci / updater-windows (push) Failing after 4m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:01:02 +03:00
39 changed files with 1053 additions and 290 deletions

No files matched your search

+8
View File
@@ -1,6 +1,9 @@
name: ci name: ci
on: on:
# Branches only: a release tag points at a commit CI already checked on
# main, and release.yml builds it.
push: push:
branches: ['**']
pull_request: pull_request:
jobs: jobs:
@@ -60,3 +63,8 @@ jobs:
dart pub get dart pub get
if ($LASTEXITCODE) { exit 1 } if ($LASTEXITCODE) { exit 1 }
dart test test/updater_test.dart dart test test/updater_test.dart
# The release build, without publishing: a broken Windows build shows
# here, not only on a tag.
- name: Windows build
shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
+4 -1
View File
@@ -145,7 +145,10 @@ folder to Files, or fill Link).
what it is (a page's title, uploader, length and thumbnail, else the what it is (a page's title, uploader, length and thumbnail, else the
file name); in a server, pick how the file name); in a server, pick how the
others get it (everyone fetches it, the server fetches it, or through others get it (everyone fetches it, the server fetches it, or through
you). Your last 12 links are listed under it. you). Your last 12 links are listed under it. A link that does not
open here (needs cookies, 403, 404, not a video) says why and goes up
only with "Put it up anyway"; in the player, whoever cannot play it is
named ("Bob can't play this: Not allowed (403)") and nobody waits for them.
- **Files**: video folders you add on this computer, grouped by series - **Files**: video folders you add on this computer, grouped by series
(click a series to list its episodes), or any file through Browse. The others stream it from you. (click a series to list its episodes), or any file through Browse. The others stream it from you.
@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:ffi' show Int8;
import 'dart:io'; import 'dart:io';
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
@@ -64,6 +66,22 @@ class PlaybackController {
/// mpv's yt-dlp hook could not open the current link (its error text). /// mpv's yt-dlp hook could not open the current link (its error text).
void Function(String error)? onYtdlError; void Function(String error)? onYtdlError;
/// The current media failed to load (the HTTP error when there was one,
/// e.g. a CDN's 403), at most once per [openUrl].
void Function(String error)? onLoadError;
// Per opened media: the last HTTP error ffmpeg logged, and whether a
// load error was reported.
String? _httpError;
bool _loadErrorSent = false;
/// mpv's log level for this app. media_kit drains mpv's log on the UI
/// thread one line at a time, so a chatty level (ffmpeg retrying a
/// refused request logs every attempt) can starve the UI: warnings and
/// errors only, except while the video output starts ([video]), when
/// the render-mode probe needs mpv's verbose GL init lines.
static const logLevel = 'warn';
/// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web /// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web
/// page links, at most 1080p, once per player; its cookies /// page links, at most 1080p, once per player; its cookies
/// (`ytdl-raw-options`) whenever they changed. /// (`ytdl-raw-options`) whenever they changed.
@@ -162,6 +180,7 @@ class PlaybackController {
final existing = _video; final existing = _video;
if (existing != null) return existing; if (existing != null) return existing;
final hwdec = _hwdecOverride; final hwdec = _hwdecOverride;
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
// media_kit's video output sets hwdec itself on attach ('auto' unless // media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC. // configured), which used to override SYNC_RELAY_HWDEC.
final video = _video = VideoController( final video = _video = VideoController(
@@ -187,8 +206,7 @@ class PlaybackController {
bufferSize: maxBytes, bufferSize: maxBytes,
libass: true, libass: true,
vo: Platform.environment['SYNC_RELAY_VO'], vo: Platform.environment['SYNC_RELAY_VO'],
// Verbose: the render-mode probe needs mpv's GL init lines. logLevel: MPVLogLevel.warn, // see [logLevel]
logLevel: MPVLogLevel.v,
)) ))
..onUserSeek = (d) => onUserSeek?.call(d); ..onUserSeek = (d) => onUserSeek?.call(d);
_shaders = ''; _shaders = '';
@@ -243,8 +261,29 @@ class PlaybackController {
if (hwdec != null) { if (hwdec != null) {
await _pin('hwdec', hwdec); await _pin('hwdec', hwdec);
} }
await _pin('stream-lavf-o', await _pin('stream-lavf-o', streamLavfOptions);
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4'); }
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect
/// after a dropped connection, also on a streamed (growing) relay
/// response, and a server error (5xx) while connecting; a refused request
/// (4xx) fails at once. At most 3 tries per read, 4 s apart at most.
static const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
'reconnect_delay_max=4,reconnect_max_retries=3';
// mpv_request_log_messages on [p]'s handle (media_kit has no API for it).
static void _requestLog(Player p, String level) {
try {
final dynamic platform = p.platform;
final name = level.toNativeUtf8();
try {
platform.mpv.mpv_request_log_messages(platform.ctx, name.cast<Int8>());
} finally {
malloc.free(name);
}
} catch (e) {
debugPrint('mpv log level $level not set: $e');
}
} }
/// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on /// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on
@@ -331,7 +370,27 @@ class PlaybackController {
void _onLog(PlayerLog log) { void _onLog(PlayerLog log) {
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) { if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), ''); final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
if (text.isNotEmpty) onYtdlError?.call(text); if (text.isNotEmpty) {
_loadErrorSent = true;
onYtdlError?.call(text);
}
return;
}
// ffmpeg logs through one mpv instance per process (the first one
// alive), so the HTTP status is a bonus, not a given.
if (log.prefix == 'ffmpeg') {
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
if (http != null) _httpError = http[0]!.trim();
return;
}
// Nothing playable opened (a track of mpv's yt-dlp EDL refused, ...).
if (!_loadErrorSent &&
duration.peek() == Duration.zero &&
((log.prefix == 'cplayer' && log.level == 'fatal') ||
(log.prefix == 'demux' && log.level == 'error'))) {
_loadErrorSent = true;
final http = _httpError;
onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http');
return; return;
} }
if (log.prefix != 'libmpv_render') return; if (log.prefix != 'libmpv_render') return;
@@ -364,10 +423,12 @@ class PlaybackController {
Future<void> _detectRenderMode(Player p, VideoController video) async { Future<void> _detectRenderMode(Player p, VideoController video) async {
try { try {
await video.platform.future; await video.platform.future;
await Future.delayed(const Duration(milliseconds: 1500));
} catch (_) { } catch (_) {
return; return;
} finally {
if (identical(p, _player)) _requestLog(p, logLevel);
} }
await Future.delayed(const Duration(milliseconds: 1500));
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) { if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
return; return;
} }
@@ -451,6 +512,8 @@ class PlaybackController {
if (!_ready) return; if (!_ready) return;
try { try {
_chapterJson = ''; _chapterJson = '';
_httpError = null;
_loadErrorSent = false;
chapters.value = const []; chapters.value = const [];
tracks.value = const []; tracks.value = const [];
buffered.value = Duration.zero; buffered.value = Duration.zero;
@@ -248,7 +248,6 @@ class AnimeFlowScheduleItem {
final int? anidbId; final int? anidbId;
final int? tvdbId; final int? tvdbId;
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
final bool followed;
const AnimeFlowScheduleItem({ const AnimeFlowScheduleItem({
required this.episode, required this.episode,
@@ -257,7 +256,6 @@ class AnimeFlowScheduleItem {
this.anidbId, this.anidbId,
this.tvdbId, this.tvdbId,
this.jellyfinSeriesId, this.jellyfinSeriesId,
this.followed = false,
}); });
static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem( static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem(
@@ -267,7 +265,6 @@ class AnimeFlowScheduleItem {
anidbId: _int(j['anidb_id']), anidbId: _int(j['anidb_id']),
tvdbId: _int(j['tvdb_id']), tvdbId: _int(j['tvdb_id']),
jellyfinSeriesId: _str(j['jellyfin_series_id']), jellyfinSeriesId: _str(j['jellyfin_series_id']),
followed: j['followed'] == true,
); );
} }
@@ -296,7 +293,6 @@ class AnimeFlowSeries {
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
final SeriesState state; final SeriesState state;
final bool monitored; final bool monitored;
final bool followed;
final int episodesTotal; final int episodesTotal;
final int episodesAvailable; final int episodesAvailable;
final DateTime? nextAirDate; final DateTime? nextAirDate;
@@ -312,7 +308,6 @@ class AnimeFlowSeries {
this.jellyfinSeriesId, this.jellyfinSeriesId,
this.state = SeriesState.releasing, this.state = SeriesState.releasing,
this.monitored = false, this.monitored = false,
this.followed = false,
this.episodesTotal = 0, this.episodesTotal = 0,
this.episodesAvailable = 0, this.episodesAvailable = 0,
this.nextAirDate, this.nextAirDate,
@@ -328,7 +323,6 @@ class AnimeFlowSeries {
jellyfinSeriesId: _str(j['jellyfin_series_id']), jellyfinSeriesId: _str(j['jellyfin_series_id']),
state: SeriesState.parse(j['state']), state: SeriesState.parse(j['state']),
monitored: j['monitored'] == true, monitored: j['monitored'] == true,
followed: j['followed'] == true,
episodesTotal: _int(j['episodes_total']) ?? 0, episodesTotal: _int(j['episodes_total']) ?? 0,
episodesAvailable: _int(j['episodes_available']) ?? 0, episodesAvailable: _int(j['episodes_available']) ?? 0,
nextAirDate: _time(j['next_air_date']), nextAirDate: _time(j['next_air_date']),
@@ -684,10 +678,6 @@ class AnimeFlowClient {
return AnimeFlowSeries.fromJson(j is Map ? j : const {}); return AnimeFlowSeries.fromJson(j is Map ? j : const {});
} }
/// Follow (or unfollow) a series [view].
Future<void> setFollowed(int seriesId, bool followed) =>
_call(followed ? 'PUT' : 'DELETE', '/series/$seriesId/follow');
/// Titles that can be requested [request]. /// Titles that can be requested [request].
Future<List<AnimeFlowSearchHit>> search(String q) async => [ Future<List<AnimeFlowSearchHit>> search(String q) async => [
for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h), for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h),
@@ -17,9 +17,16 @@ enum ScheduleStatus {
unmonitored, unmonitored,
/// AnimeFlow does not track this series (or is unreachable). /// AnimeFlow does not track this series (or is unreachable).
notTracked, notTracked,
/// Not tracked by AnimeFlow, but the series is on Jellyfin (`/library`): /// Not tracked by AnimeFlow, the series is on Jellyfin (`/library`) and
/// the episode may be there; Put it up finds out. /// so is the episode ([confirmOnJellyfin]; [matchSchedule] alone sets
inLibrary; /// 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) { static ScheduleStatus of(EpisodeStatus s) => switch (s) {
EpisodeStatus.available => available, EpisodeStatus.available => available,
@@ -41,11 +48,14 @@ class ScheduleEntry {
final String? reason; final String? reason;
/// AnimeFlow tracks the series. /// AnimeFlow tracks the series.
final bool tracked; final bool tracked;
final bool followed; /// AnimeFlow's series id (requests).
/// AnimeFlow's series id (follow, requests).
final int? seriesId; final int? seriesId;
/// The AnimeFlow season of the matched episode (requests). /// The AnimeFlow season of the matched episode (requests); untracked
/// on Jellyfin: the TVDB season of the AniList entry.
final int? season; 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. /// Play target once available.
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
/// The series is on Jellyfin (tracked or not). /// The series is on Jellyfin (tracked or not).
@@ -57,12 +67,24 @@ class ScheduleEntry {
this.progress, this.progress,
this.reason, this.reason,
this.tracked = false, this.tracked = false,
this.followed = false,
this.seriesId, this.seriesId,
this.season, this.season,
this.jellyfinEpisode,
this.jellyfinSeriesId, 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 @override
String toString() => 'ScheduleEntry(${airing.anilistId}#${airing.episode} $status)'; String toString() => 'ScheduleEntry(${airing.anilistId}#${airing.episode} $status)';
} }
@@ -81,8 +103,7 @@ String _day(DateTime d) {
class _SeriesRef { class _SeriesRef {
final int seriesId; final int seriesId;
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
bool followed; _SeriesRef(this.seriesId, this.jellyfinSeriesId);
_SeriesRef(this.seriesId, this.jellyfinSeriesId, this.followed);
} }
/// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked /// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked
@@ -98,33 +119,40 @@ List<ScheduleEntry> matchSchedule(
DateTime? now, DateTime? now,
}) { }) {
final at = now ?? DateTime.now(); final at = now ?? DateTime.now();
// AniList id -> (Jellyfin series, its TVDB season) from /library. // AniList id -> (Jellyfin series, its TVDB season, TVDB - AniList
final lib = <int, (String, int)>{}; // episode offset) from /library.
final lib = <int, (String, int, int)>{};
for (final item in library) { for (final item in library) {
if (item.jellyfinSeriesId.isEmpty) continue; if (item.jellyfinSeriesId.isEmpty) continue;
for (final r in item.anilistMap) { 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) { 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>{}; final refs = <int, _SeriesRef>{};
for (final s in series) { for (final s in series) {
final id = s.anilistId; final id = s.anilistId;
if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId, s.followed); if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId);
} }
// AnimeFlow's AniList episode number wins outright: TVDB numbering runs
// on across split entries (JoJo S6E4 is "2nd & 3rd STAGE" ep 3), so a TVDB
// or absolute number, or an air day, is only a guess for AniList entries
// AnimeFlow gives no AniList numbers for.
final byAni = <String, AnimeFlowScheduleItem>{};
final byEp = <String, AnimeFlowScheduleItem>{}; final byEp = <String, AnimeFlowScheduleItem>{};
final byAbs = <String, AnimeFlowScheduleItem>{}; final byAbs = <String, AnimeFlowScheduleItem>{};
final byDay = <String, AnimeFlowScheduleItem>{}; final byDay = <String, AnimeFlowScheduleItem>{};
final numbered = <int>{};
for (final it in schedule) { for (final it in schedule) {
final e = it.episode, id = e.anilistId; final e = it.episode, id = e.anilistId;
if (id == null) continue; if (id == null) continue;
final ref = refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId, it.followed); refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId);
if (it.followed) ref.followed = true; if (e.anilistEpisode != null) {
// AnimeFlow's AniList episode number first: TVDB numbering can run on numbered.add(id);
// across split cours. byAni.putIfAbsent('$id#${e.anilistEpisode}', () => it);
if (e.anilistEpisode != null) byEp.putIfAbsent('$id#${e.anilistEpisode}', () => it); }
byEp.putIfAbsent('$id#${e.episode}', () => it); byEp.putIfAbsent('$id#${e.episode}', () => it);
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it); if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it); if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it);
@@ -134,7 +162,9 @@ List<ScheduleEntry> matchSchedule(
() { () {
final id = a.anilistId; final id = a.anilistId;
final ref = refs[id]; final ref = refs[id];
final it = byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}']; final it = numbered.contains(id)
? byAni['$id#${a.episode}']
: byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}'];
if (it != null) { if (it != null) {
final e = it.episode; final e = it.episode;
return ScheduleEntry( return ScheduleEntry(
@@ -143,7 +173,6 @@ List<ScheduleEntry> matchSchedule(
progress: e.status == EpisodeStatus.downloading ? e.progress : null, progress: e.status == EpisodeStatus.downloading ? e.progress : null,
reason: e.status == EpisodeStatus.failed ? e.reason : null, reason: e.status == EpisodeStatus.failed ? e.reason : null,
tracked: true, tracked: true,
followed: it.followed || (ref?.followed ?? false),
seriesId: it.seriesId, seriesId: it.seriesId,
season: e.season, season: e.season,
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1, jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
@@ -156,6 +185,7 @@ List<ScheduleEntry> matchSchedule(
airing: a, airing: a,
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.inLibrary, status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.inLibrary,
season: onJf.$2, season: onJf.$2,
jellyfinEpisode: a.episode + onJf.$3,
jellyfinSeriesId: onJf.$1, jellyfinSeriesId: onJf.$1,
); );
} }
@@ -163,7 +193,6 @@ List<ScheduleEntry> matchSchedule(
airing: a, airing: a,
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing, status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing,
tracked: true, tracked: true,
followed: ref.followed,
seriesId: ref.seriesId, seriesId: ref.seriesId,
jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1, jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1,
); );
@@ -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 /// One loaded window. Errors are display text; [entries] is empty only
/// when AniList failed with nothing cached. /// when AniList failed with nothing cached.
class ScheduleSnapshot { class ScheduleSnapshot {
@@ -206,12 +256,15 @@ class ScheduleSnapshot {
/// Loads AniList and AnimeFlow for a window and merges them. /// Loads AniList and AnimeFlow for a window and merges them.
class ScheduleRepository { 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; : _now = now ?? DateTime.now;
final AniListClient aniList; final AniListClient aniList;
/// Null = not configured (AniList only). /// Null = not configured (AniList only).
final AnimeFlowClient? animeFlow; final AnimeFlowClient? animeFlow;
/// Confirms "on Jellyfin" per episode ([confirmOnJellyfin]); null =
/// never confirmed (aired entries read [ScheduleStatus.aired]).
final JellyfinEpisodeLookup? jellyfinEpisodes;
final DateTime Function() _now; final DateTime Function() _now;
/// Last week's Monday 00:00 local .. 4 weeks later. /// Last week's Monday 00:00 local .. 4 weeks later.
@@ -236,7 +289,8 @@ class ScheduleRepository {
final flow = await flowF; final flow = await flowF;
final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt)); final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt));
final entries = flow.error == null 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)]; : [for (final a in airings) untrackedEntry(a, now)];
return ScheduleSnapshot( return ScheduleSnapshot(
from: start, 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})> Future<({AnimeFlowMe? me, List<AnimeFlowScheduleItem> schedule, List<AnimeFlowSeries> series, List<AnimeFlowLibraryItem> library, Object? error})>
_animeFlow(DateTime from, DateTime to, bool force) async { _animeFlow(DateTime from, DateTime to, bool force) async {
final c = animeFlow; 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); 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: /// The AniDB id of an untracked AniList entry, from AnimeFlow's search:
@@ -112,6 +112,25 @@ JellyfinAccount? jellyfinAccountFor(JellyfinAccounts accounts, AnimeFlowClient?
return accounts.defaultAccount; 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 /// The Jellyfin episode of an available [e] as a media pick. Throws
/// [ScheduleActionException] (or a client error) when it cannot be found. /// [ScheduleActionException] (or a client error) when it cannot be found.
Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlowClient? flow, ScheduleEntry e) async { 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.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public), ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined), 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 { class DayColumn extends StatelessWidget {
@@ -125,7 +127,6 @@ class AiringCard extends StatelessWidget {
final ScheduleEntry entry; final ScheduleEntry entry;
final DateTime now; final DateTime now;
final bool compact; final bool compact;
final bool followed;
final CardAction action; final CardAction action;
/// Why [action] is disabled (no request scope, not on Jellyfin, ...); /// Why [action] is disabled (no request scope, not on Jellyfin, ...);
@@ -136,20 +137,15 @@ class AiringCard extends StatelessWidget {
final bool busy; final bool busy;
final VoidCallback onAction; final VoidCallback onAction;
/// Null hides the bookmark (untracked series).
final VoidCallback? onFollow;
const AiringCard({ const AiringCard({
super.key, super.key,
required this.entry, required this.entry,
required this.now, required this.now,
required this.compact, required this.compact,
required this.followed,
required this.action, required this.action,
required this.onAction, required this.onAction,
this.disabledHint, this.disabledHint,
this.busy = false, this.busy = false,
this.onFollow,
}); });
String get _id => '${entry.airing.anilistId}-${entry.airing.episode}'; String get _id => '${entry.airing.anilistId}-${entry.airing.episode}';
@@ -179,22 +175,6 @@ class AiringCard extends StatelessWidget {
return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b); return disabledHint == null ? b : Tooltip(message: disabledHint!, child: b);
} }
Widget? _follow(BuildContext context, double size) {
final f = onFollow;
if (f == null) return null;
return IconButton(
key: ValueKey('follow-$_id'),
onPressed: f,
tooltip: followed ? 'Stop following' : 'Follow',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: BoxConstraints.tightFor(width: size + 10, height: size + 10),
iconSize: size,
color: followed ? Theme.of(context).colorScheme.primary : null,
icon: Icon(followed ? Icons.bookmark : Icons.bookmark_outline),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
@@ -244,7 +224,6 @@ class AiringCard extends StatelessWidget {
], ],
), ),
), ),
if (followed && onFollow == null) Icon(Icons.bookmark, size: 14, color: scheme.primary),
if (requestable) if (requestable)
Tooltip( Tooltip(
message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'), message: disabledHint ?? (action == CardAction.request ? 'Request' : 'Request series'),
@@ -268,7 +247,6 @@ class AiringCard extends StatelessWidget {
); );
} }
final button = _button(context); final button = _button(context);
final follow = _follow(context, 16);
// The cover poster leads: it is what people recognise at a glance. // The cover poster leads: it is what people recognise at a glance.
return Material( return Material(
key: ValueKey('card-$_id'), key: ValueKey('card-$_id'),
@@ -338,26 +316,12 @@ class AiringCard extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25), style: const TextStyle(fontWeight: FontWeight.w600, height: 1.25),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Row( Text(
children: [
Flexible(
child: Text(
'Episode ${a.episode}', 'Episode ${a.episode}',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
), ),
),
const SizedBox(width: 2),
if (follow != null)
follow
else if (followed)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(Icons.bookmark, size: 14, color: scheme.primary),
),
],
),
const Spacer(), const Spacer(),
Row( Row(
children: [ children: [
@@ -18,7 +18,7 @@ import 'schedule_card.dart';
// one thing to do about it (put it up, request it). Refreshes every // one thing to do about it (put it up, request it). Refreshes every
// minute while visible; ETags keep that cheap. // minute while visible; ETags keep that cheap.
enum ScheduleFilter { jellyfin, following, all } enum ScheduleFilter { jellyfin, all }
class SchedulePage extends StatefulWidget { class SchedulePage extends StatefulWidget {
/// Nobody else watches (offline or solo): available episodes say Play /// Nobody else watches (offline or solo): available episodes say Play
@@ -75,8 +75,6 @@ class _SchedulePageState extends State<SchedulePage> {
/// The sheet stays built while it slides out. /// The sheet stays built while it slides out.
bool _sheetBuilt = false; bool _sheetBuilt = false;
/// Follow toggles not yet confirmed by a reload (series id -> followed).
final _followOverride = <int, bool>{};
/// Requested in this run: "anilistId#episode" or "series:anilistId". /// Requested in this run: "anilistId#episode" or "series:anilistId".
final _requested = <String>{}; final _requested = <String>{};
@@ -97,18 +95,22 @@ class _SchedulePageState extends State<SchedulePage> {
void initState() { void initState() {
super.initState(); super.initState();
_ownsRepo = widget.repository == null; _ownsRepo = widget.repository == null;
_repo = _repo = widget.repository ?? _ownRepo();
widget.repository ??
ScheduleRepository(
aniList: AniListClient(),
animeFlow: AnimeFlowClient.fromAccounts(JellyfinAccounts.instance),
);
_load(); _load();
_timer = Timer.periodic(SchedulePage.refreshEvery, (_) { _timer = Timer.periodic(SchedulePage.refreshEvery, (_) {
if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load(); 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 @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
@@ -186,11 +188,8 @@ class _SchedulePageState extends State<SchedulePage> {
animate ? _scroll.animateTo(x, duration: easeTime, curve: ease) : _scroll.jumpTo(x); animate ? _scroll.animateTo(x, duration: easeTime, curve: ease) : _scroll.jumpTo(x);
} }
bool _followed(ScheduleEntry e) => (e.seriesId == null ? null : _followOverride[e.seriesId]) ?? e.followed;
bool _shows(ScheduleEntry e) => switch (_filter) { bool _shows(ScheduleEntry e) => switch (_filter) {
ScheduleFilter.jellyfin => e.tracked || e.onJellyfin, ScheduleFilter.jellyfin => e.tracked || e.onJellyfin,
ScheduleFilter.following => _followed(e),
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow, ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
}; };
@@ -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 { Future<void> _decide(AnimeFlowRequest r, {required bool approve}) async {
final c = _flow; final c = _flow;
if (c == null || _reqBusy.contains(r.id)) return; if (c == null || _reqBusy.contains(r.id)) return;
@@ -389,11 +373,6 @@ class _SchedulePageState extends State<SchedulePage> {
icon: Icon(Icons.video_library_outlined), icon: Icon(Icons.video_library_outlined),
label: Text('On Jellyfin'), label: Text('On Jellyfin'),
), ),
ButtonSegment(
value: ScheduleFilter.following,
icon: Icon(Icons.bookmark_outline),
label: Text('Following'),
),
ButtonSegment(value: ScheduleFilter.all, icon: Icon(Icons.public), label: Text('All airing')), ButtonSegment(value: ScheduleFilter.all, icon: Icon(Icons.public), label: Text('All airing')),
], ],
selected: {_filter}, selected: {_filter},
@@ -537,12 +516,10 @@ class _SchedulePageState extends State<SchedulePage> {
entry: e, entry: e,
now: now, now: now,
compact: _compact, compact: _compact,
followed: _followed(e),
action: action, action: action,
disabledHint: hint, disabledHint: hint,
busy: busy, busy: busy,
onAction: () => _onAction(e, action), onAction: () => _onAction(e, action),
onFollow: e.tracked && e.seriesId != null && _flow != null ? () => _toggleFollow(e) : null,
); );
} }
@@ -16,7 +16,10 @@ enum LinkRelay { direct, server, me }
final class LinkMedia extends PickedMedia { final class LinkMedia extends PickedMedia {
final String url; final String url;
final LinkRelay relay; final LinkRelay relay;
const LinkMedia(this.url, {this.relay = LinkRelay.direct}); /// Put it up even though this computer's probe found a problem
/// (`LinkProbe.problem`): the server or a friend may still open it.
final bool anyway;
const LinkMedia(this.url, {this.relay = LinkRelay.direct, this.anyway = false});
@override @override
String get label { String get label {
@@ -182,6 +182,10 @@ class SessionCoordinator {
final toasts = signal<List<ToastItem>>([]); final toasts = signal<List<ToastItem>>([]);
final reconnecting = signal(false); final reconnecting = signal(false);
final starving = signal<String?>(null); final starving = signal<String?>(null);
/// Why this player cannot open the current media, in plain words
/// ([playErrorText]); empty while it can. It rides in `report.error`, so
/// the server stops waiting for this member and the others see it.
final playerError = signal('');
/// The current media's Jellyfin ref (null for links and files). /// The current media's Jellyfin ref (null for links and files).
final currentRef = signal<JellyfinRef?>(null); final currentRef = signal<JellyfinRef?>(null);
@@ -318,6 +322,12 @@ class SessionCoordinator {
int _advanceDoneFor = -1; int _advanceDoneFor = -1;
String _blockedKey = ''; String _blockedKey = '';
bool _jellyfinListening = false; bool _jellyfinListening = false;
// The mediaId [playerError] belongs to, and the player's duration when
// it came: a new duration means the media opened after all.
int _playerErrorFor = -1;
Duration _playerErrorDuration = Duration.zero;
// Members' last `error`, by id, so a new one toasts once.
Map<String, String> _memberErrors = const {};
SessionCoordinator( SessionCoordinator(
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp}) {this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
@@ -332,7 +342,8 @@ class SessionCoordinator {
if (_playerReady) return; if (_playerReady) return;
try { try {
playback.onFrozen = () => unawaited(reloadVideo(auto: true)); playback.onFrozen = () => unawaited(reloadVideo(auto: true));
playback.onYtdlError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't open this link: $e"); playback.onYtdlError = _setPlayerError;
playback.onLoadError = _setPlayerError;
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6); playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
await playback.init(); await playback.init();
_playerReady = true; _playerReady = true;
@@ -344,6 +355,35 @@ class SessionCoordinator {
} }
} }
void _setPlayerError(String raw) {
debugPrint('player cannot open media $_mediaId: $raw');
_playerErrorFor = _mediaId;
_playerErrorDuration = playback.duration.peek();
playerError.value = playErrorText(raw);
}
/// Clears [playerError] once the media opened (a new duration) or the
/// server moved to another media.
void _checkPlayerError(int mediaId) {
if (playerError.peek().isEmpty) return;
final d = playback.duration.peek();
if (_playerErrorFor != mediaId || (d > Duration.zero && d != _playerErrorDuration)) {
playerError.value = '';
}
}
/// A member's new `error` toasts once (the pill is hidden while playing).
void _toastMemberErrors(StateBroadcast r) {
final now = {for (final m in r.members) if (m.error.isNotEmpty) m.id: m.error};
for (final m in r.members) {
final e = now[m.id];
if (e != null && m.id != sync.memberId && _memberErrors[m.id] != e) {
_pushToast("${m.name} can't play this: $e", actor: m.name);
}
}
_memberErrors = now;
}
bool _reloadingVideo = false; bool _reloadingVideo = false;
/// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh /// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh
@@ -397,6 +437,8 @@ class SessionCoordinator {
diff.reset(); diff.reset();
_mediaId = -1; _mediaId = -1;
_openUrl = null; _openUrl = null;
playerError.value = '';
_memberErrors = const {};
_lastReady = false; _lastReady = false;
_restoringReady = false; _restoringReady = false;
_lastOut = false; _lastOut = false;
@@ -494,8 +536,7 @@ class SessionCoordinator {
return true; return true;
} catch (e) { } catch (e) {
debugPrint('open media failed: $e'); debugPrint('open media failed: $e');
health.value = _setPlayerError('$e');
HealthStatus(HealthSeverity.error, 'cannot open media: $e');
return false; return false;
} finally { } finally {
_opening = false; _opening = false;
@@ -613,6 +654,8 @@ class SessionCoordinator {
void _onState(StateBroadcast r) { void _onState(StateBroadcast r) {
_lastStateAt = DateTime.now().toUtc(); _lastStateAt = DateTime.now().toUtc();
serverState.value = r; serverState.value = r;
_toastMemberErrors(r);
_checkPlayerError(r.mediaId);
_maybeSendArt(r); _maybeSendArt(r);
_queuedState = r; _queuedState = r;
if (_applying) return; if (_applying) return;
@@ -916,6 +959,8 @@ class SessionCoordinator {
// Duration only once the open media is the server's current one. // Duration only once the open media is the server's current one.
// Sitting out: still a report (it keeps this member fresh), empty. // Sitting out: still a report (it keeps this member fresh), empty.
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId; final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
_checkPlayerError(r.mediaId);
final error = playerError.peek();
sync.sendReport( sync.sendReport(
position: _lastOut ? 0 : playback.positionSecs, position: _lastOut ? 0 : playback.positionSecs,
buffered: _lastOut ? 0 : playback.bufferedSecsAhead, buffered: _lastOut ? 0 : playback.bufferedSecsAhead,
@@ -923,6 +968,7 @@ class SessionCoordinator {
duration: known ? playback.durationSecs : 0, duration: known ? playback.durationSecs : 0,
mediaId: known ? r.mediaId : 0, mediaId: known ? r.mediaId : 0,
jf: _jfAccessFor(r), jf: _jfAccessFor(r),
error: known && _playerErrorFor == r.mediaId ? error : '',
); );
if (!_lastOut) { if (!_lastOut) {
_checkSeekLanded(r); _checkSeekLanded(r);
@@ -1243,10 +1289,13 @@ class SessionCoordinator {
/// A pasted link: a recognised Jellyfin item link goes up as a ref /// A pasted link: a recognised Jellyfin item link goes up as a ref
/// (see [mediaForLink]) that this member relays for viewers without an /// (see [mediaForLink]) that this member relays for viewers without an
/// account (with the link's key, which stays here); anything else as the /// account (with the link's key, which stays here); anything else as the
/// link, or relayed per [relay]. Returns an error text, or null when sent. /// link, or relayed per [relay]. A link this computer cannot open (its
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async { /// probe's [LinkProbe.problem], or unreachable) is refused with the
/// reason in plain words unless [anyway]. Returns an error text, or null
/// when sent.
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct, bool anyway = false}) async {
markInput(); markInput();
_lastPick = LinkMedia(url, relay: relay); _lastPick = LinkMedia(url, relay: relay, anyway: anyway);
final media = await mediaForLink(jellyfin, url); final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) { if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url); _pendingOrigin = _JellyfinOrigin(url);
@@ -1260,6 +1309,8 @@ class SessionCoordinator {
} catch (e) { } catch (e) {
probeError = '$e'; probeError = '$e';
} }
final problem = probe == null ? playErrorText(probeError ?? '') : probe.problem;
if (problem != null && !anyway) return problem;
// A page is named by its title, never its URL tail ("1", "watch"). // A page is named by its title, never its URL tail ("1", "watch").
final uri = Uri.tryParse(url) ?? Uri(); final uri = Uri.tryParse(url) ?? Uri();
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host); final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
@@ -1270,9 +1321,9 @@ class SessionCoordinator {
if (relay == LinkRelay.me) { if (relay == LinkRelay.me) {
// This computer reads it: a file, or a page's formats (one file, // This computer reads it: a file, or a page's formats (one file,
// separate video and audio tracks, or segmented playlists). // separate video and audio tracks, or segmented playlists).
if (probe == null) return 'cannot pass this link on: $probeError'; if (probe == null) return problem;
final problem = probe.relayProblem; final relayProblem = probe.relayProblem;
if (problem != null) return problem; if (relayProblem != null) return relayProblem;
final y = await ytDlp.ytDlp; final y = await ytDlp.ytDlp;
final check = LinkRelayMedia(uri, ytDlp: y, probe: probe); final check = LinkRelayMedia(uri, ytDlp: y, probe: probe);
var size = 0; var size = 0;
@@ -1351,8 +1402,8 @@ class SessionCoordinator {
/// Any picker selection. /// Any picker selection.
Future<String?> swapPicked(PickedMedia media) async { Future<String?> swapPicked(PickedMedia media) async {
switch (media) { switch (media) {
case LinkMedia(:final url, :final relay): case LinkMedia(:final url, :final relay, :final anyway):
return swapLink(url, relay: relay); return swapLink(url, relay: relay, anyway: anyway);
case JellyfinMedia(): case JellyfinMedia():
swapJellyfin(media); swapJellyfin(media);
case FileMedia(:final path): case FileMedia(:final path):
+1 -1
View File
@@ -379,7 +379,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (error != null || !mounted) return error; if (error != null || !mounted) return error;
} }
// Alone, a link plays direct: there is nobody to pass it on to. // Alone, a link plays direct: there is nobody to pass it on to.
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url); if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url, anyway: media.anyway);
final error = await session.coordinator.swapPicked(media); final error = await session.coordinator.swapPicked(media);
if (error != null || !mounted) return error; if (error != null || !mounted) return error;
final f = session.lastForm; final f = session.lastForm;
+23 -4
View File
@@ -19,6 +19,9 @@ class AvatarInfo {
final bool ready, buffering, relaying, stale, host, self; final bool ready, buffering, relaying, stale, host, self;
/// Sitting out: dimmed, with an "away" mark. /// Sitting out: dimmed, with an "away" mark.
final bool out; final bool out;
/// Why their player cannot open the media (`members[].error`): an error
/// mark; empty when it can.
final String error;
const AvatarInfo({ const AvatarInfo({
this.id = '', this.id = '',
required this.name, required this.name,
@@ -31,6 +34,7 @@ class AvatarInfo {
this.host = false, this.host = false,
this.self = false, this.self = false,
this.out = false, this.out = false,
this.error = '',
}); });
} }
@@ -78,7 +82,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
} }
/// Your own avatar from the profile (also offline). /// Your own avatar from the profile (also offline).
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false}) { AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) {
final p = Profile.instance; final p = Profile.instance;
final png = p.picture.value; final png = p.picture.value;
final name = p.name.value; final name = p.name.value;
@@ -90,6 +94,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = fal
host: host, host: host,
self: true, self: true,
out: out, out: out,
error: error,
); );
} }
@@ -104,7 +109,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
color: self ? Profile.instance.color.value : colorForName(m.name), color: self ? Profile.instance.color.value : colorForName(m.name),
image: memberAvatarImage(m, coord), image: memberAvatarImage(m, coord),
ready: m.ready, ready: m.ready,
buffering: !m.stale && m.ready && playing && m.buffered < r!.bufferNeed, buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed,
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id, relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
stale: m.stale, stale: m.stale,
// The state marks the host (`members[].host`); your own app also // The state marks the host (`members[].host`); your own app also
@@ -112,6 +117,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
host: m.host || (self && coord.hostFile != null), host: m.host || (self && coord.hostFile != null),
self: self, self: self,
out: m.out, out: m.out,
error: m.error,
); );
} }
@@ -138,7 +144,7 @@ class PartyWatch extends StatelessWidget {
/// Circle avatar with a status ring outside it and a gap in the background /// Circle avatar with a status ring outside it and a gap in the background
/// colour, so overlapping neighbours never hide the status. Crown = host, /// colour, so overlapping neighbours never hide the status. Crown = host,
/// tower = passing the video on, spinner = buffering, dimmed = stale, /// tower = passing the video on, spinner = buffering, dimmed = stale,
/// dimmed with a moon = sitting out. /// dimmed with a moon = sitting out, red "!" = cannot play the media.
class StatusAvatar extends StatelessWidget { class StatusAvatar extends StatelessWidget {
final AvatarInfo info; final AvatarInfo info;
final double size; final double size;
@@ -222,6 +228,17 @@ class StatusAvatar extends StatelessWidget {
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200), child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
), ),
), ),
if (info.error.isNotEmpty)
Positioned(
key: const ValueKey('error-mark'),
right: -3,
top: -3,
child: Container(
padding: const EdgeInsets.all(1),
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
child: Icon(Icons.error, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.redAccent.shade200),
),
),
if (badges && info.relaying) if (badges && info.relaying)
Positioned( Positioned(
right: -3, right: -3,
@@ -287,7 +304,9 @@ class _AvatarStackState extends State<AvatarStack> {
left: i * step, left: i * step,
top: 0, top: 0,
child: Tooltip( child: Tooltip(
message: visible[i].name, message: visible[i].error.isEmpty
? visible[i].name
: "${visible[i].name} can't play this: ${visible[i].error}",
child: StatusAvatar(info: visible[i], size: size), child: StatusAvatar(info: visible[i], size: size),
), ),
), ),
+48 -15
View File
@@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, notAVideoText; import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, parseJellyfinLink, playErrorText;
import '../playback/yt_dlp_app.dart'; import '../playback/yt_dlp_app.dart';
import '../screens/home/media_picker.dart'; import '../screens/home/media_picker.dart';
@@ -107,7 +107,10 @@ class LinkPreview {
/// "youtube.com · Channel", or the host. /// "youtube.com · Channel", or the host.
final String subtitle; final String subtitle;
final String? thumbnail; final String? thumbnail;
const LinkPreview({required this.title, required this.subtitle, this.thumbnail}); /// Why this computer cannot open it, in plain words ([LinkProbe.problem]);
/// null when it looks playable.
final String? problem;
const LinkPreview({required this.title, required this.subtitle, this.thumbnail, this.problem});
} }
/// What a link is (see [AppYtDlp.probe]). /// What a link is (see [AppYtDlp.probe]).
@@ -125,24 +128,30 @@ String clockText(double seconds) {
} }
/// The preview of [url]: a web page shows yt-dlp's title, uploader, /// The preview of [url]: a web page shows yt-dlp's title, uploader,
/// duration and thumbnail (or why it is not a video); a media file (by /// duration and thumbnail; a media file its file name and host; a link
/// name or by probe) its file name and host. Null for text that is not an http(s) link. /// that does not open, why ([LinkPreview.problem]). Null for text that is
/// not an http(s) link.
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async { Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
final uri = Uri.tryParse(url.trim()); final uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null; if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
final host = _host(uri); final host = _host(uri);
// A media file name needs no lookup. // A Jellyfin item goes up as the item (see `mediaForLink`), not the page.
if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host); if (parseJellyfinLink(url.trim()) != null) return LinkPreview(title: host, subtitle: '$host · Jellyfin');
final file = hasMediaExtension(uri);
final LinkProbe p; final LinkProbe p;
try { try {
p = await probe(url.trim()); p = await probe(url.trim());
} catch (e) { } catch (e) {
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e'); final problem = playErrorText('$e');
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $problem', problem: problem);
} }
final info = p.info; final info = p.info;
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host); if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
if (info == null) { final problem = p.problem;
return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}'); if (info == null || problem != null) {
// A media file name that did not answer as one keeps its name.
final title = file ? LinkMedia(url.trim()).label : p.title ?? host;
return LinkPreview(title: title, subtitle: '$host · ${problem ?? 'Not a video'}', problem: problem);
} }
return LinkPreview( return LinkPreview(
title: p.title ?? host, title: p.title ?? host,
@@ -221,6 +230,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Timer? _debounce; Timer? _debounce;
bool _busy = false; bool _busy = false;
String? _error; String? _error;
/// [_error] is the probe's problem: "Put it up anyway" is offered.
bool _canForce = false;
@override @override
void initState() { void initState() {
@@ -242,7 +253,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (url == _previewUrl) return; if (url == _previewUrl) return;
_debounce?.cancel(); _debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url)); _debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
if (mounted) setState(() => _error = null); if (mounted) {
setState(() {
_error = null;
_canForce = false;
});
}
} }
Future<void> _preview(String url) async { Future<void> _preview(String url) async {
@@ -263,19 +279,27 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
} catch (_) {} } catch (_) {}
} }
Future<void> _go() async { Future<void> _go({bool anyway = false}) async {
final url = _c.link.text.trim(); final url = _c.link.text.trim();
if (url.isEmpty || _busy) return; if (url.isEmpty || _busy) return;
setState(() { setState(() {
_busy = true; _busy = true;
_error = null; _error = null;
_canForce = false;
}); });
String? error; // What the probe found refuses it here, with "Put it up anyway": the
// server or a friend (with cookies) may still open it.
final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: widget.probe))?.problem;
String? error = problem;
if (problem == null) {
try { try {
error = await widget.onPutUp(LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay)); error = await widget.onPutUp(
LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
} catch (e) { } catch (e) {
error = '$e'; error = '$e';
} }
}
final canForce = problem != null;
if (error == null) { if (error == null) {
final p = _previews[url] ?? await previewLink(url, probe: widget.probe); final p = _previews[url] ?? await previewLink(url, probe: widget.probe);
unawaited(widget.recents.add(RecentLink( unawaited(widget.recents.add(RecentLink(
@@ -289,6 +313,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
setState(() { setState(() {
_busy = false; _busy = false;
_error = error; _error = error;
_canForce = canForce;
}); });
} }
@@ -373,6 +398,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey), style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
), ),
if (_canForce)
Center(
child: TextButton(
key: const ValueKey('link-put-up-anyway'),
onPressed: _busy ? null : () => _go(anyway: true),
child: Text(solo ? 'Play it anyway' : 'Put it up anyway'),
),
),
], ],
AnimatedSize( AnimatedSize(
duration: easeTime, duration: easeTime,
@@ -475,9 +508,9 @@ class _PreviewCard extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
p?.subtitle ?? 'Paste an http(s) address', p?.subtitle ?? 'Paste an http(s) address',
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey), style: TextStyle(color: p?.problem != null ? scheme.error : Colors.grey),
), ),
], ],
), ),
@@ -103,6 +103,7 @@ class PartyStrip extends StatelessWidget {
info: selfAvatarInfo( info: selfAvatarInfo(
ready: self?.ready ?? false, ready: self?.ready ?? false,
host: online && !solo && coord.hostFile != null, host: online && !solo && coord.hostFile != null,
error: online ? coord.playerError.value : '',
), ),
size: stripAvatarSize, size: stripAvatarSize,
ring: online, ring: online,
@@ -553,6 +554,8 @@ class _MemberTile extends StatelessWidget {
? 'reconnecting…' ? 'reconnecting…'
: m.out : m.out
? 'sitting out' ? 'sitting out'
: m.error.isNotEmpty
? "can't play this: ${m.error}"
: info.buffering : info.buffering
? 'buffering ${m.buffered.toStringAsFixed(1)} s' ? 'buffering ${m.buffered.toStringAsFixed(1)} s'
: m.ready : m.ready
+33 -6
View File
@@ -11,11 +11,12 @@ import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
/// What the player's status pill says, most pressing first. /// What the player's status pill says, most pressing first.
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, waiting } enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting }
/// The pill's button: stop reconnecting, retry, or mark yourself ready /// The pill's button: stop reconnecting, retry (reconnect), reload the
/// (the whole pill). /// video (your player could not open it), or mark yourself ready (the
enum StatusAction { cancel, retry, ready } /// whole pill).
enum StatusAction { cancel, retry, reload, ready }
/// One status line: [text], a muted [detail], a coloured [tone] for /// One status line: [text], a muted [detail], a coloured [tone] for
/// problems (warn, error), the members it is about ([who]; [self] for you) /// problems (warn, error), the members it is about ([who]; [self] for you)
@@ -55,18 +56,24 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
/// The player's status from the session's signals; null when there is /// The player's status from the session's signals; null when there is
/// nothing to say (playing, connected, everyone ready). [slowConnect]: /// nothing to say (playing, connected, everyone ready). [slowConnect]:
/// connecting has taken over 2 s. /// connecting has taken over 2 s. [playerError]: why your player cannot
/// open the media (the coordinator's `playerError`).
StatusView? playerStatusFor({ StatusView? playerStatusFor({
required HealthStatus health, required HealthStatus health,
bool reconnecting = false, bool reconnecting = false,
StateBroadcast? state, StateBroadcast? state,
MemberView? self, MemberView? self,
bool slowConnect = false, bool slowConnect = false,
String playerError = '',
}) { }) {
if (reconnecting) { if (reconnecting) {
return const StatusView(StatusKind.reconnecting, 'Reconnecting…', return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel); tone: HealthSeverity.warn, action: StatusAction.cancel);
} }
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) {
return StatusView(StatusKind.problem, "Can't play this: $playerError",
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
}
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) { if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
return StatusView(StatusKind.problem, _sentence(health.message), return StatusView(StatusKind.problem, _sentence(health.message),
tone: health.severity, action: StatusAction.retry); tone: health.severity, action: StatusAction.retry);
@@ -81,6 +88,12 @@ StatusView? playerStatusFor({
if (r.transport == 'finished') { if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished'); return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
} }
// Members whose player cannot open it: the server does not wait for
// them; say why instead of "waiting for".
final failed = [
for (final m in r.members)
if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m,
];
// As the server holds playback: someone in the ready set short of the // As the server holds playback: someone in the ready set short of the
// minimum buffer. // minimum buffer.
final need = r.bufferNeed; final need = r.bufferNeed;
@@ -92,12 +105,22 @@ StatusView? playerStatusFor({
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%'); who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
} }
} }
if (failed.isNotEmpty) {
final f = failed.first;
final who = nameList([for (final m in failed) m.name]);
final unready = self != null && !self.ready;
return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}",
who: failed,
short: "$who can't play this",
detail: unready ? "Press Space when you're ready" : null,
action: unready ? StatusAction.ready : null);
}
if (self != null && !self.ready) { if (self != null && !self.ready) {
return StatusView(StatusKind.you, "Press Space when you're ready", return StatusView(StatusKind.you, "Press Space when you're ready",
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready'); who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
} }
// Members sitting out are never waited for nor counted. // Members sitting out are never waited for nor counted.
final active = [for (final m in r.members) if (!m.stale && !m.out) m]; final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m];
final others = [ final others = [
for (final m in active) for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m, if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
@@ -117,6 +140,7 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
state: c.serverState.value, state: c.serverState.value,
self: c.selfMember.value, self: c.selfMember.value,
slowConnect: slowConnect, slowConnect: slowConnect,
playerError: c.playerError.value,
); );
} }
@@ -164,6 +188,8 @@ class _PlayerStatusState extends State<PlayerStatus> {
coord.cancelReconnect(); coord.cancelReconnect();
case StatusAction.retry: case StatusAction.retry:
coord.retry(); coord.retry();
case StatusAction.reload:
unawaited(coord.reloadVideo());
case StatusAction.ready: case StatusAction.ready:
coord.toggleReady(); coord.toggleReady();
} }
@@ -266,6 +292,7 @@ class _StatusLine extends StatelessWidget {
return null; return null;
case StatusKind.loading: case StatusKind.loading:
case StatusKind.you: case StatusKind.you:
case StatusKind.cannotPlay:
case StatusKind.waiting: case StatusKind.waiting:
final r = coord.serverState.value; final r = coord.serverState.value;
final infos = [ final infos = [
@@ -219,6 +219,7 @@ class SyncClient {
double duration = 0, double duration = 0,
int mediaId = 0, int mediaId = 0,
Map<String, bool>? jf, Map<String, bool>? jf,
String error = '',
}) { }) {
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered; final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
_send( _send(
@@ -229,6 +230,7 @@ class SyncClient {
duration: duration.isFinite && duration > 0 ? duration : 0, duration: duration.isFinite && duration > 0 ? duration : 0,
mediaId: mediaId, mediaId: mediaId,
jf: jf, jf: jf,
error: error,
).toJson(), ).toJson(),
); );
} }
+83 -54
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: android_file_picker name: android_file_picker
sha256: "014c74ab48d452c3252465682375a7fe6ddf56abb908ec361f207a3c4ffb2444" sha256: "537bce9a43bc6fc08670cb68498251acc6eb9134b86a391b43e1f7c863588388"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "2.0.1"
archive: archive:
dependency: transitive dependency: transitive
description: description:
name: archive name: archive
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 sha256: "3aa20c6b5617b632a1b7198dcca3d88065d37232485b9a6c6c14dc8f5357d279"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.0" version: "4.4.0"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -61,10 +61,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: code_assets name: code_assets
sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.1.0"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -97,14 +97,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" 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: dbus:
dependency: transitive dependency: transitive
description: description:
name: dbus name: dbus
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.15" version: "0.8.0"
dio: dio:
dependency: transitive dependency: transitive
description: description:
@@ -130,7 +138,7 @@ packages:
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi: ffi:
dependency: transitive dependency: "direct main"
description: description:
name: ffi name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
@@ -149,42 +157,42 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: "9be6aac79508dbcf8dac80a4fb20f27ce6f910837ca27e3670b9295995cc0110" sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "12.3.0" version: "13.1.0"
file_picker_darwin: file_picker_darwin:
dependency: transitive dependency: transitive
description: description:
name: file_picker_darwin name: file_picker_darwin
sha256: "59fa5394cfa5b6dc8bf491630cb17077b77b844adbb840df371e7be5355d6cf6" sha256: "3df1dc8e881de9ffd3953c9e482ac5d6e1ffe1e7f7b231b5695543e251ab7918"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "2.1.3"
file_picker_linux: file_picker_linux:
dependency: transitive dependency: transitive
description: description:
name: file_picker_linux name: file_picker_linux
sha256: bd52ff1e0048f29df95f913c55ad2991c72791d93e6c42241912c4bdee946cb9 sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "2.0.2"
file_picker_platform_interface: file_picker_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: file_picker_platform_interface name: file_picker_platform_interface
sha256: "0355558fd9af6da499d18e333d6b3beb44b0bf19e00933fccd15d18eb8d0e9ca" sha256: bbdc085a6f168e63f147e9ce8988f79fa0800a58e7f25a48f762c60837107ba5
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.0" version: "4.0.0"
file_picker_web: file_picker_web:
dependency: transitive dependency: transitive
description: description:
name: file_picker_web name: file_picker_web
sha256: "935560a9d29fa6f006f2855addebb88e88d438e13627d4cc24187033c106f227" sha256: "32af8262aca907b02b8970b32328b0312db0b42ba6f930be6d27fde179d59b7d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" version: "4.1.0"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@@ -210,10 +218,15 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_lints name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -228,27 +241,27 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "v0.1.8" ref: "v0.1.9"
resolved-ref: d2435a06b26d4f94c3c3777e6ef006ac84f4eef4 resolved-ref: ef96868b87e55935792f392dd5881d49bc672ce5
url: "https://git.senkochan.moe/Foxyz/fox_utils.git" url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
source: git source: git
version: "0.1.8" version: "0.1.9"
hive_ce: hive_ce:
dependency: "direct main" dependency: "direct main"
description: description:
name: hive_ce name: hive_ce
sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418" sha256: "85d43209e2b4903a95d65d054407ea9ad4989d17b4d47e71732070d41b427c87"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.19.3" version: "2.20.2"
hive_ce_flutter: hive_ce_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
name: hive_ce_flutter name: hive_ce_flutter
sha256: "2677e95a333ff15af43ccd06af7eb7abbf1a4f154ea071997f3de4346cae913a" sha256: "231c5f025a44f4a555b4e7417d1468027cc32d9471b00543bd5bce72464a1009"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.4" version: "2.4.0"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
@@ -277,10 +290,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: image name: image
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" sha256: "0be525eb917b032c7c9c980597c508a255814a858bd8d9abfc9c57e04f3ebf89"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted 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: isolate_channel:
dependency: transitive dependency: transitive
description: description:
@@ -293,18 +314,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: jni name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.3" version: "1.1.0"
jni_flutter: jni_flutter:
dependency: transitive dependency: transitive
description: description:
name: jni_flutter name: jni_flutter
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.3" version: "1.0.4+1"
jni_util: jni_util:
dependency: transitive dependency: transitive
description: description:
@@ -349,10 +370,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: lints name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.1" version: "6.1.0"
logging: logging:
dependency: transitive dependency: transitive
description: description:
@@ -377,6 +398,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" 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: media_kit:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -461,10 +490,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: objective_c name: objective_c
sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 sha256: "5c80c2ad6e2c0397de5db38c6d653e69616a3397e13aa7633bf39eb668f2041b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.6.0" version: "9.6.2"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:
@@ -477,10 +506,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: package_info_plus name: package_info_plus
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.2.1" version: "10.2.2"
package_info_plus_platform_interface: package_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -549,10 +578,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: petitparser name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.2" version: "7.1.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -748,14 +777,14 @@ packages:
path: "../../packages/core" path: "../../packages/core"
relative: true relative: true
source: path source: path
version: "1.0.1" version: "1.1.1"
sync_relay_server: sync_relay_server:
dependency: "direct main" dependency: "direct main"
description: description:
path: "../../packages/server" path: "../../packages/server"
relative: true relative: true
source: path source: path
version: "1.0.1" version: "1.1.1"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
@@ -816,10 +845,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.4.3"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@@ -832,10 +861,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: wakelock_plus name: wakelock_plus
sha256: "22b3e7e937721de70e63c85e7139f4ac781dc22863b9262431a53ac030eb074b" sha256: "9a91139ee876c24a9ebe1484e4b9b7923b07d368566a5e0b2301870b0f5029c8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.8.0" version: "1.8.1"
wakelock_plus_platform_interface: wakelock_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -888,10 +917,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: windows_file_picker name: windows_file_picker
sha256: "62e6e6e115231d1d1d71c7b5883b4091ce3e0190e49548dfb0ff8cba8d91ad96" sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "2.0.1"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -904,10 +933,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: xml name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" sha256: "0fe8cc98946def31cbcbc8734aa4067031e6f65ea3a78a508060bfed2200bfeb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.1.0"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:
@@ -917,5 +946,5 @@ packages:
source: hosted source: hosted
version: "3.1.4" version: "3.1.4"
sdks: sdks:
dart: ">=3.13.0-0 <4.0.0" dart: ">=3.13.0 <4.0.0"
flutter: ">=3.44.0" flutter: ">=3.47.0"
+5 -4
View File
@@ -1,7 +1,7 @@
name: watch_together name: watch_together
description: Watch-together desktop app (working title, repo sync_relay_project). One binary. description: Watch-together desktop app (working title, repo sync_relay_project). One binary.
publish_to: none publish_to: none
version: 1.1.0 version: 1.1.2
environment: environment:
sdk: '>=3.13.0-0 <4.0.0' sdk: '>=3.13.0-0 <4.0.0'
@@ -18,21 +18,22 @@ dependencies:
path: ../../packages/core path: ../../packages/core
sync_relay_server: sync_relay_server:
path: ../../packages/server path: ../../packages/server
file_picker: ^12.3.0 file_picker: ^13.1.0
hive_ce: ^2.19.3 hive_ce: ^2.19.3
hive_ce_flutter: ^2.3.4 hive_ce_flutter: ^2.3.4
image: ^4.9.2 image: ^4.9.2
window_manager: ^0.5.2 window_manager: ^0.5.2
crypto: ^3.0.6 crypto: ^3.0.6
ffi: ^2.1.0
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard). # Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
fox_utils: fox_utils:
git: git:
url: https://git.senkochan.moe/Foxyz/fox_utils.git url: https://git.senkochan.moe/Foxyz/fox_utils.git
ref: v0.1.8 ref: v0.1.9
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^5.0.0 flutter_lints: ^6.0.0
flutter: flutter:
uses-material-design: true uses-material-design: true
assets: assets:
+5 -2
View File
@@ -202,8 +202,11 @@ void main() {
final page = await previewLink('https://example.com/a', final page = await previewLink('https://example.com/a',
probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL')); probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL'));
expect((page!.title, page.subtitle), expect((page!.title, page.subtitle, page.problem),
('Example', "example.com · Not a video (and yt-dlp couldn't read it: Unsupported URL)")); ('Example', 'example.com · Not a video page yt-dlp knows', 'Not a video page yt-dlp knows'));
final refused = await previewLink('https://cdn.example/b.mp4',
probe: (url) async => LinkProbe.page(Uri.parse(url), ytDlpError: 'odd', status: 403));
expect((refused!.title, refused.problem), ('b.mp4', 'Not allowed (403)'));
expect(await previewLink('not a link'), isNull); expect(await previewLink('not a link'), isNull);
}); });
+14
View File
@@ -110,6 +110,8 @@ Future<Harness> pumpLibrary(
now: () => fixtureNow, now: () => fixtureNow,
probe: (url) async => url.endsWith('.mkv') probe: (url) async => url.endsWith('.mkv')
? LinkProbe.file(Uri.parse(url), 1) ? LinkProbe.file(Uri.parse(url), 1)
: url.contains('bilibili')
? LinkProbe.page(Uri.parse(url), ytDlpError: 'HTTP Error 412: Precondition Failed')
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')), : LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) => filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(solo: solo, relayed: relayed, onPlay: onPlay), FilesStub(solo: solo, relayed: relayed, onPlay: onPlay),
@@ -296,6 +298,18 @@ void main() {
await settle(tester, 10); await settle(tester, 10);
expect(h.controller.link.text, url); expect(h.controller.link.text, url);
// A link the probe cannot open: the reason, not put up; "anyway" is.
const bili = 'https://www.bilibili.com/video/BV1';
await tester.enterText(find.byKey(const ValueKey('link-field')), bili);
await settle(tester, 10);
await tester.tap(find.byKey(const ValueKey('link-put-up')));
await settle(tester, 10);
expect(find.text(needsCookiesText), findsOneWidget);
expect(h.put, hasLength(1));
await tester.tap(find.byKey(const ValueKey('link-put-up-anyway')));
await settle(tester, 10);
expect((h.put.last as LinkMedia).anyway, isTrue);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
+13
View File
@@ -112,6 +112,19 @@ void main() {
expect(w.who.map((m) => m.name), ['Bob', 'Mika']); expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
}); });
test("can't play: yours with Retry (reload); others' instead of waiting", () {
var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!;
expect((v.kind, v.text, v.action), (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload));
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false,
error: 'Not allowed (403)');
v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!;
expect((v.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null));
expect(v.who.single.id, 'b');
final notMe = mv('me', 'Me');
v = playerStatusFor(health: ok, state: held([notMe, bob]), self: notMe)!;
expect((v.kind, v.action), (StatusKind.cannotPlay, StatusAction.ready));
});
test('names read as a sentence', () { test('names read as a sentence', () {
expect(nameList(['Bob']), 'Bob'); expect(nameList(['Bob']), 'Bob');
expect(nameList(['Bob', 'Mika']), 'Bob and Mika'); expect(nameList(['Bob', 'Mika']), 'Bob and Mika');
+3 -11
View File
@@ -29,7 +29,6 @@ final seriesJson = {
'jellyfin_series_id': 'jf7', 'jellyfin_series_id': 'jf7',
'state': 'released', 'state': 'released',
'monitored': true, 'monitored': true,
'followed': true,
'episodes_total': 24, 'episodes_total': 24,
'episodes_available': 20, 'episodes_available': 20,
'next_air_date': null, 'next_air_date': null,
@@ -110,7 +109,7 @@ void main() {
'to': req.uri.queryParameters['to'], 'to': req.uri.queryParameters['to'],
'items': [ 'items': [
{...episodeJson, 'series_id': 7, 'title': 'Show', 'anidb_id': 70, 'tvdb_id': 700, {...episodeJson, 'series_id': 7, 'title': 'Show', 'anidb_id': 70, 'tvdb_id': 700,
'jellyfin_series_id': 'jf7', 'followed': true}, 'jellyfin_series_id': 'jf7'},
], ],
}, etag: 'W/"s1"'); }, etag: 'W/"s1"');
}, },
@@ -126,7 +125,6 @@ void main() {
expect(it.anidbId, 70); expect(it.anidbId, 70);
expect(it.tvdbId, 700); expect(it.tvdbId, 700);
expect(it.jellyfinSeriesId, 'jf7'); expect(it.jellyfinSeriesId, 'jf7');
expect(it.followed, isTrue);
final e = it.episode; final e = it.episode;
expect((e.season, e.episode, e.absolute, e.title, e.anilistId), (2, 3, 15, 'Three', 222)); expect((e.season, e.episode, e.absolute, e.title, e.anilistId), (2, 3, 15, 'Three', 222));
expect(e.airDate, DateTime.utc(2026, 10, 8, 19, 36, 32)); expect(e.airDate, DateTime.utc(2026, 10, 8, 19, 36, 32));
@@ -146,7 +144,7 @@ void main() {
expect(server.seen, isEmpty); expect(server.seen, isEmpty);
}); });
test('/series, /series/{id} with episodes, follow/unfollow', () async { test('/series, /series/{id} with episodes', () async {
await serve({ await serve({
'GET /series': (req, res) async => json(res, {'items': [seriesJson]}, etag: 'W/"a"'), 'GET /series': (req, res) async => json(res, {'items': [seriesJson]}, etag: 'W/"a"'),
'GET /series/7': (req, res) async => json(res, { 'GET /series/7': (req, res) async => json(res, {
@@ -156,24 +154,18 @@ void main() {
{'season': 1, 'episode': 1, 'status': 'failed', 'reason': 'No release'}, {'season': 1, 'episode': 1, 'status': 'failed', 'reason': 'No release'},
], ],
}), }),
'PUT /series/7/follow': (req, res) async => res.statusCode = 204,
'DELETE /series/7/follow': (req, res) async => res.statusCode = 204,
}); });
final c = client(); final c = client();
final s = (await c.series()).single; final s = (await c.series()).single;
expect(s.seriesId, 7); expect(s.seriesId, 7);
expect(s.anilistId, 111); expect(s.anilistId, 111);
expect(s.state, SeriesState.released); expect(s.state, SeriesState.released);
expect(s.monitored && s.followed, isTrue); expect(s.monitored, isTrue);
expect((s.episodesTotal, s.episodesAvailable, s.nextAirDate), (24, 20, null)); expect((s.episodesTotal, s.episodesAvailable, s.nextAirDate), (24, 20, null));
final d = await c.seriesDetail(7); final d = await c.seriesDetail(7);
expect(d.episodes.map((e) => e.season), [0, 1]); expect(d.episodes.map((e) => e.season), [0, 1]);
expect(d.episodes.last.status, EpisodeStatus.failed); expect(d.episodes.last.status, EpisodeStatus.failed);
expect(d.episodes.last.reason, 'No release'); expect(d.episodes.last.reason, 'No release');
await c.setFollowed(7, true);
await c.setFollowed(7, false);
expect(server.seen.skip(2).map((r) => '${r.method} ${r.uri.path}'),
['PUT /api/app/v1/series/7/follow', 'DELETE /api/app/v1/series/7/follow']);
}); });
test('/search and the three request bodies', () async { test('/search and the three request bodies', () async {
+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, AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
{int season = 1, int? absolute, DateTime? air, int seriesId = 7, bool followed = false, {int season = 1, int? absolute, DateTime? air, int seriesId = 7,
double? progress, String? reason}) => double? progress, String? reason}) =>
AnimeFlowScheduleItem( AnimeFlowScheduleItem(
episode: AnimeFlowEpisode( episode: AnimeFlowEpisode(
@@ -36,7 +36,6 @@ AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
seriesId: seriesId, seriesId: seriesId,
title: 'Show', title: 'Show',
jellyfinSeriesId: 'jf$seriesId', jellyfinSeriesId: 'jf$seriesId',
followed: followed,
); );
void main() { void main() {
@@ -80,6 +79,36 @@ void main() {
expect(out.single.season, 6); 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)", () { test("AnimeFlow's anilist_episode wins over TVDB numbering (split cour)", () {
// S2E13 in TVDB is part 2 (AniList 300) episode 1. // S2E13 in TVDB is part 2 (AniList 300) episode 1.
final out = matchSchedule( final out = matchSchedule(
@@ -99,13 +128,28 @@ void main() {
expect(out.single.season, 2); expect(out.single.season, 2);
}); });
test('a TVDB number never claims another AniList episode (JoJo S6E4 = 2nd STAGE ep 3)', () {
AnimeFlowScheduleItem row(int tvdbEp, int aniEp, EpisodeStatus st) => AnimeFlowScheduleItem(
episode: AnimeFlowEpisode(season: 6, episode: tvdbEp, anilistId: 210482, anilistEpisode: aniEp, status: st),
seriesId: 40,
title: 'JoJo',
jellyfinSeriesId: 'jfJ',
);
final out = matchSchedule(
[airing(210482, 3, DateTime(2026, 10, 9, 11)), airing(210482, 4, DateTime(2026, 10, 16, 11))],
schedule: [row(4, 3, EpisodeStatus.available), row(5, 4, EpisodeStatus.upcoming)],
now: now,
);
expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.upcoming]);
});
test('by AniList id + episode, per season ids', () { test('by AniList id + episode, per season ids', () {
// One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200. // One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200.
final out = matchSchedule( final out = matchSchedule(
[airing(100, 3, DateTime(2026, 10, 1, 20)), airing(200, 3, DateTime(2026, 10, 9, 20))], [airing(100, 3, DateTime(2026, 10, 1, 20)), airing(200, 3, DateTime(2026, 10, 9, 20))],
schedule: [ schedule: [
item(100, 3, EpisodeStatus.available, season: 1), item(100, 3, EpisodeStatus.available, season: 1),
item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4, followed: true), item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4),
], ],
series: [const AnimeFlowSeries(seriesId: 7, title: 'Show', anilistId: 100, jellyfinSeriesId: 'jf7')], series: [const AnimeFlowSeries(seriesId: 7, title: 'Show', anilistId: 100, jellyfinSeriesId: 'jf7')],
now: now, now: now,
@@ -113,7 +157,6 @@ void main() {
expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.downloading]); expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.downloading]);
expect(out.map((e) => e.season), [1, 2]); expect(out.map((e) => e.season), [1, 2]);
expect(out[1].progress, 0.4); expect(out[1].progress, 0.4);
expect(out[1].followed, isTrue);
expect(out.every((e) => e.tracked && e.seriesId == 7 && e.jellyfinSeriesId == 'jf7'), isTrue); expect(out.every((e) => e.tracked && e.seriesId == 7 && e.jellyfinSeriesId == 'jf7'), isTrue);
}); });
@@ -160,11 +203,11 @@ void main() {
final out = matchSchedule( final out = matchSchedule(
[airing(500, 2, DateTime(2026, 10, 7, 20)), airing(500, 3, DateTime(2026, 10, 14, 20))], [airing(500, 2, DateTime(2026, 10, 7, 20)), airing(500, 3, DateTime(2026, 10, 14, 20))],
schedule: const [], schedule: const [],
series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500, followed: true)], series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500)],
now: now, now: now,
); );
expect(out.map((e) => e.status), [ScheduleStatus.missing, ScheduleStatus.upcoming]); expect(out.map((e) => e.status), [ScheduleStatus.missing, ScheduleStatus.upcoming]);
expect(out.every((e) => e.followed && e.seriesId == 9), isTrue); expect(out.every((e) => e.seriesId == 9), isTrue);
}); });
test('progress only while downloading, reason only when failed', () { test('progress only while downloading, reason only when failed', () {
@@ -226,7 +269,7 @@ void main() {
return json(res, { return json(res, {
'items': [ 'items': [
{'season': 1, 'episode': 3, 'anilist_id': 100, 'status': 'available', 'series_id': 7, {'season': 1, 'episode': 3, 'anilist_id': 100, 'status': 'available', 'series_id': 7,
'title': 'Show', 'followed': false}, 'title': 'Show'},
], ],
}); });
case '/flow/series': case '/flow/series':
+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/anilist.dart';
import 'package:watch_together/src/schedule/animeflow.dart'; import 'package:watch_together/src/schedule/animeflow.dart';
import 'package:watch_together/src/schedule/schedule_model.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/schedule/ui/schedule_page.dart';
import 'package:watch_together/src/screens/home/picked_media.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)), airing: airing(111, 'Avail Show', 3, day(0, 9)),
status: ScheduleStatus.available, status: ScheduleStatus.available,
tracked: true, tracked: true,
followed: true,
seriesId: 7, seriesId: 7,
season: 1, season: 1,
jellyfinSeriesId: 'jf7', jellyfinSeriesId: 'jf7',
@@ -200,8 +200,6 @@ void main() {
}); });
case ('GET', '/search'): case ('GET', '/search'):
json(res, {'items': searchHits}); json(res, {'items': searchHits});
case ('PUT' || 'DELETE', '/series/7/follow'):
res.statusCode = 204;
case ('POST', '/requests/1/approve' || '/requests/2/reject'): case ('POST', '/requests/1/approve' || '/requests/2/reject'):
json(res, {}); json(res, {});
default: default:
@@ -297,13 +295,9 @@ void main() {
await done(tester); await done(tester);
}); });
testWidgets('filters: following, all airing, niche too, compact', (tester) async { testWidgets('filters: all airing, niche too, compact', (tester) async {
await pumpPage(tester); await pumpPage(tester);
await tester.tap(find.text('Following')); expect(find.text('Following'), findsNothing);
await tester.pump();
expect(card(111, 3), findsOneWidget);
expect(card(222, 5), findsNothing);
await tester.tap(find.text('All airing')); await tester.tap(find.text('All airing'));
await tester.pump(); await tester.pump();
expect(card(444, 1), findsOneWidget); expect(card(444, 1), findsOneWidget);
@@ -397,15 +391,6 @@ void main() {
await done(tester); await done(tester);
}); });
testWidgets('follow toggles through AnimeFlow', (tester) async {
final repo = await pumpPage(tester);
await tester.tap(find.byKey(const ValueKey('follow-111-3')));
await io(tester);
expect(server.seen.where((r) => r.method == 'DELETE' && r.uri.path.endsWith('/series/7/follow')), hasLength(1));
expect(repo.forced.length, greaterThanOrEqualTo(2)); // reloaded
await done(tester);
});
testWidgets('put it up resolves the Jellyfin episode', (tester) async { testWidgets('put it up resolves the Jellyfin episode', (tester) async {
final jf = FakeJf(); final jf = FakeJf();
final accounts = JellyfinAccounts( final accounts = JellyfinAccounts(
@@ -434,6 +419,51 @@ void main() {
await done(tester); 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 { testWidgets('solo: the button says Play and works', (tester) async {
await pumpPage(tester, solo: true); await pumpPage(tester, solo: true);
expect(find.text('Put it up'), findsNothing); 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`, setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
default off, sends `ready=false` itself on a new `mediaId`; not for the default off, sends `ready=false` itself on a new `mediaId`; not for the
first state of a session, and a reconnect does not restore a ready that first state of a session, and a reconnect does not restore a ready that
a swap meanwhile would have dropped). Stale, leave and sitting out a swap meanwhile would have dropped). Stale, leave, sitting out and a
remove from the set. player error remove from the set.
- **Player error** (`EngineMember.error`, `report.error`,
`state.members[].error`): a member whose player cannot open the current
media says why in its reports; the server takes it out of the set at
once (like sitting out, but automatic: `ready`/`force` do not put it
back, its ready flag is kept), so the others start without it. A report
without it (the media opened, after a reload too) or a swap clears it:
back in the set like a join while not PLAYING; while PLAYING from its
next frame if it is ready (the others then hold for its buffer, as for a
stale member coming back), else from its `ready`.
- **Sitting out** (`EngineMember.out`, `state.members[].out`): a member - **Sitting out** (`EngineMember.out`, `state.members[].out`): a member
stays connected (chat, feed, party, nudges) but is never in the set and stays connected (chat, feed, party, nudges) but is never in the set and
never ready: no `ready` or `force` reaches it, a swap leaves it out, its never ready: no `ready` or `force` reaches it, a swap leaves it out, its
@@ -256,8 +265,25 @@ mpv settings (`PlaybackController`): `Player` with `bufferSize` 256 MiB
read-ahead: rides out short drops, intro skips are instant), read-ahead: rides out short drops, intro skips are instant),
`demuxer-max-bytes=268435456` (150 s up to ~14 Mbit/s; a 1.4 GB / 24 min `demuxer-max-bytes=268435456` (150 s up to ~14 Mbit/s; a 1.4 GB / 24 min
file is ~8), `demuxer-max-back-bytes=67108864`, file is ~8), `demuxer-max-back-bytes=67108864`,
`stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4` `stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=5xx,reconnect_delay_max=4,reconnect_max_retries=3`
(comma form; this mpv build rejects colon-joined). The cache is in RAM: (comma form; this mpv build rejects colon-joined): a dropped connection
or a 5xx is retried, at most 3 times per read and 4 s apart at most; a
4xx (a CDN refusing a request) fails at once instead of being retried
(older ffmpeg ignores `reconnect_max_retries`). A load that fails (mpv's
fatal "No video or audio streams selected", or an EDL track that cannot
be opened) before a duration is known is reported once per open
(`onLoadError`), with ffmpeg's HTTP status when this player gets
ffmpeg's log (ffmpeg logs through one mpv instance per process, the
first one alive). That, a yt-dlp hook error and a failed `openUrl` become
the coordinator's `playerError` in plain words (`playErrorText`, core
`link_probe.dart`: 412/precondition/login/cookies/sign in → "This site
needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed
(403)", 404/410 → "Not found (404)", "Unsupported URL" → "Not a video
page yt-dlp knows", network errors → "Couldn't reach the site", anything
else as is). It rides in `report.error` (see "Player error" in the sync
model) until the media opens (a new duration) or the server's `mediaId`
moves on; the pill shows "Can't play this: ..." with Retry (Reload
video). The cache is in RAM:
media_kit sets `cache-on-disk` without a `cache-dir`, and mpv falls back. mpv media_kit sets `cache-on-disk` without a `cache-dir`, and mpv falls back. mpv
ignores invalid values silently, so every pin is read back and a mismatch is ignores invalid values silently, so every pin is read back and a mismatch is
logged and kept in `pinMismatches`. Measured against the `/m` endpoint logged and kept in `pinMismatches`. Measured against the `/m` endpoint
@@ -288,12 +314,18 @@ turns shaders off (saved) with a toast.
Render mode: media_kit's Linux/Windows video output creates mpv's render Render mode: media_kit's Linux/Windows video output creates mpv's render
context (OpenGL, else its software renderer) when the `VideoController` is context (OpenGL, else its software renderer) when the `VideoController` is
created, and nothing in its API says which. The player therefore requests created, and nothing in its API says which. The player therefore requests
mpv logs at `v`: OpenGL init logs `GL_VERSION` under `libmpv_render` mpv logs at `v` while the controller is created: OpenGL init logs
`GL_VERSION` under `libmpv_render`
(`RenderMode.gpu`; "suspected software renderer" means llvmpipe, (`RenderMode.gpu`; "suspected software renderer" means llvmpipe,
`softwareGl`); none within 1.5 s of the controller being ready means the `softwareGl`); none within 1.5 s of the controller being ready means the
software path (`software`, where shaders do nothing; the shader toast, the software path (`software`, where shaders do nothing; the shader toast, the
menu and Settings say so). After startup the `v` level adds no steady menu and Settings say so). Otherwise the session player logs at `warn`
traffic (measured: 0 messages over 5 s of playback). The hardware path is (`PlaybackController.logLevel`, switched with `mpv_request_log_messages`
on media_kit's handle): media_kit drains mpv's log on the UI thread, one
message at a time, so a chatty level, or ffmpeg logging every retry of a
refused request, could starve the UI. Shader and yt-dlp hook errors are
`error` lines and still arrive. The thumbnail and sound players keep
media_kit's default (`error`) and nothing listens to their log. The hardware path is
fragile: media_kit borrows whatever EGL context GDK left current on the main fragile: media_kit borrows whatever EGL context GDK left current on the main
thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change
can silently fall back to software. can silently fall back to software.
@@ -633,15 +665,30 @@ all three link modes. Nothing is bundled or installed by hand.
video/audio type, or a generic one on a media file name, is a file video/audio type, or a generic one on a media file name, is a file
(`looksLikeMediaFile`); anything else goes to yt-dlp, and when yt-dlp (`looksLikeMediaFile`); anything else goes to yt-dlp, and when yt-dlp
cannot read it the page's `og:title` / `<title>` is kept with yt-dlp's cannot read it the page's `og:title` / `<title>` is kept with yt-dlp's
reason ("Not a video (and yt-dlp couldn't read it: ...)"). The app reason and the GET's HTTP status. `LinkProbe.problem`: why it will not
probes on paste (`AppYtDlp.probe`, remembered 10 minutes) for the Link play, in plain words (`playErrorText` of yt-dlp's reason, else of a 4xx
preview and the swap: a page is named by its title, never its URL tail, status), null for a file or a page yt-dlp read. The app probes on paste
and its thumbnail goes up as the banner (no poster). (`AppYtDlp.probe`, remembered 10 minutes; media file names too, not a
Jellyfin item link) for the
Link preview and the swap: a page is named by its title, never its URL
tail, and its thumbnail goes up as the banner (no poster). A link with a
problem, or one the probe cannot reach ("Couldn't reach the site"), is
not put up in any mode: the Link page shows the reason (preview and
under the button) and offers "Put it up anyway" (`LinkMedia.anyway`),
for when the server or a friend with cookies may still open it;
`swapLink` refuses it the same way for any other caller.
- **Everyone fetches it**: the swap sends the page URL. Before opening an - **Everyone fetches it**: the swap sends the page URL. Before opening an
external link every app points mpv's hook at its managed copy external link every app points mpv's hook at its managed copy
(`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`, (`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`,
once per player), waiting for the download unless the URL names a media once per player), waiting for the download unless the URL names a media
file. A hook error shows as "can't open this link: ...". file. A hook error is a player error (see the mpv settings under "Media"). The hook (mpv
0.41) gives mpv the first requested format's `http_headers` (User-Agent
as `user-agent`; Referer, Cookie, X-Forwarded-For as file-local
`http-header-fields`, which win over the empty list media_kit sets on
load) and its `cookies` (`stream-lavf-o`), for every track of its EDL:
Bilibili's CDN answers 403 without the Referer. The app never opens a
resolved format URL in mpv itself (the putter of "Through me" plays the
page through the hook too); relays read them with yt-dlp's headers.
- **Through me**: the putter's probe must give tracks, and a pieces - **Through me**: the putter's probe must give tracks, and a pieces
track's playlist must be readable, else the swap is refused ("Live track's playlist must be readable, else the swap is refused ("Live
streams can't be passed on", DRM, or nothing a relay can read) with a streams can't be passed on", DRM, or nothing a relay can read) with a
@@ -819,7 +866,13 @@ always scrolls the page). Space never presses a focused button
against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token; against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token;
`/library` marks shows on Jellyfin it doesn't track, through every AniList `/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 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 `requests_sheet.dart`. It refreshes every minute; mouse drag and
Shift+wheel scroll the board sideways, the plain wheel scrolls a column. Shift+wheel scroll the board sideways, the plain wheel scrolls a column.
Settings (`settings_tab.dart`) has a left nav with icons 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 `StateBroadcast.bufferNeed`, the gate's own need at the current
position: less near the end, none at finished; their avatar with the progress round it), you not position: less near the end, none at finished; their avatar with the progress round it), you not
ready (a click marks you ready) and who everyone waits for (avatars, ready (a click marks you ready) and who everyone waits for (avatars,
"2/3 ready"); nothing otherwise, info never. 34 px avatars, a main line "2/3 ready"); nothing otherwise, info never. Your own `playerError`
comes right after reconnecting ("Can't play this: ...", Retry reloads
the video); members with `members[].error` show as "Bob can't play
this: ..." (before your ready prompt, which then rides in the detail
line) and are never in "waiting for". A member's new error also toasts
once (the pill is hidden while playing); avatars carry a red error mark
and the members card says "can't play this: ...". 34 px avatars, a main line
and a muted detail line. Frosted glass, colour only for problems; it fades through and a muted detail line. Frosted glass, colour only for problems; it fades through
between kinds. The mini shows its short line in its hover bar. ⚙ in both between kinds. The mini shows its short line in its hover bar. ⚙ in both
control designs opens the player settings card (`controls/ control designs opens the player settings card (`controls/
+16 -3
View File
@@ -40,7 +40,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
{"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}} {"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}}
{"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}} {"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}}
{"t":"report","position":123.1,"buffered":6.2,"eventId":41, {"t":"report","position":123.1,"buffered":6.2,"eventId":41,
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}} "duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"}
{"t":"ping","c":1767225600000} {"t":"ping","c":1767225600000}
{"t":"chat","text":"..."} {"t":"chat","text":"..."}
{"t":"out","target":"<memberId>","out":true} {"t":"out","target":"<memberId>","out":true}
@@ -72,6 +72,17 @@ Field notes:
client plays the item with its own account, `false` = through the relay. client plays the item with its own account, `false` = through the relay.
Sticky: a report without `jf` keeps the last one. At most 8 entries, keys Sticky: a report without `jf` keeps the last one. At most 8 entries, keys
up to 64 characters; others are dropped. up to 64 characters; others are dropped.
- `report.error` (optional, omitted when empty): short plain text saying
why this client's player cannot open the media `report.mediaId` (e.g.
`Not allowed (403)`). The client sends it in every report while that
lasts and drops it once the media opens (a reload included). The server
keeps it per member (one line, cut to 120 characters) only for the
current `mediaId`: a report without it, or for another `mediaId`,
clears it, and so does a swap. An errored member is out of the ready
gate: out of the set, a `ready` or `force` does not put it back; when
the error clears it is in the set again like a join (unless `playing`,
then from its `ready`). Optional on both sides: older servers ignore it,
older clients ignore `members[].error`.
- `ping.c`: the client's clock in epoch ms; answered with `pong`. - `ping.c`: the client's clock in epoch ms; answered with `pong`.
- `user.avatar`: base64 PNG, at most 256 KiB of characters (else - `user.avatar`: base64 PNG, at most 256 KiB of characters (else
`rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32 `rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32
@@ -113,7 +124,7 @@ Field notes:
"serverTime":"<ISO-8601 UTC>","mediaId":1, "serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent, "media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?, "members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
"host"?,"out"?}], "host"?,"out"?,"error"?}],
"seekBy":"m1","seekSeq":3,"relay":true} "seekBy":"m1","seekSeq":3,"relay":true}
{"t":"pong","c":1767225600000,"s":1767225600012} {"t":"pong","c":1767225600000,"s":1767225600012}
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"} {"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
@@ -145,7 +156,9 @@ Field notes:
(omitted when the member has no picture) names the picture's bytes (see (omitted when the member has no picture) names the picture's bytes (see
"Avatars"). `members[].host` (omitted when false): the member sent the "Avatars"). `members[].host` (omitted when false): the member sent the
server's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when server's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when
false): the member sits out (see `out`). false): the member sits out (see `out`). `members[].error` (omitted when
empty): the member's player cannot open the current media (see
`report.error`).
- `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms - `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms
(the clock `serverTime` uses). With `t3` the local receive time, the offset (the clock `serverTime` uses). With `t3` the local receive time, the offset
is `s - (c + t3) / 2`, accurate to half the round trip. is `s - (c + t3) / 2`, accurate to half the round trip.
+2 -4
View File
@@ -33,8 +33,8 @@ off by deleting it in the same commit as the code. Wire changes also edit
should open the video track's URL alone, not the EDL. should open the video track's URL alone, not the EDL.
4. **AniList account**: sign in (OAuth via a localhost callback), "My list" 4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
filter on the Schedule; today's "Following" (AnimeFlow's monitored filter on the Schedule from the user's AniList lists. This is the
shows) becomes "On the server". Schedule's personal tracking; AnimeFlow has no per-user follow.
5. **Ops**: check shaders on Windows; the relay e2e "source drops 5. **Ops**: check shaders on Windows; the relay e2e "source drops
uncleanly and resumes" fails about half the time under the full suite. uncleanly and resumes" fails about half the time under the full suite.
@@ -76,8 +76,6 @@ off by deleting it in the same commit as the code. Wire changes also edit
not wanted yet. not wanted yet.
- HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater) - HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater)
to dio from `fox_utils` when touching them, for retries/interceptors. to dio from `fox_utils` when touching them, for retries/interceptors.
- The `state` broadcast carries the full media URL, which can include an
`api_key` (recognised Jellyfin links become refs; other links do not).
- TLS: document a reverse proxy in front of the server. - TLS: document a reverse proxy in front of the server.
- Playlist: `queue-add`, `queue-skip`, auto-advance like a swap. - Playlist: `queue-add`, `queue-skip`, auto-advance like a swap.
- Sidecar subtitles. - Sidecar subtitles.
+2
View File
@@ -50,6 +50,8 @@ abstract class RelayConstants {
static const int maxBannerBytes = 768 * 1024; static const int maxBannerBytes = 768 * 1024;
static const int maxNameLength = 32; static const int maxNameLength = 32;
static const int maxChatLength = 500; static const int maxChatLength = 500;
/// Characters of `report.error` the server keeps.
static const int maxMemberErrorLength = 120;
/// Nudges one member may send: at most this many per second and per /// Nudges one member may send: at most this many per second and per
/// minute; the server drops the rest silently. /// minute; the server drops the rest silently.
static const int nudgesPerSec = 3; static const int nudgesPerSec = 3;
+53 -4
View File
@@ -75,6 +75,38 @@ String notAVideoText(String? ytDlpError) =>
/// The text for a page with no video a relay can read. /// The text for a page with no video a relay can read.
const noRelayFormatText = "This page has no video a relay can read. Choose 'Everyone fetches it' instead."; const noRelayFormatText = "This page has no video a relay can read. Choose 'Everyone fetches it' instead.";
/// The plain-words texts of [playErrorText].
const needsCookiesText = 'This site needs cookies — Settings → Player → yt-dlp → Cookies';
const notAllowedText = 'Not allowed (403)';
const notFoundText = 'Not found (404)';
const unsupportedUrlText = 'Not a video page yt-dlp knows';
const unreachableText = "Couldn't reach the site";
const _plainTexts = {needsCookiesText, notAllowedText, notFoundText, unsupportedUrlText, unreachableText};
final _cookiesHint = RegExp(r'\b412\b|precondition|log ?in\b|logged|cookies|sign ?in\b', caseSensitive: false);
final _forbidden = RegExp(r'(^|[\s(:])403\b|forbidden', caseSensitive: false);
final _notFound = RegExp(r'(^|[\s(:])404\b|\b410\b|not found', caseSensitive: false);
final _unreachable = RegExp(
r'SocketException|HandshakeException|TimeoutException|timed out|failed host lookup|connection (refused|reset|closed)|'
r'network is unreachable|no route to host|name or service not known|getaddrinfo|unable to download webpage',
caseSensitive: false);
/// Why a link or media cannot be played, in plain words, from the raw
/// error of yt-dlp, ffmpeg/mpv or the link probe; anything it does not
/// know comes back as is (one line, at most 100 characters).
String playErrorText(String raw) {
final s = raw.trim();
// First: its message quotes the URL, which may say anything.
if (s.toLowerCase().contains('unsupported url')) return unsupportedUrlText;
if (_cookiesHint.hasMatch(s)) return needsCookiesText;
if (_forbidden.hasMatch(s)) return notAllowedText;
if (_notFound.hasMatch(s)) return notFoundText;
if (_unreachable.hasMatch(s)) return unreachableText;
final line = s.replaceAll(RegExp(r'\s+'), ' ');
if (line.isEmpty) return "Couldn't open it";
return line.length > 100 ? '${line.substring(0, 99)}…' : line;
}
class LinkProbeException implements Exception { class LinkProbeException implements Exception {
final String message; final String message;
const LinkProbeException(this.message); const LinkProbeException(this.message);
@@ -94,15 +126,30 @@ class LinkProbe {
final String? pageTitle; final String? pageTitle;
/// Why yt-dlp could not read the page. /// Why yt-dlp could not read the page.
final String? ytDlpError; final String? ytDlpError;
/// The HTTP status of the probe's GET; 0 when unknown.
final int status;
const LinkProbe.file(this.url, this.size) const LinkProbe.file(this.url, this.size)
: isPage = false, : isPage = false,
info = null, info = null,
pageTitle = null, pageTitle = null,
ytDlpError = null; ytDlpError = null,
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError}) status = 0;
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.status = 0})
: isPage = true, : isPage = true,
size = -1; size = -1;
/// Why nobody may be able to play this link ([playErrorText]), or null
/// when it looks playable: a page yt-dlp could not read, or a link
/// answering 4xx.
String? get problem {
if (!isPage || info != null) return null;
final err = ytDlpError == null ? null : playErrorText(ytDlpError!);
// A 4xx says more than yt-dlp's words it has no plain text for.
final known = err != null && _plainTexts.contains(err);
if (!known && status >= 400 && status < 500) return playErrorText('HTTP Error $status');
return err;
}
/// The name to show: yt-dlp's title, the page's, else the host; null /// The name to show: yt-dlp's title, the page's, else the host; null
/// for a file (named by its file name). /// for a file (named by its file name).
String? get title { String? get title {
@@ -134,12 +181,14 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
try { try {
String? body; String? body;
Object? getError; Object? getError;
var status = 0;
try { try {
final req = await c.getUrl(url).timeout(const Duration(seconds: 15)); final req = await c.getUrl(url).timeout(const Duration(seconds: 15));
req.headers req.headers
..set(HttpHeaders.rangeHeader, 'bytes=0-0') ..set(HttpHeaders.rangeHeader, 'bytes=0-0')
..set(HttpHeaders.acceptEncodingHeader, 'identity'); ..set(HttpHeaders.acceptEncodingHeader, 'identity');
final res = await req.close().timeout(const Duration(seconds: 15)); final res = await req.close().timeout(const Duration(seconds: 15));
status = res.statusCode;
final type = res.headers.contentType?.mimeType ?? ''; final type = res.headers.contentType?.mimeType ?? '';
if (looksLikeMediaFile(status: res.statusCode, contentType: type, url: url)) { if (looksLikeMediaFile(status: res.statusCode, contentType: type, url: url)) {
await res.drain<void>().catchError((_) {}); await res.drain<void>().catchError((_) {});
@@ -155,7 +204,7 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
String? ytErr; String? ytErr;
if (ytDlp != null) { if (ytDlp != null) {
try { try {
return LinkProbe.page(url, info: await ytDlp.resolve(url.toString())); return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()), status: status);
} catch (e) { } catch (e) {
ytErr = '$e'; ytErr = '$e';
} }
@@ -170,7 +219,7 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
body = await _readText(await req.close().timeout(const Duration(seconds: 15))); body = await _readText(await req.close().timeout(const Duration(seconds: 15)));
} catch (_) {} } catch (_) {}
} }
return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr); return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr, status: status);
} finally { } finally {
if (client == null) c.close(force: true); if (client == null) c.close(force: true);
} }
+22 -1
View File
@@ -435,13 +435,17 @@ class ReportMessage {
/// Jellyfin access by `ServerId`: true = plays with its own account, /// Jellyfin access by `ServerId`: true = plays with its own account,
/// false = through the server's relay. Null leaves the last one in place. /// false = through the server's relay. Null leaves the last one in place.
final Map<String, bool>? jf; final Map<String, bool>? jf;
/// Why the player cannot open [mediaId] (short plain text); empty while
/// it can.
final String error;
const ReportMessage( const ReportMessage(
{required this.position, {required this.position,
required this.buffered, required this.buffered,
required this.eventId, required this.eventId,
this.duration = 0, this.duration = 0,
this.mediaId = 0, this.mediaId = 0,
this.jf}); this.jf,
this.error = ''});
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
't': 'report', 't': 'report',
@@ -451,6 +455,7 @@ class ReportMessage {
if (duration > 0) 'duration': duration, if (duration > 0) 'duration': duration,
if (mediaId != 0) 'mediaId': mediaId, if (mediaId != 0) 'mediaId': mediaId,
if (jf != null) 'jf': jf, if (jf != null) 'jf': jf,
if (error.isNotEmpty) 'error': error,
}; };
factory ReportMessage.fromJson(Map<String, Object?> json) { factory ReportMessage.fromJson(Map<String, Object?> json) {
@@ -462,10 +467,20 @@ class ReportMessage {
duration: finiteDouble(json['duration'], 'duration'), duration: finiteDouble(json['duration'], 'duration'),
mediaId: finiteInt(json['mediaId'], 'mediaId'), mediaId: finiteInt(json['mediaId'], 'mediaId'),
jf: jfAccessFromJson(json['jf']), jf: jfAccessFromJson(json['jf']),
error: memberErrorFromJson(json['error']),
); );
} }
} }
/// Reads a `report.error` / `members[].error`: trimmed, single line, at
/// most [RelayConstants.maxMemberErrorLength] characters; empty otherwise.
String memberErrorFromJson(Object? value) {
if (value is! String) return '';
final line = value.replaceAll(RegExp(r'\s+'), ' ').trim();
const max = RelayConstants.maxMemberErrorLength;
return line.length > max ? line.substring(0, max) : line;
}
/// Reads a `jf` map (`{serverId: bool}`), dropping non-bool entries and /// Reads a `jf` map (`{serverId: bool}`), dropping non-bool entries and
/// keys over 64 characters, at most 8 entries (it rides in every `state`); /// keys over 64 characters, at most 8 entries (it rides in every `state`);
/// null when absent or not a map. /// null when absent or not a map.
@@ -582,6 +597,9 @@ class MemberView {
final bool host; final bool host;
/// Sitting out: connected, outside the ready gate, not playing. /// Sitting out: connected, outside the ready gate, not playing.
final bool out; final bool out;
/// Why its player cannot open the current media (`report.error`); empty
/// while it can. Errored: outside the ready gate.
final String error;
const MemberView({ const MemberView({
required this.id, required this.id,
required this.name, required this.name,
@@ -593,6 +611,7 @@ class MemberView {
this.jf = const {}, this.jf = const {},
this.host = false, this.host = false,
this.out = false, this.out = false,
this.error = '',
}); });
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
@@ -606,6 +625,7 @@ class MemberView {
if (jf.isNotEmpty) 'jf': jf, if (jf.isNotEmpty) 'jf': jf,
if (host) 'host': true, if (host) 'host': true,
if (out) 'out': true, if (out) 'out': true,
if (error.isNotEmpty) 'error': error,
}; };
factory MemberView.fromJson(Map<String, Object?> json) => MemberView( factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
@@ -619,6 +639,7 @@ class MemberView {
jf: jfAccessFromJson(json['jf']) ?? const {}, jf: jfAccessFromJson(json['jf']) ?? const {},
host: json['host'] == true, host: json['host'] == true,
out: json['out'] == true, out: json['out'] == true,
error: memberErrorFromJson(json['error']),
); );
} }
+28 -6
View File
@@ -56,6 +56,9 @@ class EngineMember {
bool host = false; bool host = false;
/// Sitting out: never in the set, never ready, its buffer ignored. /// Sitting out: never in the set, never ready, its buffer ignored.
bool out = false; bool out = false;
/// Its player cannot open the current media (`report.error`): outside
/// the ready gate until a report clears it or the media changes.
String error = '';
DateTime lastSeen; DateTime lastSeen;
EngineMember({ EngineMember({
required this.id, required this.id,
@@ -97,6 +100,7 @@ class EngineMember {
jf: jf, jf: jf,
host: host, host: host,
out: out, out: out,
error: error,
); );
} }
@@ -197,6 +201,7 @@ class SyncEngine {
final wasStale = isStale(member); final wasStale = isStale(member);
member.lastSeen = now; member.lastSeen = now;
if (!member.out && if (!member.out &&
member.error.isEmpty &&
!member.inSet && !member.inSet &&
(member.ready || (wasStale && transport != Transport.playing))) { (member.ready || (wasStale && transport != Transport.playing))) {
member.inSet = true; member.inSet = true;
@@ -307,12 +312,12 @@ class SyncEngine {
_seen(member); _seen(member);
// Sitting out: back first ([applyOut]), then ready. // Sitting out: back first ([applyOut]), then ready.
if (member.out) return; if (member.out) return;
if (member.ready == ready && member.inSet) { if (member.ready == ready && (member.inSet || member.error.isNotEmpty)) {
evaluate(); evaluate();
return; return;
} }
member.ready = ready; member.ready = ready;
if (ready) member.inSet = true; if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage( feed.add(FeedMessage(
actor: member.name, actor: member.name,
action: ready ? 'ready' : 'unready', action: ready ? 'ready' : 'unready',
@@ -328,13 +333,13 @@ class SyncEngine {
for (final member in members.values) { for (final member in members.values) {
if (member.out) continue; if (member.out) continue;
member.ready = ready; member.ready = ready;
if (ready) member.inSet = true; if (ready && member.error.isEmpty) member.inSet = true;
} }
} else { } else {
final member = byId(target); final member = byId(target);
if (member == null || member.out) return; if (member == null || member.out) return;
member.ready = ready; member.ready = ready;
if (ready) member.inSet = true; if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage( feed.add(FeedMessage(
actor: actorName, actor: actorName,
action: ready ? 'force-ready' : 'force-unready', action: ready ? 'force-ready' : 'force-unready',
@@ -366,7 +371,7 @@ class SyncEngine {
member.out = out; member.out = out;
member.ready = false; member.ready = false;
member.buffered = 0; member.buffered = 0;
member.inSet = !out && transport != Transport.playing; member.inSet = !out && member.error.isEmpty && transport != Transport.playing;
feed.add(FeedMessage( feed.add(FeedMessage(
actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now)); actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now));
evaluate(); evaluate();
@@ -408,6 +413,7 @@ class SyncEngine {
// (a stale member rejoins the set with its next frame if still ready). // (a stale member rejoins the set with its next frame if still ready).
final t = now; final t = now;
for (final member in members.values) { for (final member in members.values) {
member.error = '';
member.inSet = !member.out && !isStale(member, t); member.inSet = !member.out && !isStale(member, t);
member.buffered = 0; member.buffered = 0;
} }
@@ -416,10 +422,11 @@ class SyncEngine {
} }
void applyReport(String token, double reportPosition, double buffered, int reportEventId, void applyReport(String token, double reportPosition, double buffered, int reportEventId,
{double duration = 0, int mediaId = 0, Map<String, bool>? jf}) { {double duration = 0, int mediaId = 0, Map<String, bool>? jf, String error = ''}) {
final member = members[token]; final member = members[token];
if (member == null) return; if (member == null) return;
if (!reportPosition.isFinite) return; if (!reportPosition.isFinite) return;
_setError(member, mediaId == this.mediaId ? error : '');
_seen(member); _seen(member);
if (jf != null) member.jf = Map.unmodifiable(jf); if (jf != null) member.jf = Map.unmodifiable(jf);
member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered; member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered;
@@ -433,6 +440,21 @@ class SyncEngine {
evaluate(); evaluate();
} }
/// An error takes the member out of the set (the gate stops waiting for
/// it); clearing it puts it back like a join: in the set unless playing
/// (then from its next `ready`).
void _setError(EngineMember member, String error) {
if (member.error == error) return;
member.error = error;
if (error.isNotEmpty) {
member.inSet = false;
} else if (!member.out) {
member.inSet = transport != Transport.playing;
}
// Leaving the set may let the others start.
evaluate();
}
void sweepStale() { void sweepStale() {
var changed = false; var changed = false;
for (final member in members.values) { for (final member in members.values) {
+1 -1
View File
@@ -1,6 +1,6 @@
name: sync_relay_core name: sync_relay_core
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter. description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
version: 1.1.0 version: 1.1.2
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
dependencies: dependencies:
+46
View File
@@ -85,6 +85,52 @@ void main() {
}); });
}); });
group('player errors', () {
void fail(String token, String error, {int? mediaId}) => engine.applyReport(
token, engine.positionAt(), 0, engine.eventId,
mediaId: mediaId ?? engine.mediaId, error: error);
test('an errored member leaves the gate; clearing puts it back', () {
join('a', 'alice');
join('b', 'bob');
engine.applyReady('a', true);
engine.applyReady('b', true);
buffer('a', 4.0);
expect(engine.transport, Transport.held, reason: 'bob has no buffer');
fail('b', "Not allowed (403)");
final b = engine.members['b']!;
expect([b.error, b.inSet], ['Not allowed (403)', false]);
expect(engine.transport, Transport.playing, reason: 'alice plays alone');
final view = engine.broadcast().members;
expect(view.last.toJson()['error'], 'Not allowed (403)');
expect(view.first.toJson().containsKey('error'), isFalse);
engine.applyReady('b', true);
expect(b.inSet, isFalse, reason: 'ready does not bring it back while errored');
// A report of another media does not set it; a clean one clears it.
fail('b', 'x', mediaId: engine.mediaId - 1);
expect(b.error, isEmpty);
expect(b.inSet, isTrue, reason: 'ready and playing: back like a stale return');
expect(engine.transport, Transport.held, reason: 'bob is buffering');
});
test('a swap clears it; held, clearing puts it in the set', () {
join('a', 'alice');
join('b', 'bob');
fail('b', 'Not found (404)');
engine.applySwap('a', const HelloMedia(name: 'n', size: 1, duration: 0, url: 'u'));
final b = engine.members['b']!;
expect([b.error, b.inSet], ['', true]);
fail('b', 'Not found (404)');
expect(b.inSet, isFalse);
buffer('b', 4.0);
expect([b.error, b.inSet], ['', true]);
expect(ReportMessage.fromJson({
't': 'report', 'position': 0, 'buffered': 0, 'eventId': 0,
'error': ' a\nb ${'x' * 200}'}).error.length,
RelayConstants.maxMemberErrorLength);
});
});
group('membership + gate', () { group('membership + gate', () {
test('join while held lands in the set, playback stays held', () { test('join while held lands in the set, playback stays held', () {
join('a', 'alice'); join('a', 'alice');
+18
View File
@@ -115,6 +115,24 @@ void main() {
}); });
}); });
test('play errors in plain words; a probe refuses unread pages and 4xx links', () {
expect(playErrorText('[BiliBili] BV1x: Unable to download webpage: HTTP Error 412: Precondition Failed'),
needsCookiesText);
expect(playErrorText('Sign in to confirm your age'), needsCookiesText);
expect(playErrorText('the video server answered HTTP error 403 Forbidden'), notAllowedText);
expect(playErrorText('HTTP Error 404: Not Found'), notFoundText);
expect(playErrorText('Unsupported URL: https://example.com/login/403'), unsupportedUrlText);
expect(playErrorText('cannot open the link: SocketException: Failed host lookup'), unreachableText);
expect(playErrorText('the video stream could not be opened'), 'the video stream could not be opened');
expect(playErrorText(notAllowedText), notAllowedText);
final u = Uri.parse('https://x/v.mp4');
expect(LinkProbe.page(u, ytDlpError: 'HTTP Error 412: Precondition Failed').problem, needsCookiesText);
expect(LinkProbe.page(u, ytDlpError: 'odd failure', status: 403).problem, notAllowedText);
expect(LinkProbe.page(u, ytDlpError: 'odd failure').problem, 'odd failure');
expect(LinkProbe.page(u, info: YtDlpInfo.parse({'title': 'x', 'formats': []}), status: 404).problem, isNull);
expect(LinkProbe.file(u, 10).problem, isNull);
});
test('viewer URL: one track plain, several an mpv EDL with %length% framing', () { test('viewer URL: one track plain, several an mpv EDL with %length% framing', () {
expect(relayViewerUrl('http://h:1/r/default/t/3', 'a/b c'), 'http://h:1/r/default/t/3/a_b%20c'); expect(relayViewerUrl('http://h:1/r/default/t/3', 'a/b c'), 'http://h:1/r/default/t/3/a_b%20c');
const v = 'http://h:1/r/default/t/3/0/x', a = 'http://h:1/r/default/t/3/1/x'; const v = 'http://h:1/r/default/t/3/0/x', a = 'http://h:1/r/default/t/3/1/x';
+1 -1
View File
@@ -428,7 +428,7 @@ void mountSyncRoute(
final report = ReportMessage.fromJson(decoded); final report = ReportMessage.fromJson(decoded);
hub.engine.applyReport( hub.engine.applyReport(
token, report.position, report.buffered, report.eventId, token, report.position, report.buffered, report.eventId,
duration: report.duration, mediaId: report.mediaId, jf: report.jf); duration: report.duration, mediaId: report.mediaId, jf: report.jf, error: report.error);
hub.changed(); hub.changed();
case 'ping': case 'ping':
final ping = PingMessage.fromJson(decoded); final ping = PingMessage.fromJson(decoded);
+1 -1
View File
@@ -1,7 +1,7 @@
name: sync_relay_server name: sync_relay_server
description: Pure-Dart sync + media server. Embeddable, no Flutter. description: Pure-Dart sync + media server. Embeddable, no Flutter.
publish_to: none publish_to: none
version: 1.1.0 version: 1.1.2
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
dependencies: dependencies:
+27 -1
View File
@@ -25,9 +25,35 @@ function Invoke-Checked([string]$What, [scriptblock]$Cmd) {
} }
if (-not $SkipBuild) { if (-not $SkipBuild) {
# The host runner keeps the pub cache between jobs; its clone of a git
# dependency (fox_utils) went stale ("some refs were not updated") and
# resolved the wrong revision. Git deps are tiny: always fetch them fresh.
$PubCache = if ($env:PUB_CACHE) { $env:PUB_CACHE } else { Join-Path $env:LOCALAPPDATA 'Pub\Cache' }
foreach ($d in @((Join-Path $PubCache 'git'), (Join-Path $PubCache 'git\cache'))) {
Get-ChildItem -Path $d -Directory -Filter 'fox_utils*' -ErrorAction SilentlyContinue |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
}
Push-Location $App Push-Location $App
try { try {
Invoke-Checked 'flutter build windows' { flutter build windows --release "--dart-define=APP_VERSION=$Version" } Invoke-Checked 'flutter pub get' { flutter pub get }
} finally { Pop-Location }
Push-Location $App
try {
flutter build windows --release "--dart-define=APP_VERSION=$Version"
if ($LASTEXITCODE -ne 0) {
# Flutter hides MSBuild's messages: rebuild verbosely and show only
# the compiler/linker errors, so CI logs say what broke.
# Through cmd and with 'Continue': under 'Stop' PowerShell aborts on
# the first stderr line of a native command.
$ErrorActionPreference = 'Continue'
$log = cmd /c "flutter build windows --release -v --dart-define=APP_VERSION=$Version 2>&1"
$ErrorActionPreference = 'Stop'
Write-Host '--- error lines ---'
$log | Select-String -Pattern 'error' | ForEach-Object { $_.Line } | Select-Object -Unique -First 60
Write-Host '--- last lines ---'
$log | Select-Object -Last 80
throw 'flutter build windows failed'
}
} finally { Pop-Location } } finally { Pop-Location }
} }
+5 -5
View File
@@ -1,10 +1,10 @@
{ {
"version": "1.1.0", "version": "1.1.2",
"protocol": 11, "protocol": 11,
"url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.0/watch-together-1.1.0-linux-x64.tar.gz", "url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.2/watch-together-1.1.2-linux-x64.tar.gz",
"notes": "- One updater for app and server: updater in core; watch-together-server --upgrade / --check-update; update.sh uses the server", "notes": "- Roadmap: drop the link-token item (what a user pastes is theirs)",
"assets": { "assets": {
"linux-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.0/watch-together-1.1.0-linux-x64.tar.gz", "linux-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.2/watch-together-1.1.2-linux-x64.tar.gz",
"windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.0/watch-together-1.1.0-windows-x64.zip" "windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.1.2/watch-together-1.1.2-windows-x64.zip"
} }
} }