Swap shows only the new media, no guessed endings, quieter screen

- A swap stops the previous media at once; buffer counts only for the
  media the player holds (client) and the current mediaId (server), so
  the old video can no longer start the new one.
- The automatic frozen-picture reload is gone (false alarms on still
  scenes); Reload video stays.
- The "Next episode" pill shows only from a known ending.
- A web link's thumbnail is its cover, under the video's own name.
- While playing, the unread chip stays off the picture; chat pop-ups
  can be All / Only when paused / Off.
- Server fetch failures say when the server has no cookies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 04:31:14 +03:00
1 parent ab133dd82f
commit 27b4ec2c96
25 files changed
+274 -485

No files matched your search

-108
View File
@@ -1,108 +0,0 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/playback/frame_watchdog.dart';
final _t0 = DateTime.utc(2026, 1, 1);
/// Feeds one sample per second; [frames]/[pos] give the value at second i.
List<bool> _run(
FrameWatchdog w,
int seconds, {
required int? Function(int i) frames,
double Function(int i)? pos,
bool Function(int i)? eligible,
int start = 0,
}) {
return [
for (var i = start; i < start + seconds; i++)
w.feed(FrameSample(
at: _t0.add(Duration(seconds: i)),
position: pos?.call(i) ?? i.toDouble(),
frames: frames(i),
eligible: eligible?.call(i) ?? true,
)),
];
}
void main() {
test('hashes tell frames apart and ignore the edges', () {
final a = ByteData(100 * 60 * 4);
final b = ByteData(100 * 60 * 4);
expect(hashCenterPixels(a, 100, 60), hashCenterPixels(b, 100, 60));
b.setUint8((2 * 100 + 2) * 4, 255); // corner: overlay area
expect(hashCenterPixels(a, 100, 60), hashCenterPixels(b, 100, 60));
b.setUint8((30 * 100 + 50) * 4, 255); // centre: video
expect(hashCenterPixels(a, 100, 60), isNot(hashCenterPixels(b, 100, 60)));
final raw = Uint8List(1 << 20);
final h = hashSpread(raw);
raw[raw.length ~/ 2 - (raw.length ~/ 2) % (raw.length ~/ 4096)] = 1;
expect(hashSpread(raw), isNot(h));
});
test('frames advancing never fires', () {
final w = FrameWatchdog();
expect(_run(w, 30, frames: (i) => i * 30), everyElement(isFalse));
});
test('frames stuck while position advances fires once after ~3 s', () {
final w = FrameWatchdog();
final fired = _run(w, 20, frames: (i) => i < 5 ? i * 30 : 150);
expect(fired.where((f) => f).length, 1);
expect(fired.indexOf(true), 8, reason: 'stuck from t=5, 3 s later');
});
test('pause, buffering, missing data and seeks do not fire', () {
final w = FrameWatchdog();
// Paused / buffering: not eligible.
expect(_run(w, 10, frames: (_) => 100, eligible: (_) => false),
everyElement(isFalse));
// No video output (headless / audio-only).
expect(_run(w, 10, frames: (_) => null, start: 10), everyElement(isFalse));
// Position stuck too (network stall): not a frozen picture.
expect(_run(w, 10, frames: (_) => 100, pos: (_) => 50, start: 20),
everyElement(isFalse));
// Seeking every 2 s with frames stuck in between: jumps reset it.
expect(
_run(w, 12,
frames: (_) => 100,
pos: (i) => i.isEven ? i * 10.0 : i * 10.0 - 9,
start: 30),
everyElement(isFalse));
});
test('no reload loop: re-arms only after 10 s of healthy frames', () {
final w = FrameWatchdog();
expect(_run(w, 10, frames: (_) => 7).where((f) => f).length, 1);
w.reset(); // new player
// Brief healthy spell, then stuck again: must not fire.
expect(
_run(w, 15, frames: (i) => i < 13 ? i : 13, start: 10)
.where((f) => f)
.length,
0);
// Healthy for 10 s+, then stuck: fires again.
w.reset();
expect(
_run(w, 25, frames: (i) => i < 42 ? i : 42, start: 30)
.where((f) => f)
.length,
1);
});
test('still scene: dismiss re-arms after another full stall', () {
final w = FrameWatchdog();
final fired = <int>[];
for (var i = 0; i < 20; i++) {
if (w.feed(FrameSample(
at: _t0.add(Duration(seconds: i)),
position: i.toDouble(),
frames: 5,
eligible: true))) {
fired.add(i);
w.dismiss();
}
}
expect(fired, [3, 6, 9, 12, 15, 18]);
});
}
+3 -3
View File
@@ -301,9 +301,9 @@ void main() {
a.toggleReady();
await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again');
// Account holders see the "Next episode" prompt (this item is
// shorter than its 2 min lead); the guest cannot put it up.
await _poll(() => a.nextPromptVisible.peek(), 'next prompt');
// No known ending (no Outro segment, no ED chapter): no floating
// "Next episode" prompt mid-episode, only the card at the end.
expect(a.nextPromptVisible.peek(), isFalse);
expect(b.nextPromptVisible.peek(), isFalse);
// Alice and the guest pick English; it carries to the next episode.
+3 -3
View File
@@ -321,9 +321,9 @@ void main() {
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
});
test('the floating prompt starts two minutes before the end', () {
expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22));
expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero);
test('no floating prompt without a known ending', () {
expect(nextPromptStart(null, const Duration(minutes: 24)), isNull);
expect(nextPromptStart(null, const Duration(seconds: 90)), isNull);
});
test('one auto-advance swapper: the lowest id among members with their own account', () {
+7 -7
View File
@@ -154,14 +154,14 @@ void main() {
});
group('next episode prompt', () {
test('at the ED start when known, else the last two minutes', () {
test('at the ED start when known, else none (never a guessed time)', () {
expect(nextPromptStart(null, m(24), endingStart: m(20)), m(20));
expect(nextPromptStart(null, m(24)), m(22));
expect(nextPromptStart(null, m(24), endingStart: null), m(22));
// Nonsense ending starts fall back.
expect(nextPromptStart(null, m(24), endingStart: m(25)), m(22));
expect(nextPromptStart(null, m(24), endingStart: Duration.zero), m(22));
expect(nextPromptStart(null, s(90)), Duration.zero);
expect(nextPromptStart(null, m(24)), isNull);
expect(nextPromptStart(null, m(24), endingStart: null), isNull);
// Nonsense ending starts count as unknown.
expect(nextPromptStart(null, m(24), endingStart: m(25)), isNull);
expect(nextPromptStart(null, m(24), endingStart: Duration.zero), isNull);
expect(nextPromptStart(null, s(90)), isNull);
});
});
+5 -5
View File
@@ -374,19 +374,19 @@ void main() {
final app = shellToastFor(ToastItem(id: 3, text: 'Bob joined', at: at), who);
expect(app.who, isNull);
expect(app.text, 'Bob joined');
final froze = shellToastFor(ToastItem(id: 4, text: 'Video froze — reloaded', at: at), who);
expect(froze.who, isNull);
final notice = shellToastFor(ToastItem(id: 4, text: 'Shaders off', at: at), who);
expect(notice.who, isNull);
});
testWidgets('toasts slide in with an avatar and leave after their time', (tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts(life: Duration(seconds: 1)))));
coord.toasts.value = [ToastItem(id: 1, text: 'Video froze — reloaded', at: DateTime.now())];
coord.toasts.value = [ToastItem(id: 1, text: 'Shaders off', at: DateTime.now())];
await tester.pump();
await tester.pump();
expect(find.text('Video froze — reloaded'), findsOneWidget);
expect(find.text('Shaders off'), findsOneWidget);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Video froze — reloaded'), findsNothing);
expect(find.text('Shaders off'), findsNothing);
});
testWidgets('status avatar fills its box at any size', (tester) async {