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:
1 parent
ab133dd82f
commit
27b4ec2c96
25 files changed
+274
-485
No files matched your search
@@ -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]);
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user