From e0db5cc655ef85954becc9a139fb3a19288d3e8e Mon Sep 17 00:00:00 2001 From: foxyz Date: Fri, 9 Oct 2026 23:08:11 +0300 Subject: [PATCH] Solo watching and a clearer offline start Putting anything up offline starts a solo session: an in-app server bound to 127.0.0.1 only, joined like hosting, so tracking, next episode and the player all work. Invite friends re-hosts on the Host card's port and password with the same media and position; Stop needs no confirmation. Offline, the Library opens with a welcome band (Join a friend, Host, Watch solo); without a Jellyfin account it leads with Files and Link, Jellyfin is an optional card. Play buttons work offline. Co-Authored-By: Claude Opus 5.5 --- README.md | 25 +- apps/desktop/lib/src/files/ui/file_tiles.dart | 213 ++++++----- apps/desktop/lib/src/files/ui/files_page.dart | 22 +- .../lib/src/jellyfin/details_panel.dart | 30 +- .../lib/src/jellyfin/picker_source.dart | 6 +- .../lib/src/schedule/ui/schedule_card.dart | 15 +- .../lib/src/schedule/ui/schedule_page.dart | 16 +- .../lib/src/session/session_coordinator.dart | 8 + apps/desktop/lib/src/shell/app_shell.dart | 84 ++++- apps/desktop/lib/src/shell/connect_cards.dart | 36 +- apps/desktop/lib/src/shell/handoff.dart | 19 +- apps/desktop/lib/src/shell/library_link.dart | 26 +- apps/desktop/lib/src/shell/library_tab.dart | 45 ++- apps/desktop/lib/src/shell/party_strip.dart | 134 +++++-- apps/desktop/lib/src/shell/profile_card.dart | 2 +- apps/desktop/lib/src/shell/welcome.dart | 332 ++++++++++++++++++ .../desktop/lib/src/state/server_isolate.dart | 4 +- apps/desktop/lib/src/state/session_state.dart | 100 +++++- apps/desktop/test/files_page_test.dart | 23 +- apps/desktop/test/home_test.dart | 27 +- apps/desktop/test/library_tab_test.dart | 48 ++- apps/desktop/test/schedule_page_test.dart | 10 +- apps/desktop/test/server_isolate_test.dart | 42 +++ apps/desktop/test/shell_test.dart | 23 ++ docs/ARCHITECTURE.md | 47 ++- docs/HOW-IT-WORKS.md | 8 +- packages/server/lib/src/serve.dart | 5 +- packages/server/lib/src/sync_server.dart | 8 +- 28 files changed, 1013 insertions(+), 345 deletions(-) create mode 100644 apps/desktop/lib/src/shell/welcome.dart diff --git a/README.md b/README.md index 919187d..98d7923 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,28 @@ release with a newer protocol is a required update: everyone on a server must run the same protocol version, and a mismatch is refused with a message telling you to update. +## Getting started + +Three ways in, all at the top of the Library when you are not in a server. +None of them needs Jellyfin or an account. + +- **Join a friend** (most people): your friend sends you an address and, + maybe, a password. See [Join](#join). +- **Host**: you have the video and friends watch it with you. See + [Host from the app](#host-from-the-app). +- **Watch solo**: just play something from the Library. It plays for you + alone, with everything a server has (Jellyfin progress, next episode, + skip buttons); nothing is reachable from outside this computer. Invite + friends in the strip turns it into a hosted server (the Host card's port + and password) and the video carries on from where it was; Stop ends it. + ## The window One window. Along the top, the strip: your avatar (click it for your profile: name, picture with a crop editor, colour, and links to the settings), the Library / Schedule / Settings tabs, and the party. Offline -the party side has Join and Host; in a server it shows everyone's avatar +the party side has Join a friend and Host; watching solo it says Solo, +with Invite friends and Stop; in a server it shows everyone's avatar with a ring for their state (green ready, amber buffering, grey not ready, dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever hosts, a tower on whoever passes the video on). Click the avatars for the @@ -108,7 +124,8 @@ a folder or a link anywhere on the window, or press Ctrl+V outside a text field, and a small card asks how to open it (put it up, add the folder to Files, or fill Link). -- **Jellyfin**: your linked servers (see below). Library chips beside the +- **Jellyfin**: your linked servers (see below); with none linked it + points to Files and Link and offers Jellyfin as optional. Library chips beside the switch, then one **Continue** row (what you were watching and what comes next, one card per series, the one you watched most recently first; series not touched for two months drop out) and **Latest in** each @@ -129,8 +146,8 @@ folder to Files, or fill Link). - **Files**: video folders you add on this computer, grouped by series (click a series to list its episodes), or any file through Browse. The others stream it from you. -Put it up and the player opens. Offline the library browses but nothing -goes up until you join or host ("Join or host to watch together"). +Put it up and the player opens. Not in a server, the buttons say Play +and it plays for you alone ([Watch solo](#getting-started)). ## Dedicated server diff --git a/apps/desktop/lib/src/files/ui/file_tiles.dart b/apps/desktop/lib/src/files/ui/file_tiles.dart index 1e1f786..6827b5f 100644 --- a/apps/desktop/lib/src/files/ui/file_tiles.dart +++ b/apps/desktop/lib/src/files/ui/file_tiles.dart @@ -10,9 +10,6 @@ import 'thumb.dart'; // The Files page's rows and cards: a series header, an episode row (also // used for Recent), a loose-video card, and the empty states. -/// Shown on play controls while there is nobody to watch with. -const offlinePlayHint = 'Join or host to watch'; - /// What a play control is doing right now. enum PlayState { idle, starting, playing } @@ -28,10 +25,6 @@ String parentName(String path) => baseName(File(path).parent.path); /// Cleaned title of a loose video: `Title (2019)`. String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})'; -/// Disabled with [offlinePlayHint] when [onTap] is null because offline. -Widget _offlineTip(bool offline, Widget child) => - offline ? Tooltip(message: offlinePlayHint, child: child) : child; - class SeriesHeader extends StatelessWidget { final SeriesGroup group; final ThumbCache thumbs; @@ -110,9 +103,8 @@ class FileRow extends StatelessWidget { final String? size; final PlayState state; final ThumbCache thumbs; - /// Null disables the row ([offline] adds the reason as a tooltip). + /// Null disables the row. final VoidCallback? onTap; - final bool offline; final bool indent; final Widget? trailing; const FileRow({ @@ -124,7 +116,6 @@ class FileRow extends StatelessWidget { required this.state, required this.thumbs, required this.onTap, - required this.offline, this.indent = true, this.trailing, }); @@ -148,62 +139,59 @@ class FileRow extends StatelessWidget { builder: (context, t, _) { final duration = durationText(t); final meta = [?duration, ?size].join(' · '); - return _offlineTip( - offline, - Material( - color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent, + return Material( + color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent, + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(10), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Padding( - padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: ThumbImage(t, width: 80, height: 45), - ), - const SizedBox(width: 10), - SizedBox(width: 20, child: Center(child: icon)), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w600, - color: playing ? scheme.primary : null, - ), + child: Padding( + padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: ThumbImage(t, width: 80, height: 45), + ), + const SizedBox(width: 10), + SizedBox(width: 20, child: Center(child: icon)), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + color: playing ? scheme.primary : null, ), ), - if (playing) ...[ - const SizedBox(width: 8), - Pill('now playing', scheme.primary), - ], + ), + if (playing) ...[ + const SizedBox(width: 8), + Pill('now playing', scheme.primary), ], - ), - Text(subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: muted, fontSize: 12)), - ], - ), + ], + ), + Text(subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: muted, fontSize: 12)), + ], ), - if (meta.isNotEmpty) ...[ - const SizedBox(width: 12), - Text(meta, style: TextStyle(color: muted, fontSize: 12)), - ], - ?trailing, + ), + if (meta.isNotEmpty) ...[ + const SizedBox(width: 12), + Text(meta, style: TextStyle(color: muted, fontSize: 12)), ], - ), + ?trailing, + ], ), ), ), @@ -223,14 +211,12 @@ class VideoCard extends StatelessWidget { final PlayState state; final ThumbCache thumbs; final VoidCallback? onTap; - final bool offline; const VideoCard({ super.key, required this.video, required this.state, required this.thumbs, required this.onTap, - required this.offline, }); @override @@ -242,65 +228,62 @@ class VideoCard extends StatelessWidget { path: video.path, builder: (context, t, _) { final duration = durationText(t); - return _offlineTip( - offline, - InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: 16 / 9, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: playing ? scheme.primary : Colors.transparent, - width: 2, - ), + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: playing ? scheme.primary : Colors.transparent, + width: 2, ), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - fit: StackFit.expand, - children: [ - ThumbImage(t), - if (duration != null) - Positioned(right: 6, bottom: 6, child: _Badge(duration)), - if (state != PlayState.idle) - Container( - color: Colors.black38, - alignment: Alignment.center, - child: state == PlayState.playing - ? const Icon(Icons.graphic_eq, color: Colors.white, size: 32) - : const SizedBox.square( - dimension: 28, - child: CircularProgressIndicator( - strokeWidth: 3, color: Colors.white)), - ), - ], - ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Stack( + fit: StackFit.expand, + children: [ + ThumbImage(t), + if (duration != null) + Positioned(right: 6, bottom: 6, child: _Badge(duration)), + if (state != PlayState.idle) + Container( + color: Colors.black38, + alignment: Alignment.center, + child: state == PlayState.playing + ? const Icon(Icons.graphic_eq, color: Colors.white, size: 32) + : const SizedBox.square( + dimension: 28, + child: CircularProgressIndicator( + strokeWidth: 3, color: Colors.white)), + ), + ], ), ), ), - const SizedBox(height: 6), - Flexible( - child: Text( - cleanTitle(video.parsed), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 13, - color: playing ? scheme.primary : null, - ), + ), + const SizedBox(height: 6), + Flexible( + child: Text( + cleanTitle(video.parsed), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: playing ? scheme.primary : null, ), ), - Text(formatSize(video.size), - style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), - ], - ), + ), + Text(formatSize(video.size), + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), + ], ), ); }, diff --git a/apps/desktop/lib/src/files/ui/files_page.dart b/apps/desktop/lib/src/files/ui/files_page.dart index 2a3d75b..721340b 100644 --- a/apps/desktop/lib/src/files/ui/files_page.dart +++ b/apps/desktop/lib/src/files/ui/files_page.dart @@ -48,8 +48,8 @@ void resetFilesPageSession() { } class FilesPage extends StatefulWidget { - /// In a session: false disables playing (browsing still works). - final bool online; + /// Nobody else watches (offline or solo): no relay notice. + final bool solo; /// The file will stream from this computer through the server. final bool relayed; /// Plays [path] in the session; returns an error text, or null. @@ -67,7 +67,7 @@ class FilesPage extends StatefulWidget { const FilesPage({ super.key, - required this.online, + this.solo = false, required this.relayed, required this.onPlay, this.nowPlayingPath, @@ -207,7 +207,7 @@ class _FilesPageState extends State { } Future _play(String path) async { - if (!widget.online || _starting != null) return; + if (_starting != null) return; setState(() { _starting = path; _error = null; @@ -236,7 +236,7 @@ class _FilesPageState extends State { ? PlayState.playing : PlayState.idle; - VoidCallback? _tapOf(String path) => widget.online ? () => _play(path) : null; + VoidCallback _tapOf(String path) => () => _play(path); @override Widget build(BuildContext context) => LayoutBuilder( @@ -264,9 +264,7 @@ class _FilesPageState extends State { }, ); - Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => widget.online - ? build(_browse) - : Tooltip(message: offlinePlayHint, child: build(null)); + Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => build(_browse); Widget _sidebar(BuildContext context, List folders, String? open) { final scheme = Theme.of(context).colorScheme; @@ -345,7 +343,7 @@ class _FilesPageState extends State { final top = [ _header(context, open), if (narrow) _chips(folders, open), - if (widget.online && widget.relayed) const _RelayNotice(), + if (!widget.solo && widget.relayed) const _RelayNotice(), AnimatedSize( duration: easeTime, curve: ease, @@ -476,7 +474,6 @@ class _FilesPageState extends State { state: _stateOf(path), thumbs: _thumbs, onTap: _tapOf(path), - offline: !widget.online, onRemove: () => _store.removeRecent(path), ); }, @@ -594,7 +591,6 @@ class _FilesPageState extends State { state: _stateOf(v.path), thumbs: _thumbs, onTap: _tapOf(v.path), - offline: !widget.online, ); }, ), @@ -630,7 +626,6 @@ class _FilesPageState extends State { state: _stateOf(v.path), thumbs: _thumbs, onTap: _tapOf(v.path), - offline: !widget.online, ); }, ); @@ -647,7 +642,6 @@ class _RecentRow extends StatefulWidget { final PlayState state; final ThumbCache thumbs; final VoidCallback? onTap; - final bool offline; final VoidCallback onRemove; const _RecentRow({ super.key, @@ -655,7 +649,6 @@ class _RecentRow extends StatefulWidget { required this.state, required this.thumbs, required this.onTap, - required this.offline, required this.onRemove, }); @@ -688,7 +681,6 @@ class _RecentRowState extends State<_RecentRow> { state: widget.state, thumbs: widget.thumbs, onTap: missing ? null : widget.onTap, - offline: widget.offline && !missing, indent: false, trailing: IconButton( tooltip: 'Remove from recent', diff --git a/apps/desktop/lib/src/jellyfin/details_panel.dart b/apps/desktop/lib/src/jellyfin/details_panel.dart index 70a5efc..a597c57 100644 --- a/apps/desktop/lib/src/jellyfin/details_panel.dart +++ b/apps/desktop/lib/src/jellyfin/details_panel.dart @@ -32,16 +32,16 @@ typedef _Data = ({ }); /// The selected card's details, at the right of the library: banner, -/// title, meta, "Put it up · S1E3" (a disabled Play with [offlineHint] -/// offline), mark watched / unwatched, remove from Continue, overview and, +/// title, meta, "Put it up · S1E3" ("Play · S1E3" when watching +/// [solo]), mark watched / unwatched, remove from Continue, overview and, /// for a series or an episode, the episode list (click picks which /// episode goes up, double-click puts it up). Reloads when the /// [controller]'s data version moves. class JellyfinDetailsPanel extends StatefulWidget { final BrowseController controller; final JellyfinSelection selection; - /// Null when online; else why nothing can go up. - final String? offlineHint; + /// Nobody else watches (offline or solo): the button says Play. + final bool solo; /// Puts [item] (playable) up; returns an error text, or null when sent. final Future Function(JellyfinSelection item) onPutUp; final VoidCallback onClose; @@ -51,7 +51,7 @@ class JellyfinDetailsPanel extends StatefulWidget { required this.selection, required this.onPutUp, required this.onClose, - this.offlineHint, + this.solo = false, }); @override @@ -104,7 +104,7 @@ class _JellyfinDetailsPanelState extends State { _cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId)); Future _putUp(JellyfinItem target) async { - if (_busy || widget.offlineHint != null) return; + if (_busy) return; setState(() { _busy = true; _error = null; @@ -253,14 +253,8 @@ class _JellyfinDetailsPanelState extends State { if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}', if (!isSeries && shown.userData.played) 'Watched', ].join(' · '); - final hint = widget.offlineHint; - final label = playable == null - ? (hint == null ? 'Put it up' : 'Play') - : hint != null - ? 'Play' - : isSeries - ? 'Put it up · ${playable.episodeLabel ?? playable.name}' - : 'Put it up'; + final verb = widget.solo ? 'Play' : 'Put it up'; + final label = playable != null && isSeries ? '$verb · ${playable.episodeLabel ?? playable.name}' : verb; final inContinue = playable != null && (playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable))); final left = playable == null ? null : timeLeft(playable); @@ -307,7 +301,7 @@ class _JellyfinDetailsPanelState extends State { children: [ FilledButton.icon( key: const ValueKey('details-put-up'), - onPressed: playable == null || hint != null || _busy ? null : () => _putUp(playable), + onPressed: playable == null || _busy ? null : () => _putUp(playable), icon: _busy ? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.play_arrow), @@ -336,12 +330,12 @@ class _JellyfinDetailsPanelState extends State { ), ], ), - if (hint != null || _error != null) ...[ + if (_error != null) ...[ const SizedBox(height: 6), Text( - _error ?? hint!, + _error!, key: const ValueKey('details-hint'), - style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey), + style: t.bodySmall?.copyWith(color: scheme.error), ), ], if (isSeries && playable != null) ...[ diff --git a/apps/desktop/lib/src/jellyfin/picker_source.dart b/apps/desktop/lib/src/jellyfin/picker_source.dart index c144e20..fab4569 100644 --- a/apps/desktop/lib/src/jellyfin/picker_source.dart +++ b/apps/desktop/lib/src/jellyfin/picker_source.dart @@ -7,7 +7,7 @@ import 'browser.dart'; import 'sign_in_panel.dart'; /// The Library's Jellyfin source: the library browser once a server is -/// linked, else an inline add-server panel. +/// linked, else [empty] (default: an inline add-server panel). class JellyfinPickerSource extends StatefulWidget { final JellyfinAccounts accounts; final BrowseController? controller; @@ -17,6 +17,8 @@ class JellyfinPickerSource extends StatefulWidget { final VoidCallback? onOpenIntegrations; final BrowseStart? start; final bool libraryStrip; + /// Shown while no server is linked. + final WidgetBuilder? empty; const JellyfinPickerSource({ super.key, this.start, @@ -27,6 +29,7 @@ class JellyfinPickerSource extends StatefulWidget { this.selected, this.onOpenIntegrations, this.libraryStrip = true, + this.empty, }); @override @@ -49,6 +52,7 @@ class _JellyfinPickerSourceState extends State { if (!a.loaded) { return const Center(child: CircularProgressIndicator(strokeWidth: 2)); } + if (a.isEmpty && widget.empty != null) return widget.empty!(context); if (a.isEmpty) return _LinkFirst(accounts: a, onOpenIntegrations: widget.onOpenIntegrations); return JellyfinBrowser( key: const ValueKey('media-jellyfin'), diff --git a/apps/desktop/lib/src/schedule/ui/schedule_card.dart b/apps/desktop/lib/src/schedule/ui/schedule_card.dart index b3e349e..a7ff554 100644 --- a/apps/desktop/lib/src/schedule/ui/schedule_card.dart +++ b/apps/desktop/lib/src/schedule/ui/schedule_card.dart @@ -13,8 +13,8 @@ enum CardAction { none, putUp, - /// Available, but not in a server: shown disabled with a hint. - playOffline, + /// Available, nobody else watching (offline or solo): plays it here. + play, request, requestSeries, requested, @@ -162,15 +162,10 @@ class AiringCard extends StatelessWidget { final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)); Widget? b = switch (action) { CardAction.none => null, - CardAction.putUp => FilledButton.tonalIcon( + CardAction.putUp || CardAction.play => FilledButton.tonalIcon( onPressed: enabled ? onAction : null, icon: busy ? spinner : const Icon(Icons.play_arrow, size: 18), - label: const Text('Put it up'), - ), - CardAction.playOffline => FilledButton.tonalIcon( - onPressed: null, - icon: const Icon(Icons.play_arrow, size: 18), - label: const Text('Play'), + label: Text(action == CardAction.play ? 'Play' : 'Put it up'), ), CardAction.request || CardAction.requestSeries => OutlinedButton.icon( onPressed: enabled ? onAction : null, @@ -216,7 +211,7 @@ class AiringCard extends StatelessWidget { ), ), ); - final tap = action == CardAction.putUp && disabledHint == null && !busy ? onAction : null; + final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy ? onAction : null; if (compact) { final requestable = action == CardAction.request || action == CardAction.requestSeries; return Material( diff --git a/apps/desktop/lib/src/schedule/ui/schedule_page.dart b/apps/desktop/lib/src/schedule/ui/schedule_page.dart index b7995b8..6a3854e 100644 --- a/apps/desktop/lib/src/schedule/ui/schedule_page.dart +++ b/apps/desktop/lib/src/schedule/ui/schedule_page.dart @@ -21,8 +21,9 @@ import 'schedule_card.dart'; enum ScheduleFilter { jellyfin, following, all } class SchedulePage extends StatefulWidget { - /// In a server: available episodes can be put up. - final bool online; + /// Nobody else watches (offline or solo): available episodes say Play + /// (offline that starts a solo session). + final bool solo; /// Puts a Jellyfin episode up in the server; returns an error to show. final Future Function(JellyfinMedia pick) onPutUp; @@ -36,7 +37,7 @@ class SchedulePage extends StatefulWidget { const SchedulePage({ super.key, - required this.online, + this.solo = false, required this.onPutUp, required this.onToast, this.repository, @@ -215,9 +216,9 @@ class _SchedulePageState extends State { (CardAction, String?) _actionFor(ScheduleEntry e) { if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) { - if (!widget.online) return (CardAction.playOffline, 'Join or host a server to put it up'); - if (e.jellyfinSeriesId == null) return (CardAction.putUp, 'Not linked to a Jellyfin series yet'); - return (CardAction.putUp, null); + final put = widget.solo ? CardAction.play : CardAction.putUp; + if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet'); + return (put, null); } final me = _me; final CardAction kind; @@ -242,6 +243,7 @@ class _SchedulePageState extends State { Future _onAction(ScheduleEntry e, CardAction action) async { switch (action) { case CardAction.putUp: + case CardAction.play: return _putUp(e); case CardAction.request: case CardAction.requestSeries: @@ -530,7 +532,7 @@ class _SchedulePageState extends State { Widget _card(ScheduleEntry e, DateTime now) { final (action, hint) = _actionFor(e); - final busy = _busy.contains(action == CardAction.putUp ? 'play:${_key(e)}' : _key(e)); + final busy = _busy.contains(action == CardAction.putUp || action == CardAction.play ? 'play:${_key(e)}' : _key(e)); return AiringCard( entry: e, now: now, diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index b945487..59b2fe3 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -277,6 +277,10 @@ class SessionCoordinator { int _relaySourceMediaId = -1; /// This member serves the current media's bytes through the server. final relaying = signal(false); + /// What this client put up last in this connection (a solo session + /// carries it over when it becomes a hosted server). + PickedMedia? get lastPick => _lastPick; + PickedMedia? _lastPick; // Server seek applied to the player (event id and when), checked once it // should have landed. int? _pendingSeekEvent; @@ -402,6 +406,7 @@ class SessionCoordinator { _seekGate.reset(); _clearPendingSeek(); _lastEventId = -1; + _lastPick = null; _resetJellyfin(); _jfToastShown = false; if (!_jellyfinListening) { @@ -1235,6 +1240,7 @@ class SessionCoordinator { /// link, or relayed per [relay]. Returns an error text, or null when sent. Future swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async { markInput(); + _lastPick = LinkMedia(url, relay: relay); final media = await mediaForLink(jellyfin, url); if (media.jellyfin != null) { _pendingOrigin = _JellyfinOrigin(url); @@ -1304,6 +1310,7 @@ class SessionCoordinator { /// relayed by this member for viewers without an account. void swapJellyfin(JellyfinMedia media) { markInput(); + _lastPick = media; _pendingOrigin = const _JellyfinOrigin(null); _swap(mediaForPick(media).copyWith(relay: const MediaRelay())); } @@ -1313,6 +1320,7 @@ class SessionCoordinator { /// when sent. Future swapFile(String path) async { markInput(); + _lastPick = FileMedia(path); final host = hostFile; if (host != null) { final file = await host(path); diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index f911e66..989703e 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -13,6 +13,7 @@ import '../jellyfin/accounts.dart'; import '../jellyfin/browse_scope.dart' show BrowseStart; import '../jellyfin/session_media.dart'; import '../screens/home/connect_flow.dart'; +import '../session/session_coordinator.dart' show ConnectParams; import '../screens/home/health_probe.dart'; import '../screens/home/media_picker.dart'; import '../screens/home/profile.dart'; @@ -31,6 +32,7 @@ import '../schedule/ui/schedule_page.dart'; import 'session_player.dart'; import 'settings_tab.dart'; import 'toasts.dart'; +import 'welcome.dart'; /// The one window: the party strip over a body that fades through between /// the tabs and the full player, the floating mini player, the chat panel @@ -116,6 +118,11 @@ class AppShellState extends State with SingleTickerProviderStateMixin final _stops = []; // Rebuild the shell on these flips, not on every state broadcast. final _online = computed(() => SessionState.instance.route.value == AppRoute.session); + /// Nobody else watches: offline, solo, or solo becoming a server. + final _alone = computed(() { + final s = SessionState.instance; + return s.route.value != AppRoute.session || s.solo.value || s.switching.value; + }); final _hasMedia = computed(() => SessionState.instance.route.value == AppRoute.session && SessionState.instance.coordinator.serverState.value?.media != null); @@ -190,6 +197,17 @@ class AppShellState extends State with SingleTickerProviderStateMixin } wasOut = out; })); + // Solo becoming a hosted server: the player closes while the media + // moves over and comes back the way it was once it is up again. + _stops.add(effect(() { + if (!session.switching.value) return; + _later(() { + _lastMediaId = -1; + _autoFull = _mode == PlayerMode.full; + if (_fullscreen) _toggleFullscreen(); + _show(PlayerMode.none); + }); + })); // The hosted server died mid-session (SessionState leaves): say so. _stops.add(effect(() { final crash = session.serverCrash.value; @@ -209,6 +227,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin HardwareKeyboard.instance.removeHandler(_onKey); _fade.dispose(); _online.dispose(); + _alone.dispose(); _hasMedia.dispose(); _flow.dispose(); _join.dispose(); @@ -343,7 +362,8 @@ class AppShellState extends State with SingleTickerProviderStateMixin _selectTab(ShellTab.library); } - /// Library → "Put it up for everyone"; the player opens full. + /// Library → "Put it up for everyone"; the player opens full. Offline + /// it starts a solo session first (just you, on this computer). Future _putUp(PickedMedia media) async { final session = SessionState.instance; if (media is JellyfinMedia) { @@ -354,6 +374,12 @@ class AppShellState extends State with SingleTickerProviderStateMixin } unawaited(_jellyfin.rememberPick(media)); } + if (!_online.peek()) { + final error = await _startSolo(); + if (error != null || !mounted) return error; + } + // Alone, a link plays direct: there is nobody to pass it on to. + if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url); final error = await session.coordinator.swapPicked(media); if (error != null || !mounted) return error; final f = session.lastForm; @@ -366,6 +392,29 @@ class AppShellState extends State with SingleTickerProviderStateMixin return null; } + /// Starts a solo session; returns an error text, or null. + Future _startSolo() async { + final profile = Profile.instance; + try { + await profile.ensureLoaded(); + final name = profile.name.peek().trim(); + await SessionState.instance.startSolo(ConnectParams( + host: '127.0.0.1', + port: 0, + secret: '', + name: name.isEmpty ? 'Me' : name, + avatar: profile.avatarBase64(), + )); + return null; + } catch (e) { + return "couldn't start playing: $e"; + } + } + + /// Library's "Watch solo": somewhere to pick from (Jellyfin when linked, + /// else Files). + void _watchSolo() => _openLibrary(_jellyfin.isEmpty ? MediaKind.file : MediaKind.jellyfin); + // --------------------------------------------------------------- handoff /// Nothing (a dialog, a popover) is over the shell. @@ -406,11 +455,10 @@ class AppShellState extends State with SingleTickerProviderStateMixin final align = at == null || size.isEmpty ? Alignment.center : Alignment((at.dx / size.width * 2 - 1).clamp(-1, 1), (at.dy / size.height * 2 - 1).clamp(-1, 1)); - final online = _online.peek(); _handoffOpen = true; final HandoffAction? action; try { - action = await showPopover(context, at: align, child: HandoffCard(item: item, online: online)); + action = await showPopover(context, at: align, child: HandoffCard(item: item, solo: _alone.peek())); } finally { _handoffOpen = false; } @@ -424,10 +472,6 @@ class AppShellState extends State with SingleTickerProviderStateMixin await FolderStore.instance.ensureLoaded(); FolderStore.instance.addRecent(paths.first); } - case (HandoffVideos(:final paths), HandoffAction.openInFiles): - await FolderStore.instance.ensureLoaded(); - FolderStore.instance.addRecent(paths.first); - _openFiles(null); case (HandoffVideos(:final paths), HandoffAction.addFolder): await _addFolder(File(paths.first).parent.path); case (HandoffFolder(:final path), _): @@ -484,7 +528,13 @@ class AppShellState extends State with SingleTickerProviderStateMixin // --------------------------------------------------------------- dialogs + /// Leave asks first; a solo session just stops. Future _confirmLeave() async { + final session = SessionState.instance; + if (session.solo.peek()) { + await session.leave(); + return; + } final ok = await showDialog( context: context, builder: (context) => AlertDialog( @@ -542,6 +592,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin backgroundColor: _fullscreen ? Colors.black : scheme.surface, body: SignalBuilder(builder: (context) { final online = _online.value; + final alone = _alone.value; final hasMedia = _hasMedia.value; return Column( children: [ @@ -556,6 +607,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin onJoin: () => _connect(ConnectMode.join), onHost: () => _connect(ConnectMode.host), onLeave: _confirmLeave, + onInvite: () => _connect(ConnectMode.host), onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates), onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null, updates: widget.updates, @@ -565,7 +617,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin key: const ValueKey('body'), child: Row( children: [ - Expanded(child: _body(online)), + Expanded(child: _body(online, alone)), AnimatedSize( duration: easeTime, curve: ease, @@ -589,21 +641,29 @@ class AppShellState extends State with SingleTickerProviderStateMixin ); } - Widget _body(bool online) { + Widget _body(bool online, bool alone) { final coord = SessionState.instance.coordinator; final tabBody = switch (_tab) { ShellTab.library => LibraryTab( key: ValueKey('library-$_libraryGen'), controller: _picker, accounts: _jellyfin, - online: online, - isHost: coord.hostFile != null, + solo: alone, + // Offline, a put-up starts a solo session, which hosts. + isHost: !online || coord.hostFile != null, + banner: online + ? null + : WelcomeBand( + onJoin: () => _connect(ConnectMode.join), + onHost: () => _connect(ConnectMode.host), + onSolo: _watchSolo, + ), jellyfinStart: _libraryStart, onPutUp: _putUp, onOpenIntegrations: () => _openSettings(SettingsSection.integrations), ), ShellTab.schedule => SchedulePage( - online: online, + solo: alone, onPutUp: _putUp, onToast: coord.showToast, ), diff --git a/apps/desktop/lib/src/shell/connect_cards.dart b/apps/desktop/lib/src/shell/connect_cards.dart index 5188843..5670795 100644 --- a/apps/desktop/lib/src/shell/connect_cards.dart +++ b/apps/desktop/lib/src/shell/connect_cards.dart @@ -41,8 +41,9 @@ class HostFields { } } -/// The strip's Join / Host buttons: a card over the window. It closes -/// itself once the attempt got in; a failed one shows its error inside. +/// The strip's Join / Host buttons (and solo's Invite friends, the Host +/// card): a card over the window. It closes itself once the attempt got +/// in; a failed one shows its error inside. Future showConnectCard( BuildContext context, { required ConnectMode mode, @@ -77,7 +78,9 @@ class _CloseWhenOnlineState extends State<_CloseWhenOnline> { void initState() { super.initState(); _stop = effect(() { - if (SessionState.instance.route.value != AppRoute.session) return; + final s = SessionState.instance; + // Solo is in a session too: wait until it is a hosted server. + if (s.route.value != AppRoute.session || s.solo.value || s.switching.value) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) Navigator.of(context).maybePop(); }); @@ -153,7 +156,7 @@ class JoinCard extends StatelessWidget { builder: (context, _) { final busy = flow.busy == ConnectMode.join; return _Card( - title: 'Join a server', + title: 'Join a friend', children: [ SignalBuilder(builder: (context) { final list = [ @@ -221,6 +224,13 @@ class JoinCard extends StatelessWidget { const SizedBox(height: 16), ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true), ConnectFeedback(flow: flow, mode: ConnectMode.join), + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + 'Your friend gives you the address and password. No account or Jellyfin needed.', + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + ), ], ); }, @@ -243,10 +253,13 @@ class HostCard extends StatelessWidget { @override Widget build(BuildContext context) { + // From a solo session this is "Invite friends": same server settings, + // and what plays carries over. + final solo = SessionState.instance.solo.peek(); return ListenableBuilder( listenable: flow, builder: (context, _) => _Card( - title: 'Host a server', + title: solo ? 'Invite friends' : 'Host a server', children: [ AbsorbPointer( absorbing: flow.busy == ConnectMode.host, @@ -281,13 +294,16 @@ class HostCard extends StatelessWidget { ), ), const SizedBox(height: 16), - ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: _host, expand: true), + ConnectButton( + flow: flow, mode: ConnectMode.host, label: solo ? 'Start hosting' : 'Host', onSubmit: _host, expand: true), ConnectFeedback(flow: flow, mode: ConnectMode.host), - const Padding( - padding: EdgeInsets.only(top: 8), + Padding( + padding: const EdgeInsets.only(top: 8), child: Text( - 'You pick what to watch afterwards, in the library.', - style: TextStyle(color: Colors.grey, fontSize: 12), + solo + ? 'Your video keeps playing from where it is; friends join with your address.' + : 'You pick what to watch afterwards, in the library.', + style: const TextStyle(color: Colors.grey, fontSize: 12), ), ), ], diff --git a/apps/desktop/lib/src/shell/handoff.dart b/apps/desktop/lib/src/shell/handoff.dart index 7a649f1..df70cbc 100644 --- a/apps/desktop/lib/src/shell/handoff.dart +++ b/apps/desktop/lib/src/shell/handoff.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import '../files/scanner.dart' show FileKind, kindOf; import '../files/ui/file_tiles.dart' show baseName; -import 'library_tab.dart' show offlinePutUpHint; // Something handed to the app, by a drop or by Ctrl+V outside a text // field: what it is ([classifyHandoff], pure) and the small card that asks @@ -95,10 +94,8 @@ Handoff classifyHandoff({ /// What the card's buttons ask the shell to do. enum HandoffAction { - /// Put the (first) video up for everyone. + /// Put the (first) video up (offline: watch it solo). putUp, - /// Offline: list the video in Files → Recent and open it there. - openInFiles, /// Add the video's folder (or the folder) to Files and open it. addFolder, /// Open Library → Link with the URL filled. @@ -109,8 +106,9 @@ enum HandoffAction { /// pops the [HandoffAction] chosen. class HandoffCard extends StatelessWidget { final Handoff item; - final bool online; - const HandoffCard({super.key, required this.item, required this.online}); + /// Nobody else watches (offline or solo): videos play, no relay talk. + final bool solo; + const HandoffCard({super.key, required this.item, required this.solo}); @override Widget build(BuildContext context) { @@ -128,10 +126,7 @@ class HandoffCard extends StatelessWidget { baseName(paths.first), paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer', [ - if (online) - button(HandoffAction.putUp, Icons.play_arrow, 'Put it up', primary: true) - else - button(HandoffAction.openInFiles, Icons.history, 'Open in Files', primary: true), + button(HandoffAction.putUp, Icons.play_arrow, solo ? 'Play' : 'Put it up', primary: true), button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'), ], ), @@ -144,9 +139,9 @@ class HandoffCard extends StatelessWidget { HandoffLink(:final url) => ( Icons.link, url, - online ? 'Pick how the others get it on the Link page' : offlinePutUpHint, + solo ? 'Opens it on the Link page' : 'Pick how the others get it on the Link page', [ - button(HandoffAction.openLink, Icons.link, online ? 'Put it up as a link…' : 'Open in Link', + button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…', primary: true), ], ), diff --git a/apps/desktop/lib/src/shell/library_link.dart b/apps/desktop/lib/src/shell/library_link.dart index 001a7d2..065cea1 100644 --- a/apps/desktop/lib/src/shell/library_link.dart +++ b/apps/desktop/lib/src/shell/library_link.dart @@ -189,10 +189,14 @@ String linkRelayHelp(LinkRelay r) => switch (r) { 'while it plays; the link is not shown to anyone.', }; -/// The Link source. [offlineHint] set: the button is off and says why. +/// Under Play when nobody else watches. +const soloPlayHint = 'Plays just for you. Invite friends any time; it keeps playing.'; + +/// The Link source. [solo]: nobody else watches (offline or solo), so the +/// button says Play and there are no relay modes (the link plays direct). class LibraryLinkSource extends StatefulWidget { final MediaPickerController controller; - final String? offlineHint; + final bool solo; /// Puts the link up; returns an error text, or null when sent. final Future Function(LinkMedia media) onPutUp; final RecentLinks recents; @@ -202,7 +206,7 @@ class LibraryLinkSource extends StatefulWidget { required this.controller, required this.onPutUp, required this.recents, - this.offlineHint, + this.solo = false, this.probe = defaultLinkProber, }); @@ -261,14 +265,14 @@ class _LibraryLinkSourceState extends State { Future _go() async { final url = _c.link.text.trim(); - if (url.isEmpty || _busy || widget.offlineHint != null) return; + if (url.isEmpty || _busy) return; setState(() { _busy = true; _error = null; }); String? error; try { - error = await widget.onPutUp(LinkMedia(url, relay: _c.linkRelay)); + error = await widget.onPutUp(LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay)); } catch (e) { error = '$e'; } @@ -292,7 +296,7 @@ class _LibraryLinkSourceState extends State { Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final t = Theme.of(context).textTheme; - final hint = widget.offlineHint; + final solo = widget.solo; final url = _c.link.text.trim(); final preview = url.isEmpty ? null : _previews[url]; final loading = url.isNotEmpty && !_previews.containsKey(url); @@ -312,11 +316,11 @@ class _LibraryLinkSourceState extends State { ); final button = FilledButton.icon( key: const ValueKey('link-put-up'), - onPressed: url.isEmpty || _busy || hint != null ? null : _go, + onPressed: url.isEmpty || _busy ? null : _go, icon: _busy ? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.play_arrow), - label: Text(hint == null ? 'Put it up' : 'Play'), + label: Text(solo ? 'Play' : 'Put it up'), style: FilledButton.styleFrom( minimumSize: const Size(0, 56), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), @@ -361,10 +365,10 @@ class _LibraryLinkSourceState extends State { ]) : Row(children: [Expanded(child: field), const SizedBox(width: 12), button]), ), - if (hint != null || _error != null) ...[ + if (solo || _error != null) ...[ const SizedBox(height: 8), Text( - _error ?? hint!, + _error ?? soloPlayHint, key: const ValueKey('link-hint'), textAlign: TextAlign.center, style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey), @@ -380,7 +384,7 @@ class _LibraryLinkSourceState extends State { child: _PreviewCard(preview: preview, loading: loading), ), ), - if (hint == null) ...[ + if (!solo) ...[ const SizedBox(height: 28), Text('How the others get it', style: t.titleSmall), const SizedBox(height: 10), diff --git a/apps/desktop/lib/src/shell/library_tab.dart b/apps/desktop/lib/src/shell/library_tab.dart index ce2f09d..def619b 100644 --- a/apps/desktop/lib/src/shell/library_tab.dart +++ b/apps/desktop/lib/src/shell/library_tab.dart @@ -16,6 +16,7 @@ import '../screens/home/media_picker.dart'; import '../state/session_state.dart'; import 'library_link.dart'; import 'motion.dart'; +import 'welcome.dart'; /// What putting up a Jellyfin pick sends: its ref (no URL, no token). /// Throws [FormatException] with a user-facing message when the server is @@ -58,9 +59,6 @@ String? nowPlayingFile(Map? media, String lastPath) { return name == media['name'] ? lastPath : null; } -/// Offline, nothing goes up; this says why. -const offlinePutUpHint = 'Join or host to watch together'; - /// Window width under which the details panel floats over the library. const detailsDockFrom = 1000.0; @@ -68,12 +66,13 @@ const detailsDockFrom = 1000.0; /// through), the Jellyfin library chips beside it, and the source below. /// Jellyfin cards select on click and open their details in a panel at /// the right (docked from [detailsDockFrom], floating over the library -/// below it); a double-click or the panel's button puts it up. Offline -/// everything browses and the play buttons say [offlinePutUpHint]. +/// below it); a double-click or the panel's button puts it up. With +/// nobody else watching ([solo]: offline or a solo session) the buttons +/// say Play and putting something up starts (or uses) a solo session. class LibraryTab extends StatefulWidget { final MediaPickerController controller; final JellyfinAccounts accounts; - final bool online; + final bool solo; /// This app hosts the server: its files stream from here directly. final bool isHost; final BrowseStart? jellyfinStart; @@ -85,15 +84,17 @@ class LibraryTab extends StatefulWidget { final RecentLinks? recentLinks; final LinkProber probe; /// The Files source (tests inject one); defaults to [FilesPage]. - final Widget Function(BuildContext context, {required bool online, required bool relayed, + final Widget Function(BuildContext context, {required bool solo, required bool relayed, required Future Function(String path) onPlay, String? nowPlayingPath})? filesBuilder; /// Clock for the Jellyfin Continue row (tests pin it). final DateTime Function() now; + /// Over the source switch (the shell's welcome band while offline). + final Widget? banner; const LibraryTab({ super.key, required this.controller, required this.accounts, - required this.online, + required this.solo, required this.isHost, required this.onPutUp, this.onPutUpDone, @@ -103,6 +104,7 @@ class LibraryTab extends StatefulWidget { this.probe = defaultLinkProber, this.filesBuilder, this.now = DateTime.now, + this.banner, }); @override @@ -115,7 +117,6 @@ class _LibraryTabState extends State { JellyfinSelection? _shown; MediaPickerController get _c => widget.controller; - String? get _hint => widget.online ? null : offlinePutUpHint; @override void initState() { @@ -163,7 +164,6 @@ class _LibraryTabState extends State { /// A Jellyfin card or episode: a series goes up as its next episode. Future _putUpJellyfin(JellyfinSelection sel) async { - if (!widget.online) return offlinePutUpHint; final account = widget.accounts.account(sel.serverId); final client = widget.accounts.clientFor(sel.serverId); if (account == null || client == null) return 'that Jellyfin server is not linked any more'; @@ -181,7 +181,6 @@ class _LibraryTabState extends State { /// Double-click on a card: put it up, errors as a snack bar. Future _doublePutUp(JellyfinSelection sel) async { _c.select(sel); - if (!widget.online) return; final error = await _putUpJellyfin(sel); if (error != null && mounted) { ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error))); @@ -239,11 +238,16 @@ class _LibraryTabState extends State { onOpenIntegrations: widget.onOpenIntegrations, start: widget.jellyfinStart, libraryStrip: false, + empty: (context) => NoJellyfinPanel( + accounts: widget.accounts, + onFiles: () => _c.source = MediaKind.file, + onLink: () => _c.source = MediaKind.link, + ), ), ), MediaKind.link => LibraryLinkSource( controller: _c, - offlineHint: _hint, + solo: widget.solo, recents: widget.recentLinks ?? RecentLinks.instance, probe: widget.probe, onPutUp: _put, @@ -253,6 +257,15 @@ class _LibraryTabState extends State { final main = Column( children: [ + AnimatedSize( + duration: easeTime, + curve: ease, + alignment: Alignment.topCenter, + child: AnimatedSwitcher( + duration: easeTime, + child: widget.banner ?? const SizedBox(width: double.infinity), + ), + ), header, Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))), ], @@ -267,7 +280,7 @@ class _LibraryTabState extends State { child: JellyfinDetailsPanel( controller: _browse, selection: shown, - offlineHint: _hint, + solo: widget.solo, onPutUp: _putUpJellyfin, onClose: () => _c.select(null), ), @@ -328,14 +341,14 @@ class _LibraryTabState extends State { Future play(String path) => _put(FileMedia(path)); return SignalBuilder(builder: (context) { final session = SessionState.instance; - final now = widget.online + final now = session.route.value == AppRoute.session ? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath) : null; final b = widget.filesBuilder; if (b != null) { - return b(context, online: widget.online, relayed: relayed, onPlay: play, nowPlayingPath: now); + return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now); } - return FilesPage(online: widget.online, relayed: relayed, onPlay: play, nowPlayingPath: now); + return FilesPage(solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now); }); } } diff --git a/apps/desktop/lib/src/shell/party_strip.dart b/apps/desktop/lib/src/shell/party_strip.dart index 7d925ba..527d2e6 100644 --- a/apps/desktop/lib/src/shell/party_strip.dart +++ b/apps/desktop/lib/src/shell/party_strip.dart @@ -39,8 +39,9 @@ bool viaHostOf(MemberView m, JellyfinRef? ref) => ref != null && m.jf[ref.serverId] == false; /// The 60 px strip over everything: your avatar (profile), the tabs, and -/// the party: offline Join / Host, online the avatars (members card), -/// server and readiness, chat and Leave. Empty space drags the window. +/// the party: offline Join a friend / Host, solo a "Solo" chip, Invite +/// friends and Stop, online the avatars (members card), server and +/// readiness, chat and Leave. Empty space drags the window. class PartyStrip extends StatelessWidget { final ShellTab tab; final ValueChanged onTab; @@ -51,6 +52,8 @@ class PartyStrip extends StatelessWidget { final VoidCallback onHost; final VoidCallback onLeave; final VoidCallback onProfile; + /// Solo: host from here, keeping what plays. + final VoidCallback? onInvite; /// Online with something up but the player closed: brings it back. final VoidCallback? onShowPlayer; @@ -69,6 +72,7 @@ class PartyStrip extends StatelessWidget { required this.onHost, required this.onLeave, required this.onProfile, + this.onInvite, this.onShowPlayer, this.updates, this.windowControls = true, @@ -83,6 +87,7 @@ class PartyStrip extends StatelessWidget { final session = SessionState.instance; final coord = session.coordinator; final online = session.route.value == AppRoute.session; + final solo = online && (session.solo.value || session.switching.value); final self = online ? coord.selfMember.value : null; return Row( children: [ @@ -97,7 +102,7 @@ class PartyStrip extends StatelessWidget { child: StatusAvatar( info: selfAvatarInfo( ready: self?.ready ?? false, - host: online && coord.hostFile != null, + host: online && !solo && coord.hostFile != null, ), size: stripAvatarSize, ring: online, @@ -130,14 +135,36 @@ class PartyStrip extends StatelessWidget { ), UpdateIndicator(controller: updates), const SizedBox(width: 8), - if (online) ...[ - if (onShowPlayer != null) - IconButton( - key: const ValueKey('show-player'), - tooltip: 'Show the player', - onPressed: onShowPlayer, - icon: const Icon(Icons.smart_display_outlined, size: 20), + if (online && onShowPlayer != null) + IconButton( + key: const ValueKey('show-player'), + tooltip: 'Show the player', + onPressed: onShowPlayer, + icon: const Icon(Icons.smart_display_outlined, size: 20), + ), + if (solo) ...[ + _SoloChip(compact: compact, busy: session.switching.value), + const SizedBox(width: 10), + if (compact) + FilledButton( + key: const ValueKey('strip-invite'), + onPressed: session.switching.value ? null : onInvite, + child: const Text('Invite'), + ) + else + FilledButton.icon( + key: const ValueKey('strip-invite'), + onPressed: session.switching.value ? null : onInvite, + icon: const Icon(Icons.group_add_outlined, size: 18), + label: const Text('Invite friends'), ), + const SizedBox(width: 8), + OutlinedButton( + key: const ValueKey('strip-leave'), + onPressed: session.switching.value ? null : onLeave, + child: const Text('Stop'), + ), + ] else if (online) ...[ PartyWatch( builder: (context) => AvatarStack( key: const ValueKey('party-avatars'), @@ -177,23 +204,35 @@ class PartyStrip extends StatelessWidget { child: const Text('Leave'), ), ] else ...[ - if (!compact) - Text( - 'Offline', - style: TextStyle(color: scheme.onSurfaceVariant), + if (!compact) ...[ + Container( + width: 7, + height: 7, + decoration: BoxDecoration(color: scheme.outline, shape: BoxShape.circle), ), - const SizedBox(width: 12), - FilledButton.tonal( - key: const ValueKey('strip-join'), - onPressed: onJoin, - child: const Text('Join'), - ), - const SizedBox(width: 8), - FilledButton( - key: const ValueKey('strip-host'), - onPressed: onHost, - child: const Text('Host'), - ), + const SizedBox(width: 6), + Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)), + const SizedBox(width: 12), + ], + if (compact) ...[ + FilledButton(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join')), + const SizedBox(width: 8), + FilledButton.tonal(key: const ValueKey('strip-host'), onPressed: onHost, child: const Text('Host')), + ] else ...[ + FilledButton.icon( + key: const ValueKey('strip-join'), + onPressed: onJoin, + icon: const Icon(Icons.login_rounded, size: 18), + label: const Text('Join a friend'), + ), + const SizedBox(width: 8), + FilledButton.tonalIcon( + key: const ValueKey('strip-host'), + onPressed: onHost, + icon: const Icon(Icons.podcasts_rounded, size: 18), + label: const Text('Host'), + ), + ], ], const SizedBox(width: 8), if (windowControls) ...[ @@ -270,13 +309,54 @@ class _ServerLabel extends StatelessWidget { ); } -/// "Your server" when hosting, else the address joined. +/// "Solo" alone, "Your server" when hosting, else the address joined. String serverName() { final session = SessionState.instance; + if (session.solo.peek()) return 'Solo'; if (session.coordinator.hostFile != null) return 'Your server'; return session.lastForm.host; } +/// Solo: just you, on your own; a spinner while it becomes a server. +class _SoloChip extends StatelessWidget { + final bool compact; + final bool busy; + const _SoloChip({required this.compact, required this.busy}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Tooltip( + message: busy + ? 'Starting your server…' + : 'Watching on your own. Nobody can join until you invite friends.', + child: Container( + key: const ValueKey('solo-chip'), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (busy) + const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2)) + else + Icon(Icons.person_outline_rounded, size: 16, color: scheme.primary), + const SizedBox(width: 6), + Text(busy ? 'Inviting…' : 'Solo', style: const TextStyle(fontWeight: FontWeight.w600)), + if (!compact && !busy) ...[ + const SizedBox(width: 6), + Text('only you', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), + ], + ], + ), + ), + ); + } +} + /// One strip tab: label and an underline that grows in when selected. class StripTab extends StatelessWidget { final String label; diff --git a/apps/desktop/lib/src/shell/profile_card.dart b/apps/desktop/lib/src/shell/profile_card.dart index 5724ce5..ecb5130 100644 --- a/apps/desktop/lib/src/shell/profile_card.dart +++ b/apps/desktop/lib/src/shell/profile_card.dart @@ -78,7 +78,7 @@ class _ProfileCardState extends State { final coord = session.coordinator; final online = session.route.value == AppRoute.session; final self = online ? coord.selfMember.value : null; - final hosting = online && coord.hostFile != null; + final hosting = online && !session.solo.value && coord.hostFile != null; final color = _p.color.value; final png = _p.picture.value; final out = self?.out ?? false; diff --git a/apps/desktop/lib/src/shell/welcome.dart b/apps/desktop/lib/src/shell/welcome.dart new file mode 100644 index 0000000..ffef960 --- /dev/null +++ b/apps/desktop/lib/src/shell/welcome.dart @@ -0,0 +1,332 @@ +import 'package:flutter/material.dart'; + +import '../jellyfin/accounts.dart'; +import '../jellyfin/sign_in_panel.dart'; +import '../screens/home/ui.dart' show appName; +import 'motion.dart'; + +/// One line under the app name. +const appTagline = 'Watch together, in sync'; + +/// Window width from which the welcome band puts the three ways beside +/// the app name (else under it). +const welcomeWideFrom = 980.0; + +/// Top of the Library while not in a server: the app's name and the three +/// ways in, Join a friend first (what most people came for), then Host and +/// Watch solo. None of them needs Jellyfin. +class WelcomeBand extends StatelessWidget { + final VoidCallback onJoin; + final VoidCallback onHost; + final VoidCallback onSolo; + const WelcomeBand({super.key, required this.onJoin, required this.onHost, required this.onSolo}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final t = Theme.of(context).textTheme; + final hero = Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Image.asset('assets/icon/icon_256.png', width: 52, height: 52, filterQuality: FilterQuality.medium), + ), + const SizedBox(width: 14), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(appName, style: t.titleLarge?.copyWith(fontWeight: FontWeight.w700, height: 1.1)), + const SizedBox(height: 2), + Text(appTagline, style: TextStyle(color: scheme.onSurfaceVariant)), + ], + ), + ], + ); + final ways = [ + WayCard( + key: const ValueKey('way-join'), + icon: Icons.login_rounded, + title: 'Join a friend', + caption: 'Got an address from a friend? Join with it. Nothing to set up.', + primary: true, + onTap: onJoin, + ), + WayCard( + key: const ValueKey('way-host'), + icon: Icons.podcasts_rounded, + title: 'Host', + caption: 'You have the video. Friends join you and watch it with you.', + onTap: onHost, + ), + WayCard( + key: const ValueKey('way-solo'), + icon: Icons.person_outline_rounded, + title: 'Watch solo', + caption: 'Just you: play anything below. Invite friends any time.', + onTap: onSolo, + ), + ]; + Widget row(List cards) => IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(width: 10), Expanded(child: c)], + ], + ), + ); + return Container( + key: const ValueKey('welcome-band'), + margin: const EdgeInsets.fromLTRB(20, 16, 20, 0), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [scheme.primary.withValues(alpha: 0.16), scheme.primary.withValues(alpha: 0.03)], + ), + border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.4)), + ), + child: LayoutBuilder(builder: (context, box) { + if (box.maxWidth >= welcomeWideFrom - 40) { + return Row( + children: [ + Padding(padding: const EdgeInsets.only(left: 4, right: 20), child: hero), + Expanded(child: row(ways)), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding(padding: const EdgeInsets.only(left: 4, bottom: 12), child: hero), + if (box.maxWidth >= 520) + row(ways) + else + for (final (i, c) in ways.indexed) ...[if (i > 0) const SizedBox(height: 8), c], + ], + ); + }), + ); + } +} + +/// One way in: a tinted icon, a title and a one-line caption. +class WayCard extends StatefulWidget { + final IconData icon; + final String title; + final String caption; + final bool primary; + final VoidCallback onTap; + const WayCard({ + super.key, + required this.icon, + required this.title, + required this.caption, + required this.onTap, + this.primary = false, + }); + + @override + State createState() => _WayCardState(); +} + +class _WayCardState extends State { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final primary = widget.primary; + final bg = primary ? scheme.primaryContainer : scheme.surfaceContainerHigh; + final fg = primary ? scheme.onPrimaryContainer : scheme.onSurface; + return MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: AnimatedScale( + duration: easeTime, + curve: ease, + scale: _hover ? 1.015 : 1, + child: Material( + color: bg, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: widget.onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 14, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: primary ? scheme.primary : scheme.primary.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(widget.icon, size: 20, color: primary ? scheme.onPrimary : scheme.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.title, style: TextStyle(fontWeight: FontWeight.w700, color: fg)), + const SizedBox(height: 2), + Text(widget.caption, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, height: 1.3, color: fg.withValues(alpha: 0.72))), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// The Library's Jellyfin source with no server linked: what plays with +/// nothing set up (Files, Link), then Jellyfin as an optional extra that +/// guests never need. The sign-in opens in place. +class NoJellyfinPanel extends StatefulWidget { + final JellyfinAccounts accounts; + final VoidCallback onFiles; + final VoidCallback onLink; + const NoJellyfinPanel({super.key, required this.accounts, required this.onFiles, required this.onLink}); + + @override + State createState() => _NoJellyfinPanelState(); +} + +class _NoJellyfinPanelState extends State { + bool _signIn = false; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final t = Theme.of(context).textTheme; + final muted = TextStyle(color: scheme.onSurfaceVariant); + final sources = [ + WayCard( + key: const ValueKey('source-files'), + icon: Icons.folder_outlined, + title: 'Files', + caption: 'Videos on this computer. Drop a file or folder anywhere.', + onTap: widget.onFiles, + ), + WayCard( + key: const ValueKey('source-link'), + icon: Icons.link, + title: 'Link', + caption: 'YouTube, X, or any video address. Ctrl+V anywhere.', + onTap: widget.onLink, + ), + ]; + return SingleChildScrollView( + key: const ValueKey('media-jellyfin'), + padding: const EdgeInsets.only(bottom: 24), + child: Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Pick something to watch', style: t.titleMedium), + const SizedBox(height: 4), + Text('No account needed: these work for everyone.', style: muted), + const SizedBox(height: 12), + LayoutBuilder( + builder: (context, box) => box.maxWidth < 460 + ? Column(children: [sources[0], const SizedBox(height: 8), sources[1]]) + : IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: sources[0]), + const SizedBox(width: 10), + Expanded(child: sources[1]), + ], + ), + ), + ), + const SizedBox(height: 20), + Material( + key: const ValueKey('jellyfin-optional'), + color: scheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)), + ), + clipBehavior: Clip.antiAlias, + child: AnimatedSize( + duration: easeTime, + curve: ease, + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(Icons.video_library_outlined, color: scheme.onSurfaceVariant), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ + const Text('Jellyfin', style: TextStyle(fontWeight: FontWeight.w600)), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Text('optional', style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant)), + ), + ]), + Text( + 'Only if you have a Jellyfin server: browse it here, ' + 'with progress and next episodes. Joining a friend never needs it.', + style: muted.copyWith(fontSize: 12), + ), + ], + ), + ), + ], + ), + if (!_signIn) + Align( + alignment: Alignment.centerRight, + child: TextButton( + key: const ValueKey('jellyfin-link'), + onPressed: () => setState(() => _signIn = true), + child: const Text('Link a server'), + ), + ), + if (_signIn) ...[ + const SizedBox(height: 14), + JellyfinSignInPanel(accounts: widget.accounts, onCancel: () => setState(() => _signIn = false)), + ], + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/state/server_isolate.dart b/apps/desktop/lib/src/state/server_isolate.dart index 224d07d..6f20b05 100644 --- a/apps/desktop/lib/src/state/server_isolate.dart +++ b/apps/desktop/lib/src/state/server_isolate.dart @@ -33,8 +33,10 @@ class ServerIsolate { /// Spawns the server and waits until it listens. Throws a /// [StateError] with the server's message if it cannot start. + /// [loopbackOnly]: 127.0.0.1 only (the solo session). static Future start( AppConfig config, { + bool loopbackOnly = false, required void Function(String line) onLog, required void Function(String error) onCrash, Duration timeout = const Duration(seconds: 15), @@ -83,7 +85,7 @@ class ServerIsolate { final Isolate isolate; try { isolate = await Isolate.spawn( - _serverMain, (config.toMap(), events.sendPort), + _serverMain, ({...config.toMap(), if (loopbackOnly) 'loopbackOnly': true}, events.sendPort), errorsAreFatal: false, onError: errors.sendPort, onExit: exit.sendPort, diff --git a/apps/desktop/lib/src/state/session_state.dart b/apps/desktop/lib/src/state/session_state.dart index b08f0d1..f0040a7 100644 --- a/apps/desktop/lib/src/state/session_state.dart +++ b/apps/desktop/lib/src/state/session_state.dart @@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart'; import '../jellyfin/accounts.dart'; import '../jellyfin/session_media.dart'; +import '../screens/home/picked_media.dart'; import '../playback/yt_dlp_app.dart'; import '../sfx/sfx.dart'; import '../sfx/sound_bank.dart'; @@ -169,6 +170,13 @@ class SessionState { /// Set when the hosted server died mid-session; Home shows it on the /// Host card and clears it. final serverCrash = signal(null); + /// In a solo session: this app's own server on 127.0.0.1, only you in + /// it. Hosting from it ([host]) keeps what plays and where. + final solo = signal(false); + /// A solo session is becoming a hosted one: the player has nothing open + /// until the media is up again on the new server. + final switching = signal(false); + ConnectParams? _soloParams; /// The app's yt-dlp folder for the hosted server; '' (the server's own /// default) when the app-support folder is not available. @@ -243,9 +251,80 @@ class SessionState { await _saveFormQuietly(); } + /// Hosts a server friends can join. From a solo session it keeps the + /// media and position: the solo server goes, the new one gets the same + /// pick, seeked to where it was; if hosting fails, solo comes back. Future host(ConnectParams params, ConnectForm form) async { + if (!solo.peek()) return _host(params, form: form); + final carry = _carry(); + switching.value = true; + try { + await coordinator.leave(); + await stopServer(); + solo.value = false; + try { + await _host(params, form: form); + } catch (_) { + // Not hosted (failed or cancelled): back to solo, same media. + try { + await _host(_soloParams ?? params, soloSession: true); + if (carry != null) await _resume(carry); + } catch (e) { + debugPrint('back to solo failed: $e'); + await leave(); + } + rethrow; + } + try { + if (carry != null) await _resume(carry); + } catch (e) { + coordinator.showToast("couldn't put it up again: $e"); + } + } finally { + switching.value = false; + } + } + + /// Watch alone: the same hosted path, on 127.0.0.1 and a free port, no + /// password, nothing put up yet. Nobody else can reach it. + Future startSolo(ConnectParams params) async { + if (route.peek() == AppRoute.session) return; + _soloParams = params; + await _host(params, soloSession: true); + } + + ({PickedMedia pick, double position})? _carry() { + final pick = coordinator.lastPick; + if (pick == null || coordinator.serverState.peek()?.media == null) return null; + final pos = coordinator.playback.positionSecs; + return (pick: pick, position: pos.isFinite && pos > 0 ? pos : 0.0); + } + + /// Puts [carry] up on the server just joined and seeks to its position + /// once the swap shows in a state. + Future _resume(({PickedMedia pick, double position}) carry) async { + final before = coordinator.serverState.peek()?.mediaId ?? 0; + final error = await coordinator.swapPicked(carry.pick); + if (error != null) throw StateError(error); + if (carry.position < 1) return; + final landed = Completer(); + final stop = effect(() { + final r = coordinator.serverState.value; + if (r != null && r.media != null && r.mediaId > before && !landed.isCompleted) landed.complete(); + }); + try { + await landed.future.timeout(const Duration(seconds: 20)); + coordinator.sync.sendSeek(carry.position); + } on TimeoutException { + debugPrint('resume: the media did not come up; not seeking'); + } finally { + stop(); + } + } + + Future _host(ConnectParams params, {ConnectForm? form, bool soloSession = false}) async { final op = ++_op; - _rememberForm(form); + if (form != null) _rememberForm(form); try { status.value = const HealthStatus(HealthSeverity.info, 'starting server…'); @@ -265,14 +344,15 @@ class SessionState { try { server = await ServerIsolate.start( AppConfig( - port: params.port, - secret: params.secret, + port: soloSession ? 0 : params.port, + secret: soloSession ? '' : params.secret, name: 'server', mediaPath: params.mediaPath, hostKey: hostKey, jellyfin: relay, ytDlpDir: await _sharedYtDlpDir(), ), + loopbackOnly: soloSession, onLog: (line) => debugPrint('[server] $line'), onCrash: (cause) => _onServerCrash(server, cause), ); @@ -289,13 +369,13 @@ class SessionState { _server = server; coordinator.hostFile = (path) async => identical(_server, server) ? server.setFile(path) : null; - status.value = - HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}'); + status.value = HealthStatus( + HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}'); try { await coordinator.connect(ConnectParams( host: '127.0.0.1', port: server.port, - secret: params.secret, + secret: soloSession ? '' : params.secret, name: params.name, avatar: params.avatar, hostKey: hostKey, @@ -309,9 +389,10 @@ class SessionState { if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash); rethrow; } + solo.value = soloSession; route.value = AppRoute.session; } finally { - await _saveFormQuietly(); + if (form != null) await _saveFormQuietly(); } } @@ -349,7 +430,9 @@ class SessionState { try { await coordinator.cancelConnect(); } catch (_) {} - await stopServer(); + // Solo becoming hosted: [host] stops what it started and goes back + // to solo itself. + if (!switching.peek()) await stopServer(); } /// Saved whatever the outcome: a failed join is exactly when the user @@ -385,6 +468,7 @@ class SessionState { Future leave() async { await coordinator.leave(); await stopServer(); + solo.value = false; status.value = const HealthStatus(HealthSeverity.info, 'idle'); route.value = AppRoute.home; } diff --git a/apps/desktop/test/files_page_test.dart b/apps/desktop/test/files_page_test.dart index ed202b8..05e93bf 100644 --- a/apps/desktop/test/files_page_test.dart +++ b/apps/desktop/test/files_page_test.dart @@ -44,7 +44,7 @@ Future frames(WidgetTester tester, [int n = 10]) async { Future pumpPage( WidgetTester tester, { Size size = const Size(1280, 800), - bool online = true, + bool solo = false, bool relayed = false, Future Function(String path)? onPlay, ThumbnailLoader? thumbnails, @@ -56,7 +56,7 @@ Future pumpPage( await tester.pumpWidget(MaterialApp( home: Scaffold( body: FilesPage( - online: online, + solo: solo, relayed: relayed, onPlay: onPlay ?? (_) async => null, thumbnails: thumbnails ?? fakeThumb, @@ -206,24 +206,6 @@ void main() { expect(find.text('4 episodes · 1 extra · Grp'), findsOneWidget); }); - testWidgets('offline: browsing works, playing is disabled', (tester) async { - store.addFolder(anime); - final played = []; - await pumpPage(tester, online: false, onPlay: (p) async { - played.add(p); - return null; - }); - await openFolder(tester, 'anime'); - await openSeries(tester); - expect(find.text('E01'), findsOneWidget); - await tester.tap(find.text('E01')); - await tester.tap(find.text('Some Movie (2019)')); - await frames(tester); - expect(played, isEmpty); - expect(store.recent.value, isEmpty); - expect(find.byTooltip(offlinePlayHint), findsWidgets); - }); - testWidgets('a play goes to onPlay and into Recent', (tester) async { store.addFolder(anime); final played = []; @@ -261,7 +243,6 @@ void main() { await tester.pumpWidget(MaterialApp( home: Scaffold( body: FilesPage( - online: true, relayed: false, onPlay: (_) async => null, nowPlayingPath: '$anime/[Grp] Show - 03.mkv', diff --git a/apps/desktop/test/home_test.dart b/apps/desktop/test/home_test.dart index 59f24e0..34726a7 100644 --- a/apps/desktop/test/home_test.dart +++ b/apps/desktop/test/home_test.dart @@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart'; import 'package:watch_together/src/screens/home/health_probe.dart'; import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:watch_together/src/shell/app_shell.dart'; +import 'package:watch_together/src/shell/library_link.dart' show soloPlayHint; import 'package:watch_together/src/state/session_state.dart'; import 'package:watch_together/src/state/settings.dart'; import 'package:watch_together/src/update/update_controller.dart'; @@ -230,24 +231,28 @@ void main() { expect(find.byKey(const ValueKey('join-row')), findsOneWidget); }); - testWidgets('library: sources switch; offline nothing goes up; Integrations opens Settings', (tester) async { + testWidgets('library offline: welcome band, sources that need nothing, Jellyfin optional', (tester) async { await pumpShell(tester, const Size(1280, 900)); - await tester.tap(find.text('Files')); + // The three ways in, Join first; none needs Jellyfin. + expect(find.byKey(const ValueKey('welcome-band')), findsOneWidget); + expect(find.text('Watch together, in sync'), findsOneWidget); + final join = tester.getTopLeft(find.byKey(const ValueKey('way-join'))); + expect(join.dx, lessThan(tester.getTopLeft(find.byKey(const ValueKey('way-solo'))).dx)); + // No linked server: Files and Link first, Jellyfin as an optional card. + expect(find.text('Pick something to watch'), findsOneWidget); + expect(find.text('Add a Jellyfin server'), findsNothing); + await tester.tap(find.byKey(const ValueKey('jellyfin-link'))); + await tester.pumpAndSettle(); + expect(find.text('Add a Jellyfin server'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('source-files'))); await tester.pumpAndSettle(); expect(find.byType(FilesPage), findsOneWidget); await tester.tap(find.text('Link')); await tester.pumpAndSettle(); expect(find.text('Watch from a link'), findsOneWidget); - // Offline nothing goes up. - expect(find.text('Join or host to watch together'), findsOneWidget); + // Alone: Play, no relay modes. + expect(find.text(soloPlayHint), findsOneWidget); expect(find.text('How the others get it'), findsNothing); - await tester.tap(find.text('Jellyfin')); - await tester.pumpAndSettle(); - // No linked server: sign-in right in the library. - expect(find.text('Add a Jellyfin server'), findsOneWidget); - await tester.tap(find.text('Open Integrations')); - await tester.pumpAndSettle(); - expect(find.text('Add server'), findsOneWidget); expect(tester.takeException(), isNull); }); diff --git a/apps/desktop/test/library_tab_test.dart b/apps/desktop/test/library_tab_test.dart index 471de2b..163758e 100644 --- a/apps/desktop/test/library_tab_test.dart +++ b/apps/desktop/test/library_tab_test.dart @@ -48,16 +48,16 @@ FakeJellyfin libraryFake() { } class FilesStub extends StatelessWidget { - final bool online, relayed; + final bool solo, relayed; final Future Function(String path) onPlay; - const FilesStub({super.key, required this.online, required this.relayed, required this.onPlay}); + const FilesStub({super.key, required this.solo, required this.relayed, required this.onPlay}); @override Widget build(BuildContext context) => Column(children: [ - Text('files online=$online relayed=$relayed'), + Text('files solo=$solo relayed=$relayed'), TextButton( key: const ValueKey('stub-play'), - onPressed: online ? () => onPlay('/v/a.mkv') : null, + onPressed: () => onPlay('/v/a.mkv'), child: const Text('play'), ), ]); @@ -75,7 +75,7 @@ class Harness { Future pumpLibrary( WidgetTester tester, Size size, { - bool online = true, + bool solo = false, bool isHost = false, MediaKind source = MediaKind.jellyfin, Harness? reuse, @@ -103,7 +103,7 @@ Future pumpLibrary( key: UniqueKey(), controller: h.controller, accounts: h.accounts, - online: online, + solo: solo, isHost: isHost, jellyfinStart: start, recentLinks: h.recents, @@ -111,8 +111,8 @@ Future pumpLibrary( probe: (url) async => url.endsWith('.mkv') ? LinkProbe.file(Uri.parse(url), 1) : LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')), - filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) => - FilesStub(online: online, relayed: relayed, onPlay: onPlay), + filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) => + FilesStub(solo: solo, relayed: relayed, onPlay: onPlay), onPutUp: (m) async { h.put.add(m); return null; @@ -152,7 +152,7 @@ void main() { await tester.tap(find.text('Files')); await settle(tester, 10); - expect(find.text('files online=true relayed=true'), findsOneWidget); + expect(find.text('files solo=false relayed=true'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('stub-play'))); await settle(tester); expect(h.put, [const FileMedia('/v/a.mkv')]); @@ -247,32 +247,26 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('offline: everything browses, nothing goes up', (tester) async { - final h = await pumpLibrary(tester, const Size(1600, 1000), online: false); + testWidgets('solo: buttons say Play and put up; a link goes direct, no relay modes', (tester) async { + final h = await pumpLibrary(tester, const Size(1600, 1000), solo: true); await tester.tap(arifuretaCard()); await settle(tester, 10); final go = find.byKey(const ValueKey('details-put-up')); - expect(tester.widget(go).onPressed, isNull); - expect(find.descendant(of: go, matching: find.text('Play')), findsOneWidget); - expect(find.text(offlinePutUpHint), findsOneWidget); - await doubleTap(tester, arifuretaCard()); - expect(h.put, isEmpty); + expect(find.descendant(of: go, matching: find.textContaining('Play')), findsOneWidget); + await tester.tap(go); + await settle(tester, 10); + expect(h.put.single, isA()); await tester.tap(find.text('Link')); await settle(tester, 10); + h.controller.linkRelay = LinkRelay.me; await tester.enterText(find.byKey(const ValueKey('link-field')), 'https://cdn.example/a.mkv'); await settle(tester, 10); - final linkGo = find.byKey(const ValueKey('link-put-up')); - expect(tester.widget(linkGo).onPressed, isNull); - expect(find.descendant(of: find.byKey(const ValueKey('media-link')), matching: find.text(offlinePutUpHint)), - findsOneWidget); expect(find.text('How the others get it'), findsNothing); - expect(find.text('a.mkv'), findsOneWidget, reason: 'the preview still works'); - - await tester.tap(find.text('Files')); + expect(find.text(soloPlayHint), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('link-put-up'))); await settle(tester, 10); - expect(find.text('files online=false relayed=true'), findsOneWidget); - expect(h.put, isEmpty); + expect(h.put.last, const LinkMedia('https://cdn.example/a.mkv')); expect(tester.takeException(), isNull); }); @@ -307,7 +301,7 @@ void main() { testWidgets('hosting: files stream from here; now-playing file passes through', (tester) async { await pumpLibrary(tester, const Size(1400, 900), source: MediaKind.file, isHost: true); - expect(find.text('files online=true relayed=false'), findsOneWidget); + expect(find.text('files solo=false relayed=false'), findsOneWidget); }); testWidgets('the last pick comes back selected; "Change video" opens on its series', (tester) async { @@ -325,7 +319,7 @@ void main() { expect(c.selected!.itemId, bsEpisode); expect(c.source, MediaKind.jellyfin); final h = Harness(c, accounts, FakeJellyfin()); - await pumpLibrary(tester, const Size(1600, 1000), reuse: h, online: false); + await pumpLibrary(tester, const Size(1600, 1000), reuse: h, solo: true); expect(find.byKey(const ValueKey('jf-details')), findsOneWidget); expect(find.text('Black Summoner'), findsWidgets); diff --git a/apps/desktop/test/schedule_page_test.dart b/apps/desktop/test/schedule_page_test.dart index 711367e..0856527 100644 --- a/apps/desktop/test/schedule_page_test.dart +++ b/apps/desktop/test/schedule_page_test.dart @@ -231,7 +231,7 @@ void main() { WidgetTester tester, { Size size = const Size(2560, 1080), ScheduleSnapshot Function()? snap, - bool online = true, + bool solo = false, JellyfinAccounts? accounts, }) async { toasts.clear(); @@ -245,7 +245,7 @@ void main() { theme: ThemeData.dark(), home: Scaffold( body: SchedulePage( - online: online, + solo: solo, repository: repo, accounts: accounts, onToast: toasts.add, @@ -434,11 +434,11 @@ void main() { await done(tester); }); - testWidgets('offline: Play is disabled', (tester) async { - await pumpPage(tester, online: false); + testWidgets('solo: the button says Play and works', (tester) async { + await pumpPage(tester, solo: true); expect(find.text('Put it up'), findsNothing); final b = widgetOf(tester, action(111, 3)); - expect(b.onPressed, isNull); + expect(b.onPressed, isNotNull); expect(find.text('Play'), findsOneWidget); await done(tester); }); diff --git a/apps/desktop/test/server_isolate_test.dart b/apps/desktop/test/server_isolate_test.dart index 78dd6f8..3b6b72f 100644 --- a/apps/desktop/test/server_isolate_test.dart +++ b/apps/desktop/test/server_isolate_test.dart @@ -8,6 +8,7 @@ import 'package:hive_ce/hive.dart'; import 'package:media_kit/media_kit.dart'; import 'package:sync_relay_core/core.dart'; import 'package:watch_together/src/jellyfin/accounts.dart'; +import 'package:watch_together/src/screens/home/picked_media.dart'; import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:watch_together/src/state/server_isolate.dart'; import 'package:watch_together/src/state/session_state.dart'; @@ -218,4 +219,45 @@ void main() { if (file.existsSync()) file.deleteSync(); } }); + + test('solo: loopback-only server; inviting friends hosts the same media', + timeout: const Timeout(Duration(minutes: 2)), () async { + MediaKit.ensureInitialized(); + final tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; + Hive.init('$tmp/hive_isolate_solo_test'); + final session = SessionState.instance; + final port = await _freePort(); + _Raw? friend; + final file = File('$tmp/solo carry.mkv')..writeAsBytesSync(List.filled(4096, 7)); + session.route.value = AppRoute.home; // the tests above leave it set + try { + await session.startSolo(const ConnectParams(host: '127.0.0.1', port: 0, secret: '', name: 'me')); + expect(session.route.peek(), AppRoute.session); + expect(session.solo.peek(), isTrue); + await session.coordinator.playback.player.setVolume(0); + final soloPort = session.hostedServer!.port; + // Reachable on loopback only: a LAN address of this machine is refused. + final lan = [ + for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4)) + for (final a in i.addresses) + if (!a.isLoopback) a, + ]; + if (lan.isNotEmpty) { + await expectLater(Socket.connect(lan.first, soloPort, timeout: const Duration(seconds: 3)), + throwsA(isA())); + } + expect(await session.coordinator.swapPicked(FileMedia(file.path)), isNull); + await _poll(() => session.coordinator.serverState.peek()?.media?['name'] == 'solo carry.mkv', 'solo media'); + + await session.host(ConnectParams(host: '', port: port, secret: 'pw', name: 'me'), ConnectForm()); + expect(session.solo.peek(), isFalse); + expect(session.route.peek(), AppRoute.session); + friend = await _Raw.join(port, 'friend', secret: 'pw'); + await friend.next('state', where: (m) => (m['media'] as Map?)?['name'] == 'solo carry.mkv'); + } finally { + await friend?.ws.close(); + await session.leave(); + if (file.existsSync()) file.deleteSync(); + } + }); } diff --git a/apps/desktop/test/shell_test.dart b/apps/desktop/test/shell_test.dart index 292a9e0..a570838 100644 --- a/apps/desktop/test/shell_test.dart +++ b/apps/desktop/test/shell_test.dart @@ -128,6 +128,29 @@ void main() { expect(session.route.value, AppRoute.session); }); + testWidgets('solo: just you, Invite friends opens the host card, Stop asks nothing', (tester) async { + await pumpShell(tester, const Size(1280, 800)); + session.solo.value = true; + addTearDown(() => session.solo.value = false); + online([member('me', 'Me')], media: false); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('solo-chip')), findsOneWidget); + expect(find.byKey(const ValueKey('party-avatars')), findsNothing); + expect(find.byKey(const ValueKey('strip-chat')), findsNothing); + expect(find.byKey(const ValueKey('welcome-band')), findsNothing); + await tester.tap(find.byKey(const ValueKey('strip-invite'))); + await tester.pumpAndSettle(); + expect(find.text('Invite friends'), findsWidgets); + expect(find.text('Start hosting'), findsOneWidget); + await tester.tapAt(const Offset(5, 790)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('strip-leave'))); + await tester.pumpAndSettle(); + expect(find.text('Leave server?'), findsNothing); + expect(session.route.value, AppRoute.home); + expect(session.solo.value, isFalse); + }); + testWidgets('compact below 1100 px: 3 avatars, no server text', (tester) async { await pumpShell(tester, const Size(1000, 800)); online(party, media: false); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 47df71c..682fbb5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,6 +40,23 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a shell says so in a dialog (during setup the host attempt fails with it). Stop (leave, close, cancel during setup, a failed own join) sends `stop`, waits up to 3 s for exit, then kills the isolate. +- **Solo session** (watching alone): the same hosting path, so nothing + about solo is a second code path or on the wire. Putting something up + while offline (`AppShellState._putUp`) calls `SessionState.startSolo`: + a `ServerIsolate` with `loopbackOnly` (an extra `'loopbackOnly': true` + key next to the `AppConfig` map; `SyncServer` binds 127.0.0.1 instead + of every IPv4 interface), port 0 (a free one), no password, a host key; + the app joins it and `SessionState.solo` is set. Everything a session + has works (Jellyfin progress, next episode, skip pills, the player); a + link goes up direct (no relay mode). Invite friends opens the Host card, + whose submit (`SessionState.host` while solo) remembers the + coordinator's `lastPick` (the last `swapLink` / `swapJellyfin` / + `swapFile` of this connection) and position, leaves and stops the solo + server, hosts on the card's port and password, swaps the same pick and + seeks once a state shows it (`switching` is set meanwhile; the shell + closes the player and reopens it as it was). If hosting fails or is + cancelled, solo starts again with the same media. Stop leaves without + asking. - **Dedicated server**: `bin/server.dart` compiled with `dart compile exe` and shipped next to the app as `watch-together-server`. It parses flags (`--port`, `--secret`, `--name`, `--media`, `--advertise-host`, @@ -673,8 +690,11 @@ always scrolls the page). Space never presses a focused button - **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's), the Library / Schedule / Settings tabs with a growing underline (an - `HScroll` when narrow), the update indicator, then offline Join and Host - (connect cards), online the party: `AvatarStack` (host first, then + `HScroll` when narrow), the update indicator, then offline a grey dot, + "Offline", Join a friend and Host (connect cards; icon-less "Join" and + "Host" under 1100 px), solo a "Solo · only you" chip, Invite friends + (the Host card, titled "Invite friends", "Start hosting") and Stop (no + dialog; no avatars or chat), online the party: `AvatarStack` (host first, then ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and "ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave (asks first). The stack opens the members card (status, host / relay @@ -702,7 +722,8 @@ always scrolls the page). Space never presses a focused button to watch is picked in the library afterwards. Submit state (busy, cancel, per-card errors) is `ConnectFlow`, owned by the shell so an attempt and its error outlive the card; a card closes itself once - online. + in a server that is not solo. Join says that a friend gives the address + and that no account or Jellyfin is needed. - **Profile** (`profile_card.dart`, `screens/home/profile.dart`): a card tinted with your colour: picture (hover "Change" opens `CropPicker`: drag, wheel or slider zoom, previews at 88/42/24 px, exports a 256 px @@ -711,6 +732,14 @@ always scrolls the page). Space never presses a focused button the `connect_form` box (an old picture path is shrunk once and kept as bytes). Online, a name change (once typing settles) or picture change goes out through `Profile.push` (`coordinator.updateProfile`). +- **Welcome** (`welcome.dart`): offline the Library opens with + `WelcomeBand` over the source switch (sizes away when online): the app + icon, Starsync and "Watch together, in sync", then three `WayCard`s, + Join a friend first (filled), Host, Watch solo (opens Files, or + Jellyfin when linked); beside the name from 940 px, under it below, + stacked under 520 px. With no Jellyfin server linked the Jellyfin source + is `NoJellyfinPanel`: Files and Link cards ("No account needed") and an + optional Jellyfin card whose "Link a server" opens the sign-in in place. - **Tabs**: Library (`library_tab.dart`): a source switch (Jellyfin / Link / Files, fade through) with the Jellyfin library chips beside it. Its state (source, link field and relay mode, selected Jellyfin card, @@ -730,8 +759,10 @@ always scrolls the page). Space never presses a focused button is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in an isolate, videos grouped by series; series start collapsed, a header click opens one and open ones stay open for the app session - (`showFilesFolder` opens a folder from outside). Offline everything browses; the buttons say "Join or host to - watch together". Schedule (`schedule/`) is a week of day columns built + (`showFilesFolder` opens a folder from outside). With nobody else + watching (`solo` on the Library, Files and Schedule: offline or solo) + the buttons say Play, Link has no relay modes and Files no relay notice; + offline a put-up starts a solo session. Schedule (`schedule/`) is a week of day columns built from AniList's airing schedule (`anilist.dart`: pages cached 10 min in memory and 1 h on disk, stale data shown on 429 or failure) matched against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token; @@ -755,9 +786,9 @@ always scrolls the page). Space never presses a focused button into videos (the Files extensions, `kindOf`), a folder, an http(s) link, an image or nothing; videos win over folders, folders over images. `HandoffCard` opens as a popover at the pointer: a video goes up through - the shell's put-up (offline: into Files → Recent) or adds its folder to - Files; a folder is added to Files and opened; a link fills Library → - Link (online the relay mode is picked there). Images are ignored. + the shell's put-up (Play when alone; offline that starts solo) or adds + its folder to Files; a folder is added to Files and opened; a link fills + Library → Link (with others the relay mode is picked there). Images are ignored. Nothing happens while a dialog or popover is over the shell. - **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready, unready, force-ready, join, leave, swap and others' chat (feed diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 3d1efe4..649750d 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -44,7 +44,13 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format: - **Dedicated server**: `watch-together-server`, a separate program in the release tarball, for an always-on box. See the README. -Friends join either way with Join in the strip at the top of the window. +Friends join either way with Join a friend in the strip at the top of the +window; guests need no Jellyfin or account. + +- **Watch solo**: put something up without joining or hosting and the + app hosts a private server reachable only from this computer, so + watching alone works like a session (progress, next episode). Invite + friends turns it into a normal hosted server, video and position kept. ## One session, start to finish diff --git a/packages/server/lib/src/serve.dart b/packages/server/lib/src/serve.dart index e553e0b..dd64fc3 100644 --- a/packages/server/lib/src/serve.dart +++ b/packages/server/lib/src/serve.dart @@ -18,7 +18,8 @@ import 'sync_server.dart'; /// a file on this machine the hosted file (the hosting app's in-session file /// swap; a following `swap` with its name and size puts it up) and answers /// `{'ok':true,'name':n,'size':s}` or `{'ok':false}`. Without [events] a -/// start failure throws. +/// start failure throws. `'loopbackOnly': true` in [config] (next to the +/// [AppConfig] keys) binds 127.0.0.1 only: the app's solo session. Future serve(Map config, {SendPort? events}) async { StreamSubscription? logs; if (events != null) { @@ -32,7 +33,7 @@ Future serve(Map config, {SendPort? events}) async { } final SyncServer server; try { - server = SyncServer(AppConfig.fromMap(config)); + server = SyncServer(AppConfig.fromMap(config), loopbackOnly: config['loopbackOnly'] == true); await server.start(); } catch (e) { await logs?.cancel(); diff --git a/packages/server/lib/src/sync_server.dart b/packages/server/lib/src/sync_server.dart index e8ab192..4276e18 100644 --- a/packages/server/lib/src/sync_server.dart +++ b/packages/server/lib/src/sync_server.dart @@ -48,8 +48,12 @@ class SyncServer { /// Upstream connections of the Jellyfin relay; created on first use. HttpClient? _relayClient; + /// Listen on 127.0.0.1 only (the app's solo session), not on every + /// IPv4 interface. + final bool loopbackOnly; + /// [ytDlp] replaces the server's own (tests). - SyncServer(this.config, {YtDlp? ytDlp}) { + SyncServer(this.config, {YtDlp? ytDlp, this.loopbackOnly = false}) { hub = ServerHub(sessions, config.secret, hostKey: config.hostKey, jellyfin: config.jellyfin, @@ -131,7 +135,7 @@ class SyncServer { await _resolveJellyfinServerId(); _mountRoutes(); _server = await _app.serve( - address: InternetAddress.anyIPv4, + address: loopbackOnly ? InternetAddress.loopbackIPv4 : InternetAddress.anyIPv4, port: config.port, // A killed isolate never closes its listening socket, so the port stays // bound in this process. Shared lets the next server in the same