Protocol 10 presence gate, drop/paste anywhere, quick UI fixes
Ready gate: whoever is in the server while it is not playing counts (joining while playing does not pause anyone); a swap counts everyone present and keeps ready flags; the quorum rule is gone. Optional 'Unready when the video changes'. No 'Skip 1:30' guess when the media has chapters. Drag & drop and Ctrl+V anywhere (fox_utils): files, folders and links get a small card asking how to open them. Files series start collapsed. UI: Tab never moves focus and Space is always ready outside text fields; chat focuses its field; unread badge on uosc and a chip in fullscreen; people-only chat with an Activity toggle; skip pills show 5 s then with the controls; volume up to 150%; own avatar same size as the party. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f22be6c20
commit
8effa573a0
38 files changed
+1529
-303
No files matched your search
@@ -1,10 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show File;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../files/folder_store.dart';
|
||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
||||
import '../jellyfin/session_media.dart';
|
||||
@@ -18,6 +22,7 @@ import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import 'chat_panel.dart';
|
||||
import 'connect_cards.dart';
|
||||
import 'handoff.dart';
|
||||
import 'library_tab.dart';
|
||||
import 'motion.dart';
|
||||
import 'party_strip.dart';
|
||||
@@ -36,6 +41,9 @@ import 'toasts.dart';
|
||||
/// hidden), mini it is a 384x216 box snapped to a corner. A mode change
|
||||
/// fades the layer out, moves it while invisible and fades it back in, so
|
||||
/// the video is never mounted twice nor remounted.
|
||||
///
|
||||
/// A drop anywhere on the window or Ctrl+V outside a text field is a
|
||||
/// [Handoff]: a card near the pointer asks how to open it.
|
||||
class AppShell extends StatefulWidget {
|
||||
final UpdateController? updates;
|
||||
final bool autoUpdateCheck;
|
||||
@@ -88,6 +96,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
Offset? _drag; // mini top-left while dragging
|
||||
bool _snapping = false;
|
||||
|
||||
/// Last pointer position in the window (where a paste's card opens).
|
||||
Offset? _pointer;
|
||||
bool _dragging = false;
|
||||
bool _handoffOpen = false;
|
||||
|
||||
final _flow = ConnectFlow();
|
||||
late final _join = JoinFields(SessionState.instance.lastForm);
|
||||
late final _host = HostFields(SessionState.instance.lastForm);
|
||||
@@ -128,6 +141,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
_jellyfin.ensureLoaded();
|
||||
if (widget.autoUpdateCheck) _autoCheck();
|
||||
_flow.onKicked = _showKicked;
|
||||
HardwareKeyboard.instance.addHandler(_onKey);
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
_feedSeen = coord.feedLog.peek().length;
|
||||
@@ -195,6 +209,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
for (final stop in _stops) {
|
||||
stop();
|
||||
}
|
||||
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||
_fade.dispose();
|
||||
_online.dispose();
|
||||
_hasMedia.dispose();
|
||||
@@ -368,6 +383,122 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
return null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- handoff
|
||||
|
||||
/// Nothing (a dialog, a popover) is over the shell.
|
||||
bool get _onTop => !_handoffOpen && (ModalRoute.of(context)?.isCurrent ?? true);
|
||||
|
||||
/// Ctrl+V outside a text field (a focused field pastes on its own).
|
||||
bool _onKey(KeyEvent e) {
|
||||
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||
final keys = HardwareKeyboard.instance;
|
||||
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
|
||||
if (!mounted || !_onTop) return false;
|
||||
final inField =
|
||||
FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
|
||||
if (inField) return false;
|
||||
unawaited(_paste());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Clipboard files first, then text (a link or absolute paths). A pasted
|
||||
/// image has no taker yet, so the clipboard's image data is not read.
|
||||
Future<void> _paste() async {
|
||||
var files = const <String>[];
|
||||
String? text;
|
||||
try {
|
||||
files = await ClipboardX.readFiles();
|
||||
} catch (_) {}
|
||||
if (files.isEmpty) {
|
||||
try {
|
||||
text = await ClipboardX.readText();
|
||||
} catch (_) {}
|
||||
}
|
||||
await _handOff(classifyHandoff(paths: files, text: text), _pointer);
|
||||
}
|
||||
|
||||
Future<void> _handOff(Handoff item, Offset? at) async {
|
||||
if (!mounted || !_onTop || item is HandoffNothing || item is HandoffImage) return;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
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));
|
||||
} finally {
|
||||
_handoffOpen = false;
|
||||
}
|
||||
if (action == null || !mounted) return;
|
||||
switch ((item, action)) {
|
||||
case (HandoffVideos(:final paths), HandoffAction.putUp):
|
||||
final error = await _putUp(FileMedia(paths.first));
|
||||
if (error != null) {
|
||||
SessionState.instance.coordinator.showToast(error);
|
||||
} else {
|
||||
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), _):
|
||||
await _addFolder(path);
|
||||
case (HandoffLink(:final url), _):
|
||||
_picker.link.text = url;
|
||||
_openLibrary(MediaKind.link);
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addFolder(String dir) async {
|
||||
await FolderStore.instance.ensureLoaded();
|
||||
FolderStore.instance.addFolder(dir);
|
||||
_openFiles(dir);
|
||||
}
|
||||
|
||||
/// Library → Files on [dir] (null: Recent).
|
||||
void _openFiles(String? dir) {
|
||||
showFilesFolder(dir);
|
||||
_openLibrary(MediaKind.file);
|
||||
}
|
||||
|
||||
void _openLibrary(MediaKind source) {
|
||||
if (!mounted) return;
|
||||
_picker.source = source;
|
||||
_selectTab(ShellTab.library);
|
||||
}
|
||||
|
||||
Widget _dropTarget(Widget child) => DropTarget(
|
||||
onDragEntered: (_) => setState(() => _dragging = _onTop),
|
||||
onDragExited: (_) => setState(() => _dragging = false),
|
||||
onDragDone: (d) {
|
||||
setState(() => _dragging = false);
|
||||
_handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition);
|
||||
},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
child,
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
opacity: _dragging ? 1 : 0,
|
||||
child: const _DropOverlay(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------- dialogs
|
||||
|
||||
Future<void> _confirmLeave() async {
|
||||
@@ -426,7 +557,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return CallbackShortcuts(
|
||||
final shell = CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings),
|
||||
},
|
||||
@@ -474,6 +605,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
}),
|
||||
),
|
||||
);
|
||||
return Listener(
|
||||
onPointerHover: (e) => _pointer = e.localPosition,
|
||||
onPointerDown: (e) => _pointer = e.localPosition,
|
||||
child: _dropTarget(shell),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(bool online) {
|
||||
@@ -619,3 +755,39 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Over the window while something is dragged over it.
|
||||
class _DropOverlay extends StatelessWidget {
|
||||
const _DropOverlay();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
key: const ValueKey('drop-overlay'),
|
||||
margin: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surface.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: scheme.primary.withValues(alpha: 0.7), width: 2),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Material(
|
||||
color: scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
elevation: 8,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.file_download_outlined, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Text('Drop to open', style: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/text_focus.dart';
|
||||
import 'avatar.dart';
|
||||
|
||||
/// Side panel: chat with avatars, other activity as quiet lines, and the
|
||||
/// message field. Width is fixed; the shell slides it in and out.
|
||||
class ChatPanel extends StatelessWidget {
|
||||
/// Activity lines (joins, ready, seeks...) between the messages: off by
|
||||
/// default, kept for the app's run.
|
||||
final showActivity = ValueNotifier(false);
|
||||
|
||||
/// Side panel: chat with avatars, other activity as quiet lines (behind
|
||||
/// the "Activity" toggle), and the message field. Width is fixed; the
|
||||
/// shell slides it in and out. The field takes focus on open; Tab and Esc
|
||||
/// in it close the panel.
|
||||
class ChatPanel extends StatefulWidget {
|
||||
static const width = 340.0;
|
||||
final TextEditingController chat;
|
||||
final FocusNode chatFocus;
|
||||
final VoidCallback onClose;
|
||||
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose});
|
||||
|
||||
@override
|
||||
State<ChatPanel> createState() => _ChatPanelState();
|
||||
}
|
||||
|
||||
class _ChatPanelState extends State<ChatPanel> {
|
||||
// Where focus was before the field took it; it goes back there on close
|
||||
// (else a field behind could take it, and Space with it).
|
||||
FocusNode? _before;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final before = FocusManager.instance.primaryFocus;
|
||||
if (before != widget.chatFocus) _before = before;
|
||||
widget.chatFocus.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final before = _before;
|
||||
if (before != null && before.context != null && before.canRequestFocus && !textInputFocused(before)) {
|
||||
before.requestFocus();
|
||||
} else if (textInputFocused()) {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send() {
|
||||
SessionState.instance.coordinator.sendChat(chat.text);
|
||||
chat.clear();
|
||||
chatFocus.requestFocus();
|
||||
SessionState.instance.coordinator.sendChat(widget.chat.text);
|
||||
widget.chat.clear();
|
||||
widget.chatFocus.requestFocus();
|
||||
}
|
||||
|
||||
KeyEventResult _fieldKey(FocusNode node, KeyEvent e) {
|
||||
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (e.logicalKey != LogicalKeyboardKey.tab && e.logicalKey != LogicalKeyboardKey.escape) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
widget.onClose();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -24,7 +72,7 @@ class ChatPanel extends StatelessWidget {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
key: const ValueKey('chat-panel'),
|
||||
width: width,
|
||||
width: ChatPanel.width,
|
||||
color: scheme.surfaceContainerLow,
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -32,41 +80,64 @@ class ChatPanel extends StatelessWidget {
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Chat & activity', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const Text('Chat', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
IconButton(tooltip: 'Close (Tab)', onPressed: onClose, icon: const Icon(Icons.close, size: 18)),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: showActivity,
|
||||
builder: (context, on, _) => FilterChip(
|
||||
key: const ValueKey('chat-activity'),
|
||||
label: const Text('Activity', style: TextStyle(fontSize: 12)),
|
||||
tooltip: 'Show joins, ready, seeks and other activity',
|
||||
selected: on,
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onSelected: (v) => showActivity.value = v,
|
||||
),
|
||||
),
|
||||
IconButton(tooltip: 'Close (Tab)', onPressed: widget.onClose, icon: const Icon(Icons.close, size: 18)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PartyWatch(builder: (context) {
|
||||
final log = SessionState.instance.coordinator.feedLog.value;
|
||||
if (log.isEmpty) {
|
||||
return Center(
|
||||
child: Text('Nothing yet', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: showActivity,
|
||||
builder: (context, activity, _) => PartyWatch(builder: (context) {
|
||||
final all = SessionState.instance.coordinator.feedLog.value;
|
||||
final log = activity ? all : [for (final f in all) if (f.action == 'chat') f];
|
||||
if (log.isEmpty) {
|
||||
return Center(
|
||||
child: Text(activity ? 'Nothing yet' : 'No messages yet',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
itemCount: log.length,
|
||||
itemBuilder: (context, i) => ActivityRow(item: log[log.length - 1 - i]),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
itemCount: log.length,
|
||||
itemBuilder: (context, i) => ActivityRow(item: log[log.length - 1 - i]),
|
||||
);
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: TextField(
|
||||
key: const ValueKey('chat-field'),
|
||||
controller: chat,
|
||||
focusNode: chatFocus,
|
||||
onSubmitted: (_) => _send(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message (Enter)',
|
||||
isDense: true,
|
||||
filled: true,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
suffixIcon: IconButton(tooltip: 'Send', onPressed: _send, icon: const Icon(Icons.send, size: 18)),
|
||||
child: Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
onKeyEvent: _fieldKey,
|
||||
child: TextField(
|
||||
key: const ValueKey('chat-field'),
|
||||
controller: widget.chat,
|
||||
focusNode: widget.chatFocus,
|
||||
onSubmitted: (_) => _send(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message (Enter)',
|
||||
isDense: true,
|
||||
filled: true,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
suffixIcon: IconButton(tooltip: 'Send', onPressed: _send, icon: const Icon(Icons.send, size: 18)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'dart:io';
|
||||
|
||||
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
|
||||
// how to open it ([HandoffCard]). The shell wires both.
|
||||
|
||||
/// What was dropped or pasted.
|
||||
sealed class Handoff {
|
||||
const Handoff();
|
||||
}
|
||||
|
||||
/// Video files (by the Files page's extensions), in the order given.
|
||||
final class HandoffVideos extends Handoff {
|
||||
final List<String> paths;
|
||||
const HandoffVideos(this.paths);
|
||||
}
|
||||
|
||||
final class HandoffFolder extends Handoff {
|
||||
final String path;
|
||||
const HandoffFolder(this.path);
|
||||
}
|
||||
|
||||
/// An http(s) link.
|
||||
final class HandoffLink extends Handoff {
|
||||
final String url;
|
||||
const HandoffLink(this.url);
|
||||
}
|
||||
|
||||
/// An image file. Nothing takes one yet; the shell ignores it.
|
||||
final class HandoffImage extends Handoff {
|
||||
final String path;
|
||||
const HandoffImage(this.path);
|
||||
}
|
||||
|
||||
final class HandoffNothing extends Handoff {
|
||||
const HandoffNothing();
|
||||
}
|
||||
|
||||
const _imageExtensions = {'png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'};
|
||||
|
||||
final _link = RegExp(r'^https?://\S+$', caseSensitive: false);
|
||||
final _windowsAbsolute = RegExp(r'^([A-Za-z]:[\\/]|\\\\)');
|
||||
|
||||
bool _absolute(String p) => p.startsWith('/') || _windowsAbsolute.hasMatch(p);
|
||||
|
||||
/// A `file://` URI as a path; anything else as is.
|
||||
String _pathOf(String p) {
|
||||
if (!p.startsWith('file://')) return p;
|
||||
try {
|
||||
return Uri.parse(p).toFilePath();
|
||||
} catch (_) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies dropped or pasted [paths] (files first) or, when there are
|
||||
/// none, pasted [text]: a link, or one absolute path (or `file://` URI) per
|
||||
/// line. Videos win over folders, folders over images; paths that do not
|
||||
/// exist ([typeOf], defaults to the file system) count for nothing.
|
||||
Handoff classifyHandoff({
|
||||
List<String> paths = const [],
|
||||
String? text,
|
||||
FileSystemEntityType Function(String path)? typeOf,
|
||||
}) {
|
||||
final type = typeOf ?? (p) => FileSystemEntity.typeSync(p);
|
||||
var list = [for (final p in paths) if (p.trim().isNotEmpty) _pathOf(p.trim())];
|
||||
if (list.isEmpty && text != null) {
|
||||
final t = text.trim();
|
||||
if (_link.hasMatch(t)) return HandoffLink(t);
|
||||
final lines = [for (final l in t.split(RegExp(r'[\r\n]+'))) if (l.trim().isNotEmpty) _pathOf(l.trim())];
|
||||
if (lines.isEmpty || !lines.every(_absolute)) return const HandoffNothing();
|
||||
list = lines;
|
||||
}
|
||||
final types = {for (final p in list) p: type(p)};
|
||||
bool file(String p) => types[p] == FileSystemEntityType.file;
|
||||
final videos = [for (final p in list) if (file(p) && kindOf(p) == FileKind.video) p];
|
||||
if (videos.isNotEmpty) return HandoffVideos(videos);
|
||||
for (final p in list) {
|
||||
if (types[p] == FileSystemEntityType.directory) return HandoffFolder(p);
|
||||
}
|
||||
for (final p in list) {
|
||||
final dot = p.lastIndexOf('.');
|
||||
if (file(p) && dot >= 0 && _imageExtensions.contains(p.substring(dot + 1).toLowerCase())) {
|
||||
return HandoffImage(p);
|
||||
}
|
||||
}
|
||||
return const HandoffNothing();
|
||||
}
|
||||
|
||||
/// What the card's buttons ask the shell to do.
|
||||
enum HandoffAction {
|
||||
/// Put the (first) video up for everyone.
|
||||
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.
|
||||
openLink,
|
||||
}
|
||||
|
||||
/// The card asking how to open a [Handoff] (videos, a folder or a link);
|
||||
/// pops the [HandoffAction] chosen.
|
||||
class HandoffCard extends StatelessWidget {
|
||||
final Handoff item;
|
||||
final bool online;
|
||||
const HandoffCard({super.key, required this.item, required this.online});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
void pick(HandoffAction a) => Navigator.of(context).pop(a);
|
||||
Widget button(HandoffAction a, IconData icon, String label, {bool primary = false}) => primary
|
||||
? FilledButton.icon(
|
||||
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label))
|
||||
: OutlinedButton.icon(
|
||||
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label));
|
||||
|
||||
final (IconData icon, String title, String detail, List<Widget> buttons) = switch (item) {
|
||||
HandoffVideos(:final paths) => (
|
||||
Icons.movie_outlined,
|
||||
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.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
|
||||
],
|
||||
),
|
||||
HandoffFolder(:final path) => (
|
||||
Icons.folder_outlined,
|
||||
baseName(path),
|
||||
path,
|
||||
[button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add to Files', primary: true)],
|
||||
),
|
||||
HandoffLink(:final url) => (
|
||||
Icons.link,
|
||||
url,
|
||||
online ? 'Pick how the others get it on the Link page' : offlinePutUpHint,
|
||||
[
|
||||
button(HandoffAction.openLink, Icons.link, online ? 'Put it up as a link…' : 'Open in Link',
|
||||
primary: true),
|
||||
],
|
||||
),
|
||||
HandoffImage() || HandoffNothing() => (Icons.help_outline, '', '', const <Widget>[]),
|
||||
};
|
||||
return Material(
|
||||
key: const ValueKey('handoff-card'),
|
||||
elevation: 12,
|
||||
color: scheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall),
|
||||
Text(detail,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: buttons),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -346,7 +346,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'A video file, YouTube, a Jellyfin link… Ctrl+V anywhere pastes it here.',
|
||||
'A video file, YouTube, a Jellyfin link… Ctrl+V or drop it anywhere to bring it here.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem;
|
||||
|
||||
@@ -189,32 +188,6 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- paste
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||
final ctrl = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed;
|
||||
if (e is! KeyDownEvent || !ctrl || e.logicalKey != LogicalKeyboardKey.keyV) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
// A focused text field handles its own paste.
|
||||
final inField =
|
||||
FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
|
||||
if (inField) return KeyEventResult.ignored;
|
||||
unawaited(_pasteLink());
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
/// Ctrl+V outside a field: an http(s) link goes to the Link source.
|
||||
Future<void> _pasteLink() async {
|
||||
String? text;
|
||||
try {
|
||||
text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
|
||||
} catch (_) {}
|
||||
if (text == null || !(text.startsWith('http://') || text.startsWith('https://'))) return;
|
||||
_c.link.text = text;
|
||||
_c.source = MediaKind.link;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- build
|
||||
|
||||
@override
|
||||
@@ -306,10 +279,10 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child),
|
||||
);
|
||||
|
||||
// Holds focus so the shell's shortcuts (Ctrl+,) work.
|
||||
return Focus(
|
||||
key: const ValueKey('library-tab'),
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
if (box.maxWidth >= detailsDockFrom) {
|
||||
return Row(
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../screens/home/update_indicator.dart';
|
||||
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/dialogs.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
@@ -27,6 +28,9 @@ enum ShellTab {
|
||||
|
||||
const partyStripHeight = 60.0;
|
||||
|
||||
/// Every avatar in the strip, yours and the party's.
|
||||
const stripAvatarSize = 42.0;
|
||||
|
||||
/// Below this window width the strip shows 3 avatars and no server text,
|
||||
/// so the tabs keep their room.
|
||||
const stripCompactBelow = 1100.0;
|
||||
@@ -95,7 +99,7 @@ class PartyStrip extends StatelessWidget {
|
||||
ready: self?.ready ?? false,
|
||||
host: online && coord.hostFile != null,
|
||||
),
|
||||
size: 32,
|
||||
size: stripAvatarSize,
|
||||
ring: online,
|
||||
),
|
||||
),
|
||||
@@ -139,25 +143,30 @@ class PartyStrip extends StatelessWidget {
|
||||
key: const ValueKey('party-avatars'),
|
||||
members: partyAvatars(coord),
|
||||
shown: compact ? 3 : 5,
|
||||
size: stripAvatarSize,
|
||||
onTap: () => showMembersCard(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
if (!compact) _ServerLabel(scheme: scheme),
|
||||
const SizedBox(width: 6),
|
||||
Badge(
|
||||
isLabelVisible: unread > 0,
|
||||
label: Text('$unread'),
|
||||
child: IconButton(
|
||||
key: const ValueKey('strip-chat'),
|
||||
tooltip: 'Chat & activity',
|
||||
isSelected: chatOpen,
|
||||
onPressed: onChat,
|
||||
icon: const Icon(Icons.chat_bubble_outline, size: 20),
|
||||
selectedIcon: Icon(
|
||||
Icons.chat_bubble,
|
||||
size: 20,
|
||||
color: scheme.primary,
|
||||
// Tab toggles the chat while the strip is shown.
|
||||
ChatKey(
|
||||
onToggle: onChat,
|
||||
child: Badge(
|
||||
isLabelVisible: unread > 0,
|
||||
label: Text('$unread'),
|
||||
child: IconButton(
|
||||
key: const ValueKey('strip-chat'),
|
||||
tooltip: 'Chat & activity (Tab)',
|
||||
isSelected: chatOpen,
|
||||
onPressed: onChat,
|
||||
icon: const Icon(Icons.chat_bubble_outline, size: 20),
|
||||
selectedIcon: Icon(
|
||||
Icons.chat_bubble,
|
||||
size: 20,
|
||||
color: scheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -193,7 +193,7 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('player-status'),
|
||||
onTap: ready ? coord.toggleReady : null,
|
||||
child: _Glass(
|
||||
child: PlayerGlass(
|
||||
child: AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
@@ -217,9 +217,10 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
||||
}
|
||||
}
|
||||
|
||||
class _Glass extends StatelessWidget {
|
||||
/// Frosted dark glass in a pill: the status pill, the unread chip.
|
||||
class PlayerGlass extends StatelessWidget {
|
||||
final Widget child;
|
||||
const _Glass({required this.child});
|
||||
const PlayerGlass({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -10,7 +13,8 @@ import '../controls/player_settings.dart';
|
||||
import '../controls/uosc/buttons.dart';
|
||||
import '../controls/uosc/controls.dart';
|
||||
import '../controls/uosc/timeline.dart';
|
||||
import '../playback/media_marks.dart' show SkipOffer, formatClock;
|
||||
import '../controls/volume.dart';
|
||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||
import '../screens/session/jellyfin_widgets.dart';
|
||||
import '../screens/session/player_controls.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
@@ -18,6 +22,7 @@ import '../shaders/shader_menu.dart';
|
||||
import '../shaders/shader_presets.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/shown_frame_probe.dart';
|
||||
import '../widgets/text_focus.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
@@ -96,9 +101,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
int _toastSeq = 0;
|
||||
int _shaderNoticeSeen = 0;
|
||||
bool _miniHover = false;
|
||||
// Whether the standard controls are up (tracked like media_kit does:
|
||||
// shown on mount and on mouse movement, hidden 3 s later or on exit).
|
||||
final _standardShown = ValueNotifier(true);
|
||||
// Whether the controls are up: the standard ones tracked like media_kit
|
||||
// does (shown on mount and on mouse movement, hidden 3 s later or on
|
||||
// exit), the uosc ones reporting it.
|
||||
final _controlsShown = ValueNotifier(true);
|
||||
|
||||
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
||||
bool get _full => widget.mode == PlayerMode.full;
|
||||
@@ -109,6 +115,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_coord.jellyfin.ensureLoaded();
|
||||
_coord.playback.shownFrame = _frameProbe.sample;
|
||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
||||
AppKeys.addChatToggle(_toggleChat);
|
||||
// Shader messages (preset changes, load failures) become toasts.
|
||||
_shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0;
|
||||
_stopShaderWatch = effect(() {
|
||||
@@ -122,11 +130,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||
AppKeys.removeChatToggle(_toggleChat);
|
||||
final playback = _coord.playback;
|
||||
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
|
||||
_stopShaderWatch();
|
||||
_noFocus.dispose();
|
||||
_standardShown.dispose();
|
||||
_controlsShown.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -152,7 +161,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
|
||||
/// The player keys, while the player fills the body and nothing (a
|
||||
/// dialog, a card, a text field) is in front of it.
|
||||
/// dialog, a card, a text field) is in front of it. Space and Tab are
|
||||
/// app-wide ([AppKeys]).
|
||||
bool _globalKey(KeyEvent event) {
|
||||
if (event is! KeyDownEvent || !mounted || !_full) return false;
|
||||
if (SessionState.instance.route.peek() != AppRoute.session) return false;
|
||||
@@ -184,11 +194,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_reloadVideo();
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.space) {
|
||||
_lastKeyAt = now;
|
||||
coord.toggleReady();
|
||||
return true;
|
||||
}
|
||||
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
|
||||
@@ -213,9 +218,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
coord.skipForward();
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.tab) {
|
||||
_lastKeyAt = now;
|
||||
_toggleChat();
|
||||
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
|
||||
final player = coord.playback.player;
|
||||
player.setVolume(stepVolume(player.state.volume, key == LogicalKeyboardKey.arrowUp ? 5 : -5));
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
@@ -282,7 +287,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_status(),
|
||||
_progressLine(),
|
||||
_jellyfinOverlay(),
|
||||
if (widget.fullscreen) _pausedParty(),
|
||||
if (widget.fullscreen) ...[_pausedParty(), _unreadChip()],
|
||||
] else
|
||||
_miniOverlay(),
|
||||
],
|
||||
@@ -340,7 +345,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final s = AppSettings.instance;
|
||||
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _standardShown,
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, _) => shown
|
||||
? const SizedBox.shrink()
|
||||
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
|
||||
@@ -437,7 +442,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
onPressed: coord.playNextEpisode,
|
||||
),
|
||||
),
|
||||
const MaterialDesktopVolumeButton(),
|
||||
VolumeButton(player: playback.player, focusNode: _noFocus),
|
||||
_ReadyControlButton(focusNode: _noFocus),
|
||||
_BarTextButton(
|
||||
focusNode: _noFocus,
|
||||
@@ -504,7 +509,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
Widget _controlsSwitch(VideoState state) {
|
||||
return SessionControlsSwitch(
|
||||
material: (_) => ControlsHoverProbe(
|
||||
shown: _standardShown,
|
||||
shown: _controlsShown,
|
||||
hideAfter: _controlsHide,
|
||||
child: DoubleClickListener(
|
||||
onDoubleClick: widget.onToggleFullscreen,
|
||||
@@ -536,12 +541,19 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
});
|
||||
return UoscControls(
|
||||
shown: _controlsShown,
|
||||
onDoubleTap: widget.onToggleFullscreen,
|
||||
topLeft: const NowPlayingTitle(),
|
||||
top: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
UoscButton(icon: Icons.chat_bubble_outline_rounded, tooltip: 'Chat & activity (Tab)', onPressed: _toggleChat),
|
||||
UoscButton(
|
||||
key: const ValueKey('uosc-chat'),
|
||||
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
|
||||
tooltip: 'Chat & activity (Tab)',
|
||||
badge: widget.unread,
|
||||
onPressed: _toggleChat,
|
||||
),
|
||||
UoscButton(
|
||||
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
||||
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
||||
@@ -653,7 +665,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
|
||||
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
|
||||
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
|
||||
/// 1:30) above it. Outside the auto-hiding controls, so they stay up.
|
||||
/// 1:30) above it ([SkipPillGate]). Outside the auto-hiding controls, so
|
||||
/// they stay up.
|
||||
Widget _jellyfinOverlay() {
|
||||
final coord = _coord;
|
||||
return Positioned.fill(
|
||||
@@ -695,7 +708,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
spacing: 10,
|
||||
children: [
|
||||
if (skip != null) SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
||||
if (skip != null)
|
||||
SkipPillGate(
|
||||
key: const ValueKey('skip-gate'),
|
||||
offer: skip,
|
||||
controlsShown: _controlsShown,
|
||||
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
||||
),
|
||||
if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
|
||||
],
|
||||
),
|
||||
@@ -723,6 +742,40 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}),
|
||||
);
|
||||
|
||||
/// Fullscreen hides the strip and its chat badge: while there is unread
|
||||
/// chat and the controls are down, a small glass chip says so top right.
|
||||
Widget _unreadChip() => Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: IgnorePointer(
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, _) {
|
||||
final on = widget.unread > 0 && !widget.chatOpen && !shown;
|
||||
return AnimatedOpacity(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
opacity: on ? 1 : 0,
|
||||
child: PlayerGlass(
|
||||
key: const ValueKey('unread-chip'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.chat_bubble_rounded, size: 14, color: Colors.white70),
|
||||
const SizedBox(width: 6),
|
||||
Text('${widget.unread}', style: const TextStyle(color: Colors.white, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// Mini: title, expand and close on hover; ready, time and a seekable
|
||||
/// bar at the bottom. A thin progress line stays while not hovered.
|
||||
Widget _miniOverlay() {
|
||||
@@ -809,6 +862,75 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The skip pill's visibility: up for [firstShown] after an offer
|
||||
/// appears (a new kind: OP, ED, 1:30), then only while the controls are
|
||||
/// up or the mouse is on it; gone with the offer (the parent drops it).
|
||||
class SkipPillGate extends StatefulWidget {
|
||||
static const firstShown = Duration(seconds: 5);
|
||||
final SkipOffer offer;
|
||||
final ValueListenable<bool> controlsShown;
|
||||
final Widget child;
|
||||
const SkipPillGate({super.key, required this.offer, required this.controlsShown, required this.child});
|
||||
|
||||
@override
|
||||
State<SkipPillGate> createState() => _SkipPillGateState();
|
||||
}
|
||||
|
||||
class _SkipPillGateState extends State<SkipPillGate> {
|
||||
SkipKind? _kind;
|
||||
bool _fresh = false;
|
||||
bool _hover = false;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_arm();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SkipPillGate old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.offer.kind != _kind) _arm();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _arm() {
|
||||
_kind = widget.offer.kind;
|
||||
_fresh = true;
|
||||
_timer?.cancel();
|
||||
_timer = Timer(SkipPillGate.firstShown, () {
|
||||
if (mounted) setState(() => _fresh = false);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The pill covers the controls' hover area: on it, they may hide while
|
||||
// it stays.
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: widget.controlsShown,
|
||||
builder: (context, shown, child) {
|
||||
final on = _fresh || shown || _hover;
|
||||
return IgnorePointer(
|
||||
ignoring: !on,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: on ? 1 : 0, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _shaderKeys = [
|
||||
LogicalKeyboardKey.digit0,
|
||||
LogicalKeyboardKey.digit1,
|
||||
|
||||
@@ -79,7 +79,23 @@ class SettingsTab extends StatelessWidget {
|
||||
),
|
||||
];
|
||||
case SettingsSection.network:
|
||||
return const [HostDefaultsPanel(), RecentServersPanel()];
|
||||
return [
|
||||
const HostDefaultsPanel(),
|
||||
const RecentServersPanel(),
|
||||
SettingsPanel(
|
||||
title: 'Watching',
|
||||
child: SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('unready-on-swap'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Unready when the video changes'),
|
||||
subtitle: const Text('Otherwise you stay ready and the next video starts once everyone ready has buffered'),
|
||||
value: AppSettings.instance.unreadyOnSwap.value,
|
||||
onChanged: AppSettings.instance.setUnreadyOnSwap,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
case SettingsSection.player:
|
||||
return [
|
||||
const SettingsHintCard(
|
||||
|
||||
Reference in new issue
Block a user