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
+1013
-345
No files matched your search
@@ -10,9 +10,6 @@ import 'thumb.dart';
|
||||
// The Files page's rows and cards: a series header, an episode row (also
|
||||
// used for Recent), a loose-video card, and the empty states.
|
||||
|
||||
/// Shown on play controls while there is nobody to watch with.
|
||||
const offlinePlayHint = 'Join or host to watch';
|
||||
|
||||
/// What a play control is doing right now.
|
||||
enum PlayState { idle, starting, playing }
|
||||
|
||||
@@ -28,10 +25,6 @@ String parentName(String path) => baseName(File(path).parent.path);
|
||||
/// Cleaned title of a loose video: `Title (2019)`.
|
||||
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})';
|
||||
|
||||
/// Disabled with [offlinePlayHint] when [onTap] is null because offline.
|
||||
Widget _offlineTip(bool offline, Widget child) =>
|
||||
offline ? Tooltip(message: offlinePlayHint, child: child) : child;
|
||||
|
||||
class SeriesHeader extends StatelessWidget {
|
||||
final SeriesGroup group;
|
||||
final ThumbCache thumbs;
|
||||
@@ -110,9 +103,8 @@ class FileRow extends StatelessWidget {
|
||||
final String? size;
|
||||
final PlayState state;
|
||||
final ThumbCache thumbs;
|
||||
/// Null disables the row ([offline] adds the reason as a tooltip).
|
||||
/// Null disables the row.
|
||||
final VoidCallback? onTap;
|
||||
final bool offline;
|
||||
final bool indent;
|
||||
final Widget? trailing;
|
||||
const FileRow({
|
||||
@@ -124,7 +116,6 @@ class FileRow extends StatelessWidget {
|
||||
required this.state,
|
||||
required this.thumbs,
|
||||
required this.onTap,
|
||||
required this.offline,
|
||||
this.indent = true,
|
||||
this.trailing,
|
||||
});
|
||||
@@ -148,62 +139,59 @@ class FileRow extends StatelessWidget {
|
||||
builder: (context, t, _) {
|
||||
final duration = durationText(t);
|
||||
final meta = [?duration, ?size].join(' · ');
|
||||
return _offlineTip(
|
||||
offline,
|
||||
Material(
|
||||
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
|
||||
return Material(
|
||||
color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: ThumbImage(t, width: 80, height: 45),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(width: 20, child: Center(child: icon)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: playing ? scheme.primary : null,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: ThumbImage(t, width: 80, height: 45),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(width: 20, child: Center(child: icon)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: playing ? scheme.primary : null,
|
||||
),
|
||||
),
|
||||
if (playing) ...[
|
||||
const SizedBox(width: 8),
|
||||
Pill('now playing', scheme.primary),
|
||||
],
|
||||
),
|
||||
if (playing) ...[
|
||||
const SizedBox(width: 8),
|
||||
Pill('now playing', scheme.primary),
|
||||
],
|
||||
),
|
||||
Text(subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: muted, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: muted, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
if (meta.isNotEmpty) ...[
|
||||
const SizedBox(width: 12),
|
||||
Text(meta, style: TextStyle(color: muted, fontSize: 12)),
|
||||
],
|
||||
?trailing,
|
||||
),
|
||||
if (meta.isNotEmpty) ...[
|
||||
const SizedBox(width: 12),
|
||||
Text(meta, style: TextStyle(color: muted, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -223,14 +211,12 @@ class VideoCard extends StatelessWidget {
|
||||
final PlayState state;
|
||||
final ThumbCache thumbs;
|
||||
final VoidCallback? onTap;
|
||||
final bool offline;
|
||||
const VideoCard({
|
||||
super.key,
|
||||
required this.video,
|
||||
required this.state,
|
||||
required this.thumbs,
|
||||
required this.onTap,
|
||||
required this.offline,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -242,65 +228,62 @@ class VideoCard extends StatelessWidget {
|
||||
path: video.path,
|
||||
builder: (context, t, _) {
|
||||
final duration = durationText(t);
|
||||
return _offlineTip(
|
||||
offline,
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: playing ? scheme.primary : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: playing ? scheme.primary : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ThumbImage(t),
|
||||
if (duration != null)
|
||||
Positioned(right: 6, bottom: 6, child: _Badge(duration)),
|
||||
if (state != PlayState.idle)
|
||||
Container(
|
||||
color: Colors.black38,
|
||||
alignment: Alignment.center,
|
||||
child: state == PlayState.playing
|
||||
? const Icon(Icons.graphic_eq, color: Colors.white, size: 32)
|
||||
: const SizedBox.square(
|
||||
dimension: 28,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3, color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ThumbImage(t),
|
||||
if (duration != null)
|
||||
Positioned(right: 6, bottom: 6, child: _Badge(duration)),
|
||||
if (state != PlayState.idle)
|
||||
Container(
|
||||
color: Colors.black38,
|
||||
alignment: Alignment.center,
|
||||
child: state == PlayState.playing
|
||||
? const Icon(Icons.graphic_eq, color: Colors.white, size: 32)
|
||||
: const SizedBox.square(
|
||||
dimension: 28,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3, color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
cleanTitle(video.parsed),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
color: playing ? scheme.primary : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
cleanTitle(video.parsed),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
color: playing ? scheme.primary : null,
|
||||
),
|
||||
),
|
||||
Text(formatSize(video.size),
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(formatSize(video.size),
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user