Protocol 12: Up next (a shared queue) and where the video comes from
Up next: a server-side queue anyone edits by item id (add at end or next, move, remove, play now, skip); each item says who added it and how it plays. At the real end the first item swaps in after the end card's countdown, put up by whoever can (its adder for through-me links and files, someone with access for Jellyfin, else anyone); items that can't play are skipped with the reason. The next item's link is resolved ahead. Greyed "Continues with" episodes below. A chip with the controls opens it as a popover (leaf_widgets, local path dependency for now); "Add to queue" next to Put it up everywhere. Delivery: the server measures the source's rate, the media's bitrate and each member's read rate; the members card shows a source line (who passes it on, through what, keeping up or not, source left) and each member's route and buffer. A slow source is told once per media. A failed server fetch is announced to everyone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
95cd668cfb
commit
6b10cae4e6
50 files changed
+4239
-157
No files matched your search
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/session/delivery.dart';
|
||||
import 'package:watch_together/src/shell/delivery_line.dart';
|
||||
|
||||
MemberView _m(String id, String name, {double buffered = 30, bool host = false, int rate = 0}) =>
|
||||
MemberView(id: id, name: name, ready: true, inSet: true, buffered: buffered, stale: false, host: host, rate: rate);
|
||||
|
||||
StateBroadcast _state(Map<String, Object?>? media, List<MemberView> members, {String transport = 'playing'}) =>
|
||||
StateBroadcast(
|
||||
eventId: 1,
|
||||
transport: transport,
|
||||
position: 10,
|
||||
serverTime: DateTime.now().toUtc(),
|
||||
mediaId: 2,
|
||||
media: media,
|
||||
members: members,
|
||||
);
|
||||
|
||||
Map<String, Object?> _relayed(MediaRelay relay) =>
|
||||
{'name': 'x.mkv', 'size': 0, 'duration': 1000, 'relay': relay.toJson()};
|
||||
|
||||
void main() {
|
||||
final foxyz = _m('m1', 'Foxyz');
|
||||
final bob = _m('m2', 'Bob', rate: 3200000);
|
||||
final ann = _m('m3', 'Ann');
|
||||
|
||||
test('a member relay: who passes it on, how many read it, how fast', () {
|
||||
final r = _state(
|
||||
_relayed(const MediaRelay(source: 'm1', live: true, rate: 4100000, need: 2300000)), [foxyz, bob, ann]);
|
||||
final d = deliveryOf(r, selfId: 'm2')!;
|
||||
expect(d.kind, DeliveryKind.memberRelay);
|
||||
expect(d.state, SourceState.ok);
|
||||
expect(d.title, 'Foxyz passes it on through the server');
|
||||
expect(d.detail, '2 through the server · 4.1 MB/s, needs 2.3 MB/s');
|
||||
expect(d.headroom, closeTo(4.1 / 2.3, 0.01));
|
||||
expect(deliveryOf(r, selfId: 'm1')!.title, 'You pass it on through the server');
|
||||
final mb = memberDeliveryOf(bob, r, d);
|
||||
expect(mb.route, DeliveryRoute.server);
|
||||
expect(mb.detail, 'via server · 3.2 MB/s · 30 s buffered');
|
||||
expect(memberDeliveryOf(foxyz, r, d).route, DeliveryRoute.source);
|
||||
});
|
||||
|
||||
test('slow source: amber for everyone, the warning for the source only', () {
|
||||
final r = _state(
|
||||
_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000)), [foxyz, bob]);
|
||||
expect(deliveryOf(r, selfId: 'm2')!.state, SourceState.slow);
|
||||
expect(deliveryOf(r, selfId: 'm2')!.title, "Foxyz's upload is slower than the video");
|
||||
expect(slowSourceWarning(r, 'm1'), contains('Your upload (1.2 MB/s) is slower than this video needs (2.3 MB/s)'));
|
||||
expect(slowSourceWarning(r, 'm2'), isNull);
|
||||
// Bitrate unknown: rates without a comparison, no warning.
|
||||
final unknown = _state(_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000)), [foxyz, bob]);
|
||||
expect(deliveryOf(unknown, selfId: 'm2')!.state, SourceState.ok);
|
||||
expect(deliveryOf(unknown, selfId: 'm2')!.detail, '1 through the server · 1.2 MB/s');
|
||||
expect(slowSourceWarning(unknown, 'm1'), isNull);
|
||||
});
|
||||
|
||||
test('the source left: named from before, no rates', () {
|
||||
final names = MemberNames()..observe(_state(null, [foxyz, bob]));
|
||||
final r = _state(_relayed(const MediaRelay(source: 'm1', rate: 4100000, need: 2300000)), [bob, ann]);
|
||||
final d = deliveryOf(r, selfId: 'm2', names: names)!;
|
||||
expect(d.state, SourceState.left);
|
||||
expect(d.title, 'Foxyz left: playing what the server has');
|
||||
expect(d.detail, '2 through the server');
|
||||
});
|
||||
|
||||
test('channel down: waiting', () {
|
||||
final r = _state(_relayed(const MediaRelay(source: 'm1')), [foxyz, bob]);
|
||||
expect(deliveryOf(r, selfId: 'm2')!.title, 'Waiting for Foxyz to reconnect');
|
||||
});
|
||||
|
||||
test('links are direct, the host file is the host\'s, solo shows nothing', () {
|
||||
final link = _state({'name': 'v', 'url': 'https://example.com/v.mp4'}, [foxyz, bob]);
|
||||
final d = deliveryOf(link, selfId: 'm2')!;
|
||||
expect(d.title, 'Everyone plays the link directly');
|
||||
expect(d.detail, isEmpty);
|
||||
expect(memberDeliveryOf(bob, link, d).short, 'direct');
|
||||
final file = _state({'name': 'f.mkv'}, [_m('m1', 'Foxyz', host: true), bob]);
|
||||
expect(deliveryOf(file, selfId: 'm2')!.title, 'Foxyz hosts the file');
|
||||
expect(deliveryOf(_state({'name': 'f.mkv'}, [bob]), selfId: 'm2'), isNull);
|
||||
expect(deliveryOf(_state(null, [foxyz, bob]), selfId: 'm2'), isNull);
|
||||
});
|
||||
|
||||
test('Jellyfin: own accounts direct, the rest via server', () {
|
||||
const ref = {'serverId': 'srv', 'itemId': '0123456789abcdef0123456789abcdef', 'title': 'Pilot'};
|
||||
MemberView jf(String id, String name, bool own) =>
|
||||
MemberView(id: id, name: name, ready: true, inSet: true, buffered: 30, stale: false, jf: {'srv': own});
|
||||
final all = [jf('m1', 'Foxyz', true), jf('m2', 'Bob', true)];
|
||||
final media = {'name': 'Pilot', 'jellyfin': ref, 'relay': const MediaRelay(source: 'm1', live: true).toJson()};
|
||||
final d = deliveryOf(_state(media, all), selfId: 'm2')!;
|
||||
expect(d.title, 'Everyone streams it from Jellyfin', reason: 'the relay stands by');
|
||||
final mixed = _state(media, [...all, jf('m3', 'Guest', false)]);
|
||||
final m = deliveryOf(mixed, selfId: 'm2')!;
|
||||
expect(m.kind, DeliveryKind.memberRelay);
|
||||
expect(m.detail, '1 with their own account · 1 through the server');
|
||||
expect(memberDeliveryOf(jf('m2', 'Bob', true), mixed, m).short, 'own Jellyfin account');
|
||||
});
|
||||
|
||||
test('buffer health: enough, low, empty while playing', () {
|
||||
final r = _state({'name': 'v', 'url': 'https://e/v', 'duration': 1000}, [foxyz, bob]);
|
||||
final d = deliveryOf(r, selfId: 'm2')!;
|
||||
expect(memberDeliveryOf(_m('a', 'A', buffered: 30), r, d).health, BufferHealth.good);
|
||||
expect(memberDeliveryOf(_m('a', 'A', buffered: 2), r, d).health, BufferHealth.low);
|
||||
expect(memberDeliveryOf(_m('a', 'A', buffered: 0), r, d).health, BufferHealth.starving);
|
||||
final held = _state(r.media, [foxyz, bob], transport: 'held');
|
||||
expect(memberDeliveryOf(_m('a', 'A', buffered: 0), held, d).health, BufferHealth.low);
|
||||
});
|
||||
|
||||
test('rates read as MB/s or kB/s', () {
|
||||
expect(formatRate(4100000), '4.1 MB/s');
|
||||
expect(formatRate(12000000), '12 MB/s');
|
||||
expect(formatRate(850000), '850 kB/s');
|
||||
});
|
||||
|
||||
testWidgets('the source line and a member\'s status fit the members card', (tester) async {
|
||||
final r = _state(
|
||||
_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000)), [foxyz, bob, ann]);
|
||||
final d = deliveryOf(r, selfId: 'm2')!;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 340,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
SourceLine(delivery: d),
|
||||
DeliveryStatus(status: 'ready', delivery: memberDeliveryOf(bob, r, d)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text("Foxyz's upload is slower than the video"), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('headroom-bar')), findsOneWidget);
|
||||
expect(find.text('ready · via server · 3.2 MB/s'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('health-dot')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user