Relay: a member's media through the server's buffer (proto 8)
Viewers who cannot reach the media get it through the server: a file on
any member's computer, a link passed on by a member ("Through me") or
fetched by the server ("Server fetches it", password-protected servers
only), and Jellyfin items for guests without an account, sourced by
whoever put them up with their own sign-in (or a pasted link's key),
which never leaves their machine. Fixes guests on a dedicated server
without a Jellyfin login being blocked.
The source dials /r/.../source (works behind NAT) and answers pulls with
an adaptive in-flight window (2 x rate x min RTT, 256 KiB..8 MiB). The
server keeps a memory-only buffer of 1 MiB slices sized like a viewer's
mpv buffer (pinned head/tail, 64 MiB back, 256 MiB ahead), fills it with
one sequential pull at a time (blocked viewers first, newest request
only), fetches nothing while nobody watches through it, and drops it on
the next swap or stop. Measured with mpv through shaped links
(tool/relay_e2e.dart): 400 Mbit/s seeks ~0.03 s; 40 Mbit/s no stalls,
3 viewers cost the source ~1x the bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c5733719b
commit
e7f33b5dc3
27 files changed
+2438
-116
No files matched your search
@@ -201,7 +201,8 @@ final class OwnPlay extends JellyfinPlan {
|
||||
bool get jfAccess => true;
|
||||
}
|
||||
|
||||
/// Through the sync server's relay (`/j/...`).
|
||||
/// Through the server: its own Jellyfin login (`/j/...`) or the member
|
||||
/// who put it up (`/r/...`).
|
||||
final class RelayPlay extends JellyfinPlan {
|
||||
final String url;
|
||||
const RelayPlay(this.url);
|
||||
@@ -210,7 +211,7 @@ final class RelayPlay extends JellyfinPlan {
|
||||
bool get jfAccess => false;
|
||||
}
|
||||
|
||||
/// Neither: no (usable) account here and the server cannot relay.
|
||||
/// Neither: no (usable) account here and nothing relays it.
|
||||
final class BlockedPlay extends JellyfinPlan {
|
||||
final String serverId;
|
||||
/// Known only when this install has (had) an account there.
|
||||
@@ -224,20 +225,22 @@ final class BlockedPlay extends JellyfinPlan {
|
||||
|
||||
String get message {
|
||||
final where = serverName == null || serverName!.isEmpty ? 'that Jellyfin server' : serverName!;
|
||||
final base = 'This needs a Jellyfin account on $where (or the host can relay)';
|
||||
return problem == null ? base : '$base — $problem';
|
||||
final base = 'No Jellyfin account here for $where, and nobody is passing it on';
|
||||
return problem == null ? base : '$base ($problem)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks how to play [ref]: this install's account first (PlaybackInfo
|
||||
/// with its own user; a 401 marks the account expired), else the relay
|
||||
/// when the server offers it ([relay] = `state.relay`, [relayUrl] this
|
||||
/// member's `/j/...` URL), else [BlockedPlay].
|
||||
/// with its own user; a 401 marks the account expired), else the server's
|
||||
/// own Jellyfin login ([relay] = `state.relay`, [relayUrl] this member's
|
||||
/// `/j/...` URL), else the member who put it up ([memberRelayUrl], the
|
||||
/// `/r/...` URL when `media.relay` is set), else [BlockedPlay].
|
||||
Future<JellyfinPlan> resolveJellyfinRef(
|
||||
JellyfinAccounts accounts,
|
||||
JellyfinRef ref, {
|
||||
required bool relay,
|
||||
required String? relayUrl,
|
||||
String? memberRelayUrl,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
await accounts.ensureLoaded();
|
||||
@@ -282,6 +285,7 @@ Future<JellyfinPlan> resolveJellyfinRef(
|
||||
}
|
||||
}
|
||||
if (relay && relayUrl != null && relayUrl.isNotEmpty) return RelayPlay(relayUrl);
|
||||
if (memberRelayUrl != null && memberRelayUrl.isNotEmpty) return RelayPlay(memberRelayUrl);
|
||||
return BlockedPlay(serverId: ref.serverId, serverName: account?.displayName, problem: problem);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,14 @@ class MediaPickerController extends ChangeNotifier {
|
||||
final TextEditingController file;
|
||||
MediaKind _source;
|
||||
String? _notice;
|
||||
LinkRelay _linkRelay = LinkRelay.direct;
|
||||
|
||||
LinkRelay get linkRelay => _linkRelay;
|
||||
set linkRelay(LinkRelay v) {
|
||||
if (v == _linkRelay) return;
|
||||
_linkRelay = v;
|
||||
notifyListeners();
|
||||
}
|
||||
JellyfinMedia? _jellyfin;
|
||||
|
||||
/// The Jellyfin item picked in the browser (kept while another source
|
||||
@@ -71,7 +79,7 @@ class MediaPickerController extends ChangeNotifier {
|
||||
switch (_source) {
|
||||
case MediaKind.link:
|
||||
final url = link.text.trim();
|
||||
return url.isEmpty ? null : LinkMedia(url);
|
||||
return url.isEmpty ? null : LinkMedia(url, relay: _linkRelay);
|
||||
case MediaKind.file:
|
||||
final path = file.text.trim();
|
||||
return path.isEmpty ? null : FileMedia(path);
|
||||
@@ -117,15 +125,21 @@ class MediaPicker extends StatefulWidget {
|
||||
final bool expanded;
|
||||
/// Where the Jellyfin browser opens instead of its remembered location.
|
||||
final BrowseStart? jellyfinStart;
|
||||
/// False hides the File source (only the host can serve a file).
|
||||
/// False hides the File source.
|
||||
final bool allowFile;
|
||||
/// Shown under the Link field instead of the default help.
|
||||
final String? linkHelp;
|
||||
/// Shown under the File field instead of the default help.
|
||||
final String? fileHelp;
|
||||
/// Offer the [LinkRelay] choice under the Link field (in a session).
|
||||
final bool linkRelayChoice;
|
||||
const MediaPicker({
|
||||
super.key,
|
||||
this.jellyfinStart,
|
||||
this.allowFile = true,
|
||||
this.linkHelp,
|
||||
this.fileHelp,
|
||||
this.linkRelayChoice = false,
|
||||
required this.controller,
|
||||
this.onChanged,
|
||||
this.onSubmit,
|
||||
@@ -247,10 +261,25 @@ class _MediaPickerState extends State<MediaPicker> {
|
||||
icon: const Icon(Icons.content_paste, size: 18),
|
||||
label: const Text('Paste'),
|
||||
),
|
||||
help: widget.linkHelp ??
|
||||
'A direct http(s) link to a video file; everyone streams it from there. '
|
||||
'Jellyfin links are shared as the item, without your sign-in.',
|
||||
help: widget.linkRelayChoice
|
||||
? _linkRelayHelp(_c.linkRelay)
|
||||
: widget.linkHelp ??
|
||||
'A direct http(s) link to a video file; everyone streams it from there. '
|
||||
'Jellyfin links are shared as the item, without your sign-in.',
|
||||
notice: _c.notice,
|
||||
extra: widget.linkRelayChoice
|
||||
? SegmentedButton<LinkRelay>(
|
||||
key: const ValueKey('link-relay'),
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(value: LinkRelay.direct, label: Text('Everyone fetches it')),
|
||||
ButtonSegment(value: LinkRelay.server, label: Text('Server fetches it')),
|
||||
ButtonSegment(value: LinkRelay.me, label: Text('Through me')),
|
||||
],
|
||||
selected: {_c.linkRelay},
|
||||
onSelectionChanged: (s) => _c.linkRelay = s.first,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
MediaKind.file => _FieldRow(
|
||||
key: const ValueKey('media-file'),
|
||||
@@ -264,7 +293,8 @@ class _MediaPickerState extends State<MediaPicker> {
|
||||
icon: const Icon(Icons.folder_open, size: 18),
|
||||
label: const Text('Browse'),
|
||||
),
|
||||
help: 'Friends stream it from you while you watch. Optional — you can host without media.',
|
||||
help: widget.fileHelp ??
|
||||
'Friends stream it from you while you watch. Optional — you can host without media.',
|
||||
notice: _c.notice,
|
||||
),
|
||||
};
|
||||
@@ -325,8 +355,10 @@ class _FieldRow extends StatelessWidget {
|
||||
final Widget button;
|
||||
final String help;
|
||||
final String? notice;
|
||||
/// Under the field, above the help (e.g. the link relay choice).
|
||||
final Widget? extra;
|
||||
const _FieldRow(
|
||||
{super.key, required this.field, required this.button, required this.help, this.notice});
|
||||
{super.key, required this.field, required this.button, required this.help, this.notice, this.extra});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -346,6 +378,7 @@ class _FieldRow extends StatelessWidget {
|
||||
button,
|
||||
],
|
||||
),
|
||||
if (extra != null) ...[const SizedBox(height: 10), extra!],
|
||||
const SizedBox(height: 8),
|
||||
Text(notice ?? help, style: small),
|
||||
],
|
||||
@@ -354,3 +387,12 @@ class _FieldRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _linkRelayHelp(LinkRelay r) => switch (r) {
|
||||
LinkRelay.direct => 'Everyone streams the link themselves. Jellyfin links are shared as the item, '
|
||||
'without your sign-in.',
|
||||
LinkRelay.server => 'The server downloads it once and streams it to everyone: for links that are '
|
||||
'slow or blocked for some. The server must be able to reach the link; it is not shown to anyone.',
|
||||
LinkRelay.me => 'This computer downloads it and passes it on through the server: for links only you '
|
||||
'can open. Keep the app open while it plays; the link is not shown to anyone.',
|
||||
};
|
||||
@@ -7,10 +7,16 @@ sealed class PickedMedia {
|
||||
String get label;
|
||||
}
|
||||
|
||||
/// A direct http(s) link everyone streams from.
|
||||
/// How the others get a link: each fetches it ([direct]), the server
|
||||
/// fetches it once for everyone ([server]), or this computer does and
|
||||
/// passes it on through the server ([me]; for links only you can reach).
|
||||
enum LinkRelay { direct, server, me }
|
||||
|
||||
/// An http(s) link.
|
||||
final class LinkMedia extends PickedMedia {
|
||||
final String url;
|
||||
const LinkMedia(this.url);
|
||||
final LinkRelay relay;
|
||||
const LinkMedia(this.url, {this.relay = LinkRelay.direct});
|
||||
|
||||
@override
|
||||
String get label {
|
||||
@@ -25,14 +31,15 @@ final class LinkMedia extends PickedMedia {
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is LinkMedia && other.url == url;
|
||||
bool operator ==(Object other) => other is LinkMedia && other.url == url && other.relay == relay;
|
||||
@override
|
||||
int get hashCode => url.hashCode;
|
||||
int get hashCode => Object.hash(url, relay);
|
||||
@override
|
||||
String toString() => 'LinkMedia($url)';
|
||||
}
|
||||
|
||||
/// A file on this computer; friends stream it from the host.
|
||||
/// A file on this computer; the others stream it from here (the host's
|
||||
/// app serves it itself, anyone else's goes through the server's relay).
|
||||
final class FileMedia extends PickedMedia {
|
||||
final String path;
|
||||
const FileMedia(this.path);
|
||||
|
||||
@@ -144,7 +144,9 @@ class JellyfinBlockedCard extends StatelessWidget {
|
||||
const SizedBox(height: 10),
|
||||
Text(blocked.message, textAlign: TextAlign.center, style: t.titleSmall),
|
||||
const SizedBox(height: 6),
|
||||
Text('Link your own account in Integrations, or ask the host to relay it.',
|
||||
Text(
|
||||
'Whoever put it up passes it on automatically while their app is open; '
|
||||
'if they left, ask them to put it up again. Or link your own account in Integrations.',
|
||||
textAlign: TextAlign.center, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
|
||||
@@ -6,8 +6,9 @@ import '../home/media_picker.dart';
|
||||
|
||||
/// "Change video" in a session: the same [MediaPicker] as Home's Host tab
|
||||
/// (Jellyfin browser, Link, File) and a button that puts the pick up for
|
||||
/// everyone. File is offered to the host only (the file streams from the
|
||||
/// host's disk). [onSwap] returns an error text, or null when sent.
|
||||
/// everyone. A file streams from this computer: the host's app serves it,
|
||||
/// anyone else's goes through the server's relay. [onSwap] returns an
|
||||
/// error text, or null when sent.
|
||||
class SwapPanel extends StatefulWidget {
|
||||
final MediaPickerController controller;
|
||||
final JellyfinAccounts accounts;
|
||||
@@ -35,13 +36,6 @@ class _SwapPanelState extends State<SwapPanel> {
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final c = widget.controller;
|
||||
if (!widget.isHost && c.source == MediaKind.file) c.source = MediaKind.link;
|
||||
}
|
||||
|
||||
Future<void> _swap() async {
|
||||
final media = widget.controller.selection;
|
||||
if (media == null || _busy) return;
|
||||
@@ -91,7 +85,10 @@ class _SwapPanelState extends State<SwapPanel> {
|
||||
controller: widget.controller,
|
||||
jellyfin: widget.accounts,
|
||||
enabled: !_busy,
|
||||
allowFile: widget.isHost,
|
||||
linkRelayChoice: true,
|
||||
fileHelp: widget.isHost
|
||||
? 'Friends stream it from your computer.'
|
||||
: 'Streams from your computer through the server. Keep the app open while it plays.',
|
||||
jellyfinStart: widget.jellyfinStart,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
onSubmit: _swap,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
@@ -92,6 +93,35 @@ class ToastItem {
|
||||
|
||||
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
|
||||
|
||||
/// `media.relay` of a state, or null when the media is not relayed.
|
||||
MediaRelay? stateMediaRelay(StateBroadcast state) {
|
||||
final r = state.media?['relay'];
|
||||
return r is Map ? MediaRelay.fromJson(Map<String, Object?>.from(r)) : null;
|
||||
}
|
||||
|
||||
/// What this member serves when its own swap is relayed: set at swap time,
|
||||
/// claimed by the state that names this member as the source.
|
||||
sealed class _RelayOrigin {
|
||||
const _RelayOrigin();
|
||||
}
|
||||
|
||||
final class _FileOrigin extends _RelayOrigin {
|
||||
final String path;
|
||||
const _FileOrigin(this.path);
|
||||
}
|
||||
|
||||
final class _LinkOrigin extends _RelayOrigin {
|
||||
final String url;
|
||||
const _LinkOrigin(this.url);
|
||||
}
|
||||
|
||||
/// A Jellyfin item: served with this install's account, else with the
|
||||
/// pasted [link] (its api_key stays on this machine).
|
||||
final class _JellyfinOrigin extends _RelayOrigin {
|
||||
final String? link;
|
||||
const _JellyfinOrigin(this.link);
|
||||
}
|
||||
|
||||
String? stateMediaUrl(StateBroadcast state) {
|
||||
final media = state.media;
|
||||
if (media == null) return null;
|
||||
@@ -184,6 +214,14 @@ class SessionCoordinator {
|
||||
String? _openUrl;
|
||||
String _wsHost = '';
|
||||
int _wsPort = 0;
|
||||
_RelayOrigin? _pendingOrigin;
|
||||
// What this member serves for media [$1]; survives a reconnect (the
|
||||
// state then looks new again).
|
||||
(int, _RelayOrigin)? _activeOrigin;
|
||||
RelaySource? _relaySource;
|
||||
int _relaySourceMediaId = -1;
|
||||
/// This member serves the current media's bytes through the server.
|
||||
final relaying = signal<bool>(false);
|
||||
// Server seek applied to the player (event id and when), checked once it
|
||||
// should have landed.
|
||||
int? _pendingSeekEvent;
|
||||
@@ -392,6 +430,78 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
bool _relayWaitShown = false;
|
||||
|
||||
/// Relayed through a member whose channel is down: say who everyone is
|
||||
/// waiting for (the buffer still plays what it has).
|
||||
void _relayStatus(StateBroadcast r) {
|
||||
final relay = stateMediaRelay(r);
|
||||
final waiting = relay != null &&
|
||||
relay.source.isNotEmpty &&
|
||||
relay.source != sync.memberId &&
|
||||
!relay.live &&
|
||||
_openUrl != null &&
|
||||
jellyfinPlan.peek() is! OwnPlay;
|
||||
if (waiting) {
|
||||
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name;
|
||||
health.value = HealthStatus(HealthSeverity.warn,
|
||||
who == null ? 'the member who put this up left' : 'waiting for $who to pass the video on');
|
||||
_relayWaitShown = true;
|
||||
} else if (_relayWaitShown) {
|
||||
_relayWaitShown = false;
|
||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
}
|
||||
}
|
||||
|
||||
String _relayMediaUrlFor(String name, int mediaId) =>
|
||||
'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${Uri.encodeComponent(name)}';
|
||||
|
||||
/// Serves [bytes] for media [mediaId] on this member's relay channel.
|
||||
/// The server only pulls while someone watches through it.
|
||||
void _startRelaySource(int mediaId, RelayBytes bytes, {bool announce = false}) {
|
||||
// Back after a reconnect on the same media: it is still serving (and
|
||||
// redials by itself).
|
||||
if (_relaySource != null && _relaySourceMediaId == mediaId) {
|
||||
unawaited(bytes.close());
|
||||
return;
|
||||
}
|
||||
_stopRelaySource();
|
||||
final src = RelaySource(
|
||||
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source'), bytes);
|
||||
_relaySource = src;
|
||||
_relaySourceMediaId = mediaId;
|
||||
relaying.value = true;
|
||||
if (announce) {
|
||||
_pushToast('Passing the video on through the server. Keep the app open while it plays.');
|
||||
}
|
||||
unawaited(() async {
|
||||
try {
|
||||
await src.start();
|
||||
} catch (e) {
|
||||
debugPrint('relay source failed: $e');
|
||||
if (identical(_relaySource, src)) {
|
||||
_relaySource = null;
|
||||
relaying.value = false;
|
||||
_pushToast('Cannot pass the video on: $e');
|
||||
}
|
||||
return;
|
||||
}
|
||||
await src.done;
|
||||
if (identical(_relaySource, src)) {
|
||||
_relaySource = null;
|
||||
relaying.value = false;
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
void _stopRelaySource() {
|
||||
final src = _relaySource;
|
||||
_relaySource = null;
|
||||
_relaySourceMediaId = -1;
|
||||
relaying.value = false;
|
||||
if (src != null) unawaited(src.close());
|
||||
}
|
||||
|
||||
String _fileUrlFor(String name) {
|
||||
return 'http://$_wsHost:$_wsPort/m/default/${sync.token}/${Uri.encodeComponent(name)}';
|
||||
}
|
||||
@@ -440,12 +550,35 @@ class SessionCoordinator {
|
||||
r.mediaId == _jfMediaId &&
|
||||
sameJellyfinItem(ref, _jfRef) &&
|
||||
jellyfinPlan.peek() is OwnPlay;
|
||||
final relay = stateMediaRelay(r);
|
||||
final mine = relay != null && relay.source.isNotEmpty && relay.source == sync.memberId;
|
||||
_RelayOrigin? origin;
|
||||
if (mine) {
|
||||
final pending = _pendingOrigin;
|
||||
if (pending != null) {
|
||||
_pendingOrigin = null;
|
||||
_activeOrigin = (r.mediaId, pending);
|
||||
}
|
||||
origin = _activeOrigin?.$1 == r.mediaId ? _activeOrigin!.$2 : null;
|
||||
}
|
||||
if (_relaySourceMediaId != r.mediaId) _stopRelaySource();
|
||||
if (!same) {
|
||||
_seekGate.reset();
|
||||
_clearPendingSeek();
|
||||
_leaveJellyfinItem();
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
if (ref != null) {
|
||||
await _playJellyfin(ref, r);
|
||||
await _playJellyfin(ref, r,
|
||||
origin: origin is _JellyfinOrigin ? origin : null,
|
||||
memberRelayUrl: relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId) : null);
|
||||
} else if (origin is _FileOrigin) {
|
||||
await _openMedia(origin.path);
|
||||
_startRelaySource(r.mediaId, FileRelayBytes(File(origin.path)), announce: true);
|
||||
} else if (origin is _LinkOrigin) {
|
||||
await _openMedia(origin.url);
|
||||
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(origin.url)), announce: true);
|
||||
} else if (relay != null && !mine) {
|
||||
await _openMedia(_relayMediaUrlFor(name, r.mediaId));
|
||||
} else {
|
||||
var url = stateMediaUrl(r);
|
||||
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
|
||||
@@ -463,6 +596,7 @@ class SessionCoordinator {
|
||||
_clearPendingSeek();
|
||||
}
|
||||
_jellyfinTransport(r);
|
||||
_relayStatus(r);
|
||||
if (!_playerReady || _openUrl == null) {
|
||||
_wasPlayAllowed = false;
|
||||
_updateStarving(r, 0);
|
||||
@@ -825,35 +959,77 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
/// A pasted link: a recognised Jellyfin item link goes up as a ref
|
||||
/// (see [mediaForLink]), anything else as the link.
|
||||
Future<void> swapLink(String url) async {
|
||||
/// (see [mediaForLink]) that this member relays for viewers without an
|
||||
/// account (with the link's key, which stays here); anything else as the
|
||||
/// link, or relayed per [relay]. Returns an error text, or null when sent.
|
||||
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async {
|
||||
markInput();
|
||||
sync.sendSwap(await mediaForLink(jellyfin, url));
|
||||
final media = await mediaForLink(jellyfin, url);
|
||||
if (media.jellyfin != null) {
|
||||
_pendingOrigin = _JellyfinOrigin(url);
|
||||
sync.sendSwap(media.copyWith(relay: const MediaRelay()));
|
||||
return null;
|
||||
}
|
||||
if (relay == LinkRelay.direct) {
|
||||
sync.sendSwap(media);
|
||||
return null;
|
||||
}
|
||||
// Relayed: it must be a single file the relay can range-read (not a
|
||||
// page like YouTube, which only the player can resolve).
|
||||
final probe = HttpRelayBytes(Uri.parse(url));
|
||||
final int size;
|
||||
try {
|
||||
size = await probe.size().timeout(const Duration(seconds: 15));
|
||||
} catch (e) {
|
||||
return relay == LinkRelay.me
|
||||
? 'cannot pass this link on: $e'
|
||||
: 'cannot check this link: $e';
|
||||
} finally {
|
||||
await probe.close();
|
||||
}
|
||||
if (relay == LinkRelay.me) {
|
||||
_pendingOrigin = _LinkOrigin(url);
|
||||
sync.sendSwap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
|
||||
} else {
|
||||
sync.sendSwap(HelloMedia(
|
||||
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true)));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref.
|
||||
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
|
||||
/// relayed by this member for viewers without an account.
|
||||
void swapJellyfin(JellyfinMedia media) {
|
||||
markInput();
|
||||
sync.sendSwap(mediaForPick(media));
|
||||
_pendingOrigin = const _JellyfinOrigin(null);
|
||||
sync.sendSwap(mediaForPick(media).copyWith(relay: const MediaRelay()));
|
||||
}
|
||||
|
||||
/// The host's File swap: the server takes the file, then the swap puts
|
||||
/// it up. Returns an error text, or null when sent.
|
||||
/// A file on this computer. The host's app serves it itself; anyone
|
||||
/// else's goes through the server's relay. Returns an error text, or null
|
||||
/// when sent.
|
||||
Future<String?> swapFile(String path) async {
|
||||
markInput();
|
||||
final host = hostFile;
|
||||
if (host == null) return 'only the host can share a file';
|
||||
final file = await host(path);
|
||||
if (file == null) return 'file not found — check the path';
|
||||
sync.sendSwap(HelloMedia(name: file.name, size: file.size, duration: 0));
|
||||
if (host != null) {
|
||||
final file = await host(path);
|
||||
if (file == null) return 'file not found — check the path';
|
||||
sync.sendSwap(HelloMedia(name: file.name, size: file.size, duration: 0));
|
||||
return null;
|
||||
}
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return 'file not found — check the path';
|
||||
_pendingOrigin = _FileOrigin(path);
|
||||
sync.sendSwap(HelloMedia(
|
||||
name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()));
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Any picker selection.
|
||||
Future<String?> swapPicked(PickedMedia media) async {
|
||||
switch (media) {
|
||||
case LinkMedia(:final url):
|
||||
await swapLink(url);
|
||||
case LinkMedia(:final url, :final relay):
|
||||
return swapLink(url, relay: relay);
|
||||
case JellyfinMedia():
|
||||
swapJellyfin(media);
|
||||
case FileMedia(:final path):
|
||||
@@ -978,7 +1154,8 @@ class SessionCoordinator {
|
||||
/// Resolves [ref] for this viewer and opens it: own account (direct
|
||||
/// play, progress reports, neighbouring episodes), the relay, or the
|
||||
/// blocked state. Then carries the audio/subtitle choice over.
|
||||
Future<void> _playJellyfin(JellyfinRef ref, StateBroadcast r) async {
|
||||
Future<void> _playJellyfin(JellyfinRef ref, StateBroadcast r,
|
||||
{_JellyfinOrigin? origin, String? memberRelayUrl}) async {
|
||||
final gen = ++_jfGen;
|
||||
_jfRef = ref;
|
||||
_jfMediaId = r.mediaId;
|
||||
@@ -992,13 +1169,24 @@ class SessionCoordinator {
|
||||
await playback.player.pause();
|
||||
} catch (_) {}
|
||||
}
|
||||
final JellyfinPlan plan;
|
||||
JellyfinPlan plan;
|
||||
try {
|
||||
plan = await resolveJellyfinRef(jellyfin, ref, relay: r.relay, relayUrl: _relayUrlFor(ref));
|
||||
plan = await resolveJellyfinRef(jellyfin, ref,
|
||||
relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl);
|
||||
} finally {
|
||||
if (gen == _jfGen) _opening = false;
|
||||
}
|
||||
if (gen != _jfGen) return;
|
||||
// This member put it up: serve it to viewers without an account, with
|
||||
// its own sign-in, else the pasted link (played directly here too).
|
||||
if (origin != null) {
|
||||
if (plan is OwnPlay) {
|
||||
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers));
|
||||
} else if (origin.link != null) {
|
||||
plan = RelayPlay(origin.link!);
|
||||
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(origin.link!)));
|
||||
}
|
||||
}
|
||||
jellyfinPlan.value = plan;
|
||||
switch (plan) {
|
||||
case BlockedPlay():
|
||||
@@ -1321,6 +1509,9 @@ class SessionCoordinator {
|
||||
_seekGate.reset();
|
||||
reconnecting.value = false;
|
||||
await _stopJellyfin();
|
||||
_stopRelaySource();
|
||||
_pendingOrigin = null;
|
||||
_activeOrigin = null;
|
||||
_playerReady = false;
|
||||
try {
|
||||
await playback.dispose();
|
||||
|
||||
@@ -723,14 +723,14 @@ packages:
|
||||
path: "../../packages/core"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.2.1"
|
||||
version: "0.2.2"
|
||||
sync_relay_server:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/server"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.2.1"
|
||||
version: "0.2.2"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/jellyfin/session_media.dart';
|
||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||
import 'package:watch_together/src/playback/media_marks.dart';
|
||||
import 'package:watch_together/src/screens/home/picked_media.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
|
||||
// Two account holders and a guest watch Jellyfin episodes together over a
|
||||
@@ -444,7 +445,7 @@ void main() {
|
||||
await b.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: '', name: 'bob'));
|
||||
await _poll(() => b.jellyfinBlocked.peek() != null, 'blocked');
|
||||
expect(b.serverState.peek()!.relay, isFalse);
|
||||
expect(b.jellyfinBlocked.peek()!.message, contains('needs a Jellyfin account'));
|
||||
expect(b.jellyfinBlocked.peek()!.message, contains('nobody is passing it on'));
|
||||
// Linking an account (Integrations) retries by itself.
|
||||
await accB.link(
|
||||
baseUrl: jelly.base,
|
||||
@@ -462,4 +463,74 @@ void main() {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The VPS case: a dedicated server with no Jellyfin login. Whoever puts
|
||||
// the item up passes it on to guests without an account.
|
||||
test(
|
||||
'dedicated server without a login: the guest plays through the member who put it up',
|
||||
timeout: const Timeout(Duration(minutes: 3)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final jelly = _FakeJellyfin(await _media());
|
||||
await jelly.start();
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'vps'));
|
||||
await server.start();
|
||||
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
|
||||
Future<JellyfinAccounts> none() async {
|
||||
final x = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await x.ensureLoaded(validate: false);
|
||||
return x;
|
||||
}
|
||||
|
||||
final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
final b = SessionCoordinator(jellyfin: await none(), trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
final d = SessionCoordinator(jellyfin: await none(), trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
ConnectParams params(String name) =>
|
||||
ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: '', name: name);
|
||||
try {
|
||||
await a.connect(params('alice'));
|
||||
await b.connect(params('guest'));
|
||||
await d.connect(params('dave'));
|
||||
for (final x in [a, b, d]) {
|
||||
await x.playback.player.setVolume(0);
|
||||
}
|
||||
// Alice picks from her browser: a ref plus a relay she sources.
|
||||
await a.swapPicked(JellyfinMedia(serverId: _sid, baseUrl: jelly.base, itemId: _ep1, title: 'Pilot'));
|
||||
await _poll(() => b.jellyfinPlan.peek() is RelayPlay && b.playback.durationSecs > 19, 'guest plays');
|
||||
expect((b.jellyfinPlan.peek() as RelayPlay).url, contains('/r/default/'));
|
||||
expect(a.jellyfinPlan.peek(), isA<OwnPlay>());
|
||||
expect(a.relaying.peek(), isTrue);
|
||||
expect(b.jellyfinBlocked.peek(), isNull);
|
||||
for (final x in [a, b]) {
|
||||
x.toggleReady();
|
||||
}
|
||||
await _poll(() => a.playback.player.state.playing && b.playback.player.state.playing, 'playing');
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
expect((a.playback.positionSecs - b.playback.positionSecs).abs(), lessThan(1.0));
|
||||
// Every stream request carried alice's token, from her machine.
|
||||
expect(jelly.streams('ATOKEN'), isNotEmpty);
|
||||
expect(jelly.calls.where((c) => c.path.startsWith('/Videos/')).every((c) =>
|
||||
_FakeJellyfin.authFields(c.auth)['Token'] == 'ATOKEN'), isTrue);
|
||||
for (final x in [a, b]) {
|
||||
x.toggleReady();
|
||||
}
|
||||
|
||||
// Dave has no account but a link with a key: he passes it on, the
|
||||
// key never leaves his machine.
|
||||
final link = '${jelly.base}/Videos/$_ep2/stream?static=true&api_key=ATOKEN';
|
||||
expect(await d.swapPicked(LinkMedia(link)), isNull);
|
||||
await _poll(() => (b.serverState.peek()?.media?['jellyfin'] as Map?)?['itemId'] == _ep2, 'ep2 up');
|
||||
expect(b.serverState.peek()!.toJson().toString(), isNot(contains('api_key')));
|
||||
await _poll(() => d.relaying.peek() && d.playback.durationSecs > 19, 'dave plays his link');
|
||||
await _poll(() => b.jellyfinPlan.peek() is RelayPlay && b.playback.durationSecs > 19, 'guest plays ep2');
|
||||
expect((b.jellyfinPlan.peek() as RelayPlay).url, contains('/r/default/'));
|
||||
} finally {
|
||||
for (final x in [a, b, d]) {
|
||||
await x.dispose();
|
||||
}
|
||||
await server.stop();
|
||||
await jelly.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -93,7 +93,7 @@ void main() {
|
||||
final plan = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null);
|
||||
expect(plan, isA<BlockedPlay>());
|
||||
expect((plan as BlockedPlay).message,
|
||||
'This needs a Jellyfin account on that Jellyfin server (or the host can relay)');
|
||||
'No Jellyfin account here for that Jellyfin server, and nobody is passing it on');
|
||||
expect(plan.jfAccess, isNull);
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ void main() {
|
||||
expect(plan, isA<RelayPlay>());
|
||||
expect(accounts.statusOf(fixtureServerId), JellyfinAccountStatus.expired);
|
||||
final blocked = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null);
|
||||
expect((blocked as BlockedPlay).message, contains('on Example Server'));
|
||||
expect((blocked as BlockedPlay).message, contains('for Example Server'));
|
||||
expect(blocked.message, contains('sign-in expired'));
|
||||
});
|
||||
|
||||
@@ -511,13 +511,13 @@ void main() {
|
||||
blocked: const BlockedPlay(serverId: fixtureServerId, serverName: 'Example Server'),
|
||||
onLinkAccount: () => link++,
|
||||
onRetry: () {})));
|
||||
expect(find.text('This needs a Jellyfin account on Example Server (or the host can relay)'),
|
||||
expect(find.text('No Jellyfin account here for Example Server, and nobody is passing it on'),
|
||||
findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('link-jellyfin-account')));
|
||||
expect(link, 1);
|
||||
});
|
||||
|
||||
testWidgets('swap panel: the shared picker, File only for the host', (tester) async {
|
||||
testWidgets('swap panel: the shared picker, File for everyone, link relay choice', (tester) async {
|
||||
await setSize(tester, const Size(1400, 900));
|
||||
final (accounts, _) = await linkedAccounts();
|
||||
final c = MediaPickerController(source: MediaKind.link);
|
||||
@@ -535,14 +535,16 @@ void main() {
|
||||
onClose: () => closed++,
|
||||
)));
|
||||
await settle(tester);
|
||||
expect(find.widgetWithText(ButtonSegment<MediaKind>, 'File'), findsNothing);
|
||||
expect(find.text('File'), findsNothing);
|
||||
// A guest's file goes through the server's relay.
|
||||
expect(find.text('File'), findsOneWidget);
|
||||
expect(find.text('Jellyfin'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), 'https://cdn.example/movie.mkv');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Through me'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('swap-go')));
|
||||
await settle(tester);
|
||||
expect(swapped, [const LinkMedia('https://cdn.example/movie.mkv')]);
|
||||
expect(swapped, [const LinkMedia('https://cdn.example/movie.mkv', relay: LinkRelay.me)]);
|
||||
expect(closed, 1);
|
||||
|
||||
// The Jellyfin source is the library browser; a pick goes up as is.
|
||||
@@ -554,6 +556,9 @@ void main() {
|
||||
controller: c, accounts: accounts, isHost: true, onSwap: (_) async => 'x', onClose: () {})));
|
||||
await settle(tester);
|
||||
expect(find.text('File'), findsOneWidget);
|
||||
await tester.tap(find.text('File'));
|
||||
await settle(tester);
|
||||
expect(find.text('Friends stream it from your computer.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('swap panel opens on the series of the episode playing, episode highlighted',
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||
import 'package:watch_together/src/screens/home/picked_media.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
|
||||
// The relay with real coordinators, real mpv and a dedicated server that
|
||||
// has no file and no Jellyfin login of its own (the VPS setup): a guest's
|
||||
// file, and links passed on by a member or fetched by the server.
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
|
||||
Future<String> _media() async {
|
||||
final path = '$_tmp/relay_e2e_20s.mkv';
|
||||
if (await File(path).exists()) return path;
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-y', '-v', 'error',
|
||||
'-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
|
||||
'-t', '20', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac', path,
|
||||
]);
|
||||
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||
return path;
|
||||
}
|
||||
|
||||
Future<void> _poll(FutureOr<bool> Function() cond, String what,
|
||||
{Duration timeout = const Duration(seconds: 30)}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (await cond()) return;
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
/// Serves one file with ranges; [requests] counts hits.
|
||||
Future<(HttpServer, List<String>)> _origin(String file) async {
|
||||
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final requests = <String>[];
|
||||
s.listen((req) async {
|
||||
requests.add(req.uri.toString());
|
||||
final size = await File(file).length();
|
||||
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
|
||||
req.response.statusCode = r.status;
|
||||
r.headers.forEach(req.response.headers.set);
|
||||
try {
|
||||
if (r.status == 200 || r.status == 206) {
|
||||
await req.response.addStream(File(file).openRead(r.start, (r.end ?? size - 1) + 1));
|
||||
}
|
||||
await req.response.close();
|
||||
} catch (_) {}
|
||||
});
|
||||
return (s, requests);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late SyncServer server;
|
||||
late List<SessionCoordinator> all;
|
||||
|
||||
Future<SessionCoordinator> member(String name) async {
|
||||
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await acc.ensureLoaded(validate: false);
|
||||
final c = SessionCoordinator(jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
all.add(c);
|
||||
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
|
||||
await c.playback.player.setVolume(0);
|
||||
return c;
|
||||
}
|
||||
|
||||
Future<void> playTogether(List<SessionCoordinator> xs) async {
|
||||
await _poll(() => xs.every((x) => x.playback.durationSecs > 19), 'all opened it');
|
||||
for (final x in xs) {
|
||||
x.toggleReady();
|
||||
}
|
||||
await _poll(() => xs.every((x) => x.playback.player.state.playing), 'all playing');
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
final pos = [for (final x in xs) x.playback.positionSecs];
|
||||
// ignore: avoid_print
|
||||
print('positions after 3 s: $pos');
|
||||
for (final p in pos) {
|
||||
expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos');
|
||||
}
|
||||
for (final x in xs) {
|
||||
x.toggleReady();
|
||||
}
|
||||
await _poll(() => xs.every((x) => !x.playback.player.state.playing), 'held');
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
MediaKit.ensureInitialized();
|
||||
all = [];
|
||||
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final x in all) {
|
||||
await x.dispose();
|
||||
}
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
test('a guest puts up a file: it plays for everyone through the relay', timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
final file = await _media();
|
||||
final alice = await member('alice');
|
||||
final bob = await member('bob');
|
||||
expect(await bob.swapPicked(FileMedia(file)), isNull);
|
||||
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.sync.memberId, 'relay state');
|
||||
final state = alice.serverState.peek()!;
|
||||
expect(state.media!.containsKey('url'), isFalse);
|
||||
expect(bob.relaying.peek(), isTrue);
|
||||
await playTogether([alice, bob]);
|
||||
final relay = server.hub.relay!;
|
||||
expect(relay.buffer.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
|
||||
expect(relay.sourceToken, bob.sync.token);
|
||||
|
||||
// The next swap retires bob's source and the buffer.
|
||||
alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv'));
|
||||
await _poll(() => !bob.relaying.peek(), 'source stopped');
|
||||
expect(server.hub.relay, isNull);
|
||||
});
|
||||
|
||||
test('links: through me, and fetched by the server; never broadcast', timeout: const Timeout(Duration(minutes: 3)),
|
||||
() async {
|
||||
final (origin, hits) = await _origin(await _media());
|
||||
try {
|
||||
final alice = await member('alice');
|
||||
final bob = await member('bob');
|
||||
final link = 'http://127.0.0.1:${origin.port}/private/ep.mkv?api_key=SECRET';
|
||||
|
||||
expect(await bob.swapPicked(LinkMedia(link, relay: LinkRelay.me)), isNull);
|
||||
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.sync.memberId, 'me state');
|
||||
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
|
||||
await playTogether([alice, bob]);
|
||||
expect(server.hub.relay!.buffer.bytesToViewers, greaterThan(0));
|
||||
|
||||
final before = hits.length;
|
||||
expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull);
|
||||
await _poll(() => stateMediaRelay(bob.serverState.peek()!)?.fetch == true, 'fetch state');
|
||||
expect(bob.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
|
||||
expect(alice.relaying.peek(), isFalse, reason: 'the server fetches, nobody sources');
|
||||
await playTogether([alice, bob]);
|
||||
expect(hits.length, greaterThan(before));
|
||||
|
||||
// A page instead of a file is refused up front.
|
||||
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
page.listen((r) {
|
||||
r.response.headers.contentType = ContentType.html;
|
||||
r.response.write('<html></html>');
|
||||
r.response.close();
|
||||
});
|
||||
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
|
||||
contains('cannot pass this link on'));
|
||||
await page.close(force: true);
|
||||
} finally {
|
||||
await origin.close(force: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('the source drops uncleanly and resumes: it keeps its copy and keeps serving',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final file = await _media();
|
||||
final proxy = await _Blackhole.start(server.boundPort);
|
||||
try {
|
||||
final alice = await member('alice');
|
||||
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await acc.ensureLoaded(validate: false);
|
||||
final bob = SessionCoordinator(jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
all.add(bob);
|
||||
await bob.connect(ConnectParams(host: '127.0.0.1', port: proxy.port, secret: 'pw', name: 'bob'));
|
||||
await bob.playback.player.setVolume(0);
|
||||
expect(await bob.swapPicked(FileMedia(file)), isNull);
|
||||
await _poll(() => server.hub.relay?.live == true, 'source live');
|
||||
await _poll(() => alice.playback.durationSecs > 19 && bob.playback.durationSecs > 19, 'both opened');
|
||||
final token = bob.sync.token;
|
||||
|
||||
// Bob's network goes silent (no FIN): states stop, he reconnects
|
||||
// with `resume`, the server still has him.
|
||||
proxy.cut();
|
||||
await _poll(() => bob.reconnecting.peek(), 'reconnect engaged', timeout: const Duration(seconds: 15));
|
||||
await _poll(() => !bob.reconnecting.peek() && bob.serverState.peek() != null, 'back',
|
||||
timeout: const Duration(seconds: 20));
|
||||
expect(bob.sync.token, token, reason: 'resumed as the same member');
|
||||
await _poll(() => server.hub.relay?.live == true, 'source redialled', timeout: const Duration(seconds: 15));
|
||||
expect(bob.relaying.peek(), isTrue);
|
||||
expect(bob.playback.durationSecs, greaterThan(19), reason: 'still his own copy');
|
||||
|
||||
// Alice can still get bytes the buffer never had.
|
||||
alice.seekTo(17);
|
||||
await _poll(() => (alice.playback.positionSecs - 17).abs() < 1.5, 'alice seeked');
|
||||
} finally {
|
||||
await proxy.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// TCP proxy whose [cut] silently stops forwarding on every connection made
|
||||
/// so far (no FIN, like a dead Wi-Fi); later connections work.
|
||||
class _Blackhole {
|
||||
final ServerSocket _server;
|
||||
final _conns = <(Socket, Socket, List<bool>)>[];
|
||||
_Blackhole(this._server);
|
||||
int get port => _server.port;
|
||||
|
||||
static Future<_Blackhole> start(int target) async {
|
||||
final b = _Blackhole(await ServerSocket.bind(InternetAddress.loopbackIPv4, 0));
|
||||
b._server.listen((c) async {
|
||||
final up = await Socket.connect('127.0.0.1', target);
|
||||
final dead = [false];
|
||||
b._conns.add((c, up, dead));
|
||||
c.listen((d) {
|
||||
if (!dead[0]) up.add(d);
|
||||
}, onDone: () => dead[0] ? null : up.destroy(), onError: (_) {});
|
||||
up.listen((d) {
|
||||
if (!dead[0]) c.add(d);
|
||||
}, onDone: () => dead[0] ? null : c.destroy(), onError: (_) {});
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
void cut() {
|
||||
for (final (_, _, dead) in _conns) {
|
||||
dead[0] = true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _server.close();
|
||||
for (final (a, b, _) in _conns) {
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user