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:
1 parent
8186fbb5d3
commit
e0db5cc655
28 files changed
+905
-237
No files matched your search
@@ -41,12 +41,28 @@ release with a newer protocol is a required update: everyone on a server
|
||||
must run the same protocol version, and a mismatch is refused with a message
|
||||
telling you to update.
|
||||
|
||||
## Getting started
|
||||
|
||||
Three ways in, all at the top of the Library when you are not in a server.
|
||||
None of them needs Jellyfin or an account.
|
||||
|
||||
- **Join a friend** (most people): your friend sends you an address and,
|
||||
maybe, a password. See [Join](#join).
|
||||
- **Host**: you have the video and friends watch it with you. See
|
||||
[Host from the app](#host-from-the-app).
|
||||
- **Watch solo**: just play something from the Library. It plays for you
|
||||
alone, with everything a server has (Jellyfin progress, next episode,
|
||||
skip buttons); nothing is reachable from outside this computer. Invite
|
||||
friends in the strip turns it into a hosted server (the Host card's port
|
||||
and password) and the video carries on from where it was; Stop ends it.
|
||||
|
||||
## The window
|
||||
|
||||
One window. Along the top, the strip: your avatar (click it for your
|
||||
profile: name, picture with a crop editor, colour, and links to the
|
||||
settings), the Library / Schedule / Settings tabs, and the party. Offline
|
||||
the party side has Join and Host; in a server it shows everyone's avatar
|
||||
the party side has Join a friend and Host; watching solo it says Solo,
|
||||
with Invite friends and Stop; in a server it shows everyone's avatar
|
||||
with a ring for their state (green ready, amber buffering, grey not ready,
|
||||
dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever
|
||||
hosts, a tower on whoever passes the video on). Click the avatars for the
|
||||
@@ -108,7 +124,8 @@ a folder or a link anywhere on the window, or press Ctrl+V outside a
|
||||
text field, and a small card asks how to open it (put it up, add the
|
||||
folder to Files, or fill Link).
|
||||
|
||||
- **Jellyfin**: your linked servers (see below). Library chips beside the
|
||||
- **Jellyfin**: your linked servers (see below); with none linked it
|
||||
points to Files and Link and offers Jellyfin as optional. Library chips beside the
|
||||
switch, then one **Continue** row (what you were watching and what comes
|
||||
next, one card per series, the one you watched most recently first;
|
||||
series not touched for two months drop out) and **Latest in** each
|
||||
@@ -129,8 +146,8 @@ folder to Files, or fill Link).
|
||||
- **Files**: video folders you add on this computer, grouped by series
|
||||
(click a series to list its episodes), or any file through Browse. The others stream it from you.
|
||||
|
||||
Put it up and the player opens. Offline the library browses but nothing
|
||||
goes up until you join or host ("Join or host to watch together").
|
||||
Put it up and the player opens. Not in a server, the buttons say Play
|
||||
and it plays for you alone ([Watch solo](#getting-started)).
|
||||
|
||||
## Dedicated server
|
||||
|
||||
|
||||
@@ -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,9 +139,7 @@ class FileRow extends StatelessWidget {
|
||||
builder: (context, t, _) {
|
||||
final duration = durationText(t);
|
||||
final meta = [?duration, ?size].join(' · ');
|
||||
return _offlineTip(
|
||||
offline,
|
||||
Material(
|
||||
return Material(
|
||||
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
@@ -206,7 +195,6 @@ class FileRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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,9 +228,7 @@ class VideoCard extends StatelessWidget {
|
||||
path: video.path,
|
||||
builder: (context, t, _) {
|
||||
final duration = durationText(t);
|
||||
return _offlineTip(
|
||||
offline,
|
||||
InkWell(
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
@@ -301,7 +285,6 @@ class VideoCard extends StatelessWidget {
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
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,24 +204,36 @@ 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: 6),
|
||||
Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.tonal(
|
||||
],
|
||||
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,
|
||||
child: const Text('Join'),
|
||||
icon: const Icon(Icons.login_rounded, size: 18),
|
||||
label: const Text('Join a friend'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
FilledButton.tonalIcon(
|
||||
key: const ValueKey('strip-host'),
|
||||
onPressed: onHost,
|
||||
child: const Text('Host'),
|
||||
icon: const Icon(Icons.podcasts_rounded, size: 18),
|
||||
label: const Text('Host'),
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
if (windowControls) ...[
|
||||
SizedBox(
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ Future<void> frames(WidgetTester tester, [int n = 10]) async {
|
||||
Future<void> pumpPage(
|
||||
WidgetTester tester, {
|
||||
Size size = const Size(1280, 800),
|
||||
bool online = true,
|
||||
bool solo = false,
|
||||
bool relayed = false,
|
||||
Future<String?> Function(String path)? onPlay,
|
||||
ThumbnailLoader? thumbnails,
|
||||
@@ -56,7 +56,7 @@ Future<void> pumpPage(
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FilesPage(
|
||||
online: online,
|
||||
solo: solo,
|
||||
relayed: relayed,
|
||||
onPlay: onPlay ?? (_) async => null,
|
||||
thumbnails: thumbnails ?? fakeThumb,
|
||||
@@ -206,24 +206,6 @@ void main() {
|
||||
expect(find.text('4 episodes · 1 extra · Grp'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('offline: browsing works, playing is disabled', (tester) async {
|
||||
store.addFolder(anime);
|
||||
final played = <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 {
|
||||
store.addFolder(anime);
|
||||
final played = <String>[];
|
||||
@@ -261,7 +243,6 @@ void main() {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FilesPage(
|
||||
online: true,
|
||||
relayed: false,
|
||||
onPlay: (_) async => null,
|
||||
nowPlayingPath: '$anime/[Grp] Show - 03.mkv',
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/screens/home/health_probe.dart';
|
||||
import 'package:watch_together/src/screens/home/media_picker.dart';
|
||||
import 'package:watch_together/src/shell/app_shell.dart';
|
||||
import 'package:watch_together/src/shell/library_link.dart' show soloPlayHint;
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
import 'package:watch_together/src/state/settings.dart';
|
||||
import 'package:watch_together/src/update/update_controller.dart';
|
||||
@@ -230,24 +231,28 @@ void main() {
|
||||
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('library: sources switch; offline nothing goes up; Integrations opens Settings', (tester) async {
|
||||
testWidgets('library offline: welcome band, sources that need nothing, Jellyfin optional', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 900));
|
||||
await tester.tap(find.text('Files'));
|
||||
// The three ways in, Join first; none needs Jellyfin.
|
||||
expect(find.byKey(const ValueKey('welcome-band')), findsOneWidget);
|
||||
expect(find.text('Watch together, in sync'), findsOneWidget);
|
||||
final join = tester.getTopLeft(find.byKey(const ValueKey('way-join')));
|
||||
expect(join.dx, lessThan(tester.getTopLeft(find.byKey(const ValueKey('way-solo'))).dx));
|
||||
// No linked server: Files and Link first, Jellyfin as an optional card.
|
||||
expect(find.text('Pick something to watch'), findsOneWidget);
|
||||
expect(find.text('Add a Jellyfin server'), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('jellyfin-link')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Add a Jellyfin server'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('source-files')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(FilesPage), findsOneWidget);
|
||||
await tester.tap(find.text('Link'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Watch from a link'), findsOneWidget);
|
||||
// Offline nothing goes up.
|
||||
expect(find.text('Join or host to watch together'), findsOneWidget);
|
||||
// Alone: Play, no relay modes.
|
||||
expect(find.text(soloPlayHint), findsOneWidget);
|
||||
expect(find.text('How the others get it'), findsNothing);
|
||||
await tester.tap(find.text('Jellyfin'));
|
||||
await tester.pumpAndSettle();
|
||||
// No linked server: sign-in right in the library.
|
||||
expect(find.text('Add a Jellyfin server'), findsOneWidget);
|
||||
await tester.tap(find.text('Open Integrations'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Add server'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -48,16 +48,16 @@ FakeJellyfin libraryFake() {
|
||||
}
|
||||
|
||||
class FilesStub extends StatelessWidget {
|
||||
final bool online, relayed;
|
||||
final bool solo, relayed;
|
||||
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
|
||||
Widget build(BuildContext context) => Column(children: [
|
||||
Text('files online=$online relayed=$relayed'),
|
||||
Text('files solo=$solo relayed=$relayed'),
|
||||
TextButton(
|
||||
key: const ValueKey('stub-play'),
|
||||
onPressed: online ? () => onPlay('/v/a.mkv') : null,
|
||||
onPressed: () => onPlay('/v/a.mkv'),
|
||||
child: const Text('play'),
|
||||
),
|
||||
]);
|
||||
@@ -75,7 +75,7 @@ class Harness {
|
||||
Future<Harness> pumpLibrary(
|
||||
WidgetTester tester,
|
||||
Size size, {
|
||||
bool online = true,
|
||||
bool solo = false,
|
||||
bool isHost = false,
|
||||
MediaKind source = MediaKind.jellyfin,
|
||||
Harness? reuse,
|
||||
@@ -103,7 +103,7 @@ Future<Harness> pumpLibrary(
|
||||
key: UniqueKey(),
|
||||
controller: h.controller,
|
||||
accounts: h.accounts,
|
||||
online: online,
|
||||
solo: solo,
|
||||
isHost: isHost,
|
||||
jellyfinStart: start,
|
||||
recentLinks: h.recents,
|
||||
@@ -111,8 +111,8 @@ Future<Harness> pumpLibrary(
|
||||
probe: (url) async => url.endsWith('.mkv')
|
||||
? LinkProbe.file(Uri.parse(url), 1)
|
||||
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
|
||||
filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) =>
|
||||
FilesStub(online: online, relayed: relayed, onPlay: onPlay),
|
||||
filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) =>
|
||||
FilesStub(solo: solo, relayed: relayed, onPlay: onPlay),
|
||||
onPutUp: (m) async {
|
||||
h.put.add(m);
|
||||
return null;
|
||||
@@ -152,7 +152,7 @@ void main() {
|
||||
|
||||
await tester.tap(find.text('Files'));
|
||||
await settle(tester, 10);
|
||||
expect(find.text('files online=true relayed=true'), findsOneWidget);
|
||||
expect(find.text('files solo=false relayed=true'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('stub-play')));
|
||||
await settle(tester);
|
||||
expect(h.put, [const FileMedia('/v/a.mkv')]);
|
||||
@@ -247,32 +247,26 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('offline: everything browses, nothing goes up', (tester) async {
|
||||
final h = await pumpLibrary(tester, const Size(1600, 1000), online: false);
|
||||
testWidgets('solo: buttons say Play and put up; a link goes direct, no relay modes', (tester) async {
|
||||
final h = await pumpLibrary(tester, const Size(1600, 1000), solo: true);
|
||||
await tester.tap(arifuretaCard());
|
||||
await settle(tester, 10);
|
||||
final go = find.byKey(const ValueKey('details-put-up'));
|
||||
expect(tester.widget<ButtonStyleButton>(go).onPressed, isNull);
|
||||
expect(find.descendant(of: go, matching: find.text('Play')), findsOneWidget);
|
||||
expect(find.text(offlinePutUpHint), findsOneWidget);
|
||||
await doubleTap(tester, arifuretaCard());
|
||||
expect(h.put, isEmpty);
|
||||
expect(find.descendant(of: go, matching: find.textContaining('Play')), findsOneWidget);
|
||||
await tester.tap(go);
|
||||
await settle(tester, 10);
|
||||
expect(h.put.single, isA<JellyfinMedia>());
|
||||
|
||||
await tester.tap(find.text('Link'));
|
||||
await settle(tester, 10);
|
||||
h.controller.linkRelay = LinkRelay.me;
|
||||
await tester.enterText(find.byKey(const ValueKey('link-field')), 'https://cdn.example/a.mkv');
|
||||
await settle(tester, 10);
|
||||
final linkGo = find.byKey(const ValueKey('link-put-up'));
|
||||
expect(tester.widget<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('a.mkv'), findsOneWidget, reason: 'the preview still works');
|
||||
|
||||
await tester.tap(find.text('Files'));
|
||||
expect(find.text(soloPlayHint), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('link-put-up')));
|
||||
await settle(tester, 10);
|
||||
expect(find.text('files online=false relayed=true'), findsOneWidget);
|
||||
expect(h.put, isEmpty);
|
||||
expect(h.put.last, const LinkMedia('https://cdn.example/a.mkv'));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -307,7 +301,7 @@ void main() {
|
||||
|
||||
testWidgets('hosting: files stream from here; now-playing file passes through', (tester) async {
|
||||
await pumpLibrary(tester, const Size(1400, 900), source: MediaKind.file, isHost: true);
|
||||
expect(find.text('files online=true relayed=false'), findsOneWidget);
|
||||
expect(find.text('files solo=false relayed=false'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the last pick comes back selected; "Change video" opens on its series', (tester) async {
|
||||
@@ -325,7 +319,7 @@ void main() {
|
||||
expect(c.selected!.itemId, bsEpisode);
|
||||
expect(c.source, MediaKind.jellyfin);
|
||||
final h = Harness(c, accounts, FakeJellyfin());
|
||||
await pumpLibrary(tester, const Size(1600, 1000), reuse: h, online: false);
|
||||
await pumpLibrary(tester, const Size(1600, 1000), reuse: h, solo: true);
|
||||
expect(find.byKey(const ValueKey('jf-details')), findsOneWidget);
|
||||
expect(find.text('Black Summoner'), findsWidgets);
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ void main() {
|
||||
WidgetTester tester, {
|
||||
Size size = const Size(2560, 1080),
|
||||
ScheduleSnapshot Function()? snap,
|
||||
bool online = true,
|
||||
bool solo = false,
|
||||
JellyfinAccounts? accounts,
|
||||
}) async {
|
||||
toasts.clear();
|
||||
@@ -245,7 +245,7 @@ void main() {
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
body: SchedulePage(
|
||||
online: online,
|
||||
solo: solo,
|
||||
repository: repo,
|
||||
accounts: accounts,
|
||||
onToast: toasts.add,
|
||||
@@ -434,11 +434,11 @@ void main() {
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
testWidgets('offline: Play is disabled', (tester) async {
|
||||
await pumpPage(tester, online: false);
|
||||
testWidgets('solo: the button says Play and works', (tester) async {
|
||||
await pumpPage(tester, solo: true);
|
||||
expect(find.text('Put it up'), findsNothing);
|
||||
final b = widgetOf<ButtonStyleButton>(tester, action(111, 3));
|
||||
expect(b.onPressed, isNull);
|
||||
expect(b.onPressed, isNotNull);
|
||||
expect(find.text('Play'), findsOneWidget);
|
||||
await done(tester);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:hive_ce/hive.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/screens/home/picked_media.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
import 'package:watch_together/src/state/server_isolate.dart';
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
@@ -218,4 +219,45 @@ void main() {
|
||||
if (file.existsSync()) file.deleteSync();
|
||||
}
|
||||
});
|
||||
|
||||
test('solo: loopback-only server; inviting friends hosts the same media',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
MediaKit.ensureInitialized();
|
||||
final tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
Hive.init('$tmp/hive_isolate_solo_test');
|
||||
final session = SessionState.instance;
|
||||
final port = await _freePort();
|
||||
_Raw? friend;
|
||||
final file = File('$tmp/solo carry.mkv')..writeAsBytesSync(List.filled(4096, 7));
|
||||
session.route.value = AppRoute.home; // the tests above leave it set
|
||||
try {
|
||||
await session.startSolo(const ConnectParams(host: '127.0.0.1', port: 0, secret: '', name: 'me'));
|
||||
expect(session.route.peek(), AppRoute.session);
|
||||
expect(session.solo.peek(), isTrue);
|
||||
await session.coordinator.playback.player.setVolume(0);
|
||||
final soloPort = session.hostedServer!.port;
|
||||
// Reachable on loopback only: a LAN address of this machine is refused.
|
||||
final lan = [
|
||||
for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4))
|
||||
for (final a in i.addresses)
|
||||
if (!a.isLoopback) a,
|
||||
];
|
||||
if (lan.isNotEmpty) {
|
||||
await expectLater(Socket.connect(lan.first, soloPort, timeout: const Duration(seconds: 3)),
|
||||
throwsA(isA<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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -128,6 +128,29 @@ void main() {
|
||||
expect(session.route.value, AppRoute.session);
|
||||
});
|
||||
|
||||
testWidgets('solo: just you, Invite friends opens the host card, Stop asks nothing', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 800));
|
||||
session.solo.value = true;
|
||||
addTearDown(() => session.solo.value = false);
|
||||
online([member('me', 'Me')], media: false);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('solo-chip')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('party-avatars')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('strip-chat')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('welcome-band')), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('strip-invite')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Invite friends'), findsWidgets);
|
||||
expect(find.text('Start hosting'), findsOneWidget);
|
||||
await tester.tapAt(const Offset(5, 790));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('strip-leave')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Leave server?'), findsNothing);
|
||||
expect(session.route.value, AppRoute.home);
|
||||
expect(session.solo.value, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('compact below 1100 px: 3 avatars, no server text', (tester) async {
|
||||
await pumpShell(tester, const Size(1000, 800));
|
||||
online(party, media: false);
|
||||
|
||||
+39
-8
@@ -40,6 +40,23 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
|
||||
shell says so in a dialog (during setup the host attempt fails with it).
|
||||
Stop (leave, close, cancel during setup, a failed own join)
|
||||
sends `stop`, waits up to 3 s for exit, then kills the isolate.
|
||||
- **Solo session** (watching alone): the same hosting path, so nothing
|
||||
about solo is a second code path or on the wire. Putting something up
|
||||
while offline (`AppShellState._putUp`) calls `SessionState.startSolo`:
|
||||
a `ServerIsolate` with `loopbackOnly` (an extra `'loopbackOnly': true`
|
||||
key next to the `AppConfig` map; `SyncServer` binds 127.0.0.1 instead
|
||||
of every IPv4 interface), port 0 (a free one), no password, a host key;
|
||||
the app joins it and `SessionState.solo` is set. Everything a session
|
||||
has works (Jellyfin progress, next episode, skip pills, the player); a
|
||||
link goes up direct (no relay mode). Invite friends opens the Host card,
|
||||
whose submit (`SessionState.host` while solo) remembers the
|
||||
coordinator's `lastPick` (the last `swapLink` / `swapJellyfin` /
|
||||
`swapFile` of this connection) and position, leaves and stops the solo
|
||||
server, hosts on the card's port and password, swaps the same pick and
|
||||
seeks once a state shows it (`switching` is set meanwhile; the shell
|
||||
closes the player and reopens it as it was). If hosting fails or is
|
||||
cancelled, solo starts again with the same media. Stop leaves without
|
||||
asking.
|
||||
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
|
||||
and shipped next to the app as `watch-together-server`. It parses flags
|
||||
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
|
||||
@@ -673,8 +690,11 @@ always scrolls the page). Space never presses a focused button
|
||||
|
||||
- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's),
|
||||
the Library / Schedule / Settings tabs with a growing underline (an
|
||||
`HScroll` when narrow), the update indicator, then offline Join and Host
|
||||
(connect cards), online the party: `AvatarStack` (host first, then
|
||||
`HScroll` when narrow), the update indicator, then offline a grey dot,
|
||||
"Offline", Join a friend and Host (connect cards; icon-less "Join" and
|
||||
"Host" under 1100 px), solo a "Solo · only you" chip, Invite friends
|
||||
(the Host card, titled "Invite friends", "Start hosting") and Stop (no
|
||||
dialog; no avatars or chat), online the party: `AvatarStack` (host first, then
|
||||
ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and
|
||||
"ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave
|
||||
(asks first). The stack opens the members card (status, host / relay
|
||||
@@ -702,7 +722,8 @@ always scrolls the page). Space never presses a focused button
|
||||
to watch is picked in the library afterwards. Submit state (busy,
|
||||
cancel, per-card errors) is `ConnectFlow`, owned by the shell so an
|
||||
attempt and its error outlive the card; a card closes itself once
|
||||
online.
|
||||
in a server that is not solo. Join says that a friend gives the address
|
||||
and that no account or Jellyfin is needed.
|
||||
- **Profile** (`profile_card.dart`, `screens/home/profile.dart`): a card
|
||||
tinted with your colour: picture (hover "Change" opens `CropPicker`:
|
||||
drag, wheel or slider zoom, previews at 88/42/24 px, exports a 256 px
|
||||
@@ -711,6 +732,14 @@ always scrolls the page). Space never presses a focused button
|
||||
the `connect_form` box (an old picture path is shrunk once and kept as
|
||||
bytes). Online, a name change (once typing settles) or picture change
|
||||
goes out through `Profile.push` (`coordinator.updateProfile`).
|
||||
- **Welcome** (`welcome.dart`): offline the Library opens with
|
||||
`WelcomeBand` over the source switch (sizes away when online): the app
|
||||
icon, Starsync and "Watch together, in sync", then three `WayCard`s,
|
||||
Join a friend first (filled), Host, Watch solo (opens Files, or
|
||||
Jellyfin when linked); beside the name from 940 px, under it below,
|
||||
stacked under 520 px. With no Jellyfin server linked the Jellyfin source
|
||||
is `NoJellyfinPanel`: Files and Link cards ("No account needed") and an
|
||||
optional Jellyfin card whose "Link a server" opens the sign-in in place.
|
||||
- **Tabs**: Library (`library_tab.dart`): a source switch (Jellyfin /
|
||||
Link / Files, fade through) with the Jellyfin library chips beside it.
|
||||
Its state (source, link field and relay mode, selected Jellyfin card,
|
||||
@@ -730,8 +759,10 @@ always scrolls the page). Space never presses a focused button
|
||||
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
|
||||
an isolate, videos grouped by series; series start collapsed, a header
|
||||
click opens one and open ones stay open for the app session
|
||||
(`showFilesFolder` opens a folder from outside). Offline everything browses; the buttons say "Join or host to
|
||||
watch together". Schedule (`schedule/`) is a week of day columns built
|
||||
(`showFilesFolder` opens a folder from outside). With nobody else
|
||||
watching (`solo` on the Library, Files and Schedule: offline or solo)
|
||||
the buttons say Play, Link has no relay modes and Files no relay notice;
|
||||
offline a put-up starts a solo session. Schedule (`schedule/`) is a week of day columns built
|
||||
from AniList's airing schedule (`anilist.dart`: pages cached 10 min in
|
||||
memory and 1 h on disk, stale data shown on 429 or failure) matched
|
||||
against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token;
|
||||
@@ -755,9 +786,9 @@ always scrolls the page). Space never presses a focused button
|
||||
into videos (the Files extensions, `kindOf`), a folder, an http(s) link,
|
||||
an image or nothing; videos win over folders, folders over images.
|
||||
`HandoffCard` opens as a popover at the pointer: a video goes up through
|
||||
the shell's put-up (offline: into Files → Recent) or adds its folder to
|
||||
Files; a folder is added to Files and opened; a link fills Library →
|
||||
Link (online the relay mode is picked there). Images are ignored.
|
||||
the shell's put-up (Play when alone; offline that starts solo) or adds
|
||||
its folder to Files; a folder is added to Files and opened; a link fills
|
||||
Library → Link (with others the relay mode is picked there). Images are ignored.
|
||||
Nothing happens while a dialog or popover is over the shell.
|
||||
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
|
||||
unready, force-ready, join, leave, swap and others' chat (feed
|
||||
|
||||
@@ -44,7 +44,13 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
|
||||
- **Dedicated server**: `watch-together-server`, a separate program in the
|
||||
release tarball, for an always-on box. See the README.
|
||||
|
||||
Friends join either way with Join in the strip at the top of the window.
|
||||
Friends join either way with Join a friend in the strip at the top of the
|
||||
window; guests need no Jellyfin or account.
|
||||
|
||||
- **Watch solo**: put something up without joining or hosting and the
|
||||
app hosts a private server reachable only from this computer, so
|
||||
watching alone works like a session (progress, next episode). Invite
|
||||
friends turns it into a normal hosted server, video and position kept.
|
||||
|
||||
## One session, start to finish
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import 'sync_server.dart';
|
||||
/// a file on this machine the hosted file (the hosting app's in-session file
|
||||
/// swap; a following `swap` with its name and size puts it up) and answers
|
||||
/// `{'ok':true,'name':n,'size':s}` or `{'ok':false}`. Without [events] a
|
||||
/// start failure throws.
|
||||
/// start failure throws. `'loopbackOnly': true` in [config] (next to the
|
||||
/// [AppConfig] keys) binds 127.0.0.1 only: the app's solo session.
|
||||
Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
|
||||
StreamSubscription<pkg_logging.LogRecord>? logs;
|
||||
if (events != null) {
|
||||
@@ -32,7 +33,7 @@ Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
|
||||
}
|
||||
final SyncServer server;
|
||||
try {
|
||||
server = SyncServer(AppConfig.fromMap(config));
|
||||
server = SyncServer(AppConfig.fromMap(config), loopbackOnly: config['loopbackOnly'] == true);
|
||||
await server.start();
|
||||
} catch (e) {
|
||||
await logs?.cancel();
|
||||
|
||||
@@ -48,8 +48,12 @@ class SyncServer {
|
||||
/// Upstream connections of the Jellyfin relay; created on first use.
|
||||
HttpClient? _relayClient;
|
||||
|
||||
/// Listen on 127.0.0.1 only (the app's solo session), not on every
|
||||
/// IPv4 interface.
|
||||
final bool loopbackOnly;
|
||||
|
||||
/// [ytDlp] replaces the server's own (tests).
|
||||
SyncServer(this.config, {YtDlp? ytDlp}) {
|
||||
SyncServer(this.config, {YtDlp? ytDlp, this.loopbackOnly = false}) {
|
||||
hub = ServerHub(sessions, config.secret,
|
||||
hostKey: config.hostKey,
|
||||
jellyfin: config.jellyfin,
|
||||
@@ -131,7 +135,7 @@ class SyncServer {
|
||||
await _resolveJellyfinServerId();
|
||||
_mountRoutes();
|
||||
_server = await _app.serve(
|
||||
address: InternetAddress.anyIPv4,
|
||||
address: loopbackOnly ? InternetAddress.loopbackIPv4 : InternetAddress.anyIPv4,
|
||||
port: config.port,
|
||||
// A killed isolate never closes its listening socket, so the port stays
|
||||
// bound in this process. Shared lets the next server in the same
|
||||
|
||||
Reference in new issue
Block a user