No buffering rings at the end of short videos; links never guess an AniList show

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 17:23:43 +03:00
1 parent 9a4b04672b
commit 24fc137e4f
6 files changed
+33 -5

No files matched your search

+2 -1
View File
@@ -51,7 +51,8 @@ Future<ArtBytes> jellyfinArt(JellyfinClient client, String itemId,
Future<ArtBytes?> aniListArt(AniListClient aniList, String name, Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async { {Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final series = parseReleaseName(name).series.trim(); final series = parseReleaseName(name).series.trim();
if (series.isEmpty) return null; // "1", "2026-10-09": nothing a show could be called.
if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null;
final match = await aniList.searchMedia(series); final match = await aniList.searchMedia(series);
if (match == null) return null; if (match == null) return null;
Future<Uint8List?> get(String? url, int maxBytes) async { Future<Uint8List?> get(String? url, int maxBytes) async {
@@ -1059,6 +1059,9 @@ class SessionCoordinator {
final ArtBytes? bytes; final ArtBytes? bytes;
if (ref != null && client != null) { if (ref != null && client != null) {
bytes = await jellyfinArt(client, ref.itemId); bytes = await jellyfinArt(client, ref.itemId);
} else if (ref == null && (media.url ?? '').isNotEmpty) {
// A web link: its name is a URL tail ("1"), not a show; never guess.
bytes = null;
} else { } else {
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1; final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
bytes = await aniListArt(_aniList ??= AniListClient(), name); bytes = await aniListArt(_aniList ??= AniListClient(), name);
@@ -208,6 +208,8 @@ void main() {
expect(art.banner, jpeg); expect(art.banner, jpeg);
expect(asked, unorderedEquals(['https://img/xl.jpg', 'https://img/banner.jpg', 'https://img/l.jpg'])); expect(asked, unorderedEquals(['https://img/xl.jpg', 'https://img/banner.jpg', 'https://img/l.jpg']));
expect(await aniListArt(c, 'Nothing Like It.mkv', fetch: (_, _, _) async => jpeg), isNull); expect(await aniListArt(c, 'Nothing Like It.mkv', fetch: (_, _, _) async => jpeg), isNull);
// A link tail like ".../video/1" names no show: no AniList search at all.
expect(await aniListArt(c, '1', fetch: (_, _, _) async => jpeg), isNull);
c.close(); c.close();
}); });
+2 -2
View File
@@ -662,8 +662,8 @@ always scrolls the page). Space never presses a focused button
the controls: `playerStatusFor` (pure) picks the most pressing of the controls: `playerStatusFor` (pure) picks the most pressing of
removed, reconnecting (Cancel), a warn/error health message (Retry), removed, reconnecting (Cancel), a warn/error health message (Retry),
connecting (only after 2 s), finished, someone loading (below connecting (only after 2 s), finished, someone loading (below
`StateBroadcast.bufferNeed`, the gate's own need: less near the end, `StateBroadcast.bufferNeed`, the gate's own need at the current
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. 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
+7 -2
View File
@@ -559,12 +559,17 @@ class StateBroadcast {
/// Buffer an in-set member needs before playback can go on, as the /// Buffer an in-set member needs before playback can go on, as the
/// server's gate counts it: [RelayConstants.minBufferSecs], less near the /// server's gate counts it: [RelayConstants.minBufferSecs], less near the
/// end (mpv cannot buffer past it), none once finished. /// end (mpv cannot buffer past it), none once finished.
double get bufferNeed { double get bufferNeed => bufferNeedAt(DateTime.now().toUtc());
/// [bufferNeed] at [now]: while playing the position has moved on since
/// [serverTime] (the local clock is close enough for this).
double bufferNeedAt(DateTime now) {
if (transport == 'finished') return 0; if (transport == 'finished') return 0;
final d = (media?['duration'] as num?)?.toDouble() ?? 0; final d = (media?['duration'] as num?)?.toDouble() ?? 0;
const full = RelayConstants.minBufferSecs; const full = RelayConstants.minBufferSecs;
if (d <= 0) return full; if (d <= 0) return full;
return (d - position - RelayConstants.endSlackSecs).clamp(0.0, full).toDouble(); final moved = transport == 'playing' ? now.difference(serverTime).inMilliseconds / 1000 : 0;
return (d - (position + moved) - RelayConstants.endSlackSecs).clamp(0.0, full).toDouble();
} }
final int eventId; final int eventId;
+17
View File
@@ -193,4 +193,21 @@ void main() {
expect((r.duration, r.mediaId), (0.0, 0)); expect((r.duration, r.mediaId), (0.0, 0));
}); });
}); });
test('bufferNeed counts the time played since the last event', () {
final t0 = DateTime.utc(2026, 1, 1);
final s = StateBroadcast(
eventId: 1,
transport: 'playing',
position: 0,
serverTime: t0,
mediaId: 1,
media: const {'name': 'clip', 'duration': 6.0},
members: const [],
);
expect(s.bufferNeedAt(t0), 4.0);
// 3 s in: 3 s left, minus the 2 s end slack.
expect(s.bufferNeedAt(t0.add(const Duration(seconds: 3))), closeTo(1.0, 1e-9));
expect(s.bufferNeedAt(t0.add(const Duration(seconds: 5))), 0);
});
} }