- Status: computed from connection, presence, hold, relay, player facts; no setters (health writes and HealthStatus gone); pill, strip and source line read it; transitions go to the Notifier. - Notifier: noticeFor(event, context) with self presence, alone, window activity, playing and prefs; toasts for joins/leaves/returns/swaps/ Up next adds (with picture)/problems/chat, the rest logged; while sitting out only chat, nudges, starting, Up next (+ joins if chosen); alone no social notices; replayed lines logged; owns its toast timer. - System notifications (flutter_local_notifications: D-Bus on Linux, WinRT on Windows) when the window isn't focused; a click focuses it. - Toasts sit above the control bar and move with it. - Versions: each member's in the members card and avatar tooltip, the server's in the card header; the strip no longer shows it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
7.8 KiB
Dart
159 lines
7.8 KiB
Dart
// Who fetches the current media and how it spreads (the members card's
|
|
// source line, part of the one status), and each member's route and
|
|
// buffer.
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:sync_relay_core/core.dart';
|
|
import 'package:starsync/src/session/delivery.dart';
|
|
import 'package:starsync/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, 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("the source's connection dropped: what the server has, for how long, no rates", () {
|
|
final since = DateTime.utc(2026, 1, 1, 12);
|
|
final gone = MemberView(id: 'm1', name: 'Foxyz', ready: true, inSet: false, buffered: 30, presence: Presence.gone);
|
|
final r = _state(
|
|
_relayed(MediaRelay(source: 'm1', live: true, rate: 4100000, need: 2300000, sourceGone: true, sourceGoneSince: since)),
|
|
[gone, bob, ann]);
|
|
final d = deliveryOf(r, selfId: 'm2', now: since.add(const Duration(seconds: 30)))!;
|
|
expect(d.state, SourceState.gone);
|
|
expect(d.title, "Foxyz's connection dropped — playing what the server has");
|
|
expect(d.detail, '2 through the server');
|
|
expect(deliveryOf(r, selfId: 'm2', now: since.add(const Duration(minutes: 3)))!.title,
|
|
"Foxyz's connection dropped — playing what the server has (3 min)");
|
|
});
|
|
|
|
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, 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);
|
|
});
|
|
}
|