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