Relay: a member's media through the server's buffer (proto 8)
ci / core (push) Successful in 28s
ci / updater-windows (push) Successful in 49s
ci / server (push) Successful in 49s

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:
foxyzandClaude Opus 5.5 committed 2026-10-08 20:41:01 +03:00
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();