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