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 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 23:08:11 +03:00
1 parent 8186fbb5d3
commit e0db5cc655
28 files changed
+1013 -345

No files matched your search

+21 -4
View File
@@ -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 must run the same protocol version, and a mismatch is refused with a message
telling you to update. 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 ## The window
One window. Along the top, the strip: your avatar (click it for your 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 profile: name, picture with a crop editor, colour, and links to the
settings), the Library / Schedule / Settings tabs, and the party. Offline 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, 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 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 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 text field, and a small card asks how to open it (put it up, add the
folder to Files, or fill Link). 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 switch, then one **Continue** row (what you were watching and what comes
next, one card per series, the one you watched most recently first; next, one card per series, the one you watched most recently first;
series not touched for two months drop out) and **Latest in** each 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 - **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. (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 Put it up and the player opens. Not in a server, the buttons say Play
goes up until you join or host ("Join or host to watch together"). and it plays for you alone ([Watch solo](#getting-started)).
## Dedicated server ## Dedicated server
+98 -115
View File
@@ -10,9 +10,6 @@ import 'thumb.dart';
// The Files page's rows and cards: a series header, an episode row (also // 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. // 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. /// What a play control is doing right now.
enum PlayState { idle, starting, playing } 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)`. /// Cleaned title of a loose video: `Title (2019)`.
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})'; 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 { class SeriesHeader extends StatelessWidget {
final SeriesGroup group; final SeriesGroup group;
final ThumbCache thumbs; final ThumbCache thumbs;
@@ -110,9 +103,8 @@ class FileRow extends StatelessWidget {
final String? size; final String? size;
final PlayState state; final PlayState state;
final ThumbCache thumbs; final ThumbCache thumbs;
/// Null disables the row ([offline] adds the reason as a tooltip). /// Null disables the row.
final VoidCallback? onTap; final VoidCallback? onTap;
final bool offline;
final bool indent; final bool indent;
final Widget? trailing; final Widget? trailing;
const FileRow({ const FileRow({
@@ -124,7 +116,6 @@ class FileRow extends StatelessWidget {
required this.state, required this.state,
required this.thumbs, required this.thumbs,
required this.onTap, required this.onTap,
required this.offline,
this.indent = true, this.indent = true,
this.trailing, this.trailing,
}); });
@@ -148,62 +139,59 @@ class FileRow extends StatelessWidget {
builder: (context, t, _) { builder: (context, t, _) {
final duration = durationText(t); final duration = durationText(t);
final meta = [?duration, ?size].join(' · '); final meta = [?duration, ?size].join(' · ');
return _offlineTip( return Material(
offline, color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
Material( borderRadius: BorderRadius.circular(10),
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent, child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
child: InkWell( child: Padding(
onTap: onTap, padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5),
borderRadius: BorderRadius.circular(10), child: Row(
child: Padding( children: [
padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5), ClipRRect(
child: Row( borderRadius: BorderRadius.circular(6),
children: [ child: ThumbImage(t, width: 80, height: 45),
ClipRRect( ),
borderRadius: BorderRadius.circular(6), const SizedBox(width: 10),
child: ThumbImage(t, width: 80, height: 45), SizedBox(width: 20, child: Center(child: icon)),
), const SizedBox(width: 10),
const SizedBox(width: 10), Expanded(
SizedBox(width: 20, child: Center(child: icon)), child: Column(
const SizedBox(width: 10), crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: Column( Row(
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Flexible(
Row( child: Text(
children: [ title,
Flexible( maxLines: 1,
child: Text( overflow: TextOverflow.ellipsis,
title, style: TextStyle(
maxLines: 1, fontWeight: FontWeight.w600,
overflow: TextOverflow.ellipsis, color: playing ? scheme.primary : null,
style: TextStyle(
fontWeight: FontWeight.w600,
color: playing ? scheme.primary : null,
),
), ),
), ),
if (playing) ...[ ),
const SizedBox(width: 8), if (playing) ...[
Pill('now playing', scheme.primary), const SizedBox(width: 8),
], Pill('now playing', scheme.primary),
], ],
), ],
Text(subtitle, ),
maxLines: 1, Text(subtitle,
overflow: TextOverflow.ellipsis, maxLines: 1,
style: TextStyle(color: muted, fontSize: 12)), overflow: TextOverflow.ellipsis,
], style: TextStyle(color: muted, fontSize: 12)),
), ],
), ),
if (meta.isNotEmpty) ...[ ),
const SizedBox(width: 12), if (meta.isNotEmpty) ...[
Text(meta, style: TextStyle(color: muted, fontSize: 12)), const SizedBox(width: 12),
], Text(meta, style: TextStyle(color: muted, fontSize: 12)),
?trailing,
], ],
), ?trailing,
],
), ),
), ),
), ),
@@ -223,14 +211,12 @@ class VideoCard extends StatelessWidget {
final PlayState state; final PlayState state;
final ThumbCache thumbs; final ThumbCache thumbs;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool offline;
const VideoCard({ const VideoCard({
super.key, super.key,
required this.video, required this.video,
required this.state, required this.state,
required this.thumbs, required this.thumbs,
required this.onTap, required this.onTap,
required this.offline,
}); });
@override @override
@@ -242,65 +228,62 @@ class VideoCard extends StatelessWidget {
path: video.path, path: video.path,
builder: (context, t, _) { builder: (context, t, _) {
final duration = durationText(t); final duration = durationText(t);
return _offlineTip( return InkWell(
offline, onTap: onTap,
InkWell( borderRadius: BorderRadius.circular(12),
onTap: onTap, child: Column(
borderRadius: BorderRadius.circular(12), crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, AspectRatio(
children: [ aspectRatio: 16 / 9,
AspectRatio( child: Container(
aspectRatio: 16 / 9, decoration: BoxDecoration(
child: Container( borderRadius: BorderRadius.circular(12),
decoration: BoxDecoration( border: Border.all(
borderRadius: BorderRadius.circular(12), color: playing ? scheme.primary : Colors.transparent,
border: Border.all( width: 2,
color: playing ? scheme.primary : Colors.transparent,
width: 2,
),
), ),
child: ClipRRect( ),
borderRadius: BorderRadius.circular(10), child: ClipRRect(
child: Stack( borderRadius: BorderRadius.circular(10),
fit: StackFit.expand, child: Stack(
children: [ fit: StackFit.expand,
ThumbImage(t), children: [
if (duration != null) ThumbImage(t),
Positioned(right: 6, bottom: 6, child: _Badge(duration)), if (duration != null)
if (state != PlayState.idle) Positioned(right: 6, bottom: 6, child: _Badge(duration)),
Container( if (state != PlayState.idle)
color: Colors.black38, Container(
alignment: Alignment.center, color: Colors.black38,
child: state == PlayState.playing alignment: Alignment.center,
? const Icon(Icons.graphic_eq, color: Colors.white, size: 32) child: state == PlayState.playing
: const SizedBox.square( ? const Icon(Icons.graphic_eq, color: Colors.white, size: 32)
dimension: 28, : const SizedBox.square(
child: CircularProgressIndicator( dimension: 28,
strokeWidth: 3, color: Colors.white)), child: CircularProgressIndicator(
), strokeWidth: 3, color: Colors.white)),
], ),
), ],
), ),
), ),
), ),
const SizedBox(height: 6), ),
Flexible( const SizedBox(height: 6),
child: Text( Flexible(
cleanTitle(video.parsed), child: Text(
maxLines: 2, cleanTitle(video.parsed),
overflow: TextOverflow.ellipsis, maxLines: 2,
style: TextStyle( overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w600, style: TextStyle(
fontSize: 13, fontWeight: FontWeight.w600,
color: playing ? scheme.primary : null, 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)),
), ],
), ),
); );
}, },
+7 -15
View File
@@ -48,8 +48,8 @@ void resetFilesPageSession() {
} }
class FilesPage extends StatefulWidget { class FilesPage extends StatefulWidget {
/// In a session: false disables playing (browsing still works). /// Nobody else watches (offline or solo): no relay notice.
final bool online; final bool solo;
/// The file will stream from this computer through the server. /// The file will stream from this computer through the server.
final bool relayed; final bool relayed;
/// Plays [path] in the session; returns an error text, or null. /// Plays [path] in the session; returns an error text, or null.
@@ -67,7 +67,7 @@ class FilesPage extends StatefulWidget {
const FilesPage({ const FilesPage({
super.key, super.key,
required this.online, this.solo = false,
required this.relayed, required this.relayed,
required this.onPlay, required this.onPlay,
this.nowPlayingPath, this.nowPlayingPath,
@@ -207,7 +207,7 @@ class _FilesPageState extends State<FilesPage> {
} }
Future<void> _play(String path) async { Future<void> _play(String path) async {
if (!widget.online || _starting != null) return; if (_starting != null) return;
setState(() { setState(() {
_starting = path; _starting = path;
_error = null; _error = null;
@@ -236,7 +236,7 @@ class _FilesPageState extends State<FilesPage> {
? PlayState.playing ? PlayState.playing
: PlayState.idle; : PlayState.idle;
VoidCallback? _tapOf(String path) => widget.online ? () => _play(path) : null; VoidCallback _tapOf(String path) => () => _play(path);
@override @override
Widget build(BuildContext context) => LayoutBuilder( Widget build(BuildContext context) => LayoutBuilder(
@@ -264,9 +264,7 @@ class _FilesPageState extends State<FilesPage> {
}, },
); );
Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => widget.online Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => build(_browse);
? build(_browse)
: Tooltip(message: offlinePlayHint, child: build(null));
Widget _sidebar(BuildContext context, List<String> folders, String? open) { Widget _sidebar(BuildContext context, List<String> folders, String? open) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
@@ -345,7 +343,7 @@ class _FilesPageState extends State<FilesPage> {
final top = <Widget>[ final top = <Widget>[
_header(context, open), _header(context, open),
if (narrow) _chips(folders, open), if (narrow) _chips(folders, open),
if (widget.online && widget.relayed) const _RelayNotice(), if (!widget.solo && widget.relayed) const _RelayNotice(),
AnimatedSize( AnimatedSize(
duration: easeTime, duration: easeTime,
curve: ease, curve: ease,
@@ -476,7 +474,6 @@ class _FilesPageState extends State<FilesPage> {
state: _stateOf(path), state: _stateOf(path),
thumbs: _thumbs, thumbs: _thumbs,
onTap: _tapOf(path), onTap: _tapOf(path),
offline: !widget.online,
onRemove: () => _store.removeRecent(path), onRemove: () => _store.removeRecent(path),
); );
}, },
@@ -594,7 +591,6 @@ class _FilesPageState extends State<FilesPage> {
state: _stateOf(v.path), state: _stateOf(v.path),
thumbs: _thumbs, thumbs: _thumbs,
onTap: _tapOf(v.path), onTap: _tapOf(v.path),
offline: !widget.online,
); );
}, },
), ),
@@ -630,7 +626,6 @@ class _FilesPageState extends State<FilesPage> {
state: _stateOf(v.path), state: _stateOf(v.path),
thumbs: _thumbs, thumbs: _thumbs,
onTap: _tapOf(v.path), onTap: _tapOf(v.path),
offline: !widget.online,
); );
}, },
); );
@@ -647,7 +642,6 @@ class _RecentRow extends StatefulWidget {
final PlayState state; final PlayState state;
final ThumbCache thumbs; final ThumbCache thumbs;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool offline;
final VoidCallback onRemove; final VoidCallback onRemove;
const _RecentRow({ const _RecentRow({
super.key, super.key,
@@ -655,7 +649,6 @@ class _RecentRow extends StatefulWidget {
required this.state, required this.state,
required this.thumbs, required this.thumbs,
required this.onTap, required this.onTap,
required this.offline,
required this.onRemove, required this.onRemove,
}); });
@@ -688,7 +681,6 @@ class _RecentRowState extends State<_RecentRow> {
state: widget.state, state: widget.state,
thumbs: widget.thumbs, thumbs: widget.thumbs,
onTap: missing ? null : widget.onTap, onTap: missing ? null : widget.onTap,
offline: widget.offline && !missing,
indent: false, indent: false,
trailing: IconButton( trailing: IconButton(
tooltip: 'Remove from recent', tooltip: 'Remove from recent',
@@ -32,16 +32,16 @@ typedef _Data = ({
}); });
/// The selected card's details, at the right of the library: banner, /// The selected card's details, at the right of the library: banner,
/// title, meta, "Put it up · S1E3" (a disabled Play with [offlineHint] /// title, meta, "Put it up · S1E3" ("Play · S1E3" when watching
/// offline), mark watched / unwatched, remove from Continue, overview and, /// [solo]), mark watched / unwatched, remove from Continue, overview and,
/// for a series or an episode, the episode list (click picks which /// for a series or an episode, the episode list (click picks which
/// episode goes up, double-click puts it up). Reloads when the /// episode goes up, double-click puts it up). Reloads when the
/// [controller]'s data version moves. /// [controller]'s data version moves.
class JellyfinDetailsPanel extends StatefulWidget { class JellyfinDetailsPanel extends StatefulWidget {
final BrowseController controller; final BrowseController controller;
final JellyfinSelection selection; final JellyfinSelection selection;
/// Null when online; else why nothing can go up. /// Nobody else watches (offline or solo): the button says Play.
final String? offlineHint; final bool solo;
/// Puts [item] (playable) up; returns an error text, or null when sent. /// Puts [item] (playable) up; returns an error text, or null when sent.
final Future<String?> Function(JellyfinSelection item) onPutUp; final Future<String?> Function(JellyfinSelection item) onPutUp;
final VoidCallback onClose; final VoidCallback onClose;
@@ -51,7 +51,7 @@ class JellyfinDetailsPanel extends StatefulWidget {
required this.selection, required this.selection,
required this.onPutUp, required this.onPutUp,
required this.onClose, required this.onClose,
this.offlineHint, this.solo = false,
}); });
@override @override
@@ -104,7 +104,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
_cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId)); _cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId));
Future<void> _putUp(JellyfinItem target) async { Future<void> _putUp(JellyfinItem target) async {
if (_busy || widget.offlineHint != null) return; if (_busy) return;
setState(() { setState(() {
_busy = true; _busy = true;
_error = null; _error = null;
@@ -253,14 +253,8 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}', if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
if (!isSeries && shown.userData.played) 'Watched', if (!isSeries && shown.userData.played) 'Watched',
].join(' · '); ].join(' · ');
final hint = widget.offlineHint; final verb = widget.solo ? 'Play' : 'Put it up';
final label = playable == null final label = playable != null && isSeries ? '$verb · ${playable.episodeLabel ?? playable.name}' : verb;
? (hint == null ? 'Put it up' : 'Play')
: hint != null
? 'Play'
: isSeries
? 'Put it up · ${playable.episodeLabel ?? playable.name}'
: 'Put it up';
final inContinue = playable != null && final inContinue = playable != null &&
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable))); (playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
final left = playable == null ? null : timeLeft(playable); final left = playable == null ? null : timeLeft(playable);
@@ -307,7 +301,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
children: [ children: [
FilledButton.icon( FilledButton.icon(
key: const ValueKey('details-put-up'), key: const ValueKey('details-put-up'),
onPressed: playable == null || hint != null || _busy ? null : () => _putUp(playable), onPressed: playable == null || _busy ? null : () => _putUp(playable),
icon: _busy icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)) ? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow), : const Icon(Icons.play_arrow),
@@ -336,12 +330,12 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
), ),
], ],
), ),
if (hint != null || _error != null) ...[ if (_error != null) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
_error ?? hint!, _error!,
key: const ValueKey('details-hint'), 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) ...[ if (isSeries && playable != null) ...[
@@ -7,7 +7,7 @@ import 'browser.dart';
import 'sign_in_panel.dart'; import 'sign_in_panel.dart';
/// The Library's Jellyfin source: the library browser once a server is /// 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 { class JellyfinPickerSource extends StatefulWidget {
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
final BrowseController? controller; final BrowseController? controller;
@@ -17,6 +17,8 @@ class JellyfinPickerSource extends StatefulWidget {
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
final BrowseStart? start; final BrowseStart? start;
final bool libraryStrip; final bool libraryStrip;
/// Shown while no server is linked.
final WidgetBuilder? empty;
const JellyfinPickerSource({ const JellyfinPickerSource({
super.key, super.key,
this.start, this.start,
@@ -27,6 +29,7 @@ class JellyfinPickerSource extends StatefulWidget {
this.selected, this.selected,
this.onOpenIntegrations, this.onOpenIntegrations,
this.libraryStrip = true, this.libraryStrip = true,
this.empty,
}); });
@override @override
@@ -49,6 +52,7 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
if (!a.loaded) { if (!a.loaded) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2)); 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); if (a.isEmpty) return _LinkFirst(accounts: a, onOpenIntegrations: widget.onOpenIntegrations);
return JellyfinBrowser( return JellyfinBrowser(
key: const ValueKey('media-jellyfin'), key: const ValueKey('media-jellyfin'),
@@ -13,8 +13,8 @@ enum CardAction {
none, none,
putUp, putUp,
/// Available, but not in a server: shown disabled with a hint. /// Available, nobody else watching (offline or solo): plays it here.
playOffline, play,
request, request,
requestSeries, requestSeries,
requested, requested,
@@ -162,15 +162,10 @@ class AiringCard extends StatelessWidget {
final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)); final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2));
Widget? b = switch (action) { Widget? b = switch (action) {
CardAction.none => null, CardAction.none => null,
CardAction.putUp => FilledButton.tonalIcon( CardAction.putUp || CardAction.play => FilledButton.tonalIcon(
onPressed: enabled ? onAction : null, onPressed: enabled ? onAction : null,
icon: busy ? spinner : const Icon(Icons.play_arrow, size: 18), icon: busy ? spinner : const Icon(Icons.play_arrow, size: 18),
label: const Text('Put it up'), label: Text(action == CardAction.play ? 'Play' : 'Put it up'),
),
CardAction.playOffline => FilledButton.tonalIcon(
onPressed: null,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Play'),
), ),
CardAction.request || CardAction.requestSeries => OutlinedButton.icon( CardAction.request || CardAction.requestSeries => OutlinedButton.icon(
onPressed: enabled ? onAction : null, 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) { if (compact) {
final requestable = action == CardAction.request || action == CardAction.requestSeries; final requestable = action == CardAction.request || action == CardAction.requestSeries;
return Material( return Material(
@@ -21,8 +21,9 @@ import 'schedule_card.dart';
enum ScheduleFilter { jellyfin, following, all } enum ScheduleFilter { jellyfin, following, all }
class SchedulePage extends StatefulWidget { class SchedulePage extends StatefulWidget {
/// In a server: available episodes can be put up. /// Nobody else watches (offline or solo): available episodes say Play
final bool online; /// (offline that starts a solo session).
final bool solo;
/// Puts a Jellyfin episode up in the server; returns an error to show. /// Puts a Jellyfin episode up in the server; returns an error to show.
final Future<String?> Function(JellyfinMedia pick) onPutUp; final Future<String?> Function(JellyfinMedia pick) onPutUp;
@@ -36,7 +37,7 @@ class SchedulePage extends StatefulWidget {
const SchedulePage({ const SchedulePage({
super.key, super.key,
required this.online, this.solo = false,
required this.onPutUp, required this.onPutUp,
required this.onToast, required this.onToast,
this.repository, this.repository,
@@ -215,9 +216,9 @@ class _SchedulePageState extends State<SchedulePage> {
(CardAction, String?) _actionFor(ScheduleEntry e) { (CardAction, String?) _actionFor(ScheduleEntry e) {
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) { if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
if (!widget.online) return (CardAction.playOffline, 'Join or host a server to put it up'); final put = widget.solo ? CardAction.play : CardAction.putUp;
if (e.jellyfinSeriesId == null) return (CardAction.putUp, 'Not linked to a Jellyfin series yet'); if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet');
return (CardAction.putUp, null); return (put, null);
} }
final me = _me; final me = _me;
final CardAction kind; final CardAction kind;
@@ -242,6 +243,7 @@ class _SchedulePageState extends State<SchedulePage> {
Future<void> _onAction(ScheduleEntry e, CardAction action) async { Future<void> _onAction(ScheduleEntry e, CardAction action) async {
switch (action) { switch (action) {
case CardAction.putUp: case CardAction.putUp:
case CardAction.play:
return _putUp(e); return _putUp(e);
case CardAction.request: case CardAction.request:
case CardAction.requestSeries: case CardAction.requestSeries:
@@ -530,7 +532,7 @@ class _SchedulePageState extends State<SchedulePage> {
Widget _card(ScheduleEntry e, DateTime now) { Widget _card(ScheduleEntry e, DateTime now) {
final (action, hint) = _actionFor(e); 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( return AiringCard(
entry: e, entry: e,
now: now, now: now,
@@ -277,6 +277,10 @@ class SessionCoordinator {
int _relaySourceMediaId = -1; int _relaySourceMediaId = -1;
/// This member serves the current media's bytes through the server. /// This member serves the current media's bytes through the server.
final relaying = signal<bool>(false); final relaying = signal<bool>(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 // Server seek applied to the player (event id and when), checked once it
// should have landed. // should have landed.
int? _pendingSeekEvent; int? _pendingSeekEvent;
@@ -402,6 +406,7 @@ class SessionCoordinator {
_seekGate.reset(); _seekGate.reset();
_clearPendingSeek(); _clearPendingSeek();
_lastEventId = -1; _lastEventId = -1;
_lastPick = null;
_resetJellyfin(); _resetJellyfin();
_jfToastShown = false; _jfToastShown = false;
if (!_jellyfinListening) { if (!_jellyfinListening) {
@@ -1235,6 +1240,7 @@ class SessionCoordinator {
/// link, or relayed per [relay]. Returns an error text, or null when sent. /// link, or relayed per [relay]. Returns an error text, or null when sent.
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async { Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async {
markInput(); markInput();
_lastPick = LinkMedia(url, relay: relay);
final media = await mediaForLink(jellyfin, url); final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) { if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url); _pendingOrigin = _JellyfinOrigin(url);
@@ -1304,6 +1310,7 @@ class SessionCoordinator {
/// relayed by this member for viewers without an account. /// relayed by this member for viewers without an account.
void swapJellyfin(JellyfinMedia media) { void swapJellyfin(JellyfinMedia media) {
markInput(); markInput();
_lastPick = media;
_pendingOrigin = const _JellyfinOrigin(null); _pendingOrigin = const _JellyfinOrigin(null);
_swap(mediaForPick(media).copyWith(relay: const MediaRelay())); _swap(mediaForPick(media).copyWith(relay: const MediaRelay()));
} }
@@ -1313,6 +1320,7 @@ class SessionCoordinator {
/// when sent. /// when sent.
Future<String?> swapFile(String path) async { Future<String?> swapFile(String path) async {
markInput(); markInput();
_lastPick = FileMedia(path);
final host = hostFile; final host = hostFile;
if (host != null) { if (host != null) {
final file = await host(path); final file = await host(path);
+72 -12
View File
@@ -13,6 +13,7 @@ import '../jellyfin/accounts.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart; import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../screens/home/connect_flow.dart'; import '../screens/home/connect_flow.dart';
import '../session/session_coordinator.dart' show ConnectParams;
import '../screens/home/health_probe.dart'; import '../screens/home/health_probe.dart';
import '../screens/home/media_picker.dart'; import '../screens/home/media_picker.dart';
import '../screens/home/profile.dart'; import '../screens/home/profile.dart';
@@ -31,6 +32,7 @@ import '../schedule/ui/schedule_page.dart';
import 'session_player.dart'; import 'session_player.dart';
import 'settings_tab.dart'; import 'settings_tab.dart';
import 'toasts.dart'; import 'toasts.dart';
import 'welcome.dart';
/// The one window: the party strip over a body that fades through between /// 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 /// the tabs and the full player, the floating mini player, the chat panel
@@ -116,6 +118,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final _stops = <void Function()>[]; final _stops = <void Function()>[];
// Rebuild the shell on these flips, not on every state broadcast. // Rebuild the shell on these flips, not on every state broadcast.
final _online = computed(() => SessionState.instance.route.value == AppRoute.session); 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(() => final _hasMedia = computed(() =>
SessionState.instance.route.value == AppRoute.session && SessionState.instance.route.value == AppRoute.session &&
SessionState.instance.coordinator.serverState.value?.media != null); SessionState.instance.coordinator.serverState.value?.media != null);
@@ -190,6 +197,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
} }
wasOut = out; 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. // The hosted server died mid-session (SessionState leaves): say so.
_stops.add(effect(() { _stops.add(effect(() {
final crash = session.serverCrash.value; final crash = session.serverCrash.value;
@@ -209,6 +227,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
HardwareKeyboard.instance.removeHandler(_onKey); HardwareKeyboard.instance.removeHandler(_onKey);
_fade.dispose(); _fade.dispose();
_online.dispose(); _online.dispose();
_alone.dispose();
_hasMedia.dispose(); _hasMedia.dispose();
_flow.dispose(); _flow.dispose();
_join.dispose(); _join.dispose();
@@ -343,7 +362,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_selectTab(ShellTab.library); _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<String?> _putUp(PickedMedia media) async { Future<String?> _putUp(PickedMedia media) async {
final session = SessionState.instance; final session = SessionState.instance;
if (media is JellyfinMedia) { if (media is JellyfinMedia) {
@@ -354,6 +374,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
} }
unawaited(_jellyfin.rememberPick(media)); 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); final error = await session.coordinator.swapPicked(media);
if (error != null || !mounted) return error; if (error != null || !mounted) return error;
final f = session.lastForm; final f = session.lastForm;
@@ -366,6 +392,29 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
return null; return null;
} }
/// Starts a solo session; returns an error text, or null.
Future<String?> _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 // --------------------------------------------------------------- handoff
/// Nothing (a dialog, a popover) is over the shell. /// Nothing (a dialog, a popover) is over the shell.
@@ -406,11 +455,10 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final align = at == null || size.isEmpty final align = at == null || size.isEmpty
? Alignment.center ? Alignment.center
: Alignment((at.dx / size.width * 2 - 1).clamp(-1, 1), (at.dy / size.height * 2 - 1).clamp(-1, 1)); : 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; _handoffOpen = true;
final HandoffAction? action; final HandoffAction? action;
try { try {
action = await showPopover<HandoffAction>(context, at: align, child: HandoffCard(item: item, online: online)); action = await showPopover<HandoffAction>(context, at: align, child: HandoffCard(item: item, solo: _alone.peek()));
} finally { } finally {
_handoffOpen = false; _handoffOpen = false;
} }
@@ -424,10 +472,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
await FolderStore.instance.ensureLoaded(); await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first); 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): case (HandoffVideos(:final paths), HandoffAction.addFolder):
await _addFolder(File(paths.first).parent.path); await _addFolder(File(paths.first).parent.path);
case (HandoffFolder(:final path), _): case (HandoffFolder(:final path), _):
@@ -484,7 +528,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// --------------------------------------------------------------- dialogs // --------------------------------------------------------------- dialogs
/// Leave asks first; a solo session just stops.
Future<void> _confirmLeave() async { Future<void> _confirmLeave() async {
final session = SessionState.instance;
if (session.solo.peek()) {
await session.leave();
return;
}
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -542,6 +592,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
backgroundColor: _fullscreen ? Colors.black : scheme.surface, backgroundColor: _fullscreen ? Colors.black : scheme.surface,
body: SignalBuilder(builder: (context) { body: SignalBuilder(builder: (context) {
final online = _online.value; final online = _online.value;
final alone = _alone.value;
final hasMedia = _hasMedia.value; final hasMedia = _hasMedia.value;
return Column( return Column(
children: [ children: [
@@ -556,6 +607,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onJoin: () => _connect(ConnectMode.join), onJoin: () => _connect(ConnectMode.join),
onHost: () => _connect(ConnectMode.host), onHost: () => _connect(ConnectMode.host),
onLeave: _confirmLeave, onLeave: _confirmLeave,
onInvite: () => _connect(ConnectMode.host),
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates), onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null, onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
updates: widget.updates, updates: widget.updates,
@@ -565,7 +617,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
key: const ValueKey('body'), key: const ValueKey('body'),
child: Row( child: Row(
children: [ children: [
Expanded(child: _body(online)), Expanded(child: _body(online, alone)),
AnimatedSize( AnimatedSize(
duration: easeTime, duration: easeTime,
curve: ease, curve: ease,
@@ -589,21 +641,29 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
); );
} }
Widget _body(bool online) { Widget _body(bool online, bool alone) {
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
final tabBody = switch (_tab) { final tabBody = switch (_tab) {
ShellTab.library => LibraryTab( ShellTab.library => LibraryTab(
key: ValueKey('library-$_libraryGen'), key: ValueKey('library-$_libraryGen'),
controller: _picker, controller: _picker,
accounts: _jellyfin, accounts: _jellyfin,
online: online, solo: alone,
isHost: coord.hostFile != null, // 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, jellyfinStart: _libraryStart,
onPutUp: _putUp, onPutUp: _putUp,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations), onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
), ),
ShellTab.schedule => SchedulePage( ShellTab.schedule => SchedulePage(
online: online, solo: alone,
onPutUp: _putUp, onPutUp: _putUp,
onToast: coord.showToast, onToast: coord.showToast,
), ),
+26 -10
View File
@@ -41,8 +41,9 @@ class HostFields {
} }
} }
/// The strip's Join / Host buttons: a card over the window. It closes /// The strip's Join / Host buttons (and solo's Invite friends, the Host
/// itself once the attempt got in; a failed one shows its error inside. /// card): a card over the window. It closes itself once the attempt got
/// in; a failed one shows its error inside.
Future<void> showConnectCard( Future<void> showConnectCard(
BuildContext context, { BuildContext context, {
required ConnectMode mode, required ConnectMode mode,
@@ -77,7 +78,9 @@ class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
void initState() { void initState() {
super.initState(); super.initState();
_stop = effect(() { _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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).maybePop(); if (mounted) Navigator.of(context).maybePop();
}); });
@@ -153,7 +156,7 @@ class JoinCard extends StatelessWidget {
builder: (context, _) { builder: (context, _) {
final busy = flow.busy == ConnectMode.join; final busy = flow.busy == ConnectMode.join;
return _Card( return _Card(
title: 'Join a server', title: 'Join a friend',
children: [ children: [
SignalBuilder(builder: (context) { SignalBuilder(builder: (context) {
final list = [ final list = [
@@ -221,6 +224,13 @@ class JoinCard extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true), ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.join), 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 @override
Widget build(BuildContext context) { 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( return ListenableBuilder(
listenable: flow, listenable: flow,
builder: (context, _) => _Card( builder: (context, _) => _Card(
title: 'Host a server', title: solo ? 'Invite friends' : 'Host a server',
children: [ children: [
AbsorbPointer( AbsorbPointer(
absorbing: flow.busy == ConnectMode.host, absorbing: flow.busy == ConnectMode.host,
@@ -281,13 +294,16 @@ class HostCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), 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), ConnectFeedback(flow: flow, mode: ConnectMode.host),
const Padding( Padding(
padding: EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text( child: Text(
'You pick what to watch afterwards, in the library.', solo
style: TextStyle(color: Colors.grey, fontSize: 12), ? '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),
), ),
), ),
], ],
+7 -12
View File
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import '../files/scanner.dart' show FileKind, kindOf; import '../files/scanner.dart' show FileKind, kindOf;
import '../files/ui/file_tiles.dart' show baseName; 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 // 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 // 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. /// What the card's buttons ask the shell to do.
enum HandoffAction { enum HandoffAction {
/// Put the (first) video up for everyone. /// Put the (first) video up (offline: watch it solo).
putUp, 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. /// Add the video's folder (or the folder) to Files and open it.
addFolder, addFolder,
/// Open Library → Link with the URL filled. /// Open Library → Link with the URL filled.
@@ -109,8 +106,9 @@ enum HandoffAction {
/// pops the [HandoffAction] chosen. /// pops the [HandoffAction] chosen.
class HandoffCard extends StatelessWidget { class HandoffCard extends StatelessWidget {
final Handoff item; final Handoff item;
final bool online; /// Nobody else watches (offline or solo): videos play, no relay talk.
const HandoffCard({super.key, required this.item, required this.online}); final bool solo;
const HandoffCard({super.key, required this.item, required this.solo});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -128,10 +126,7 @@ class HandoffCard extends StatelessWidget {
baseName(paths.first), baseName(paths.first),
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer', 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, solo ? 'Play' : 'Put it up', primary: true),
button(HandoffAction.putUp, Icons.play_arrow, 'Put it up', primary: true)
else
button(HandoffAction.openInFiles, Icons.history, 'Open in Files', primary: true),
button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'), button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
], ],
), ),
@@ -144,9 +139,9 @@ class HandoffCard extends StatelessWidget {
HandoffLink(:final url) => ( HandoffLink(:final url) => (
Icons.link, Icons.link,
url, 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), primary: true),
], ],
), ),
+15 -11
View File
@@ -189,10 +189,14 @@ String linkRelayHelp(LinkRelay r) => switch (r) {
'while it plays; the link is not shown to anyone.', '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 { class LibraryLinkSource extends StatefulWidget {
final MediaPickerController controller; final MediaPickerController controller;
final String? offlineHint; final bool solo;
/// Puts the link up; returns an error text, or null when sent. /// Puts the link up; returns an error text, or null when sent.
final Future<String?> Function(LinkMedia media) onPutUp; final Future<String?> Function(LinkMedia media) onPutUp;
final RecentLinks recents; final RecentLinks recents;
@@ -202,7 +206,7 @@ class LibraryLinkSource extends StatefulWidget {
required this.controller, required this.controller,
required this.onPutUp, required this.onPutUp,
required this.recents, required this.recents,
this.offlineHint, this.solo = false,
this.probe = defaultLinkProber, this.probe = defaultLinkProber,
}); });
@@ -261,14 +265,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Future<void> _go() async { Future<void> _go() async {
final url = _c.link.text.trim(); final url = _c.link.text.trim();
if (url.isEmpty || _busy || widget.offlineHint != null) return; if (url.isEmpty || _busy) return;
setState(() { setState(() {
_busy = true; _busy = true;
_error = null; _error = null;
}); });
String? error; String? error;
try { 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) { } catch (e) {
error = '$e'; error = '$e';
} }
@@ -292,7 +296,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme; final t = Theme.of(context).textTheme;
final hint = widget.offlineHint; final solo = widget.solo;
final url = _c.link.text.trim(); final url = _c.link.text.trim();
final preview = url.isEmpty ? null : _previews[url]; final preview = url.isEmpty ? null : _previews[url];
final loading = url.isNotEmpty && !_previews.containsKey(url); final loading = url.isNotEmpty && !_previews.containsKey(url);
@@ -312,11 +316,11 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
); );
final button = FilledButton.icon( final button = FilledButton.icon(
key: const ValueKey('link-put-up'), key: const ValueKey('link-put-up'),
onPressed: url.isEmpty || _busy || hint != null ? null : _go, onPressed: url.isEmpty || _busy ? null : _go,
icon: _busy icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)) ? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow), : const Icon(Icons.play_arrow),
label: Text(hint == null ? 'Put it up' : 'Play'), label: Text(solo ? 'Play' : 'Put it up'),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size(0, 56), minimumSize: const Size(0, 56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -361,10 +365,10 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
]) ])
: Row(children: [Expanded(child: field), const SizedBox(width: 12), button]), : Row(children: [Expanded(child: field), const SizedBox(width: 12), button]),
), ),
if (hint != null || _error != null) ...[ if (solo || _error != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
_error ?? hint!, _error ?? soloPlayHint,
key: const ValueKey('link-hint'), key: const ValueKey('link-hint'),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey), style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
@@ -380,7 +384,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
child: _PreviewCard(preview: preview, loading: loading), child: _PreviewCard(preview: preview, loading: loading),
), ),
), ),
if (hint == null) ...[ if (!solo) ...[
const SizedBox(height: 28), const SizedBox(height: 28),
Text('How the others get it', style: t.titleSmall), Text('How the others get it', style: t.titleSmall),
const SizedBox(height: 10), const SizedBox(height: 10),
+29 -16
View File
@@ -16,6 +16,7 @@ import '../screens/home/media_picker.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import 'library_link.dart'; import 'library_link.dart';
import 'motion.dart'; import 'motion.dart';
import 'welcome.dart';
/// What putting up a Jellyfin pick sends: its ref (no URL, no token). /// What putting up a Jellyfin pick sends: its ref (no URL, no token).
/// Throws [FormatException] with a user-facing message when the server is /// Throws [FormatException] with a user-facing message when the server is
@@ -58,9 +59,6 @@ String? nowPlayingFile(Map<String, Object?>? media, String lastPath) {
return name == media['name'] ? lastPath : null; 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. /// Window width under which the details panel floats over the library.
const detailsDockFrom = 1000.0; const detailsDockFrom = 1000.0;
@@ -68,12 +66,13 @@ const detailsDockFrom = 1000.0;
/// through), the Jellyfin library chips beside it, and the source below. /// through), the Jellyfin library chips beside it, and the source below.
/// Jellyfin cards select on click and open their details in a panel at /// Jellyfin cards select on click and open their details in a panel at
/// the right (docked from [detailsDockFrom], floating over the library /// the right (docked from [detailsDockFrom], floating over the library
/// below it); a double-click or the panel's button puts it up. Offline /// below it); a double-click or the panel's button puts it up. With
/// everything browses and the play buttons say [offlinePutUpHint]. /// 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 { class LibraryTab extends StatefulWidget {
final MediaPickerController controller; final MediaPickerController controller;
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
final bool online; final bool solo;
/// This app hosts the server: its files stream from here directly. /// This app hosts the server: its files stream from here directly.
final bool isHost; final bool isHost;
final BrowseStart? jellyfinStart; final BrowseStart? jellyfinStart;
@@ -85,15 +84,17 @@ class LibraryTab extends StatefulWidget {
final RecentLinks? recentLinks; final RecentLinks? recentLinks;
final LinkProber probe; final LinkProber probe;
/// The Files source (tests inject one); defaults to [FilesPage]. /// 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<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder; required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
/// Clock for the Jellyfin Continue row (tests pin it). /// Clock for the Jellyfin Continue row (tests pin it).
final DateTime Function() now; final DateTime Function() now;
/// Over the source switch (the shell's welcome band while offline).
final Widget? banner;
const LibraryTab({ const LibraryTab({
super.key, super.key,
required this.controller, required this.controller,
required this.accounts, required this.accounts,
required this.online, required this.solo,
required this.isHost, required this.isHost,
required this.onPutUp, required this.onPutUp,
this.onPutUpDone, this.onPutUpDone,
@@ -103,6 +104,7 @@ class LibraryTab extends StatefulWidget {
this.probe = defaultLinkProber, this.probe = defaultLinkProber,
this.filesBuilder, this.filesBuilder,
this.now = DateTime.now, this.now = DateTime.now,
this.banner,
}); });
@override @override
@@ -115,7 +117,6 @@ class _LibraryTabState extends State<LibraryTab> {
JellyfinSelection? _shown; JellyfinSelection? _shown;
MediaPickerController get _c => widget.controller; MediaPickerController get _c => widget.controller;
String? get _hint => widget.online ? null : offlinePutUpHint;
@override @override
void initState() { void initState() {
@@ -163,7 +164,6 @@ class _LibraryTabState extends State<LibraryTab> {
/// A Jellyfin card or episode: a series goes up as its next episode. /// A Jellyfin card or episode: a series goes up as its next episode.
Future<String?> _putUpJellyfin(JellyfinSelection sel) async { Future<String?> _putUpJellyfin(JellyfinSelection sel) async {
if (!widget.online) return offlinePutUpHint;
final account = widget.accounts.account(sel.serverId); final account = widget.accounts.account(sel.serverId);
final client = widget.accounts.clientFor(sel.serverId); final client = widget.accounts.clientFor(sel.serverId);
if (account == null || client == null) return 'that Jellyfin server is not linked any more'; if (account == null || client == null) return 'that Jellyfin server is not linked any more';
@@ -181,7 +181,6 @@ class _LibraryTabState extends State<LibraryTab> {
/// Double-click on a card: put it up, errors as a snack bar. /// Double-click on a card: put it up, errors as a snack bar.
Future<void> _doublePutUp(JellyfinSelection sel) async { Future<void> _doublePutUp(JellyfinSelection sel) async {
_c.select(sel); _c.select(sel);
if (!widget.online) return;
final error = await _putUpJellyfin(sel); final error = await _putUpJellyfin(sel);
if (error != null && mounted) { if (error != null && mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error))); ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
@@ -239,11 +238,16 @@ class _LibraryTabState extends State<LibraryTab> {
onOpenIntegrations: widget.onOpenIntegrations, onOpenIntegrations: widget.onOpenIntegrations,
start: widget.jellyfinStart, start: widget.jellyfinStart,
libraryStrip: false, libraryStrip: false,
empty: (context) => NoJellyfinPanel(
accounts: widget.accounts,
onFiles: () => _c.source = MediaKind.file,
onLink: () => _c.source = MediaKind.link,
),
), ),
), ),
MediaKind.link => LibraryLinkSource( MediaKind.link => LibraryLinkSource(
controller: _c, controller: _c,
offlineHint: _hint, solo: widget.solo,
recents: widget.recentLinks ?? RecentLinks.instance, recents: widget.recentLinks ?? RecentLinks.instance,
probe: widget.probe, probe: widget.probe,
onPutUp: _put, onPutUp: _put,
@@ -253,6 +257,15 @@ class _LibraryTabState extends State<LibraryTab> {
final main = Column( final main = Column(
children: [ children: [
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: easeTime,
child: widget.banner ?? const SizedBox(width: double.infinity),
),
),
header, header,
Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))), Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))),
], ],
@@ -267,7 +280,7 @@ class _LibraryTabState extends State<LibraryTab> {
child: JellyfinDetailsPanel( child: JellyfinDetailsPanel(
controller: _browse, controller: _browse,
selection: shown, selection: shown,
offlineHint: _hint, solo: widget.solo,
onPutUp: _putUpJellyfin, onPutUp: _putUpJellyfin,
onClose: () => _c.select(null), onClose: () => _c.select(null),
), ),
@@ -328,14 +341,14 @@ class _LibraryTabState extends State<LibraryTab> {
Future<String?> play(String path) => _put(FileMedia(path)); Future<String?> play(String path) => _put(FileMedia(path));
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final session = SessionState.instance; final session = SessionState.instance;
final now = widget.online final now = session.route.value == AppRoute.session
? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath) ? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath)
: null; : null;
final b = widget.filesBuilder; final b = widget.filesBuilder;
if (b != null) { 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);
}); });
} }
} }
+107 -27
View File
@@ -39,8 +39,9 @@ bool viaHostOf(MemberView m, JellyfinRef? ref) =>
ref != null && m.jf[ref.serverId] == false; ref != null && m.jf[ref.serverId] == false;
/// The 60 px strip over everything: your avatar (profile), the tabs, and /// The 60 px strip over everything: your avatar (profile), the tabs, and
/// the party: offline Join / Host, online the avatars (members card), /// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
/// server and readiness, chat and Leave. Empty space drags the window. /// friends and Stop, online the avatars (members card), server and
/// readiness, chat and Leave. Empty space drags the window.
class PartyStrip extends StatelessWidget { class PartyStrip extends StatelessWidget {
final ShellTab tab; final ShellTab tab;
final ValueChanged<ShellTab> onTab; final ValueChanged<ShellTab> onTab;
@@ -51,6 +52,8 @@ class PartyStrip extends StatelessWidget {
final VoidCallback onHost; final VoidCallback onHost;
final VoidCallback onLeave; final VoidCallback onLeave;
final VoidCallback onProfile; final VoidCallback onProfile;
/// Solo: host from here, keeping what plays.
final VoidCallback? onInvite;
/// Online with something up but the player closed: brings it back. /// Online with something up but the player closed: brings it back.
final VoidCallback? onShowPlayer; final VoidCallback? onShowPlayer;
@@ -69,6 +72,7 @@ class PartyStrip extends StatelessWidget {
required this.onHost, required this.onHost,
required this.onLeave, required this.onLeave,
required this.onProfile, required this.onProfile,
this.onInvite,
this.onShowPlayer, this.onShowPlayer,
this.updates, this.updates,
this.windowControls = true, this.windowControls = true,
@@ -83,6 +87,7 @@ class PartyStrip extends StatelessWidget {
final session = SessionState.instance; final session = SessionState.instance;
final coord = session.coordinator; final coord = session.coordinator;
final online = session.route.value == AppRoute.session; final online = session.route.value == AppRoute.session;
final solo = online && (session.solo.value || session.switching.value);
final self = online ? coord.selfMember.value : null; final self = online ? coord.selfMember.value : null;
return Row( return Row(
children: [ children: [
@@ -97,7 +102,7 @@ class PartyStrip extends StatelessWidget {
child: StatusAvatar( child: StatusAvatar(
info: selfAvatarInfo( info: selfAvatarInfo(
ready: self?.ready ?? false, ready: self?.ready ?? false,
host: online && coord.hostFile != null, host: online && !solo && coord.hostFile != null,
), ),
size: stripAvatarSize, size: stripAvatarSize,
ring: online, ring: online,
@@ -130,14 +135,36 @@ class PartyStrip extends StatelessWidget {
), ),
UpdateIndicator(controller: updates), UpdateIndicator(controller: updates),
const SizedBox(width: 8), const SizedBox(width: 8),
if (online) ...[ if (online && onShowPlayer != null)
if (onShowPlayer != null) IconButton(
IconButton( key: const ValueKey('show-player'),
key: const ValueKey('show-player'), tooltip: 'Show the player',
tooltip: 'Show the player', onPressed: onShowPlayer,
onPressed: onShowPlayer, icon: const Icon(Icons.smart_display_outlined, size: 20),
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( PartyWatch(
builder: (context) => AvatarStack( builder: (context) => AvatarStack(
key: const ValueKey('party-avatars'), key: const ValueKey('party-avatars'),
@@ -177,23 +204,35 @@ class PartyStrip extends StatelessWidget {
child: const Text('Leave'), child: const Text('Leave'),
), ),
] else ...[ ] else ...[
if (!compact) if (!compact) ...[
Text( Container(
'Offline', width: 7,
style: TextStyle(color: scheme.onSurfaceVariant), height: 7,
decoration: BoxDecoration(color: scheme.outline, shape: BoxShape.circle),
), ),
const SizedBox(width: 12), const SizedBox(width: 6),
FilledButton.tonal( Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)),
key: const ValueKey('strip-join'), const SizedBox(width: 12),
onPressed: onJoin, ],
child: const Text('Join'), if (compact) ...[
), FilledButton(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join')),
const SizedBox(width: 8), const SizedBox(width: 8),
FilledButton( FilledButton.tonal(key: const ValueKey('strip-host'), onPressed: onHost, child: const Text('Host')),
key: const ValueKey('strip-host'), ] else ...[
onPressed: onHost, FilledButton.icon(
child: const Text('Host'), 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), const SizedBox(width: 8),
if (windowControls) ...[ 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() { String serverName() {
final session = SessionState.instance; final session = SessionState.instance;
if (session.solo.peek()) return 'Solo';
if (session.coordinator.hostFile != null) return 'Your server'; if (session.coordinator.hostFile != null) return 'Your server';
return session.lastForm.host; 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. /// One strip tab: label and an underline that grows in when selected.
class StripTab extends StatelessWidget { class StripTab extends StatelessWidget {
final String label; final String label;
+1 -1
View File
@@ -78,7 +78,7 @@ class _ProfileCardState extends State<ProfileCard> {
final coord = session.coordinator; final coord = session.coordinator;
final online = session.route.value == AppRoute.session; final online = session.route.value == AppRoute.session;
final self = online ? coord.selfMember.value : null; 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 color = _p.color.value;
final png = _p.picture.value; final png = _p.picture.value;
final out = self?.out ?? false; final out = self?.out ?? false;
+332
View File
@@ -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<Widget> 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<WayCard> createState() => _WayCardState();
}
class _WayCardState extends State<WayCard> {
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<NoJellyfinPanel> createState() => _NoJellyfinPanelState();
}
class _NoJellyfinPanelState extends State<NoJellyfinPanel> {
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)),
],
],
),
),
),
),
],
),
),
),
);
}
}
@@ -33,8 +33,10 @@ class ServerIsolate {
/// Spawns the server and waits until it listens. Throws a /// Spawns the server and waits until it listens. Throws a
/// [StateError] with the server's message if it cannot start. /// [StateError] with the server's message if it cannot start.
/// [loopbackOnly]: 127.0.0.1 only (the solo session).
static Future<ServerIsolate> start( static Future<ServerIsolate> start(
AppConfig config, { AppConfig config, {
bool loopbackOnly = false,
required void Function(String line) onLog, required void Function(String line) onLog,
required void Function(String error) onCrash, required void Function(String error) onCrash,
Duration timeout = const Duration(seconds: 15), Duration timeout = const Duration(seconds: 15),
@@ -83,7 +85,7 @@ class ServerIsolate {
final Isolate isolate; final Isolate isolate;
try { try {
isolate = await Isolate.spawn( isolate = await Isolate.spawn(
_serverMain, (config.toMap(), events.sendPort), _serverMain, ({...config.toMap(), if (loopbackOnly) 'loopbackOnly': true}, events.sendPort),
errorsAreFatal: false, errorsAreFatal: false,
onError: errors.sendPort, onError: errors.sendPort,
onExit: exit.sendPort, onExit: exit.sendPort,
+92 -8
View File
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../screens/home/picked_media.dart';
import '../playback/yt_dlp_app.dart'; import '../playback/yt_dlp_app.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/sound_bank.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 /// Set when the hosted server died mid-session; Home shows it on the
/// Host card and clears it. /// Host card and clears it.
final serverCrash = signal<String?>(null); final serverCrash = signal<String?>(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 /// The app's yt-dlp folder for the hosted server; '' (the server's own
/// default) when the app-support folder is not available. /// default) when the app-support folder is not available.
@@ -243,9 +251,80 @@ class SessionState {
await _saveFormQuietly(); 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<void> host(ConnectParams params, ConnectForm form) async { Future<void> 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<void> 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<void> _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<void>();
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<void> _host(ConnectParams params, {ConnectForm? form, bool soloSession = false}) async {
final op = ++_op; final op = ++_op;
_rememberForm(form); if (form != null) _rememberForm(form);
try { try {
status.value = status.value =
const HealthStatus(HealthSeverity.info, 'starting server…'); const HealthStatus(HealthSeverity.info, 'starting server…');
@@ -265,14 +344,15 @@ class SessionState {
try { try {
server = await ServerIsolate.start( server = await ServerIsolate.start(
AppConfig( AppConfig(
port: params.port, port: soloSession ? 0 : params.port,
secret: params.secret, secret: soloSession ? '' : params.secret,
name: 'server', name: 'server',
mediaPath: params.mediaPath, mediaPath: params.mediaPath,
hostKey: hostKey, hostKey: hostKey,
jellyfin: relay, jellyfin: relay,
ytDlpDir: await _sharedYtDlpDir(), ytDlpDir: await _sharedYtDlpDir(),
), ),
loopbackOnly: soloSession,
onLog: (line) => debugPrint('[server] $line'), onLog: (line) => debugPrint('[server] $line'),
onCrash: (cause) => _onServerCrash(server, cause), onCrash: (cause) => _onServerCrash(server, cause),
); );
@@ -289,13 +369,13 @@ class SessionState {
_server = server; _server = server;
coordinator.hostFile = (path) async => coordinator.hostFile = (path) async =>
identical(_server, server) ? server.setFile(path) : null; identical(_server, server) ? server.setFile(path) : null;
status.value = status.value = HealthStatus(
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}'); HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}');
try { try {
await coordinator.connect(ConnectParams( await coordinator.connect(ConnectParams(
host: '127.0.0.1', host: '127.0.0.1',
port: server.port, port: server.port,
secret: params.secret, secret: soloSession ? '' : params.secret,
name: params.name, name: params.name,
avatar: params.avatar, avatar: params.avatar,
hostKey: hostKey, hostKey: hostKey,
@@ -309,9 +389,10 @@ class SessionState {
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash); if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
rethrow; rethrow;
} }
solo.value = soloSession;
route.value = AppRoute.session; route.value = AppRoute.session;
} finally { } finally {
await _saveFormQuietly(); if (form != null) await _saveFormQuietly();
} }
} }
@@ -349,7 +430,9 @@ class SessionState {
try { try {
await coordinator.cancelConnect(); await coordinator.cancelConnect();
} catch (_) {} } 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 /// Saved whatever the outcome: a failed join is exactly when the user
@@ -385,6 +468,7 @@ class SessionState {
Future<void> leave() async { Future<void> leave() async {
await coordinator.leave(); await coordinator.leave();
await stopServer(); await stopServer();
solo.value = false;
status.value = const HealthStatus(HealthSeverity.info, 'idle'); status.value = const HealthStatus(HealthSeverity.info, 'idle');
route.value = AppRoute.home; route.value = AppRoute.home;
} }
+2 -21
View File
@@ -44,7 +44,7 @@ Future<void> frames(WidgetTester tester, [int n = 10]) async {
Future<void> pumpPage( Future<void> pumpPage(
WidgetTester tester, { WidgetTester tester, {
Size size = const Size(1280, 800), Size size = const Size(1280, 800),
bool online = true, bool solo = false,
bool relayed = false, bool relayed = false,
Future<String?> Function(String path)? onPlay, Future<String?> Function(String path)? onPlay,
ThumbnailLoader? thumbnails, ThumbnailLoader? thumbnails,
@@ -56,7 +56,7 @@ Future<void> pumpPage(
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
home: Scaffold( home: Scaffold(
body: FilesPage( body: FilesPage(
online: online, solo: solo,
relayed: relayed, relayed: relayed,
onPlay: onPlay ?? (_) async => null, onPlay: onPlay ?? (_) async => null,
thumbnails: thumbnails ?? fakeThumb, thumbnails: thumbnails ?? fakeThumb,
@@ -206,24 +206,6 @@ void main() {
expect(find.text('4 episodes · 1 extra · Grp'), findsOneWidget); expect(find.text('4 episodes · 1 extra · Grp'), findsOneWidget);
}); });
testWidgets('offline: browsing works, playing is disabled', (tester) async {
store.addFolder(anime);
final played = <String>[];
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 { testWidgets('a play goes to onPlay and into Recent', (tester) async {
store.addFolder(anime); store.addFolder(anime);
final played = <String>[]; final played = <String>[];
@@ -261,7 +243,6 @@ void main() {
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
home: Scaffold( home: Scaffold(
body: FilesPage( body: FilesPage(
online: true,
relayed: false, relayed: false,
onPlay: (_) async => null, onPlay: (_) async => null,
nowPlayingPath: '$anime/[Grp] Show - 03.mkv', nowPlayingPath: '$anime/[Grp] Show - 03.mkv',
+16 -11
View File
@@ -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/health_probe.dart';
import 'package:watch_together/src/screens/home/media_picker.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/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/session_state.dart';
import 'package:watch_together/src/state/settings.dart'; import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart'; import 'package:watch_together/src/update/update_controller.dart';
@@ -230,24 +231,28 @@ void main() {
expect(find.byKey(const ValueKey('join-row')), findsOneWidget); 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 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(); await tester.pumpAndSettle();
expect(find.byType(FilesPage), findsOneWidget); expect(find.byType(FilesPage), findsOneWidget);
await tester.tap(find.text('Link')); await tester.tap(find.text('Link'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Watch from a link'), findsOneWidget); expect(find.text('Watch from a link'), findsOneWidget);
// Offline nothing goes up. // Alone: Play, no relay modes.
expect(find.text('Join or host to watch together'), findsOneWidget); expect(find.text(soloPlayHint), findsOneWidget);
expect(find.text('How the others get it'), findsNothing); 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); expect(tester.takeException(), isNull);
}); });
+21 -27
View File
@@ -48,16 +48,16 @@ FakeJellyfin libraryFake() {
} }
class FilesStub extends StatelessWidget { class FilesStub extends StatelessWidget {
final bool online, relayed; final bool solo, relayed;
final Future<String?> Function(String path) onPlay; final Future<String?> 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 @override
Widget build(BuildContext context) => Column(children: [ Widget build(BuildContext context) => Column(children: [
Text('files online=$online relayed=$relayed'), Text('files solo=$solo relayed=$relayed'),
TextButton( TextButton(
key: const ValueKey('stub-play'), key: const ValueKey('stub-play'),
onPressed: online ? () => onPlay('/v/a.mkv') : null, onPressed: () => onPlay('/v/a.mkv'),
child: const Text('play'), child: const Text('play'),
), ),
]); ]);
@@ -75,7 +75,7 @@ class Harness {
Future<Harness> pumpLibrary( Future<Harness> pumpLibrary(
WidgetTester tester, WidgetTester tester,
Size size, { Size size, {
bool online = true, bool solo = false,
bool isHost = false, bool isHost = false,
MediaKind source = MediaKind.jellyfin, MediaKind source = MediaKind.jellyfin,
Harness? reuse, Harness? reuse,
@@ -103,7 +103,7 @@ Future<Harness> pumpLibrary(
key: UniqueKey(), key: UniqueKey(),
controller: h.controller, controller: h.controller,
accounts: h.accounts, accounts: h.accounts,
online: online, solo: solo,
isHost: isHost, isHost: isHost,
jellyfinStart: start, jellyfinStart: start,
recentLinks: h.recents, recentLinks: h.recents,
@@ -111,8 +111,8 @@ Future<Harness> pumpLibrary(
probe: (url) async => url.endsWith('.mkv') probe: (url) async => url.endsWith('.mkv')
? LinkProbe.file(Uri.parse(url), 1) ? LinkProbe.file(Uri.parse(url), 1)
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')), : LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) => filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(online: online, relayed: relayed, onPlay: onPlay), FilesStub(solo: solo, relayed: relayed, onPlay: onPlay),
onPutUp: (m) async { onPutUp: (m) async {
h.put.add(m); h.put.add(m);
return null; return null;
@@ -152,7 +152,7 @@ void main() {
await tester.tap(find.text('Files')); await tester.tap(find.text('Files'));
await settle(tester, 10); 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 tester.tap(find.byKey(const ValueKey('stub-play')));
await settle(tester); await settle(tester);
expect(h.put, [const FileMedia('/v/a.mkv')]); expect(h.put, [const FileMedia('/v/a.mkv')]);
@@ -247,32 +247,26 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('offline: everything browses, nothing goes up', (tester) async { 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), online: false); final h = await pumpLibrary(tester, const Size(1600, 1000), solo: true);
await tester.tap(arifuretaCard()); await tester.tap(arifuretaCard());
await settle(tester, 10); await settle(tester, 10);
final go = find.byKey(const ValueKey('details-put-up')); final go = find.byKey(const ValueKey('details-put-up'));
expect(tester.widget<ButtonStyleButton>(go).onPressed, isNull); expect(find.descendant(of: go, matching: find.textContaining('Play')), findsOneWidget);
expect(find.descendant(of: go, matching: find.text('Play')), findsOneWidget); await tester.tap(go);
expect(find.text(offlinePutUpHint), findsOneWidget); await settle(tester, 10);
await doubleTap(tester, arifuretaCard()); expect(h.put.single, isA<JellyfinMedia>());
expect(h.put, isEmpty);
await tester.tap(find.text('Link')); await tester.tap(find.text('Link'));
await settle(tester, 10); await settle(tester, 10);
h.controller.linkRelay = LinkRelay.me;
await tester.enterText(find.byKey(const ValueKey('link-field')), 'https://cdn.example/a.mkv'); await tester.enterText(find.byKey(const ValueKey('link-field')), 'https://cdn.example/a.mkv');
await settle(tester, 10); await settle(tester, 10);
final linkGo = find.byKey(const ValueKey('link-put-up'));
expect(tester.widget<ButtonStyleButton>(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('How the others get it'), findsNothing);
expect(find.text('a.mkv'), findsOneWidget, reason: 'the preview still works'); expect(find.text(soloPlayHint), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('link-put-up')));
await tester.tap(find.text('Files'));
await settle(tester, 10); await settle(tester, 10);
expect(find.text('files online=false relayed=true'), findsOneWidget); expect(h.put.last, const LinkMedia('https://cdn.example/a.mkv'));
expect(h.put, isEmpty);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
@@ -307,7 +301,7 @@ void main() {
testWidgets('hosting: files stream from here; now-playing file passes through', (tester) async { 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); 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 { 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.selected!.itemId, bsEpisode);
expect(c.source, MediaKind.jellyfin); expect(c.source, MediaKind.jellyfin);
final h = Harness(c, accounts, FakeJellyfin()); 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.byKey(const ValueKey('jf-details')), findsOneWidget);
expect(find.text('Black Summoner'), findsWidgets); expect(find.text('Black Summoner'), findsWidgets);
+5 -5
View File
@@ -231,7 +231,7 @@ void main() {
WidgetTester tester, { WidgetTester tester, {
Size size = const Size(2560, 1080), Size size = const Size(2560, 1080),
ScheduleSnapshot Function()? snap, ScheduleSnapshot Function()? snap,
bool online = true, bool solo = false,
JellyfinAccounts? accounts, JellyfinAccounts? accounts,
}) async { }) async {
toasts.clear(); toasts.clear();
@@ -245,7 +245,7 @@ void main() {
theme: ThemeData.dark(), theme: ThemeData.dark(),
home: Scaffold( home: Scaffold(
body: SchedulePage( body: SchedulePage(
online: online, solo: solo,
repository: repo, repository: repo,
accounts: accounts, accounts: accounts,
onToast: toasts.add, onToast: toasts.add,
@@ -434,11 +434,11 @@ void main() {
await done(tester); await done(tester);
}); });
testWidgets('offline: Play is disabled', (tester) async { testWidgets('solo: the button says Play and works', (tester) async {
await pumpPage(tester, online: false); await pumpPage(tester, solo: true);
expect(find.text('Put it up'), findsNothing); expect(find.text('Put it up'), findsNothing);
final b = widgetOf<ButtonStyleButton>(tester, action(111, 3)); final b = widgetOf<ButtonStyleButton>(tester, action(111, 3));
expect(b.onPressed, isNull); expect(b.onPressed, isNotNull);
expect(find.text('Play'), findsOneWidget); expect(find.text('Play'), findsOneWidget);
await done(tester); await done(tester);
}); });
@@ -8,6 +8,7 @@ import 'package:hive_ce/hive.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.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/session/session_coordinator.dart';
import 'package:watch_together/src/state/server_isolate.dart'; import 'package:watch_together/src/state/server_isolate.dart';
import 'package:watch_together/src/state/session_state.dart'; import 'package:watch_together/src/state/session_state.dart';
@@ -218,4 +219,45 @@ void main() {
if (file.existsSync()) file.deleteSync(); 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<SocketException>()));
}
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();
}
});
} }
+23
View File
@@ -128,6 +128,29 @@ void main() {
expect(session.route.value, AppRoute.session); 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 { testWidgets('compact below 1100 px: 3 avatars, no server text', (tester) async {
await pumpShell(tester, const Size(1000, 800)); await pumpShell(tester, const Size(1000, 800));
online(party, media: false); online(party, media: false);
+39 -8
View File
@@ -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). shell says so in a dialog (during setup the host attempt fails with it).
Stop (leave, close, cancel during setup, a failed own join) Stop (leave, close, cancel during setup, a failed own join)
sends `stop`, waits up to 3 s for exit, then kills the isolate. 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` - **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
and shipped next to the app as `watch-together-server`. It parses flags and shipped next to the app as `watch-together-server`. It parses flags
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`, (`--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), - **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 the Library / Schedule / Settings tabs with a growing underline (an
`HScroll` when narrow), the update indicator, then offline Join and Host `HScroll` when narrow), the update indicator, then offline a grey dot,
(connect cards), online the party: `AvatarStack` (host first, then "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; 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 "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 (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, to watch is picked in the library afterwards. Submit state (busy,
cancel, per-card errors) is `ConnectFlow`, owned by the shell so an cancel, per-card errors) is `ConnectFlow`, owned by the shell so an
attempt and its error outlive the card; a card closes itself once 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 - **Profile** (`profile_card.dart`, `screens/home/profile.dart`): a card
tinted with your colour: picture (hover "Change" opens `CropPicker`: tinted with your colour: picture (hover "Change" opens `CropPicker`:
drag, wheel or slider zoom, previews at 88/42/24 px, exports a 256 px 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 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 bytes). Online, a name change (once typing settles) or picture change
goes out through `Profile.push` (`coordinator.updateProfile`). 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 / - **Tabs**: Library (`library_tab.dart`): a source switch (Jellyfin /
Link / Files, fade through) with the Jellyfin library chips beside it. Link / Files, fade through) with the Jellyfin library chips beside it.
Its state (source, link field and relay mode, selected Jellyfin card, 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 is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
an isolate, videos grouped by series; series start collapsed, a header an isolate, videos grouped by series; series start collapsed, a header
click opens one and open ones stay open for the app session 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 (`showFilesFolder` opens a folder from outside). With nobody else
watch together". Schedule (`schedule/`) is a week of day columns built 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 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 memory and 1 h on disk, stale data shown on 429 or failure) matched
against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token; 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, into videos (the Files extensions, `kindOf`), a folder, an http(s) link,
an image or nothing; videos win over folders, folders over images. an image or nothing; videos win over folders, folders over images.
`HandoffCard` opens as a popover at the pointer: a video goes up through `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 the shell's put-up (Play when alone; offline that starts solo) or adds
Files; a folder is added to Files and opened; a link fills Library → its folder to Files; a folder is added to Files and opened; a link fills
Link (online the relay mode is picked there). Images are ignored. Library → Link (with others the relay mode is picked there). Images are ignored.
Nothing happens while a dialog or popover is over the shell. Nothing happens while a dialog or popover is over the shell.
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready, - **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
unready, force-ready, join, leave, swap and others' chat (feed unready, force-ready, join, leave, swap and others' chat (feed
+7 -1
View File
@@ -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 - **Dedicated server**: `watch-together-server`, a separate program in the
release tarball, for an always-on box. See the README. 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 ## One session, start to finish
+3 -2
View File
@@ -18,7 +18,8 @@ import 'sync_server.dart';
/// a file on this machine the hosted file (the hosting app's in-session file /// 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 /// 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 /// `{'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<void> serve(Map<String, Object?> config, {SendPort? events}) async { Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
StreamSubscription<pkg_logging.LogRecord>? logs; StreamSubscription<pkg_logging.LogRecord>? logs;
if (events != null) { if (events != null) {
@@ -32,7 +33,7 @@ Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
} }
final SyncServer server; final SyncServer server;
try { try {
server = SyncServer(AppConfig.fromMap(config)); server = SyncServer(AppConfig.fromMap(config), loopbackOnly: config['loopbackOnly'] == true);
await server.start(); await server.start();
} catch (e) { } catch (e) {
await logs?.cancel(); await logs?.cancel();
+6 -2
View File
@@ -48,8 +48,12 @@ class SyncServer {
/// Upstream connections of the Jellyfin relay; created on first use. /// Upstream connections of the Jellyfin relay; created on first use.
HttpClient? _relayClient; 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). /// [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, hub = ServerHub(sessions, config.secret,
hostKey: config.hostKey, hostKey: config.hostKey,
jellyfin: config.jellyfin, jellyfin: config.jellyfin,
@@ -131,7 +135,7 @@ class SyncServer {
await _resolveJellyfinServerId(); await _resolveJellyfinServerId();
_mountRoutes(); _mountRoutes();
_server = await _app.serve( _server = await _app.serve(
address: InternetAddress.anyIPv4, address: loopbackOnly ? InternetAddress.loopbackIPv4 : InternetAddress.anyIPv4,
port: config.port, port: config.port,
// A killed isolate never closes its listening socket, so the port stays // A killed isolate never closes its listening socket, so the port stays
// bound in this process. Shared lets the next server in the same // bound in this process. Shared lets the next server in the same