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

+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
// used for Recent), a loose-video card, and the empty states.
/// Shown on play controls while there is nobody to watch with.
const offlinePlayHint = 'Join or host to watch';
/// What a play control is doing right now.
enum PlayState { idle, starting, playing }
@@ -28,10 +25,6 @@ String parentName(String path) => baseName(File(path).parent.path);
/// Cleaned title of a loose video: `Title (2019)`.
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})';
/// Disabled with [offlinePlayHint] when [onTap] is null because offline.
Widget _offlineTip(bool offline, Widget child) =>
offline ? Tooltip(message: offlinePlayHint, child: child) : child;
class SeriesHeader extends StatelessWidget {
final SeriesGroup group;
final ThumbCache thumbs;
@@ -110,9 +103,8 @@ class FileRow extends StatelessWidget {
final String? size;
final PlayState state;
final ThumbCache thumbs;
/// Null disables the row ([offline] adds the reason as a tooltip).
/// Null disables the row.
final VoidCallback? onTap;
final bool offline;
final bool indent;
final Widget? trailing;
const FileRow({
@@ -124,7 +116,6 @@ class FileRow extends StatelessWidget {
required this.state,
required this.thumbs,
required this.onTap,
required this.offline,
this.indent = true,
this.trailing,
});
@@ -148,62 +139,59 @@ class FileRow extends StatelessWidget {
builder: (context, t, _) {
final duration = durationText(t);
final meta = [?duration, ?size].join(' · ');
return _offlineTip(
offline,
Material(
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
return Material(
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: ThumbImage(t, width: 80, height: 45),
),
const SizedBox(width: 10),
SizedBox(width: 20, child: Center(child: icon)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
color: playing ? scheme.primary : null,
),
child: Padding(
padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: ThumbImage(t, width: 80, height: 45),
),
const SizedBox(width: 10),
SizedBox(width: 20, child: Center(child: icon)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
color: playing ? scheme.primary : null,
),
),
if (playing) ...[
const SizedBox(width: 8),
Pill('now playing', scheme.primary),
],
),
if (playing) ...[
const SizedBox(width: 8),
Pill('now playing', scheme.primary),
],
),
Text(subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: muted, fontSize: 12)),
],
),
],
),
Text(subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: muted, fontSize: 12)),
],
),
if (meta.isNotEmpty) ...[
const SizedBox(width: 12),
Text(meta, style: TextStyle(color: muted, fontSize: 12)),
],
?trailing,
),
if (meta.isNotEmpty) ...[
const SizedBox(width: 12),
Text(meta, style: TextStyle(color: muted, fontSize: 12)),
],
),
?trailing,
],
),
),
),
@@ -223,14 +211,12 @@ class VideoCard extends StatelessWidget {
final PlayState state;
final ThumbCache thumbs;
final VoidCallback? onTap;
final bool offline;
const VideoCard({
super.key,
required this.video,
required this.state,
required this.thumbs,
required this.onTap,
required this.offline,
});
@override
@@ -242,65 +228,62 @@ class VideoCard extends StatelessWidget {
path: video.path,
builder: (context, t, _) {
final duration = durationText(t);
return _offlineTip(
offline,
InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: playing ? scheme.primary : Colors.transparent,
width: 2,
),
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: playing ? scheme.primary : Colors.transparent,
width: 2,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Stack(
fit: StackFit.expand,
children: [
ThumbImage(t),
if (duration != null)
Positioned(right: 6, bottom: 6, child: _Badge(duration)),
if (state != PlayState.idle)
Container(
color: Colors.black38,
alignment: Alignment.center,
child: state == PlayState.playing
? const Icon(Icons.graphic_eq, color: Colors.white, size: 32)
: const SizedBox.square(
dimension: 28,
child: CircularProgressIndicator(
strokeWidth: 3, color: Colors.white)),
),
],
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Stack(
fit: StackFit.expand,
children: [
ThumbImage(t),
if (duration != null)
Positioned(right: 6, bottom: 6, child: _Badge(duration)),
if (state != PlayState.idle)
Container(
color: Colors.black38,
alignment: Alignment.center,
child: state == PlayState.playing
? const Icon(Icons.graphic_eq, color: Colors.white, size: 32)
: const SizedBox.square(
dimension: 28,
child: CircularProgressIndicator(
strokeWidth: 3, color: Colors.white)),
),
],
),
),
),
const SizedBox(height: 6),
Flexible(
child: Text(
cleanTitle(video.parsed),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: playing ? scheme.primary : null,
),
),
const SizedBox(height: 6),
Flexible(
child: Text(
cleanTitle(video.parsed),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: playing ? scheme.primary : null,
),
),
Text(formatSize(video.size),
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
),
),
Text(formatSize(video.size),
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
),
);
},
+7 -15
View File
@@ -48,8 +48,8 @@ void resetFilesPageSession() {
}
class FilesPage extends StatefulWidget {
/// In a session: false disables playing (browsing still works).
final bool online;
/// Nobody else watches (offline or solo): no relay notice.
final bool solo;
/// The file will stream from this computer through the server.
final bool relayed;
/// Plays [path] in the session; returns an error text, or null.
@@ -67,7 +67,7 @@ class FilesPage extends StatefulWidget {
const FilesPage({
super.key,
required this.online,
this.solo = false,
required this.relayed,
required this.onPlay,
this.nowPlayingPath,
@@ -207,7 +207,7 @@ class _FilesPageState extends State<FilesPage> {
}
Future<void> _play(String path) async {
if (!widget.online || _starting != null) return;
if (_starting != null) return;
setState(() {
_starting = path;
_error = null;
@@ -236,7 +236,7 @@ class _FilesPageState extends State<FilesPage> {
? PlayState.playing
: PlayState.idle;
VoidCallback? _tapOf(String path) => widget.online ? () => _play(path) : null;
VoidCallback _tapOf(String path) => () => _play(path);
@override
Widget build(BuildContext context) => LayoutBuilder(
@@ -264,9 +264,7 @@ class _FilesPageState extends State<FilesPage> {
},
);
Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => widget.online
? build(_browse)
: Tooltip(message: offlinePlayHint, child: build(null));
Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => build(_browse);
Widget _sidebar(BuildContext context, List<String> folders, String? open) {
final scheme = Theme.of(context).colorScheme;
@@ -345,7 +343,7 @@ class _FilesPageState extends State<FilesPage> {
final top = <Widget>[
_header(context, open),
if (narrow) _chips(folders, open),
if (widget.online && widget.relayed) const _RelayNotice(),
if (!widget.solo && widget.relayed) const _RelayNotice(),
AnimatedSize(
duration: easeTime,
curve: ease,
@@ -476,7 +474,6 @@ class _FilesPageState extends State<FilesPage> {
state: _stateOf(path),
thumbs: _thumbs,
onTap: _tapOf(path),
offline: !widget.online,
onRemove: () => _store.removeRecent(path),
);
},
@@ -594,7 +591,6 @@ class _FilesPageState extends State<FilesPage> {
state: _stateOf(v.path),
thumbs: _thumbs,
onTap: _tapOf(v.path),
offline: !widget.online,
);
},
),
@@ -630,7 +626,6 @@ class _FilesPageState extends State<FilesPage> {
state: _stateOf(v.path),
thumbs: _thumbs,
onTap: _tapOf(v.path),
offline: !widget.online,
);
},
);
@@ -647,7 +642,6 @@ class _RecentRow extends StatefulWidget {
final PlayState state;
final ThumbCache thumbs;
final VoidCallback? onTap;
final bool offline;
final VoidCallback onRemove;
const _RecentRow({
super.key,
@@ -655,7 +649,6 @@ class _RecentRow extends StatefulWidget {
required this.state,
required this.thumbs,
required this.onTap,
required this.offline,
required this.onRemove,
});
@@ -688,7 +681,6 @@ class _RecentRowState extends State<_RecentRow> {
state: widget.state,
thumbs: widget.thumbs,
onTap: missing ? null : widget.onTap,
offline: widget.offline && !missing,
indent: false,
trailing: IconButton(
tooltip: 'Remove from recent',
@@ -32,16 +32,16 @@ typedef _Data = ({
});
/// The selected card's details, at the right of the library: banner,
/// title, meta, "Put it up · S1E3" (a disabled Play with [offlineHint]
/// offline), mark watched / unwatched, remove from Continue, overview and,
/// title, meta, "Put it up · S1E3" ("Play · S1E3" when watching
/// [solo]), mark watched / unwatched, remove from Continue, overview and,
/// for a series or an episode, the episode list (click picks which
/// episode goes up, double-click puts it up). Reloads when the
/// [controller]'s data version moves.
class JellyfinDetailsPanel extends StatefulWidget {
final BrowseController controller;
final JellyfinSelection selection;
/// Null when online; else why nothing can go up.
final String? offlineHint;
/// Nobody else watches (offline or solo): the button says Play.
final bool solo;
/// Puts [item] (playable) up; returns an error text, or null when sent.
final Future<String?> Function(JellyfinSelection item) onPutUp;
final VoidCallback onClose;
@@ -51,7 +51,7 @@ class JellyfinDetailsPanel extends StatefulWidget {
required this.selection,
required this.onPutUp,
required this.onClose,
this.offlineHint,
this.solo = false,
});
@override
@@ -104,7 +104,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
_cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId));
Future<void> _putUp(JellyfinItem target) async {
if (_busy || widget.offlineHint != null) return;
if (_busy) return;
setState(() {
_busy = true;
_error = null;
@@ -253,14 +253,8 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
if (!isSeries && shown.userData.played) 'Watched',
].join(' · ');
final hint = widget.offlineHint;
final label = playable == null
? (hint == null ? 'Put it up' : 'Play')
: hint != null
? 'Play'
: isSeries
? 'Put it up · ${playable.episodeLabel ?? playable.name}'
: 'Put it up';
final verb = widget.solo ? 'Play' : 'Put it up';
final label = playable != null && isSeries ? '$verb · ${playable.episodeLabel ?? playable.name}' : verb;
final inContinue = playable != null &&
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
final left = playable == null ? null : timeLeft(playable);
@@ -307,7 +301,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
children: [
FilledButton.icon(
key: const ValueKey('details-put-up'),
onPressed: playable == null || hint != null || _busy ? null : () => _putUp(playable),
onPressed: playable == null || _busy ? null : () => _putUp(playable),
icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
@@ -336,12 +330,12 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
),
],
),
if (hint != null || _error != null) ...[
if (_error != null) ...[
const SizedBox(height: 6),
Text(
_error ?? hint!,
_error!,
key: const ValueKey('details-hint'),
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
style: t.bodySmall?.copyWith(color: scheme.error),
),
],
if (isSeries && playable != null) ...[
@@ -7,7 +7,7 @@ import 'browser.dart';
import 'sign_in_panel.dart';
/// The Library's Jellyfin source: the library browser once a server is
/// linked, else an inline add-server panel.
/// linked, else [empty] (default: an inline add-server panel).
class JellyfinPickerSource extends StatefulWidget {
final JellyfinAccounts accounts;
final BrowseController? controller;
@@ -17,6 +17,8 @@ class JellyfinPickerSource extends StatefulWidget {
final VoidCallback? onOpenIntegrations;
final BrowseStart? start;
final bool libraryStrip;
/// Shown while no server is linked.
final WidgetBuilder? empty;
const JellyfinPickerSource({
super.key,
this.start,
@@ -27,6 +29,7 @@ class JellyfinPickerSource extends StatefulWidget {
this.selected,
this.onOpenIntegrations,
this.libraryStrip = true,
this.empty,
});
@override
@@ -49,6 +52,7 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
if (!a.loaded) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
}
if (a.isEmpty && widget.empty != null) return widget.empty!(context);
if (a.isEmpty) return _LinkFirst(accounts: a, onOpenIntegrations: widget.onOpenIntegrations);
return JellyfinBrowser(
key: const ValueKey('media-jellyfin'),
@@ -13,8 +13,8 @@ enum CardAction {
none,
putUp,
/// Available, but not in a server: shown disabled with a hint.
playOffline,
/// Available, nobody else watching (offline or solo): plays it here.
play,
request,
requestSeries,
requested,
@@ -162,15 +162,10 @@ class AiringCard extends StatelessWidget {
final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2));
Widget? b = switch (action) {
CardAction.none => null,
CardAction.putUp => FilledButton.tonalIcon(
CardAction.putUp || CardAction.play => FilledButton.tonalIcon(
onPressed: enabled ? onAction : null,
icon: busy ? spinner : const Icon(Icons.play_arrow, size: 18),
label: const Text('Put it up'),
),
CardAction.playOffline => FilledButton.tonalIcon(
onPressed: null,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Play'),
label: Text(action == CardAction.play ? 'Play' : 'Put it up'),
),
CardAction.request || CardAction.requestSeries => OutlinedButton.icon(
onPressed: enabled ? onAction : null,
@@ -216,7 +211,7 @@ class AiringCard extends StatelessWidget {
),
),
);
final tap = action == CardAction.putUp && disabledHint == null && !busy ? onAction : null;
final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy ? onAction : null;
if (compact) {
final requestable = action == CardAction.request || action == CardAction.requestSeries;
return Material(
@@ -21,8 +21,9 @@ import 'schedule_card.dart';
enum ScheduleFilter { jellyfin, following, all }
class SchedulePage extends StatefulWidget {
/// In a server: available episodes can be put up.
final bool online;
/// Nobody else watches (offline or solo): available episodes say Play
/// (offline that starts a solo session).
final bool solo;
/// Puts a Jellyfin episode up in the server; returns an error to show.
final Future<String?> Function(JellyfinMedia pick) onPutUp;
@@ -36,7 +37,7 @@ class SchedulePage extends StatefulWidget {
const SchedulePage({
super.key,
required this.online,
this.solo = false,
required this.onPutUp,
required this.onToast,
this.repository,
@@ -215,9 +216,9 @@ class _SchedulePageState extends State<SchedulePage> {
(CardAction, String?) _actionFor(ScheduleEntry e) {
if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) {
if (!widget.online) return (CardAction.playOffline, 'Join or host a server to put it up');
if (e.jellyfinSeriesId == null) return (CardAction.putUp, 'Not linked to a Jellyfin series yet');
return (CardAction.putUp, null);
final put = widget.solo ? CardAction.play : CardAction.putUp;
if (e.jellyfinSeriesId == null) return (put, 'Not linked to a Jellyfin series yet');
return (put, null);
}
final me = _me;
final CardAction kind;
@@ -242,6 +243,7 @@ class _SchedulePageState extends State<SchedulePage> {
Future<void> _onAction(ScheduleEntry e, CardAction action) async {
switch (action) {
case CardAction.putUp:
case CardAction.play:
return _putUp(e);
case CardAction.request:
case CardAction.requestSeries:
@@ -530,7 +532,7 @@ class _SchedulePageState extends State<SchedulePage> {
Widget _card(ScheduleEntry e, DateTime now) {
final (action, hint) = _actionFor(e);
final busy = _busy.contains(action == CardAction.putUp ? 'play:${_key(e)}' : _key(e));
final busy = _busy.contains(action == CardAction.putUp || action == CardAction.play ? 'play:${_key(e)}' : _key(e));
return AiringCard(
entry: e,
now: now,
@@ -277,6 +277,10 @@ class SessionCoordinator {
int _relaySourceMediaId = -1;
/// This member serves the current media's bytes through the server.
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
// should have landed.
int? _pendingSeekEvent;
@@ -402,6 +406,7 @@ class SessionCoordinator {
_seekGate.reset();
_clearPendingSeek();
_lastEventId = -1;
_lastPick = null;
_resetJellyfin();
_jfToastShown = false;
if (!_jellyfinListening) {
@@ -1235,6 +1240,7 @@ class SessionCoordinator {
/// link, or relayed per [relay]. Returns an error text, or null when sent.
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct}) async {
markInput();
_lastPick = LinkMedia(url, relay: relay);
final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url);
@@ -1304,6 +1310,7 @@ class SessionCoordinator {
/// relayed by this member for viewers without an account.
void swapJellyfin(JellyfinMedia media) {
markInput();
_lastPick = media;
_pendingOrigin = const _JellyfinOrigin(null);
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()));
}
@@ -1313,6 +1320,7 @@ class SessionCoordinator {
/// when sent.
Future<String?> swapFile(String path) async {
markInput();
_lastPick = FileMedia(path);
final host = hostFile;
if (host != null) {
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/session_media.dart';
import '../screens/home/connect_flow.dart';
import '../session/session_coordinator.dart' show ConnectParams;
import '../screens/home/health_probe.dart';
import '../screens/home/media_picker.dart';
import '../screens/home/profile.dart';
@@ -31,6 +32,7 @@ import '../schedule/ui/schedule_page.dart';
import 'session_player.dart';
import 'settings_tab.dart';
import 'toasts.dart';
import 'welcome.dart';
/// The one window: the party strip over a body that fades through between
/// the tabs and the full player, the floating mini player, the chat panel
@@ -116,6 +118,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final _stops = <void Function()>[];
// Rebuild the shell on these flips, not on every state broadcast.
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
/// Nobody else watches: offline, solo, or solo becoming a server.
final _alone = computed(() {
final s = SessionState.instance;
return s.route.value != AppRoute.session || s.solo.value || s.switching.value;
});
final _hasMedia = computed(() =>
SessionState.instance.route.value == AppRoute.session &&
SessionState.instance.coordinator.serverState.value?.media != null);
@@ -190,6 +197,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}
wasOut = out;
}));
// Solo becoming a hosted server: the player closes while the media
// moves over and comes back the way it was once it is up again.
_stops.add(effect(() {
if (!session.switching.value) return;
_later(() {
_lastMediaId = -1;
_autoFull = _mode == PlayerMode.full;
if (_fullscreen) _toggleFullscreen();
_show(PlayerMode.none);
});
}));
// The hosted server died mid-session (SessionState leaves): say so.
_stops.add(effect(() {
final crash = session.serverCrash.value;
@@ -209,6 +227,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
HardwareKeyboard.instance.removeHandler(_onKey);
_fade.dispose();
_online.dispose();
_alone.dispose();
_hasMedia.dispose();
_flow.dispose();
_join.dispose();
@@ -343,7 +362,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_selectTab(ShellTab.library);
}
/// Library → "Put it up for everyone"; the player opens full.
/// Library → "Put it up for everyone"; the player opens full. Offline
/// it starts a solo session first (just you, on this computer).
Future<String?> _putUp(PickedMedia media) async {
final session = SessionState.instance;
if (media is JellyfinMedia) {
@@ -354,6 +374,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}
unawaited(_jellyfin.rememberPick(media));
}
if (!_online.peek()) {
final error = await _startSolo();
if (error != null || !mounted) return error;
}
// Alone, a link plays direct: there is nobody to pass it on to.
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url);
final error = await session.coordinator.swapPicked(media);
if (error != null || !mounted) return error;
final f = session.lastForm;
@@ -366,6 +392,29 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
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
/// 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
? Alignment.center
: Alignment((at.dx / size.width * 2 - 1).clamp(-1, 1), (at.dy / size.height * 2 - 1).clamp(-1, 1));
final online = _online.peek();
_handoffOpen = true;
final HandoffAction? action;
try {
action = await showPopover<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 {
_handoffOpen = false;
}
@@ -424,10 +472,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first);
}
case (HandoffVideos(:final paths), HandoffAction.openInFiles):
await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first);
_openFiles(null);
case (HandoffVideos(:final paths), HandoffAction.addFolder):
await _addFolder(File(paths.first).parent.path);
case (HandoffFolder(:final path), _):
@@ -484,7 +528,13 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// --------------------------------------------------------------- dialogs
/// Leave asks first; a solo session just stops.
Future<void> _confirmLeave() async {
final session = SessionState.instance;
if (session.solo.peek()) {
await session.leave();
return;
}
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@@ -542,6 +592,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
backgroundColor: _fullscreen ? Colors.black : scheme.surface,
body: SignalBuilder(builder: (context) {
final online = _online.value;
final alone = _alone.value;
final hasMedia = _hasMedia.value;
return Column(
children: [
@@ -556,6 +607,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onJoin: () => _connect(ConnectMode.join),
onHost: () => _connect(ConnectMode.host),
onLeave: _confirmLeave,
onInvite: () => _connect(ConnectMode.host),
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
updates: widget.updates,
@@ -565,7 +617,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
key: const ValueKey('body'),
child: Row(
children: [
Expanded(child: _body(online)),
Expanded(child: _body(online, alone)),
AnimatedSize(
duration: easeTime,
curve: ease,
@@ -589,21 +641,29 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
);
}
Widget _body(bool online) {
Widget _body(bool online, bool alone) {
final coord = SessionState.instance.coordinator;
final tabBody = switch (_tab) {
ShellTab.library => LibraryTab(
key: ValueKey('library-$_libraryGen'),
controller: _picker,
accounts: _jellyfin,
online: online,
isHost: coord.hostFile != null,
solo: alone,
// Offline, a put-up starts a solo session, which hosts.
isHost: !online || coord.hostFile != null,
banner: online
? null
: WelcomeBand(
onJoin: () => _connect(ConnectMode.join),
onHost: () => _connect(ConnectMode.host),
onSolo: _watchSolo,
),
jellyfinStart: _libraryStart,
onPutUp: _putUp,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
),
ShellTab.schedule => SchedulePage(
online: online,
solo: alone,
onPutUp: _putUp,
onToast: coord.showToast,
),
+26 -10
View File
@@ -41,8 +41,9 @@ class HostFields {
}
}
/// The strip's Join / Host buttons: a card over the window. It closes
/// itself once the attempt got in; a failed one shows its error inside.
/// The strip's Join / Host buttons (and solo's Invite friends, the Host
/// card): a card over the window. It closes itself once the attempt got
/// in; a failed one shows its error inside.
Future<void> showConnectCard(
BuildContext context, {
required ConnectMode mode,
@@ -77,7 +78,9 @@ class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
void initState() {
super.initState();
_stop = effect(() {
if (SessionState.instance.route.value != AppRoute.session) return;
final s = SessionState.instance;
// Solo is in a session too: wait until it is a hosted server.
if (s.route.value != AppRoute.session || s.solo.value || s.switching.value) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).maybePop();
});
@@ -153,7 +156,7 @@ class JoinCard extends StatelessWidget {
builder: (context, _) {
final busy = flow.busy == ConnectMode.join;
return _Card(
title: 'Join a server',
title: 'Join a friend',
children: [
SignalBuilder(builder: (context) {
final list = [
@@ -221,6 +224,13 @@ class JoinCard extends StatelessWidget {
const SizedBox(height: 16),
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.join),
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'Your friend gives you the address and password. No account or Jellyfin needed.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
);
},
@@ -243,10 +253,13 @@ class HostCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// From a solo session this is "Invite friends": same server settings,
// and what plays carries over.
final solo = SessionState.instance.solo.peek();
return ListenableBuilder(
listenable: flow,
builder: (context, _) => _Card(
title: 'Host a server',
title: solo ? 'Invite friends' : 'Host a server',
children: [
AbsorbPointer(
absorbing: flow.busy == ConnectMode.host,
@@ -281,13 +294,16 @@ class HostCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: _host, expand: true),
ConnectButton(
flow: flow, mode: ConnectMode.host, label: solo ? 'Start hosting' : 'Host', onSubmit: _host, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.host),
const Padding(
padding: EdgeInsets.only(top: 8),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'You pick what to watch afterwards, in the library.',
style: TextStyle(color: Colors.grey, fontSize: 12),
solo
? 'Your video keeps playing from where it is; friends join with your address.'
: 'You pick what to watch afterwards, in the library.',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
+7 -12
View File
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import '../files/scanner.dart' show FileKind, kindOf;
import '../files/ui/file_tiles.dart' show baseName;
import 'library_tab.dart' show offlinePutUpHint;
// Something handed to the app, by a drop or by Ctrl+V outside a text
// field: what it is ([classifyHandoff], pure) and the small card that asks
@@ -95,10 +94,8 @@ Handoff classifyHandoff({
/// What the card's buttons ask the shell to do.
enum HandoffAction {
/// Put the (first) video up for everyone.
/// Put the (first) video up (offline: watch it solo).
putUp,
/// Offline: list the video in Files → Recent and open it there.
openInFiles,
/// Add the video's folder (or the folder) to Files and open it.
addFolder,
/// Open Library → Link with the URL filled.
@@ -109,8 +106,9 @@ enum HandoffAction {
/// pops the [HandoffAction] chosen.
class HandoffCard extends StatelessWidget {
final Handoff item;
final bool online;
const HandoffCard({super.key, required this.item, required this.online});
/// Nobody else watches (offline or solo): videos play, no relay talk.
final bool solo;
const HandoffCard({super.key, required this.item, required this.solo});
@override
Widget build(BuildContext context) {
@@ -128,10 +126,7 @@ class HandoffCard extends StatelessWidget {
baseName(paths.first),
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer',
[
if (online)
button(HandoffAction.putUp, Icons.play_arrow, 'Put it up', primary: true)
else
button(HandoffAction.openInFiles, Icons.history, 'Open in Files', primary: true),
button(HandoffAction.putUp, Icons.play_arrow, solo ? 'Play' : 'Put it up', primary: true),
button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
],
),
@@ -144,9 +139,9 @@ class HandoffCard extends StatelessWidget {
HandoffLink(:final url) => (
Icons.link,
url,
online ? 'Pick how the others get it on the Link page' : offlinePutUpHint,
solo ? 'Opens it on the Link page' : 'Pick how the others get it on the Link page',
[
button(HandoffAction.openLink, Icons.link, online ? 'Put it up as a link…' : 'Open in Link',
button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…',
primary: true),
],
),
+15 -11
View File
@@ -189,10 +189,14 @@ String linkRelayHelp(LinkRelay r) => switch (r) {
'while it plays; the link is not shown to anyone.',
};
/// The Link source. [offlineHint] set: the button is off and says why.
/// Under Play when nobody else watches.
const soloPlayHint = 'Plays just for you. Invite friends any time; it keeps playing.';
/// The Link source. [solo]: nobody else watches (offline or solo), so the
/// button says Play and there are no relay modes (the link plays direct).
class LibraryLinkSource extends StatefulWidget {
final MediaPickerController controller;
final String? offlineHint;
final bool solo;
/// Puts the link up; returns an error text, or null when sent.
final Future<String?> Function(LinkMedia media) onPutUp;
final RecentLinks recents;
@@ -202,7 +206,7 @@ class LibraryLinkSource extends StatefulWidget {
required this.controller,
required this.onPutUp,
required this.recents,
this.offlineHint,
this.solo = false,
this.probe = defaultLinkProber,
});
@@ -261,14 +265,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Future<void> _go() async {
final url = _c.link.text.trim();
if (url.isEmpty || _busy || widget.offlineHint != null) return;
if (url.isEmpty || _busy) return;
setState(() {
_busy = true;
_error = null;
});
String? error;
try {
error = await widget.onPutUp(LinkMedia(url, relay: _c.linkRelay));
error = await widget.onPutUp(LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay));
} catch (e) {
error = '$e';
}
@@ -292,7 +296,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final hint = widget.offlineHint;
final solo = widget.solo;
final url = _c.link.text.trim();
final preview = url.isEmpty ? null : _previews[url];
final loading = url.isNotEmpty && !_previews.containsKey(url);
@@ -312,11 +316,11 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
);
final button = FilledButton.icon(
key: const ValueKey('link-put-up'),
onPressed: url.isEmpty || _busy || hint != null ? null : _go,
onPressed: url.isEmpty || _busy ? null : _go,
icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
label: Text(hint == null ? 'Put it up' : 'Play'),
label: Text(solo ? 'Play' : 'Put it up'),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -361,10 +365,10 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
])
: Row(children: [Expanded(child: field), const SizedBox(width: 12), button]),
),
if (hint != null || _error != null) ...[
if (solo || _error != null) ...[
const SizedBox(height: 8),
Text(
_error ?? hint!,
_error ?? soloPlayHint,
key: const ValueKey('link-hint'),
textAlign: TextAlign.center,
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
@@ -380,7 +384,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
child: _PreviewCard(preview: preview, loading: loading),
),
),
if (hint == null) ...[
if (!solo) ...[
const SizedBox(height: 28),
Text('How the others get it', style: t.titleSmall),
const SizedBox(height: 10),
+29 -16
View File
@@ -16,6 +16,7 @@ import '../screens/home/media_picker.dart';
import '../state/session_state.dart';
import 'library_link.dart';
import 'motion.dart';
import 'welcome.dart';
/// What putting up a Jellyfin pick sends: its ref (no URL, no token).
/// Throws [FormatException] with a user-facing message when the server is
@@ -58,9 +59,6 @@ String? nowPlayingFile(Map<String, Object?>? media, String lastPath) {
return name == media['name'] ? lastPath : null;
}
/// Offline, nothing goes up; this says why.
const offlinePutUpHint = 'Join or host to watch together';
/// Window width under which the details panel floats over the library.
const detailsDockFrom = 1000.0;
@@ -68,12 +66,13 @@ const detailsDockFrom = 1000.0;
/// through), the Jellyfin library chips beside it, and the source below.
/// Jellyfin cards select on click and open their details in a panel at
/// the right (docked from [detailsDockFrom], floating over the library
/// below it); a double-click or the panel's button puts it up. Offline
/// everything browses and the play buttons say [offlinePutUpHint].
/// below it); a double-click or the panel's button puts it up. With
/// nobody else watching ([solo]: offline or a solo session) the buttons
/// say Play and putting something up starts (or uses) a solo session.
class LibraryTab extends StatefulWidget {
final MediaPickerController controller;
final JellyfinAccounts accounts;
final bool online;
final bool solo;
/// This app hosts the server: its files stream from here directly.
final bool isHost;
final BrowseStart? jellyfinStart;
@@ -85,15 +84,17 @@ class LibraryTab extends StatefulWidget {
final RecentLinks? recentLinks;
final LinkProber probe;
/// The Files source (tests inject one); defaults to [FilesPage].
final Widget Function(BuildContext context, {required bool online, required bool relayed,
final Widget Function(BuildContext context, {required bool solo, required bool relayed,
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
/// Clock for the Jellyfin Continue row (tests pin it).
final DateTime Function() now;
/// Over the source switch (the shell's welcome band while offline).
final Widget? banner;
const LibraryTab({
super.key,
required this.controller,
required this.accounts,
required this.online,
required this.solo,
required this.isHost,
required this.onPutUp,
this.onPutUpDone,
@@ -103,6 +104,7 @@ class LibraryTab extends StatefulWidget {
this.probe = defaultLinkProber,
this.filesBuilder,
this.now = DateTime.now,
this.banner,
});
@override
@@ -115,7 +117,6 @@ class _LibraryTabState extends State<LibraryTab> {
JellyfinSelection? _shown;
MediaPickerController get _c => widget.controller;
String? get _hint => widget.online ? null : offlinePutUpHint;
@override
void initState() {
@@ -163,7 +164,6 @@ class _LibraryTabState extends State<LibraryTab> {
/// A Jellyfin card or episode: a series goes up as its next episode.
Future<String?> _putUpJellyfin(JellyfinSelection sel) async {
if (!widget.online) return offlinePutUpHint;
final account = widget.accounts.account(sel.serverId);
final client = widget.accounts.clientFor(sel.serverId);
if (account == null || client == null) return 'that Jellyfin server is not linked any more';
@@ -181,7 +181,6 @@ class _LibraryTabState extends State<LibraryTab> {
/// Double-click on a card: put it up, errors as a snack bar.
Future<void> _doublePutUp(JellyfinSelection sel) async {
_c.select(sel);
if (!widget.online) return;
final error = await _putUpJellyfin(sel);
if (error != null && mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
@@ -239,11 +238,16 @@ class _LibraryTabState extends State<LibraryTab> {
onOpenIntegrations: widget.onOpenIntegrations,
start: widget.jellyfinStart,
libraryStrip: false,
empty: (context) => NoJellyfinPanel(
accounts: widget.accounts,
onFiles: () => _c.source = MediaKind.file,
onLink: () => _c.source = MediaKind.link,
),
),
),
MediaKind.link => LibraryLinkSource(
controller: _c,
offlineHint: _hint,
solo: widget.solo,
recents: widget.recentLinks ?? RecentLinks.instance,
probe: widget.probe,
onPutUp: _put,
@@ -253,6 +257,15 @@ class _LibraryTabState extends State<LibraryTab> {
final main = Column(
children: [
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: easeTime,
child: widget.banner ?? const SizedBox(width: double.infinity),
),
),
header,
Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))),
],
@@ -267,7 +280,7 @@ class _LibraryTabState extends State<LibraryTab> {
child: JellyfinDetailsPanel(
controller: _browse,
selection: shown,
offlineHint: _hint,
solo: widget.solo,
onPutUp: _putUpJellyfin,
onClose: () => _c.select(null),
),
@@ -328,14 +341,14 @@ class _LibraryTabState extends State<LibraryTab> {
Future<String?> play(String path) => _put(FileMedia(path));
return SignalBuilder(builder: (context) {
final session = SessionState.instance;
final now = widget.online
final now = session.route.value == AppRoute.session
? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath)
: null;
final b = widget.filesBuilder;
if (b != null) {
return b(context, online: widget.online, relayed: relayed, onPlay: play, nowPlayingPath: now);
return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
}
return FilesPage(online: widget.online, relayed: relayed, onPlay: play, nowPlayingPath: now);
return FilesPage(solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
});
}
}
+107 -27
View File
@@ -39,8 +39,9 @@ bool viaHostOf(MemberView m, JellyfinRef? ref) =>
ref != null && m.jf[ref.serverId] == false;
/// The 60 px strip over everything: your avatar (profile), the tabs, and
/// the party: offline Join / Host, online the avatars (members card),
/// server and readiness, chat and Leave. Empty space drags the window.
/// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
/// friends and Stop, online the avatars (members card), server and
/// readiness, chat and Leave. Empty space drags the window.
class PartyStrip extends StatelessWidget {
final ShellTab tab;
final ValueChanged<ShellTab> onTab;
@@ -51,6 +52,8 @@ class PartyStrip extends StatelessWidget {
final VoidCallback onHost;
final VoidCallback onLeave;
final VoidCallback onProfile;
/// Solo: host from here, keeping what plays.
final VoidCallback? onInvite;
/// Online with something up but the player closed: brings it back.
final VoidCallback? onShowPlayer;
@@ -69,6 +72,7 @@ class PartyStrip extends StatelessWidget {
required this.onHost,
required this.onLeave,
required this.onProfile,
this.onInvite,
this.onShowPlayer,
this.updates,
this.windowControls = true,
@@ -83,6 +87,7 @@ class PartyStrip extends StatelessWidget {
final session = SessionState.instance;
final coord = session.coordinator;
final online = session.route.value == AppRoute.session;
final solo = online && (session.solo.value || session.switching.value);
final self = online ? coord.selfMember.value : null;
return Row(
children: [
@@ -97,7 +102,7 @@ class PartyStrip extends StatelessWidget {
child: StatusAvatar(
info: selfAvatarInfo(
ready: self?.ready ?? false,
host: online && coord.hostFile != null,
host: online && !solo && coord.hostFile != null,
),
size: stripAvatarSize,
ring: online,
@@ -130,14 +135,36 @@ class PartyStrip extends StatelessWidget {
),
UpdateIndicator(controller: updates),
const SizedBox(width: 8),
if (online) ...[
if (onShowPlayer != null)
IconButton(
key: const ValueKey('show-player'),
tooltip: 'Show the player',
onPressed: onShowPlayer,
icon: const Icon(Icons.smart_display_outlined, size: 20),
if (online && onShowPlayer != null)
IconButton(
key: const ValueKey('show-player'),
tooltip: 'Show the player',
onPressed: onShowPlayer,
icon: const Icon(Icons.smart_display_outlined, size: 20),
),
if (solo) ...[
_SoloChip(compact: compact, busy: session.switching.value),
const SizedBox(width: 10),
if (compact)
FilledButton(
key: const ValueKey('strip-invite'),
onPressed: session.switching.value ? null : onInvite,
child: const Text('Invite'),
)
else
FilledButton.icon(
key: const ValueKey('strip-invite'),
onPressed: session.switching.value ? null : onInvite,
icon: const Icon(Icons.group_add_outlined, size: 18),
label: const Text('Invite friends'),
),
const SizedBox(width: 8),
OutlinedButton(
key: const ValueKey('strip-leave'),
onPressed: session.switching.value ? null : onLeave,
child: const Text('Stop'),
),
] else if (online) ...[
PartyWatch(
builder: (context) => AvatarStack(
key: const ValueKey('party-avatars'),
@@ -177,23 +204,35 @@ class PartyStrip extends StatelessWidget {
child: const Text('Leave'),
),
] else ...[
if (!compact)
Text(
'Offline',
style: TextStyle(color: scheme.onSurfaceVariant),
if (!compact) ...[
Container(
width: 7,
height: 7,
decoration: BoxDecoration(color: scheme.outline, shape: BoxShape.circle),
),
const SizedBox(width: 12),
FilledButton.tonal(
key: const ValueKey('strip-join'),
onPressed: onJoin,
child: const Text('Join'),
),
const SizedBox(width: 8),
FilledButton(
key: const ValueKey('strip-host'),
onPressed: onHost,
child: const Text('Host'),
),
const SizedBox(width: 6),
Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)),
const SizedBox(width: 12),
],
if (compact) ...[
FilledButton(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join')),
const SizedBox(width: 8),
FilledButton.tonal(key: const ValueKey('strip-host'), onPressed: onHost, child: const Text('Host')),
] else ...[
FilledButton.icon(
key: const ValueKey('strip-join'),
onPressed: onJoin,
icon: const Icon(Icons.login_rounded, size: 18),
label: const Text('Join a friend'),
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
key: const ValueKey('strip-host'),
onPressed: onHost,
icon: const Icon(Icons.podcasts_rounded, size: 18),
label: const Text('Host'),
),
],
],
const SizedBox(width: 8),
if (windowControls) ...[
@@ -270,13 +309,54 @@ class _ServerLabel extends StatelessWidget {
);
}
/// "Your server" when hosting, else the address joined.
/// "Solo" alone, "Your server" when hosting, else the address joined.
String serverName() {
final session = SessionState.instance;
if (session.solo.peek()) return 'Solo';
if (session.coordinator.hostFile != null) return 'Your server';
return session.lastForm.host;
}
/// Solo: just you, on your own; a spinner while it becomes a server.
class _SoloChip extends StatelessWidget {
final bool compact;
final bool busy;
const _SoloChip({required this.compact, required this.busy});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: busy
? 'Starting your server…'
: 'Watching on your own. Nobody can join until you invite friends.',
child: Container(
key: const ValueKey('solo-chip'),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (busy)
const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
else
Icon(Icons.person_outline_rounded, size: 16, color: scheme.primary),
const SizedBox(width: 6),
Text(busy ? 'Inviting…' : 'Solo', style: const TextStyle(fontWeight: FontWeight.w600)),
if (!compact && !busy) ...[
const SizedBox(width: 6),
Text('only you', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
],
),
),
);
}
}
/// One strip tab: label and an underline that grows in when selected.
class StripTab extends StatelessWidget {
final String label;
+1 -1
View File
@@ -78,7 +78,7 @@ class _ProfileCardState extends State<ProfileCard> {
final coord = session.coordinator;
final online = session.route.value == AppRoute.session;
final self = online ? coord.selfMember.value : null;
final hosting = online && coord.hostFile != null;
final hosting = online && !session.solo.value && coord.hostFile != null;
final color = _p.color.value;
final png = _p.picture.value;
final out = self?.out ?? false;
+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
/// [StateError] with the server's message if it cannot start.
/// [loopbackOnly]: 127.0.0.1 only (the solo session).
static Future<ServerIsolate> start(
AppConfig config, {
bool loopbackOnly = false,
required void Function(String line) onLog,
required void Function(String error) onCrash,
Duration timeout = const Duration(seconds: 15),
@@ -83,7 +85,7 @@ class ServerIsolate {
final Isolate isolate;
try {
isolate = await Isolate.spawn(
_serverMain, (config.toMap(), events.sendPort),
_serverMain, ({...config.toMap(), if (loopbackOnly) 'loopbackOnly': true}, events.sendPort),
errorsAreFatal: false,
onError: errors.sendPort,
onExit: exit.sendPort,
+92 -8
View File
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart';
import '../screens/home/picked_media.dart';
import '../playback/yt_dlp_app.dart';
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
@@ -169,6 +170,13 @@ class SessionState {
/// Set when the hosted server died mid-session; Home shows it on the
/// Host card and clears it.
final serverCrash = signal<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
/// default) when the app-support folder is not available.
@@ -243,9 +251,80 @@ class SessionState {
await _saveFormQuietly();
}
/// Hosts a server friends can join. From a solo session it keeps the
/// media and position: the solo server goes, the new one gets the same
/// pick, seeked to where it was; if hosting fails, solo comes back.
Future<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;
_rememberForm(form);
if (form != null) _rememberForm(form);
try {
status.value =
const HealthStatus(HealthSeverity.info, 'starting server…');
@@ -265,14 +344,15 @@ class SessionState {
try {
server = await ServerIsolate.start(
AppConfig(
port: params.port,
secret: params.secret,
port: soloSession ? 0 : params.port,
secret: soloSession ? '' : params.secret,
name: 'server',
mediaPath: params.mediaPath,
hostKey: hostKey,
jellyfin: relay,
ytDlpDir: await _sharedYtDlpDir(),
),
loopbackOnly: soloSession,
onLog: (line) => debugPrint('[server] $line'),
onCrash: (cause) => _onServerCrash(server, cause),
);
@@ -289,13 +369,13 @@ class SessionState {
_server = server;
coordinator.hostFile = (path) async =>
identical(_server, server) ? server.setFile(path) : null;
status.value =
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}');
status.value = HealthStatus(
HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}');
try {
await coordinator.connect(ConnectParams(
host: '127.0.0.1',
port: server.port,
secret: params.secret,
secret: soloSession ? '' : params.secret,
name: params.name,
avatar: params.avatar,
hostKey: hostKey,
@@ -309,9 +389,10 @@ class SessionState {
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
rethrow;
}
solo.value = soloSession;
route.value = AppRoute.session;
} finally {
await _saveFormQuietly();
if (form != null) await _saveFormQuietly();
}
}
@@ -349,7 +430,9 @@ class SessionState {
try {
await coordinator.cancelConnect();
} catch (_) {}
await stopServer();
// Solo becoming hosted: [host] stops what it started and goes back
// to solo itself.
if (!switching.peek()) await stopServer();
}
/// Saved whatever the outcome: a failed join is exactly when the user
@@ -385,6 +468,7 @@ class SessionState {
Future<void> leave() async {
await coordinator.leave();
await stopServer();
solo.value = false;
status.value = const HealthStatus(HealthSeverity.info, 'idle');
route.value = AppRoute.home;
}