forked from Foxyz/sync_replay_project
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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user