Merge the deduplication and dead-code removal pass
Consolidates duplicated logic behind shared implementations — paginated grid tabs, focus chrome, cached remote stores, sheet selection columns, the server artifact store and a test fixture layer — and removes code that had become unreachable. Net reduction of about 5,500 lines with no behaviour change. Where a fix had landed separately in code that moved into a shared helper, the fix was re-applied inside the helper rather than left behind in the copy that went away.
This commit is contained in:
@@ -390,16 +390,15 @@ class _AppMenuItemTileState<T> extends State<AppMenuItemTile<T>> with FocusableT
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initFocusNode();
|
||||
effectiveFocusNode.addListener(_updateFocusedState);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AppMenuItemTile<T> oldWidget) {
|
||||
final rebinds = oldWidget.focusNode != widget.focusNode;
|
||||
if (rebinds) effectiveFocusNode.removeListener(_updateFocusedState);
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.focusNode != widget.focusNode) {
|
||||
effectiveFocusNode.removeListener(_updateFocusedState);
|
||||
updateFocusNode(oldWidget.focusNode);
|
||||
if (rebinds) {
|
||||
effectiveFocusNode.addListener(_updateFocusedState);
|
||||
_isFocused = effectiveFocusNode.hasFocus;
|
||||
}
|
||||
@@ -408,7 +407,6 @@ class _AppMenuItemTileState<T> extends State<AppMenuItemTile<T>> with FocusableT
|
||||
@override
|
||||
void dispose() {
|
||||
effectiveFocusNode.removeListener(_updateFocusedState);
|
||||
disposeFocusNode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,33 +3,28 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
import '../models/catalog/catalog_item.dart';
|
||||
|
||||
/// Brand mark of a catalog source — or any service SVG via
|
||||
/// [CatalogSourceLogo.asset] — tinted with the ambient icon color. Uses
|
||||
/// [SvgTheme.currentColor] so SVGs with multiple explicit fills (AniList
|
||||
/// keeps its brand-blue L while the A follows the theme) render correctly
|
||||
/// alongside single-color wordmarks.
|
||||
/// Brand mark of a service, tinted with the ambient icon color. The single
|
||||
/// table of brand asset paths: every surface that shows a service logo goes
|
||||
/// through here, including services that do not participate in the Explore
|
||||
/// catalog. Uses [SvgTheme.currentColor] so SVGs with multiple explicit fills
|
||||
/// (AniList keeps its brand-blue L while the A follows the theme) render
|
||||
/// correctly alongside single-color wordmarks.
|
||||
class CatalogSourceLogo extends StatelessWidget {
|
||||
final CatalogSourceId? id;
|
||||
final String? assetPath;
|
||||
final CatalogSourceId id;
|
||||
final double size;
|
||||
|
||||
const CatalogSourceLogo(CatalogSourceId this.id, {super.key, this.size = 20}) : assetPath = null;
|
||||
|
||||
/// For services that do not participate in the Explore catalog.
|
||||
const CatalogSourceLogo.asset(String this.assetPath, {super.key, this.size = 20}) : id = null;
|
||||
const CatalogSourceLogo(this.id, {super.key, this.size = 20});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset =
|
||||
assetPath ??
|
||||
switch (id!) {
|
||||
CatalogSourceId.plex => 'assets/plex_chevron.svg',
|
||||
CatalogSourceId.trakt => 'assets/trakt_circlemark.svg',
|
||||
CatalogSourceId.mal => 'assets/mal_mark.svg',
|
||||
CatalogSourceId.anilist => 'assets/anilist_mark.svg',
|
||||
CatalogSourceId.simkl => 'assets/simkl_mark.svg',
|
||||
CatalogSourceId.seerr => 'assets/seerr_mark.svg',
|
||||
};
|
||||
final asset = switch (id) {
|
||||
CatalogSourceId.plex => 'assets/plex_chevron.svg',
|
||||
CatalogSourceId.trakt => 'assets/trakt_circlemark.svg',
|
||||
CatalogSourceId.mal => 'assets/mal_mark.svg',
|
||||
CatalogSourceId.anilist => 'assets/anilist_mark.svg',
|
||||
CatalogSourceId.simkl => 'assets/simkl_mark.svg',
|
||||
CatalogSourceId.seerr => 'assets/seerr_mark.svg',
|
||||
};
|
||||
final color = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface;
|
||||
return SvgPicture.asset(
|
||||
asset,
|
||||
|
||||
@@ -4,20 +4,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../connection/connection_registry.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
import '../../focus/focusable_wrapper.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../mixins/mounted_set_state_mixin.dart';
|
||||
import '../../models/plex/plex_home.dart';
|
||||
import '../../profiles/active_plex_identity.dart';
|
||||
import '../../profiles/active_profile_provider.dart';
|
||||
import '../../profiles/plex_home_service.dart';
|
||||
import '../../profiles/profile_connection_registry.dart';
|
||||
import '../../providers/companion_remote_provider.dart';
|
||||
import '../../services/base_peer_service.dart';
|
||||
import '../../services/companion_remote/companion_remote_host_controller.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../theme/mono_tokens.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
@@ -93,26 +88,7 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
|
||||
|
||||
Future<void> _initCryptoAndDiscover() async {
|
||||
try {
|
||||
final connections = context.read<ConnectionRegistry>();
|
||||
final activeProfile = context.read<ActiveProfileProvider>();
|
||||
final profileConnections = context.read<ProfileConnectionRegistry>();
|
||||
final plexHome = context.read<PlexHomeService>();
|
||||
final identity = await resolveActivePlexIdentity(
|
||||
activeProfile: activeProfile,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final home = await _resolveHome(identity?.account.id);
|
||||
if (!mounted) return;
|
||||
await _provider.ensureCryptoReady(
|
||||
home,
|
||||
connections: connections,
|
||||
activeProfile: activeProfile,
|
||||
profileConnections: profileConnections,
|
||||
identity: identity,
|
||||
plexHomeForConnection: plexHome.materializePlexHomeForConnection,
|
||||
);
|
||||
await ensureCompanionRemoteCryptoFromContext(context);
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: crypto init failed', error: e);
|
||||
}
|
||||
@@ -137,11 +113,6 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
|
||||
}
|
||||
}
|
||||
|
||||
Future<PlexHome?> _resolveHome(String? connectionId) {
|
||||
if (connectionId == null) return Future<PlexHome?>.value();
|
||||
return context.read<PlexHomeService>().materializePlexHomeForConnection(connectionId);
|
||||
}
|
||||
|
||||
void _startDiscovery() {
|
||||
final stream = _provider.discoverHosts();
|
||||
if (stream == null) return;
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../models/trackers/device_code.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'app_icon.dart';
|
||||
import 'dialog_action_button.dart';
|
||||
import 'loading_indicator_box.dart';
|
||||
import 'pending_auth_dialog.dart';
|
||||
|
||||
/// Shared device-code activation dialog for Trakt and Simkl (RFC 8628).
|
||||
///
|
||||
@@ -25,11 +19,6 @@ class DeviceCodeDialog extends StatelessWidget {
|
||||
|
||||
const DeviceCodeDialog({super.key, required this.code, required this.serviceName, required this.onCancel});
|
||||
|
||||
Future<void> _open() async {
|
||||
final url = code.verificationUrlComplete ?? code.verificationUrl;
|
||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<void> _copy(BuildContext context) async {
|
||||
await Clipboard.setData(ClipboardData(text: code.userCode));
|
||||
if (!context.mounted) return;
|
||||
@@ -39,71 +28,32 @@ class DeviceCodeDialog extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(t.services.deviceCode.title(service: serviceName)),
|
||||
content: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(t.services.deviceCode.body(url: code.verificationUrl), style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: FocusableWrapper(
|
||||
onSelect: () => _copy(context),
|
||||
semanticLabel: t.services.deviceCode.copyCode,
|
||||
semanticValue: code.userCode,
|
||||
descendantsAreFocusable: false,
|
||||
useBackgroundFocus: true,
|
||||
borderRadius: 8,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: () => _copy(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Text(
|
||||
code.userCode,
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
letterSpacing: 4,
|
||||
fontWeight: .w600,
|
||||
),
|
||||
),
|
||||
return PendingAuthDialog(
|
||||
title: t.services.deviceCode.title(service: serviceName),
|
||||
body: t.services.deviceCode.body(url: code.verificationUrl),
|
||||
url: code.verificationUrlComplete ?? code.verificationUrl,
|
||||
openLabel: t.services.deviceCode.openToActivate(service: serviceName),
|
||||
onCancel: onCancel,
|
||||
children: [
|
||||
Center(
|
||||
child: CopyTapRegion(
|
||||
onCopy: () => _copy(context),
|
||||
semanticLabel: t.services.deviceCode.copyCode,
|
||||
semanticValue: code.userCode,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Text(
|
||||
code.userCode,
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
letterSpacing: 4,
|
||||
fontWeight: .w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FocusableButton(
|
||||
onPressed: _open,
|
||||
useBackgroundFocus: true,
|
||||
child: FilledButton.icon(
|
||||
icon: const AppIcon(Symbols.open_in_new_rounded),
|
||||
label: Text(t.services.deviceCode.openToActivate(service: serviceName)),
|
||||
onPressed: _open,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const LoadingIndicatorBox(size: 16),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(t.services.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(
|
||||
onPressed: () {
|
||||
onCancel();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
label: t.common.cancel,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import '../media/media_kind.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import 'clickable_cursor.dart';
|
||||
import 'download_status_icon.dart';
|
||||
|
||||
/// Represents a node in the download tree
|
||||
@@ -439,7 +438,10 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
|
||||
/// Pause all active (downloading and queued) children of a container node
|
||||
void _pauseAllChildren(DownloadTreeNode node) {
|
||||
final keys = _getActiveChildKeys(node);
|
||||
final keys = _leafKeys(
|
||||
node,
|
||||
where: (leaf) => leaf.status == DownloadStatus.downloading || leaf.status == DownloadStatus.queued,
|
||||
);
|
||||
for (final key in keys) {
|
||||
widget.onPause?.call(key);
|
||||
}
|
||||
@@ -447,38 +449,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
|
||||
/// Resume all paused children of a container node
|
||||
void _resumeAllChildren(DownloadTreeNode node) {
|
||||
final keys = _getPausedChildKeys(node);
|
||||
final keys = _leafKeys(node, where: (leaf) => leaf.status == DownloadStatus.paused);
|
||||
for (final key in keys) {
|
||||
widget.onResume?.call(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all active (downloading or queued) child keys from a container node
|
||||
List<String> _getActiveChildKeys(DownloadTreeNode node) {
|
||||
final List<String> keys = [];
|
||||
for (final child in node.children) {
|
||||
if (child.hasChildren) {
|
||||
keys.addAll(_getActiveChildKeys(child));
|
||||
} else if (child.status == DownloadStatus.downloading || child.status == DownloadStatus.queued) {
|
||||
keys.add(child.key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/// Get all paused child keys from a container node
|
||||
List<String> _getPausedChildKeys(DownloadTreeNode node) {
|
||||
final List<String> keys = [];
|
||||
for (final child in node.children) {
|
||||
if (child.hasChildren) {
|
||||
keys.addAll(_getPausedChildKeys(child));
|
||||
} else if (child.status == DownloadStatus.paused) {
|
||||
keys.add(child.key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/// Delete all children of a container node via the container's globalKey
|
||||
/// so deleteDownload's transitive show/season path cleans up all maps.
|
||||
void _deleteAllChildren(DownloadTreeNode node) {
|
||||
@@ -489,23 +465,22 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
}
|
||||
|
||||
// Container globalKey unresolvable; fall back to per-leaf delete.
|
||||
for (final key in _getAllChildKeys(node)) {
|
||||
for (final key in _leafKeys(node)) {
|
||||
widget.onDelete?.call(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all leaf node keys from a container node
|
||||
List<String> _getAllChildKeys(DownloadTreeNode node) {
|
||||
/// Get the keys of every leaf below a container node, in tree order.
|
||||
/// [where] filters which leaves are collected; unset collects all of them.
|
||||
List<String> _leafKeys(DownloadTreeNode node, {bool Function(DownloadTreeNode leaf)? where}) {
|
||||
final List<String> keys = [];
|
||||
|
||||
for (final child in node.children) {
|
||||
if (child.hasChildren) {
|
||||
keys.addAll(_getAllChildKeys(child));
|
||||
} else {
|
||||
keys.addAll(_leafKeys(child, where: where));
|
||||
} else if (where == null || where(child)) {
|
||||
keys.add(child.key);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
@@ -556,6 +531,11 @@ class _FlatNode {
|
||||
const _FlatNode({required this.node, required this.depth});
|
||||
}
|
||||
|
||||
/// A single action button of a tree row: the guards that decide which actions
|
||||
/// exist live in one place ([_DownloadTreeItemState._actions]), so the focus
|
||||
/// node count and the rendered buttons can never disagree.
|
||||
typedef _RowAction = ({IconData icon, String tooltip, VoidCallback onPressed});
|
||||
|
||||
/// A single tree item with focusable row content and action buttons
|
||||
class _DownloadTreeItem extends StatefulWidget {
|
||||
final DownloadTreeNode node;
|
||||
@@ -628,7 +608,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
void didUpdateWidget(_DownloadTreeItem oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Reinitialize focus nodes if action count changed
|
||||
if (_getActionCount() != _buttonFocusNodes.length) {
|
||||
if (_actions().length != _buttonFocusNodes.length) {
|
||||
_disposeButtonFocusNodes();
|
||||
_initButtonFocusNodes();
|
||||
}
|
||||
@@ -641,7 +621,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
}
|
||||
|
||||
void _initButtonFocusNodes() {
|
||||
final actionCount = _getActionCount();
|
||||
final actionCount = _actions().length;
|
||||
for (int i = 0; i < actionCount; i++) {
|
||||
_buttonFocusNodes.add(FocusNode(debugLabel: 'download_action_$i'));
|
||||
}
|
||||
@@ -661,40 +641,6 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _getActionCount() {
|
||||
final isContainer =
|
||||
widget.node.type == DownloadNodeType.show ||
|
||||
widget.node.type == DownloadNodeType.season ||
|
||||
widget.node.type == DownloadNodeType.album;
|
||||
if (isContainer) {
|
||||
return _getContainerActionCount();
|
||||
}
|
||||
return _getItemActionCount();
|
||||
}
|
||||
|
||||
int _getItemActionCount() {
|
||||
int count = 0;
|
||||
final status = widget.node.status;
|
||||
if (status == DownloadStatus.downloading && widget.onPause != null) count++;
|
||||
if (status == DownloadStatus.paused && widget.onResume != null) count++;
|
||||
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) count++;
|
||||
if (status == DownloadStatus.failed && widget.onRetry != null) count++;
|
||||
if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) &&
|
||||
widget.onDelete != null) {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int _getContainerActionCount() {
|
||||
int count = 0;
|
||||
final status = widget.node.status;
|
||||
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) count++;
|
||||
if (status == DownloadStatus.paused && widget.onResume != null) count++;
|
||||
if (widget.onDelete != null) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
void _focusFirstButton() {
|
||||
if (_buttonFocusNodes.isNotEmpty) {
|
||||
_buttonFocusNodes.first.requestFocus();
|
||||
@@ -709,7 +655,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final canExpand = widget.node.hasChildren;
|
||||
final hasActions = _buttonFocusNodes.isNotEmpty;
|
||||
final actions = _actions();
|
||||
|
||||
return Padding(
|
||||
padding: .only(left: widget.depth * 16.0),
|
||||
@@ -718,7 +664,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
autofocus: widget.autofocus,
|
||||
onSelect: canExpand ? widget.onToggleExpansion : null,
|
||||
onNavigateLeft: widget.onNavigateLeft,
|
||||
onNavigateRight: hasActions ? _focusFirstButton : null,
|
||||
onNavigateRight: actions.isNotEmpty ? _focusFirstButton : null,
|
||||
onBack: widget.onBack,
|
||||
borderRadius: 8.0,
|
||||
disableScale: true,
|
||||
@@ -734,7 +680,11 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
Expanded(child: _buildRowContent(theme, canExpand)),
|
||||
|
||||
// Action buttons
|
||||
if (hasActions) _buildActions(),
|
||||
if (actions.isNotEmpty)
|
||||
Row(
|
||||
mainAxisSize: .min,
|
||||
children: [for (int i = 0; i < actions.length; i++) _buildActionButton(actions[i], i)],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -755,7 +705,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Status icon
|
||||
_buildStatusIcon(_effectiveStatus),
|
||||
DownloadStatusIcon(status: _effectiveStatus, size: 20),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
@@ -826,180 +776,121 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon(DownloadStatus status) {
|
||||
return DownloadStatusIcon(status: status, size: 20);
|
||||
}
|
||||
|
||||
String _getNodeSummary() {
|
||||
final total = widget.node.children.length;
|
||||
final completed = widget.node.completedChildrenCount;
|
||||
return '$completed/$total completed';
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
/// The actions this row offers, in render order. Single source of truth:
|
||||
/// both the button widgets and the focus nodes sizing come from this list,
|
||||
/// so they cannot drift apart. Uses the raw node status, not
|
||||
/// [_effectiveStatus] (which only remaps the row content).
|
||||
List<_RowAction> _actions() {
|
||||
final status = widget.node.status;
|
||||
final isContainer =
|
||||
widget.node.type == DownloadNodeType.show ||
|
||||
widget.node.type == DownloadNodeType.season ||
|
||||
widget.node.type == DownloadNodeType.album;
|
||||
final actions = <_RowAction>[];
|
||||
|
||||
final actions = isContainer ? _buildContainerActions() : _buildItemActions();
|
||||
if (isContainer) {
|
||||
// Pause all button
|
||||
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) {
|
||||
actions.add((
|
||||
icon: Symbols.pause_rounded,
|
||||
tooltip: t.downloads.pauseAll,
|
||||
onPressed: () => widget.pauseAllChildren(widget.node),
|
||||
));
|
||||
}
|
||||
|
||||
return Row(mainAxisSize: .min, children: actions);
|
||||
}
|
||||
// Resume all button
|
||||
if (status == DownloadStatus.paused && widget.onResume != null) {
|
||||
actions.add((
|
||||
icon: Symbols.play_arrow_rounded,
|
||||
tooltip: t.downloads.resumeAll,
|
||||
onPressed: () => widget.resumeAllChildren(widget.node),
|
||||
));
|
||||
}
|
||||
|
||||
// Delete all button
|
||||
if (widget.onDelete != null) {
|
||||
actions.add((
|
||||
icon: Symbols.delete_sweep_rounded,
|
||||
tooltip: t.downloads.deleteAll,
|
||||
onPressed: () async {
|
||||
if (await _confirmDelete()) widget.deleteAllChildren(widget.node);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
List<Widget> _buildItemActions() {
|
||||
final globalKey = widget.node.key;
|
||||
final status = widget.node.status;
|
||||
final actions = <Widget>[];
|
||||
int buttonIndex = 0;
|
||||
|
||||
// Pause button for downloading items
|
||||
if (status == DownloadStatus.downloading && widget.onPause != null) {
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.pause_rounded,
|
||||
tooltip: t.common.pause,
|
||||
onPressed: () => widget.onPause!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
actions.add((icon: Symbols.pause_rounded, tooltip: t.common.pause, onPressed: () => widget.onPause!(globalKey)));
|
||||
}
|
||||
|
||||
// Resume button for paused items
|
||||
if (status == DownloadStatus.paused && widget.onResume != null) {
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.play_arrow_rounded,
|
||||
tooltip: t.common.resume,
|
||||
onPressed: () => widget.onResume!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
actions.add((
|
||||
icon: Symbols.play_arrow_rounded,
|
||||
tooltip: t.common.resume,
|
||||
onPressed: () => widget.onResume!(globalKey),
|
||||
));
|
||||
}
|
||||
|
||||
// Cancel button for downloading/queued items
|
||||
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) {
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.close_rounded,
|
||||
tooltip: t.common.cancel,
|
||||
onPressed: () => widget.onCancel!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
actions.add((
|
||||
icon: Symbols.close_rounded,
|
||||
tooltip: t.common.cancel,
|
||||
onPressed: () => widget.onCancel!(globalKey),
|
||||
));
|
||||
}
|
||||
|
||||
// Retry button for failed items
|
||||
if (status == DownloadStatus.failed && widget.onRetry != null) {
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.refresh_rounded,
|
||||
tooltip: t.downloads.retryDownload,
|
||||
onPressed: () => widget.onRetry!(globalKey),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
actions.add((
|
||||
icon: Symbols.refresh_rounded,
|
||||
tooltip: t.downloads.retryDownload,
|
||||
onPressed: () => widget.onRetry!(globalKey),
|
||||
));
|
||||
}
|
||||
|
||||
// Delete button for completed/failed/cancelled items
|
||||
if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) &&
|
||||
widget.onDelete != null) {
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.delete_rounded,
|
||||
tooltip: t.common.delete,
|
||||
onPressed: () async {
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.downloads.deleteDownload,
|
||||
message: t.downloads.deleteConfirm(title: widget.node.title),
|
||||
);
|
||||
if (confirmed) widget.onDelete!(globalKey);
|
||||
},
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
actions.add((
|
||||
icon: Symbols.delete_rounded,
|
||||
tooltip: t.common.delete,
|
||||
onPressed: () async {
|
||||
if (await _confirmDelete()) widget.onDelete!(globalKey);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
List<Widget> _buildContainerActions() {
|
||||
final status = widget.node.status;
|
||||
final actions = <Widget>[];
|
||||
int buttonIndex = 0;
|
||||
|
||||
// Pause all button
|
||||
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) {
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.pause_rounded,
|
||||
tooltip: t.downloads.pauseAll,
|
||||
onPressed: () => widget.pauseAllChildren(widget.node),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Resume all button
|
||||
if (status == DownloadStatus.paused && widget.onResume != null) {
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.play_arrow_rounded,
|
||||
tooltip: t.downloads.resumeAll,
|
||||
onPressed: () => widget.resumeAllChildren(widget.node),
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all button
|
||||
if (widget.onDelete != null) {
|
||||
actions.add(
|
||||
_buildActionButton(
|
||||
icon: Symbols.delete_sweep_rounded,
|
||||
tooltip: t.downloads.deleteAll,
|
||||
onPressed: () async {
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.downloads.deleteDownload,
|
||||
message: t.downloads.deleteConfirm(title: widget.node.title),
|
||||
);
|
||||
if (confirmed) widget.deleteAllChildren(widget.node);
|
||||
},
|
||||
buttonIndex: buttonIndex++,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
Future<bool> _confirmDelete() {
|
||||
return showDeleteConfirmation(
|
||||
context,
|
||||
title: t.downloads.deleteDownload,
|
||||
message: t.downloads.deleteConfirm(title: widget.node.title),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
required VoidCallback onPressed,
|
||||
required int buttonIndex,
|
||||
}) {
|
||||
// Guard against race condition where action count changed between didUpdateWidget and build
|
||||
if (buttonIndex >= _buttonFocusNodes.length) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: ClickableCursor(
|
||||
child: GestureDetector(
|
||||
onTap: onPressed,
|
||||
child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton(_RowAction action, int buttonIndex) {
|
||||
final isFirst = buttonIndex == 0;
|
||||
final isLast = buttonIndex == _buttonFocusNodes.length - 1;
|
||||
|
||||
return FocusableWrapper(
|
||||
focusNode: _buttonFocusNodes[buttonIndex],
|
||||
onSelect: onPressed,
|
||||
onSelect: action.onPressed,
|
||||
onNavigateLeft: isFirst ? _focusRow : () => _buttonFocusNodes[buttonIndex - 1].requestFocus(),
|
||||
onNavigateRight: isLast ? null : () => _buttonFocusNodes[buttonIndex + 1].requestFocus(),
|
||||
onBack: widget.onBack,
|
||||
@@ -1008,10 +899,10 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
useBackgroundFocus: true,
|
||||
autoScroll: false,
|
||||
child: Tooltip(
|
||||
message: tooltip,
|
||||
message: action.tooltip,
|
||||
child: GestureDetector(
|
||||
onTap: onPressed,
|
||||
child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)),
|
||||
onTap: action.onPressed,
|
||||
child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(action.icon, fill: 1, size: 20)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+41
-104
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../focus/card_focus_scope.dart';
|
||||
import '../focus/focus_glow_overlay.dart';
|
||||
import '../focus/focus_chrome.dart';
|
||||
import '../focus/focus_theme.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import 'clickable_cursor.dart';
|
||||
@@ -63,96 +62,13 @@ class FocusBuilders {
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a card-style focusable widget with scale and border decoration.
|
||||
/// Builds a card-style wrapper with scale and border decoration but no [Focus]
|
||||
/// node — focus lives on an enclosing rail or screen that passes [isFocused]
|
||||
/// down.
|
||||
///
|
||||
/// Used by FocusableMediaCard and _LockedHubItemWrapper.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [context]: Build context for theming
|
||||
/// - [focusNode]: The focus node for this widget (optional for locked wrappers)
|
||||
/// - [isFocused]: Whether this widget currently has focus
|
||||
/// - [onKeyEvent]: Callback for handling key events (optional for locked wrappers)
|
||||
/// - [onTap]: Callback for tap/click events
|
||||
/// - [onLongPress]: Callback for long press events
|
||||
/// - [borderRadius]: Border radius for the focus decoration
|
||||
/// - [child]: The content to display inside the card
|
||||
static Widget buildFocusableCard({
|
||||
required BuildContext context,
|
||||
FocusNode? focusNode,
|
||||
required bool isFocused,
|
||||
KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent,
|
||||
VoidCallback? onTap,
|
||||
VoidCallback? onLongPress,
|
||||
double borderRadius = FocusTheme.defaultBorderRadius,
|
||||
double focusScale = FocusTheme.focusScale,
|
||||
bool useFocusGlow = false,
|
||||
bool delegateFocusBorder = false,
|
||||
Size? glowSize,
|
||||
required Widget child,
|
||||
}) {
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
|
||||
// In touch mode, no item ever shows focus effects — skip animated wrappers
|
||||
// entirely. This saves ~2 element levels per card on ARM32 Android phones.
|
||||
if (!isKeyboardMode) {
|
||||
final gestureWidget = (onTap != null || onLongPress != null)
|
||||
? ClickableCursor(
|
||||
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child),
|
||||
)
|
||||
: child;
|
||||
if (focusNode != null && onKeyEvent != null) {
|
||||
return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget);
|
||||
}
|
||||
return gestureWidget;
|
||||
}
|
||||
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
final showFocus = isFocused && isKeyboardMode;
|
||||
// Glow (full-bleed cards) renders in an overlay above siblings so it stays
|
||||
// symmetric; the in-card decoration only carries the border.
|
||||
Widget card = delegateFocusBorder
|
||||
? CardFocusScope(showFocus: showFocus, child: child)
|
||||
: AnimatedContainer(
|
||||
duration: duration,
|
||||
curve: Curves.easeOutCubic,
|
||||
decoration: FocusTheme.focusDecoration(context, isFocused: showFocus, borderRadius: borderRadius),
|
||||
child: child,
|
||||
);
|
||||
if (useFocusGlow) {
|
||||
card = FocusGlowOverlay(
|
||||
isFocused: showFocus,
|
||||
borderRadius: borderRadius,
|
||||
color: FocusTheme.getFocusBorderColor(context),
|
||||
glowSize: glowSize,
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
|
||||
final focusedWidget = AnimatedScale(
|
||||
scale: showFocus ? focusScale : 1.0,
|
||||
duration: duration,
|
||||
curve: Curves.easeOutCubic,
|
||||
child: card,
|
||||
);
|
||||
|
||||
// Wrap in GestureDetector if tap/long press handlers provided
|
||||
final gestureWidget = (onTap != null || onLongPress != null)
|
||||
? ClickableCursor(
|
||||
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget),
|
||||
)
|
||||
: focusedWidget;
|
||||
|
||||
// Wrap in Focus if focus node and key event handler provided
|
||||
if (focusNode != null && onKeyEvent != null) {
|
||||
return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget);
|
||||
}
|
||||
|
||||
return gestureWidget;
|
||||
}
|
||||
|
||||
/// Builds a simple locked wrapper (no Focus widget) with scale and border decoration.
|
||||
///
|
||||
/// Used by _LockedHubItemWrapper where focus is managed at a higher level.
|
||||
/// Used by the hub row, the TV browse rail, the cast strip and the extras row.
|
||||
/// Cards that own their focus node use [FocusableWrapper] instead; both share
|
||||
/// the same chrome through [buildFocusChrome].
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [context]: Build context for theming
|
||||
@@ -173,19 +89,40 @@ class FocusBuilders {
|
||||
Size? glowSize,
|
||||
required Widget child,
|
||||
}) {
|
||||
return buildFocusableCard(
|
||||
context: context,
|
||||
focusNode: null,
|
||||
isFocused: isFocused,
|
||||
onKeyEvent: null,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
borderRadius: borderRadius,
|
||||
focusScale: focusScale,
|
||||
useFocusGlow: useFocusGlow,
|
||||
delegateFocusBorder: delegateFocusBorder,
|
||||
glowSize: glowSize,
|
||||
child: child,
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
|
||||
// In touch mode, no item ever shows focus effects — skip animated wrappers
|
||||
// entirely. This saves ~2 element levels per card on ARM32 Android phones.
|
||||
if (!isKeyboardMode) {
|
||||
return (onTap != null || onLongPress != null)
|
||||
? ClickableCursor(
|
||||
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child),
|
||||
)
|
||||
: child;
|
||||
}
|
||||
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
final focusedWidget = AnimatedScale(
|
||||
scale: isFocused ? focusScale : 1.0,
|
||||
duration: duration,
|
||||
curve: Curves.easeOutCubic,
|
||||
child: buildFocusChrome(
|
||||
context,
|
||||
showFocus: isFocused,
|
||||
duration: duration,
|
||||
borderRadius: borderRadius,
|
||||
useFocusGlow: useFocusGlow,
|
||||
delegateFocusBorder: delegateFocusBorder,
|
||||
glowSize: glowSize,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
// Wrap in GestureDetector if tap/long press handlers provided
|
||||
return (onTap != null || onLongPress != null)
|
||||
? ClickableCursor(
|
||||
child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget),
|
||||
)
|
||||
: focusedWidget;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,24 +56,6 @@ class _FocusableFilterChipState extends State<FocusableFilterChip> with Focusabl
|
||||
@override
|
||||
String get debugLabel => 'filter_chip_${widget.label}';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initFocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FocusableFilterChip oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
updateFocusNode(oldWidget.focusNode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeFocusNode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
return handleChipKeyEvent(
|
||||
node,
|
||||
|
||||
@@ -82,24 +82,6 @@ class _FocusableListTileState extends State<FocusableListTile> with FocusableTil
|
||||
@override
|
||||
FocusNode? get widgetFocusNode => widget.focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initFocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FocusableListTile oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
updateFocusNode(oldWidget.focusNode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeFocusNode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// When hovered/focused with a custom hoverColor, use onError-style foreground
|
||||
@@ -212,24 +194,6 @@ class _FocusableRadioListTileState<T> extends State<FocusableRadioListTile<T>>
|
||||
@override
|
||||
FocusNode? get widgetFocusNode => widget.focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initFocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FocusableRadioListTile<T> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
updateFocusNode(oldWidget.focusNode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeFocusNode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClickableCursor(
|
||||
@@ -316,24 +280,6 @@ class _FocusableSwitchListTileState extends State<FocusableSwitchListTile>
|
||||
@override
|
||||
FocusNode? get widgetFocusNode => widget.focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initFocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FocusableSwitchListTile oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
updateFocusNode(oldWidget.focusNode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeFocusNode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClickableCursor(
|
||||
@@ -398,24 +344,6 @@ class _FocusableCheckboxListTileState extends State<FocusableCheckboxListTile>
|
||||
@override
|
||||
FocusNode? get widgetFocusNode => widget.focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initFocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FocusableCheckboxListTile oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
updateFocusNode(oldWidget.focusNode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeFocusNode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClickableCursor(
|
||||
|
||||
@@ -91,24 +91,6 @@ class _FocusableTabChipState extends State<FocusableTabChip> with FocusableChipS
|
||||
@override
|
||||
String get debugLabel => 'tab_chip_${widget.label}';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initFocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FocusableTabChip oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
updateFocusNode(oldWidget.focusNode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeFocusNode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
return handleChipKeyEvent(
|
||||
node,
|
||||
|
||||
@@ -230,9 +230,6 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if this hub currently has focus
|
||||
bool get hasFocusedItem => _hubFocusNode.hasFocus;
|
||||
|
||||
/// Get the number of items in this hub
|
||||
int get itemCount => _totalItemCount;
|
||||
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/dpad_reorder_mixin.dart';
|
||||
import '../focus/focus_theme.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_library.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../providers/hidden_libraries_provider.dart';
|
||||
import '../providers/libraries_provider.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
@@ -182,48 +179,24 @@ Future<void> _handleLibraryMenuAction(BuildContext context, String action, Media
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performLibraryAction(
|
||||
/// Runs a library admin action, wrapping it in progress/success/failure
|
||||
/// snackbars.
|
||||
///
|
||||
/// [resolveClient] picks the client flavour: `getPlexClientForLibrary` for the
|
||||
/// Plex-only endpoints (scan / analyze / empty trash), `getMediaClientForLibrary`
|
||||
/// for ops that exist on the backend-neutral [MediaServerClient] interface
|
||||
/// (currently just refresh metadata). Both resolvers require the library's exact
|
||||
/// owning server and throw the same error when it isn't available.
|
||||
Future<void> _performLibraryAction<T extends MediaServerClient>(
|
||||
BuildContext context, {
|
||||
required MediaLibrary library,
|
||||
required Future<void> Function(PlexClient client) action,
|
||||
required T Function(BuildContext context) resolveClient,
|
||||
required Future<void> Function(T client) action,
|
||||
required String progressMessage,
|
||||
required String successMessage,
|
||||
required String Function(Object error) failureMessage,
|
||||
}) async {
|
||||
try {
|
||||
final client = context.getPlexClientForLibrary(library);
|
||||
|
||||
if (context.mounted) {
|
||||
showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2));
|
||||
}
|
||||
|
||||
await action(client);
|
||||
|
||||
if (context.mounted) {
|
||||
showSuccessSnackBar(context, successMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Library action failed', error: e);
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, failureMessage(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-neutral counterpart to [_performLibraryAction] for ops that exist
|
||||
/// on the [MediaServerClient] interface (currently just refresh metadata).
|
||||
/// Resolves the client through `getMediaClientForLibrary` so the action requires
|
||||
/// the library's exact owning server.
|
||||
Future<void> _performMediaLibraryAction(
|
||||
BuildContext context, {
|
||||
required MediaLibrary library,
|
||||
required Future<void> Function(MediaServerClient client) action,
|
||||
required String progressMessage,
|
||||
required String successMessage,
|
||||
required String Function(Object error) failureMessage,
|
||||
}) async {
|
||||
try {
|
||||
final client = context.getMediaClientForLibrary(library);
|
||||
final client = resolveClient(context);
|
||||
|
||||
if (context.mounted) {
|
||||
showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2));
|
||||
@@ -245,7 +218,7 @@ Future<void> _performMediaLibraryAction(
|
||||
Future<void> _scanLibrary(BuildContext context, MediaLibrary library) {
|
||||
return _performLibraryAction(
|
||||
context,
|
||||
library: library,
|
||||
resolveClient: (ctx) => ctx.getPlexClientForLibrary(library),
|
||||
action: (client) => client.scanLibrary(library.id),
|
||||
progressMessage: t.messages.libraryScanning(title: library.title),
|
||||
successMessage: t.messages.libraryScanStarted(title: library.title),
|
||||
@@ -254,9 +227,9 @@ Future<void> _scanLibrary(BuildContext context, MediaLibrary library) {
|
||||
}
|
||||
|
||||
Future<void> _refreshLibraryMetadata(BuildContext context, MediaLibrary library) {
|
||||
return _performMediaLibraryAction(
|
||||
return _performLibraryAction(
|
||||
context,
|
||||
library: library,
|
||||
resolveClient: (ctx) => ctx.getMediaClientForLibrary(library),
|
||||
action: (client) => client.refreshLibraryMetadata(library.id),
|
||||
progressMessage: t.messages.metadataRefreshing(title: library.title),
|
||||
successMessage: t.messages.metadataRefreshStarted(title: library.title),
|
||||
@@ -267,7 +240,7 @@ Future<void> _refreshLibraryMetadata(BuildContext context, MediaLibrary library)
|
||||
Future<void> _emptyLibraryTrash(BuildContext context, MediaLibrary library) {
|
||||
return _performLibraryAction(
|
||||
context,
|
||||
library: library,
|
||||
resolveClient: (ctx) => ctx.getPlexClientForLibrary(library),
|
||||
action: (client) => client.emptyLibraryTrash(library.id),
|
||||
progressMessage: t.libraries.emptyingTrash(title: library.title),
|
||||
successMessage: t.libraries.trashEmptied(title: library.title),
|
||||
@@ -278,7 +251,7 @@ Future<void> _emptyLibraryTrash(BuildContext context, MediaLibrary library) {
|
||||
Future<void> _analyzeLibrary(BuildContext context, MediaLibrary library) {
|
||||
return _performLibraryAction(
|
||||
context,
|
||||
library: library,
|
||||
resolveClient: (ctx) => ctx.getPlexClientForLibrary(library),
|
||||
action: (client) => client.analyzeLibrary(library.id),
|
||||
progressMessage: t.libraries.analyzing(title: library.title),
|
||||
successMessage: t.libraries.analysisStarted(title: library.title),
|
||||
@@ -309,19 +282,41 @@ class _LibraryManagementSheet extends StatefulWidget {
|
||||
State<_LibraryManagementSheet> createState() => _LibraryManagementSheetState();
|
||||
}
|
||||
|
||||
class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
|
||||
with DpadReorderListMixin<MediaLibrary, _LibraryManagementSheet> {
|
||||
late List<MediaLibrary> _tempLibraries;
|
||||
|
||||
// Keyboard navigation state
|
||||
int _focusedIndex = 0;
|
||||
int _focusedColumn = 0; // 0 = row, 1 = visibility button, 2 = options button
|
||||
int? _movingIndex; // Non-null when in move mode
|
||||
int? _originalIndex; // Original position before move (for cancel)
|
||||
List<MediaLibrary>? _originalOrder; // Original order before move (for cancel)
|
||||
final FocusNode _listFocusNode = FocusNode();
|
||||
final ScrollController _dialogScrollController = ScrollController();
|
||||
final ScrollController _sheetScrollController = ScrollController();
|
||||
bool _backKeyDownSeen = false;
|
||||
|
||||
// Keyboard navigation: column 0 = row, 1 = visibility button, 2 = options button.
|
||||
@override
|
||||
List<MediaLibrary> get reorderItems => _tempLibraries;
|
||||
|
||||
@override
|
||||
set reorderItems(List<MediaLibrary> value) => _tempLibraries = value;
|
||||
|
||||
@override
|
||||
int get lastReorderColumn => 2;
|
||||
|
||||
/// Only the TV dialog scrolls the focused row into view; the bottom sheet
|
||||
/// list is not keyboard-driven.
|
||||
@override
|
||||
ScrollController? get reorderScrollController => widget.isDialog ? _dialogScrollController : null;
|
||||
|
||||
@override
|
||||
void onReorderMoveConfirmed() => widget.onReorder(_tempLibraries);
|
||||
|
||||
@override
|
||||
void onReorderColumnActivated(int column, int index) {
|
||||
final library = _tempLibraries[index];
|
||||
if (column == 1) {
|
||||
widget.onToggleVisibility(library);
|
||||
} else if (column == 2) {
|
||||
_showLibraryMenuBottomSheet(context, library);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -337,157 +332,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _ensureFocusedVisible() {
|
||||
if (!widget.isDialog) return;
|
||||
if (!_dialogScrollController.hasClients) return;
|
||||
|
||||
const double itemHeight = 72.0; // Material ListTile with subtitle
|
||||
const double listTopPadding = 8.0;
|
||||
final double targetTop = listTopPadding + (_focusedIndex * itemHeight);
|
||||
final double targetBottom = targetTop + itemHeight;
|
||||
|
||||
final double viewportTop = _dialogScrollController.offset;
|
||||
final double viewportHeight = _dialogScrollController.position.viewportDimension;
|
||||
final double viewportBottom = viewportTop + viewportHeight;
|
||||
|
||||
// Already fully visible — skip
|
||||
if (targetTop >= viewportTop && targetBottom <= viewportBottom) return;
|
||||
|
||||
// Place item at ~25% from top of viewport
|
||||
final double destination = (targetTop - viewportHeight * 0.25).clamp(
|
||||
0.0,
|
||||
_dialogScrollController.position.maxScrollExtent,
|
||||
);
|
||||
|
||||
_dialogScrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
// Track back key down/up pairing. If focus was elsewhere during KeyDown
|
||||
// (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray
|
||||
// KeyUp that would incorrectly pop the dialog. Consume it instead.
|
||||
if (key.isBackKey) {
|
||||
if (event is KeyDownEvent) {
|
||||
_backKeyDownSeen = true;
|
||||
} else if (event is KeyUpEvent && !_backKeyDownSeen) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event is KeyUpEvent) {
|
||||
_backKeyDownSeen = false;
|
||||
}
|
||||
}
|
||||
|
||||
final backResult = handleBackKeyAction(event, () {
|
||||
if (_movingIndex != null) {
|
||||
// Cancel move - restore original position
|
||||
setState(() {
|
||||
if (_originalOrder != null) {
|
||||
_tempLibraries = List.from(_originalOrder!);
|
||||
}
|
||||
_focusedIndex = _originalIndex ?? 0;
|
||||
_movingIndex = null;
|
||||
_originalIndex = null;
|
||||
_originalOrder = null;
|
||||
});
|
||||
} else {
|
||||
OverlaySheetController.popAdaptive(context);
|
||||
}
|
||||
});
|
||||
if (backResult != KeyEventResult.ignored) {
|
||||
return backResult;
|
||||
}
|
||||
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
|
||||
if (_movingIndex != null) {
|
||||
// Move mode - arrows reorder the item
|
||||
if (key.isUpKey && _movingIndex! > 0) {
|
||||
setState(() {
|
||||
final item = _tempLibraries.removeAt(_movingIndex!);
|
||||
_tempLibraries.insert(_movingIndex! - 1, item);
|
||||
_movingIndex = _movingIndex! - 1;
|
||||
_focusedIndex = _movingIndex!;
|
||||
});
|
||||
_ensureFocusedVisible();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey && _movingIndex! < _tempLibraries.length - 1) {
|
||||
setState(() {
|
||||
final item = _tempLibraries.removeAt(_movingIndex!);
|
||||
_tempLibraries.insert(_movingIndex! + 1, item);
|
||||
_movingIndex = _movingIndex! + 1;
|
||||
_focusedIndex = _movingIndex!;
|
||||
});
|
||||
_ensureFocusedVisible();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isSelectKey) {
|
||||
// Confirm move - apply the reorder
|
||||
widget.onReorder(_tempLibraries);
|
||||
setState(() {
|
||||
_movingIndex = null;
|
||||
_originalIndex = null;
|
||||
_originalOrder = null;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else {
|
||||
// Navigation mode
|
||||
if (key.isUpKey && _focusedIndex > 0) {
|
||||
setState(() {
|
||||
_focusedIndex--;
|
||||
_focusedColumn = 0; // Reset to row when changing rows
|
||||
});
|
||||
_ensureFocusedVisible();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey && _focusedIndex < _tempLibraries.length - 1) {
|
||||
setState(() {
|
||||
_focusedIndex++;
|
||||
_focusedColumn = 0; // Reset to row when changing rows
|
||||
});
|
||||
_ensureFocusedVisible();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isLeftKey && _focusedColumn > 0) {
|
||||
setState(() => _focusedColumn--);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isRightKey && _focusedColumn < 2) {
|
||||
setState(() => _focusedColumn++);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isSelectKey) {
|
||||
if (_focusedColumn == 0) {
|
||||
// Enter move mode
|
||||
setState(() {
|
||||
_movingIndex = _focusedIndex;
|
||||
_originalIndex = _focusedIndex;
|
||||
_originalOrder = List.from(_tempLibraries);
|
||||
});
|
||||
} else if (_focusedColumn == 1) {
|
||||
// Toggle visibility
|
||||
final library = _tempLibraries[_focusedIndex];
|
||||
widget.onToggleVisibility(library);
|
||||
} else if (_focusedColumn == 2) {
|
||||
// Show options menu
|
||||
final library = _tempLibraries[_focusedIndex];
|
||||
_showLibraryMenuBottomSheet(context, library);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
// Block d-pad keys at boundaries so focus doesn't escape the dialog
|
||||
if (key.isDpadDirection) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void _reorderLibraries(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
final library = _tempLibraries.removeAt(oldIndex);
|
||||
@@ -527,7 +371,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
if (widget.isDialog) {
|
||||
return Dialog(
|
||||
child: PopScope(
|
||||
canPop: false, // Prevent system back from double-popping; handled by _handleKeyEvent
|
||||
canPop: false, // Prevent system back from double-popping; handled by handleReorderKeyEvent
|
||||
// ignore: no-empty-block - required callback, blocks system back on Android TV
|
||||
onPopInvokedWithResult: (didPop, result) {},
|
||||
child: Scaffold(
|
||||
@@ -551,8 +395,8 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
focusNode: _listFocusNode,
|
||||
descendantsAreFocusable: false,
|
||||
autofocus: InputModeTracker.isKeyboardMode(context),
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: _buildFlatLibraryListDialog(hiddenLibraryKeys),
|
||||
onKeyEvent: handleReorderKeyEvent,
|
||||
child: _buildFlatLibraryList(_dialogScrollController, hiddenLibraryKeys),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -567,7 +411,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
focusNode: _listFocusNode,
|
||||
descendantsAreFocusable: false,
|
||||
autofocus: InputModeTracker.isKeyboardMode(context),
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
onKeyEvent: handleReorderKeyEvent,
|
||||
child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys),
|
||||
),
|
||||
),
|
||||
@@ -575,37 +419,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build library list for dialog (TV) using ListView with scroll-into-view support
|
||||
Widget _buildFlatLibraryListDialog(Set<String> hiddenLibraryKeys) {
|
||||
final showServerNames = _hasMultipleServers();
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
|
||||
return ReorderableListView.builder(
|
||||
scrollController: _dialogScrollController,
|
||||
onReorderItem: _reorderLibraries,
|
||||
itemCount: _tempLibraries.length,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
buildDefaultDragHandles: false,
|
||||
itemBuilder: (context, index) {
|
||||
final library = _tempLibraries[index];
|
||||
final showServerName = showServerNames && library.serverName != null;
|
||||
final isFocused = isKeyboardMode && index == _focusedIndex;
|
||||
final isMoving = index == _movingIndex;
|
||||
|
||||
return _buildLibraryTile(
|
||||
library,
|
||||
index,
|
||||
hiddenLibraryKeys,
|
||||
showServerName: showServerName,
|
||||
isFocused: isFocused,
|
||||
isMoving: isMoving,
|
||||
focusedColumn: isFocused ? _focusedColumn : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build flat library list with a server subtitle when multiple servers are connected
|
||||
/// Build flat library list with a server subtitle when multiple servers are
|
||||
/// connected. The TV dialog passes [_dialogScrollController] so focused rows
|
||||
/// can be scrolled into view; the bottom sheet passes its own controller.
|
||||
Widget _buildFlatLibraryList(ScrollController scrollController, Set<String> hiddenLibraryKeys) {
|
||||
final showServerNames = _hasMultipleServers();
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
@@ -619,8 +435,8 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
itemBuilder: (context, index) {
|
||||
final library = _tempLibraries[index];
|
||||
final showServerName = showServerNames && library.serverName != null;
|
||||
final isFocused = isKeyboardMode && index == _focusedIndex;
|
||||
final isMoving = index == _movingIndex;
|
||||
final isFocused = isKeyboardMode && index == focusedIndex;
|
||||
final isMoving = index == movingIndex;
|
||||
return _buildLibraryTile(
|
||||
library,
|
||||
index,
|
||||
@@ -628,7 +444,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
showServerName: showServerName,
|
||||
isFocused: isFocused,
|
||||
isMoving: isMoving,
|
||||
focusedColumn: isFocused ? _focusedColumn : null,
|
||||
focusedColumn: isFocused ? focusedColumn : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
+98
-140
@@ -654,8 +654,7 @@ class _MediaCardList extends StatelessWidget {
|
||||
if (mi.kind == MediaKind.track) return mi.trackArtistTitle;
|
||||
|
||||
if (mi.parentIndex != null && mi.index != null) {
|
||||
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
|
||||
return showEp ? 'S${mi.parentIndex} E${mi.index}' : 'S${mi.parentIndex}';
|
||||
return 'S${mi.parentIndex}${_episodeNumberSuffix(mi)}';
|
||||
}
|
||||
|
||||
if (mi.displaySubtitle != null) {
|
||||
@@ -683,33 +682,10 @@ class _MediaCardList extends StatelessWidget {
|
||||
return '';
|
||||
}
|
||||
|
||||
Widget _buildEpisodeSubtitle(BuildContext context, MediaItem mi) {
|
||||
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted.withValues(alpha: 0.85),
|
||||
fontSize: _subtitleFontSize,
|
||||
);
|
||||
final episodeTitle = mi.displaySubtitle ?? mi.displayTitle;
|
||||
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
|
||||
final episodeNum = (showEp && mi.index != null) ? ' E${mi.index}' : '';
|
||||
return Row(
|
||||
children: [
|
||||
if (enableDetailLinks)
|
||||
_ClickableText(
|
||||
text: 'S${mi.parentIndex}',
|
||||
style: style,
|
||||
onTap: () => _navigateToFocusedDetail(context, mi, isOffline: isOffline),
|
||||
)
|
||||
else
|
||||
ExcludeSemantics(child: Text('S${mi.parentIndex}', style: style)),
|
||||
ExcludeSemantics(child: Text('$episodeNum · ', style: style)),
|
||||
Expanded(
|
||||
child: ExcludeSemantics(
|
||||
child: Text(episodeTitle, maxLines: 1, overflow: .ellipsis, style: style),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
TextStyle? _subtitleStyle(BuildContext context) => Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted.withValues(alpha: 0.85),
|
||||
fontSize: _subtitleFontSize,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -798,19 +774,17 @@ class _MediaCardList extends StatelessWidget {
|
||||
(item as MediaItem).isEpisode &&
|
||||
(item as MediaItem).parentIndex != null &&
|
||||
(item as MediaItem).parentId != null) ...[
|
||||
_buildEpisodeSubtitle(context, item as MediaItem),
|
||||
_buildEpisodeSubtitleRow(
|
||||
context,
|
||||
item as MediaItem,
|
||||
style: _subtitleStyle(context),
|
||||
enableDetailLinks: enableDetailLinks,
|
||||
isOffline: isOffline,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
] else if (subtitle != null) ...[
|
||||
ExcludeSemantics(
|
||||
child: Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted.withValues(alpha: 0.85),
|
||||
fontSize: _subtitleFontSize,
|
||||
),
|
||||
),
|
||||
child: Text(subtitle, maxLines: 1, overflow: .ellipsis, style: _subtitleStyle(context)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
@@ -916,33 +890,16 @@ Widget _buildPosterImage(
|
||||
double? knownHeight,
|
||||
Animation<double>? artworkDim,
|
||||
}) {
|
||||
String? posterUrl;
|
||||
|
||||
if (item is MediaPlaylist) {
|
||||
posterUrl = item.displayImagePath;
|
||||
|
||||
if (cardShapeOverride == CardShape.square) {
|
||||
return OptimizedMediaImage(
|
||||
client: isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
|
||||
imagePath: posterUrl,
|
||||
width: knownWidth ?? double.infinity,
|
||||
height: knownHeight ?? double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: _buildPosterLoadingPlaceholder,
|
||||
fallbackIcon: Symbols.playlist_play_rounded,
|
||||
imageType: ImageType.square,
|
||||
localFilePath: localPosterPath,
|
||||
artworkDim: artworkDim,
|
||||
);
|
||||
}
|
||||
|
||||
return OptimizedMediaImage.playlist(
|
||||
return OptimizedMediaImage(
|
||||
client: isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
|
||||
imagePath: posterUrl,
|
||||
imagePath: item.displayImagePath,
|
||||
width: knownWidth ?? double.infinity,
|
||||
height: knownHeight ?? double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: _buildPosterLoadingPlaceholder,
|
||||
fallbackIcon: Symbols.playlist_play_rounded,
|
||||
imageType: cardShapeOverride == CardShape.square ? ImageType.square : ImageType.poster,
|
||||
localFilePath: localPosterPath,
|
||||
artworkDim: artworkDim,
|
||||
);
|
||||
@@ -955,7 +912,7 @@ Widget _buildPosterImage(
|
||||
final primaryPosterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext);
|
||||
final posterFallbackUrl = item.posterThumbFallback(mode: episodePosterMode, mixedHubContext: mixedHubContext);
|
||||
final useRememberedFallback = posterFallbackUrl != null && _hasFailedPosterUrl(primaryPosterUrl);
|
||||
posterUrl = useRememberedFallback ? posterFallbackUrl : primaryPosterUrl;
|
||||
final posterUrl = useRememberedFallback ? posterFallbackUrl : primaryPosterUrl;
|
||||
final mediaClient = isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId));
|
||||
final fallbackIcon = _mediaPosterFallbackIcon(item);
|
||||
final imageType = switch (cardShapeOverride) {
|
||||
@@ -965,77 +922,53 @@ Widget _buildPosterImage(
|
||||
null => MediaImageHelper.cardImageType(item, episodePosterMode, mixedHubContext: mixedHubContext),
|
||||
};
|
||||
|
||||
OptimizedMediaImage buildImage(
|
||||
String? path,
|
||||
ImageType type, {
|
||||
String? localFilePath,
|
||||
Widget Function(BuildContext, String, dynamic)? errorWidget,
|
||||
}) => OptimizedMediaImage(
|
||||
client: mediaClient,
|
||||
imagePath: path,
|
||||
width: knownWidth ?? double.infinity,
|
||||
height: knownHeight ?? double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: _buildPosterLoadingPlaceholder,
|
||||
fallbackIcon: fallbackIcon,
|
||||
errorWidget: errorWidget,
|
||||
imageType: type,
|
||||
localFilePath: localFilePath,
|
||||
artworkDim: artworkDim,
|
||||
);
|
||||
|
||||
// Remember the dead primary URL so later builds go straight to the fallback.
|
||||
Widget Function(BuildContext, String, dynamic)? retryWithFallback(ImageType type) {
|
||||
if (posterFallbackUrl == null || useRememberedFallback) return null;
|
||||
return (_, _, _) {
|
||||
_rememberFailedPosterUrl(primaryPosterUrl);
|
||||
return buildImage(posterFallbackUrl, type);
|
||||
};
|
||||
}
|
||||
|
||||
Widget image;
|
||||
|
||||
// Square 1:1 artwork for music (artists/albums/tracks)
|
||||
if (imageType == ImageType.square) {
|
||||
image = OptimizedMediaImage(
|
||||
client: mediaClient,
|
||||
imagePath: posterUrl,
|
||||
width: knownWidth ?? double.infinity,
|
||||
height: knownHeight ?? double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: _buildPosterLoadingPlaceholder,
|
||||
fallbackIcon: fallbackIcon,
|
||||
errorWidget: posterFallbackUrl == null || useRememberedFallback
|
||||
? null
|
||||
: (_, _, _) {
|
||||
_rememberFailedPosterUrl(primaryPosterUrl);
|
||||
return OptimizedMediaImage(
|
||||
client: mediaClient,
|
||||
imagePath: posterFallbackUrl,
|
||||
width: knownWidth ?? double.infinity,
|
||||
height: knownHeight ?? double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: _buildPosterLoadingPlaceholder,
|
||||
fallbackIcon: fallbackIcon,
|
||||
imageType: ImageType.square,
|
||||
artworkDim: artworkDim,
|
||||
);
|
||||
},
|
||||
imageType: ImageType.square,
|
||||
image = buildImage(
|
||||
posterUrl,
|
||||
ImageType.square,
|
||||
localFilePath: localPosterPath,
|
||||
artworkDim: artworkDim,
|
||||
errorWidget: retryWithFallback(ImageType.square),
|
||||
);
|
||||
} else if (imageType == ImageType.thumb) {
|
||||
// Use thumb image type for 16:9 content (episodes, or movies in mixed hubs)
|
||||
image = OptimizedMediaImage.thumb(
|
||||
client: mediaClient,
|
||||
imagePath: posterUrl,
|
||||
width: knownWidth ?? double.infinity,
|
||||
height: knownHeight ?? double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: _buildPosterLoadingPlaceholder,
|
||||
fallbackIcon: fallbackIcon,
|
||||
localFilePath: localPosterPath,
|
||||
artworkDim: artworkDim,
|
||||
);
|
||||
image = buildImage(posterUrl, ImageType.thumb, localFilePath: localPosterPath);
|
||||
} else {
|
||||
image = OptimizedMediaImage.poster(
|
||||
client: mediaClient,
|
||||
imagePath: posterUrl,
|
||||
width: knownWidth ?? double.infinity,
|
||||
height: knownHeight ?? double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: _buildPosterLoadingPlaceholder,
|
||||
fallbackIcon: fallbackIcon,
|
||||
errorWidget: posterFallbackUrl == null || useRememberedFallback
|
||||
? null
|
||||
: (_, _, _) {
|
||||
_rememberFailedPosterUrl(primaryPosterUrl);
|
||||
return OptimizedMediaImage.poster(
|
||||
client: mediaClient,
|
||||
imagePath: posterFallbackUrl,
|
||||
width: knownWidth ?? double.infinity,
|
||||
height: knownHeight ?? double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: _buildPosterLoadingPlaceholder,
|
||||
fallbackIcon: fallbackIcon,
|
||||
artworkDim: artworkDim,
|
||||
);
|
||||
},
|
||||
image = buildImage(
|
||||
posterUrl,
|
||||
ImageType.poster,
|
||||
localFilePath: localPosterPath,
|
||||
artworkDim: artworkDim,
|
||||
errorWidget: retryWithFallback(ImageType.poster),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1114,29 +1047,19 @@ class _MediaCardHelpers {
|
||||
|
||||
// For episodes, show "S# · Episode Title" with clickable season link
|
||||
if (mi.isEpisode && mi.parentIndex != null) {
|
||||
final episodeTitle = mi.displaySubtitle ?? mi.displayTitle;
|
||||
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
|
||||
final episodeSuffix = (showEp && mi.index != null) ? ' E${mi.index}' : '';
|
||||
if (enableDetailLinks && mi.parentId != null) {
|
||||
return Row(
|
||||
children: [
|
||||
_ClickableText(
|
||||
text: 'S${mi.parentIndex}',
|
||||
style: subtitleStyle,
|
||||
onTap: () => _navigateToFocusedDetail(context, mi, isOffline: isOffline),
|
||||
),
|
||||
ExcludeSemantics(child: Text('$episodeSuffix · ', style: subtitleStyle)),
|
||||
Expanded(
|
||||
child: ExcludeSemantics(
|
||||
child: Text(episodeTitle, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
|
||||
),
|
||||
),
|
||||
],
|
||||
return _buildEpisodeSubtitleRow(
|
||||
context,
|
||||
mi,
|
||||
style: subtitleStyle,
|
||||
enableDetailLinks: true,
|
||||
isOffline: isOffline,
|
||||
);
|
||||
}
|
||||
final episodeTitle = mi.displaySubtitle ?? mi.displayTitle;
|
||||
return ExcludeSemantics(
|
||||
child: Text(
|
||||
'S${mi.parentIndex}$episodeSuffix · $episodeTitle',
|
||||
'S${mi.parentIndex}${_episodeNumberSuffix(mi)} · $episodeTitle',
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: subtitleStyle,
|
||||
@@ -1169,6 +1092,41 @@ class _MediaCardHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
/// "S# E# · Episode title" with the season number linking to the season.
|
||||
Widget _buildEpisodeSubtitleRow(
|
||||
BuildContext context,
|
||||
MediaItem mi, {
|
||||
required TextStyle? style,
|
||||
required bool enableDetailLinks,
|
||||
required bool isOffline,
|
||||
}) {
|
||||
final seasonLabel = 'S${mi.parentIndex}';
|
||||
return Row(
|
||||
children: [
|
||||
if (enableDetailLinks)
|
||||
_ClickableText(
|
||||
text: seasonLabel,
|
||||
style: style,
|
||||
onTap: () => _navigateToFocusedDetail(context, mi, isOffline: isOffline),
|
||||
)
|
||||
else
|
||||
ExcludeSemantics(child: Text(seasonLabel, style: style)),
|
||||
ExcludeSemantics(child: Text('${_episodeNumberSuffix(mi)} · ', style: style)),
|
||||
Expanded(
|
||||
child: ExcludeSemantics(
|
||||
child: Text(mi.displaySubtitle ?? mi.displayTitle, maxLines: 1, overflow: .ellipsis, style: style),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty unless [SettingsService.showEpisodeNumberOnCards] is on.
|
||||
String _episodeNumberSuffix(MediaItem mi) {
|
||||
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
|
||||
return (showEp && mi.index != null) ? ' E${mi.index}' : '';
|
||||
}
|
||||
|
||||
/// Whether the card renders any pointer detail link for this item.
|
||||
bool _hasPointerDetailLinks(MediaItem mi) {
|
||||
if (_hasClickableTitle(mi)) return true;
|
||||
|
||||
+134
-143
@@ -23,6 +23,7 @@ import '../services/offline_watch_sync_service.dart';
|
||||
import '../services/playlist_items_loader.dart';
|
||||
import '../services/watch_actions.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/download_version_utils.dart';
|
||||
import '../utils/download_utils.dart';
|
||||
import '../utils/quality_preset_labels.dart';
|
||||
@@ -295,15 +296,13 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
_MenuAction(value: 'delete', icon: Symbols.delete_rounded, label: t.common.delete, destructive: true),
|
||||
);
|
||||
} else {
|
||||
// Music (artist/album/track) playback + navigation actions. Play is
|
||||
// always offered — the shared music_navigation helpers surface the
|
||||
// "not supported yet" notice while the stub service is bound. Queue
|
||||
// insertion only exists once a real playback engine is available.
|
||||
// Music (artist/album/track) playback + navigation actions. Queue
|
||||
// insertion only exists where a playback session is bound.
|
||||
final isMusicKind = mediaKind != null && mediaKind.isMusic;
|
||||
if (isMusicKind) {
|
||||
menuActions.add(_MenuAction(value: 'music_play', icon: Symbols.play_arrow_rounded, label: t.common.play));
|
||||
|
||||
final musicAvailable = context.read<MusicPlaybackService?>()?.isAvailable ?? false;
|
||||
final musicAvailable = context.read<MusicPlaybackService?>() != null;
|
||||
if (musicAvailable) {
|
||||
menuActions.add(
|
||||
_MenuAction(value: 'music_play_next', icon: Symbols.playlist_play_rounded, label: t.music.playNext),
|
||||
@@ -1060,22 +1059,11 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
await playTrackWithAlbumContext(context, item);
|
||||
return;
|
||||
}
|
||||
// Availability gate before the container fetch so the stub costs no
|
||||
// server round-trip.
|
||||
if (!ensureMusicPlaybackAvailable(context)) return;
|
||||
final service = context.read<MusicPlaybackService>();
|
||||
final intent = service.beginPlayIntent();
|
||||
List<MediaItem> tracks;
|
||||
try {
|
||||
tracks = await _musicTracksForItem(item);
|
||||
} catch (_) {
|
||||
if (!service.isPlayIntentCurrent(intent)) return;
|
||||
rethrow;
|
||||
}
|
||||
if (!context.mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
await playTracks(
|
||||
// No onError: a failed container fetch falls through to this menu's own
|
||||
// error boundary, which logs it and shows the snackbar.
|
||||
await playFetchedTracks(
|
||||
context,
|
||||
tracks: tracks,
|
||||
fetch: () => _musicTracksForItem(item),
|
||||
playContext: MusicPlayContext(
|
||||
id: item.id,
|
||||
title: item.displayTitle,
|
||||
@@ -1086,8 +1074,8 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
Future<void> _handleMusicEnqueue(BuildContext context, {required bool playNext}) async {
|
||||
final service = context.read<MusicPlaybackService?>();
|
||||
// Menu entries are hidden on the stub; defensive re-check.
|
||||
if (service == null || !service.isAvailable) return;
|
||||
// Menu entries are hidden without a session; defensive re-check.
|
||||
if (service == null) return;
|
||||
final queueSessionRevision = service.queueSessionRevision;
|
||||
List<MediaItem> tracks;
|
||||
try {
|
||||
@@ -1146,53 +1134,29 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
if (result == '_create_new') {
|
||||
final playlistName = await showTextInputDialog(
|
||||
context,
|
||||
await _addItemToContainer<MediaPlaylist>(
|
||||
context,
|
||||
kind: 'playlist',
|
||||
item: item,
|
||||
client: client,
|
||||
result: result,
|
||||
createPrompt: (
|
||||
title: t.playlists.create,
|
||||
labelText: t.playlists.playlistName,
|
||||
hintText: t.playlists.enterPlaylistName,
|
||||
);
|
||||
|
||||
if (playlistName == null || playlistName.isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d('Creating playlist "$playlistName" seeded with item ${item.id}');
|
||||
final newPlaylist = await client.createPlaylist(title: playlistName, items: [item]);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (context.mounted) {
|
||||
if (newPlaylist != null) {
|
||||
appLogger.d('Successfully created playlist: ${newPlaylist.title}');
|
||||
showSuccessSnackBar(context, t.playlists.created);
|
||||
// Trigger refresh of playlists tab
|
||||
LibraryRefreshNotifier().notifyPlaylistsChanged();
|
||||
} else {
|
||||
appLogger.e('Failed to create playlist - API returned null');
|
||||
showErrorSnackBar(context, t.playlists.errorCreating);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
appLogger.d('Adding item ${item.id} to playlist $result');
|
||||
final success = await client.addToPlaylist(playlistId: result, items: [item]);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (context.mounted) {
|
||||
if (success) {
|
||||
appLogger.d('Successfully added item(s) to playlist $result');
|
||||
showSuccessSnackBar(context, t.playlists.itemAdded);
|
||||
// Trigger refresh of playlists tab
|
||||
LibraryRefreshNotifier().notifyPlaylistsChanged();
|
||||
_triggerEagerSyncIfRuleExists(context, client.serverId, result);
|
||||
} else {
|
||||
appLogger.e('Failed to add item(s) to playlist $result - API returned false');
|
||||
showErrorSnackBar(context, t.playlists.errorAdding);
|
||||
}
|
||||
}
|
||||
}
|
||||
label: t.playlists.playlistName,
|
||||
hint: t.playlists.enterPlaylistName,
|
||||
),
|
||||
create: (name) => client.createPlaylist(title: name, items: [item]),
|
||||
createdLog: (playlist) => 'Successfully created playlist: ${playlist.title}',
|
||||
eagerSyncId: (_) => null,
|
||||
add: () => client.addToPlaylist(playlistId: result, items: [item]),
|
||||
messages: (
|
||||
created: t.playlists.created,
|
||||
createError: t.playlists.errorCreating,
|
||||
added: t.playlists.itemAdded,
|
||||
addError: t.playlists.errorAdding,
|
||||
),
|
||||
notifyChanged: () => LibraryRefreshNotifier().notifyPlaylistsChanged(),
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e('Error in add to playlist flow', error: e, stackTrace: stackTrace);
|
||||
if (context.mounted) {
|
||||
@@ -1250,59 +1214,34 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
if (result == '_create_new') {
|
||||
final collectionName = await showTextInputDialog(
|
||||
context,
|
||||
await _addItemToContainer<String>(
|
||||
context,
|
||||
kind: 'collection',
|
||||
item: item,
|
||||
client: client,
|
||||
result: result,
|
||||
createPrompt: (
|
||||
title: t.common.createNew,
|
||||
labelText: t.collections.collectionName,
|
||||
hintText: t.collections.enterCollectionName,
|
||||
);
|
||||
|
||||
if (collectionName == null || collectionName.isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d('Creating collection "$collectionName" seeded with item ${item.id}');
|
||||
final newCollectionId = await client.createCollection(
|
||||
label: t.collections.collectionName,
|
||||
hint: t.collections.enterCollectionName,
|
||||
),
|
||||
create: (name) => client.createCollection(
|
||||
libraryId: resolvedLibraryId,
|
||||
title: collectionName,
|
||||
title: name,
|
||||
items: [item],
|
||||
itemKind: itemKind,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (context.mounted) {
|
||||
if (newCollectionId != null) {
|
||||
appLogger.d('Successfully created collection with ID: $newCollectionId');
|
||||
showSuccessSnackBar(context, t.collections.created);
|
||||
// Trigger refresh of collections tab
|
||||
LibraryRefreshNotifier().notifyCollectionsChanged();
|
||||
_triggerEagerSyncIfRuleExists(context, client.serverId, newCollectionId);
|
||||
} else {
|
||||
appLogger.e('Failed to create collection - API returned null');
|
||||
showErrorSnackBar(context, t.collections.errorAddingToCollection);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
appLogger.d('Adding item ${item.id} to collection $result');
|
||||
final success = await client.addToCollection(collectionId: result, items: [item]);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (context.mounted) {
|
||||
if (success) {
|
||||
appLogger.d('Successfully added item(s) to collection $result');
|
||||
showSuccessSnackBar(context, t.collections.addedToCollection);
|
||||
// Trigger refresh of collections tab
|
||||
LibraryRefreshNotifier().notifyCollectionsChanged();
|
||||
_triggerEagerSyncIfRuleExists(context, client.serverId, result);
|
||||
} else {
|
||||
appLogger.e('Failed to add item(s) to collection $result - API returned false');
|
||||
showErrorSnackBar(context, t.collections.errorAddingToCollection);
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
createdLog: (id) => 'Successfully created collection with ID: $id',
|
||||
eagerSyncId: (id) => id,
|
||||
add: () => client.addToCollection(collectionId: result, items: [item]),
|
||||
messages: (
|
||||
created: t.collections.created,
|
||||
createError: t.collections.errorAddingToCollection,
|
||||
added: t.collections.addedToCollection,
|
||||
addError: t.collections.errorAddingToCollection,
|
||||
),
|
||||
notifyChanged: () => LibraryRefreshNotifier().notifyCollectionsChanged(),
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e('Error in add to collection flow', error: e, stackTrace: stackTrace);
|
||||
if (context.mounted) {
|
||||
@@ -1311,6 +1250,70 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create-or-add tail shared by the "Add to playlist" and "Add to collection"
|
||||
/// flows. [result] is the picker selection: an existing container id, or the
|
||||
/// `_create_new` sentinel to prompt for a name and create one via [create].
|
||||
/// [eagerSyncId] maps a freshly created container to the id to eager-sync, or
|
||||
/// `null` to skip it.
|
||||
Future<void> _addItemToContainer<T extends Object>(
|
||||
BuildContext context, {
|
||||
required String kind,
|
||||
required MediaItem item,
|
||||
required MediaServerClient client,
|
||||
required String result,
|
||||
required ({String title, String label, String hint}) createPrompt,
|
||||
required Future<T?> Function(String name) create,
|
||||
required String Function(T created) createdLog,
|
||||
required String? Function(T created) eagerSyncId,
|
||||
required Future<bool> Function() add,
|
||||
required ({String created, String createError, String added, String addError}) messages,
|
||||
required VoidCallback notifyChanged,
|
||||
}) async {
|
||||
if (result == '_create_new') {
|
||||
final name = await showTextInputDialog(
|
||||
context,
|
||||
title: createPrompt.title,
|
||||
labelText: createPrompt.label,
|
||||
hintText: createPrompt.hint,
|
||||
);
|
||||
|
||||
if (name == null || name.isEmpty || !context.mounted) return;
|
||||
|
||||
appLogger.d('Creating $kind "$name" seeded with item ${item.id}');
|
||||
final created = await create(name);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (created != null) {
|
||||
appLogger.d(createdLog(created));
|
||||
showSuccessSnackBar(context, messages.created);
|
||||
notifyChanged();
|
||||
final syncId = eagerSyncId(created);
|
||||
if (syncId != null) {
|
||||
_triggerEagerSyncIfRuleExists(context, client.serverId, syncId);
|
||||
}
|
||||
} else {
|
||||
appLogger.e('Failed to create $kind - API returned null');
|
||||
showErrorSnackBar(context, messages.createError);
|
||||
}
|
||||
} else {
|
||||
appLogger.d('Adding item ${item.id} to $kind $result');
|
||||
final success = await add();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (success) {
|
||||
appLogger.d('Successfully added item(s) to $kind $result');
|
||||
showSuccessSnackBar(context, messages.added);
|
||||
notifyChanged();
|
||||
_triggerEagerSyncIfRuleExists(context, client.serverId, result);
|
||||
} else {
|
||||
appLogger.e('Failed to add item(s) to $kind $result - API returned false');
|
||||
showErrorSnackBar(context, messages.addError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showRatingSheet(BuildContext context, MediaItem item, MediaServerClient client) async {
|
||||
if (!mounted) return;
|
||||
// Presented from the menu's own context so a screen-level
|
||||
@@ -1402,29 +1405,15 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
Future<void> _launchAudioPlaylist(BuildContext context, MediaPlaylist playlist, {required bool shuffle}) async {
|
||||
// Match PlaylistDetailScreen: fail the availability gate before paying
|
||||
// for a full playlist fetch, then hand the tracks to the music session.
|
||||
if (!ensureMusicPlaybackAvailable(context)) return;
|
||||
final service = context.read<MusicPlaybackService>();
|
||||
final intent = service.beginPlayIntent();
|
||||
|
||||
List<MediaItem> tracks;
|
||||
try {
|
||||
tracks = await fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id);
|
||||
} catch (e, st) {
|
||||
if (!context.mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st);
|
||||
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
||||
return;
|
||||
}
|
||||
if (!context.mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
if (tracks.isEmpty) {
|
||||
showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems);
|
||||
return;
|
||||
}
|
||||
|
||||
await playTracks(
|
||||
await playFetchedTracks(
|
||||
context,
|
||||
tracks: tracks,
|
||||
fetch: () => fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id),
|
||||
playContext: MusicPlayContext(id: playlist.id, title: playlist.title, kind: MusicPlayContextKind.playlist),
|
||||
onError: (e, st) {
|
||||
appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st);
|
||||
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
||||
},
|
||||
onEmpty: () => showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems),
|
||||
shuffle: shuffle,
|
||||
);
|
||||
}
|
||||
@@ -1512,7 +1501,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
}
|
||||
|
||||
/// Handle download collection action — opens the same sync/one-time dialog
|
||||
/// as playlists, wired to [showCollectionDownloadOptionsAndQueue].
|
||||
/// as playlists, wired to [showListDownloadOptionsAndQueue].
|
||||
Future<void> _handleDownloadCollection(BuildContext context) async {
|
||||
final collection = _mediaItem!;
|
||||
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
||||
@@ -1527,9 +1516,10 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final result = await showCollectionDownloadOptionsAndQueue(
|
||||
final result = await showListDownloadOptionsAndQueue(
|
||||
context,
|
||||
collectionMetadata: collection,
|
||||
rootMetadata: collection,
|
||||
targetType: ContentTypes.collection,
|
||||
items: items,
|
||||
client: client,
|
||||
downloadProvider: downloadProvider,
|
||||
@@ -1571,9 +1561,10 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
serverName: playlist.serverName,
|
||||
);
|
||||
|
||||
final result = await showPlaylistDownloadOptionsAndQueue(
|
||||
final result = await showListDownloadOptionsAndQueue(
|
||||
context,
|
||||
playlistMetadata: playlistMetadata,
|
||||
rootMetadata: playlistMetadata,
|
||||
targetType: ContentTypes.playlist,
|
||||
items: items,
|
||||
client: client,
|
||||
downloadProvider: downloadProvider,
|
||||
|
||||
@@ -123,24 +123,6 @@ class _TrackRowState extends State<TrackRow> with ContextMenuTapMixin<TrackRow>,
|
||||
@override
|
||||
FocusNode? get widgetFocusNode => widget.focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initFocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TrackRow oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
updateFocusNode(oldWidget.focusNode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeFocusNode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleFocusChange(bool hasFocus) {
|
||||
setState(() {
|
||||
_hasFocus = hasFocus;
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
import '../services/trackers/oauth_proxy_client.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'app_icon.dart';
|
||||
import 'dialog_action_button.dart';
|
||||
import 'loading_indicator_box.dart';
|
||||
import 'pending_auth_dialog.dart';
|
||||
|
||||
/// Sign-in dialog for OAuth-proxy flows (MAL, AniList).
|
||||
///
|
||||
@@ -25,10 +19,6 @@ class OAuthProxyDialog extends StatelessWidget {
|
||||
|
||||
const OAuthProxyDialog({super.key, required this.start, required this.serviceName, required this.onCancel});
|
||||
|
||||
Future<void> _open() async {
|
||||
await launchUrl(Uri.parse(start.url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<void> _copyUrl(BuildContext context) async {
|
||||
await Clipboard.setData(ClipboardData(text: start.url));
|
||||
if (!context.mounted) return;
|
||||
@@ -38,81 +28,42 @@ class OAuthProxyDialog extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(t.services.oauthProxy.title(service: serviceName)),
|
||||
content: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(t.services.oauthProxy.body, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 16),
|
||||
// QrImageView doesn't support intrinsic sizing; wrap in SizedBox so
|
||||
// AlertDialog's IntrinsicWidth walk sees a concrete width.
|
||||
Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 220,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: QrImageView(data: start.url, size: 220, version: QrVersions.auto, backgroundColor: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FocusableWrapper(
|
||||
onSelect: () => _copyUrl(context),
|
||||
semanticLabel: t.services.oauthProxy.copyUrl,
|
||||
semanticValue: start.url,
|
||||
descendantsAreFocusable: false,
|
||||
borderRadius: 8,
|
||||
useBackgroundFocus: true,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: () => _copyUrl(context),
|
||||
return PendingAuthDialog(
|
||||
title: t.services.oauthProxy.title(service: serviceName),
|
||||
body: t.services.oauthProxy.body,
|
||||
url: start.url,
|
||||
openLabel: t.services.oauthProxy.openToSignIn(service: serviceName),
|
||||
onCancel: onCancel,
|
||||
children: [
|
||||
// QrImageView doesn't support intrinsic sizing; wrap in SizedBox so
|
||||
// AlertDialog's IntrinsicWidth walk sees a concrete width.
|
||||
Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 220,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(
|
||||
start.url,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
child: QrImageView(data: start.url, size: 220, version: QrVersions.auto, backgroundColor: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FocusableButton(
|
||||
onPressed: _open,
|
||||
useBackgroundFocus: true,
|
||||
child: FilledButton.icon(
|
||||
icon: const AppIcon(Symbols.open_in_new_rounded),
|
||||
label: Text(t.services.oauthProxy.openToSignIn(service: serviceName)),
|
||||
onPressed: _open,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const LoadingIndicatorBox(size: 16),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(t.services.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(
|
||||
onPressed: () {
|
||||
onCancel();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
label: t.common.cancel,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CopyTapRegion(
|
||||
onCopy: () => _copyUrl(context),
|
||||
semanticLabel: t.services.oauthProxy.copyUrl,
|
||||
semanticValue: start.url,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(
|
||||
start.url,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,7 +99,15 @@ class OverlaySheetController {
|
||||
/// Re-focus the first focusable descendant within the sheet.
|
||||
/// Useful after internal page changes via setState.
|
||||
void refocus() {
|
||||
_state._refocus();
|
||||
_state._autoFocus(clearSelectSuppression: false);
|
||||
}
|
||||
|
||||
/// Sizing applied when a caller supplies no explicit constraints: capped
|
||||
/// width on desktop, three quarters of the screen height everywhere.
|
||||
static BoxConstraints _defaultSheetConstraints(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final isDesktop = size.width > 600;
|
||||
return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75);
|
||||
}
|
||||
|
||||
/// Show a sheet using the overlay system if available, otherwise fall back
|
||||
@@ -129,13 +137,7 @@ class OverlaySheetController {
|
||||
}
|
||||
// Apply the same default constraints the overlay system uses so sheets
|
||||
// shown without an OverlaySheetHost still have sensible sizing on desktop.
|
||||
final effectiveConstraints =
|
||||
constraints ??
|
||||
() {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final isDesktop = size.width > 600;
|
||||
return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75);
|
||||
}();
|
||||
final effectiveConstraints = constraints ?? _defaultSheetConstraints(context);
|
||||
openSheetCount.value++;
|
||||
try {
|
||||
return await showModalBottomSheet<T>(
|
||||
@@ -146,6 +148,7 @@ class OverlaySheetController {
|
||||
constraints: effectiveConstraints,
|
||||
backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface,
|
||||
barrierColor: Colors.black54,
|
||||
isDismissible: barrierDismissible,
|
||||
isScrollControlled: isScrollControlled,
|
||||
showDragHandle: showDragHandle,
|
||||
);
|
||||
@@ -188,28 +191,16 @@ class OverlaySheetController {
|
||||
showDragHandle: showDragHandle,
|
||||
);
|
||||
}
|
||||
final effectiveConstraints =
|
||||
constraints ??
|
||||
() {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final isDesktop = size.width > 600;
|
||||
return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75);
|
||||
}();
|
||||
BackKeyCoordinator.clear();
|
||||
openSheetCount.value++;
|
||||
try {
|
||||
return await showModalBottomSheet<T>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(top: false, child: builder(context)),
|
||||
constraints: effectiveConstraints,
|
||||
backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface,
|
||||
isDismissible: barrierDismissible,
|
||||
isScrollControlled: isScrollControlled,
|
||||
showDragHandle: showDragHandle,
|
||||
);
|
||||
} finally {
|
||||
openSheetCount.value--;
|
||||
}
|
||||
return showAdaptive<T>(
|
||||
context,
|
||||
builder: builder,
|
||||
constraints: constraints,
|
||||
backgroundColor: backgroundColor,
|
||||
barrierDismissible: barrierDismissible,
|
||||
isScrollControlled: isScrollControlled,
|
||||
showDragHandle: showDragHandle,
|
||||
);
|
||||
}
|
||||
|
||||
/// Close the sheet entirely. Uses overlay controller if available,
|
||||
@@ -457,7 +448,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
return _lastPointerPosition?.dx;
|
||||
}
|
||||
|
||||
void _autoFocus() {
|
||||
void _autoFocus({bool clearSelectSuppression = true}) {
|
||||
final focusDescendant = InputModeTracker.isKeyboardMode(context, listen: false);
|
||||
|
||||
// First post-frame: the FocusScope is now built and the node is attached.
|
||||
@@ -486,33 +477,13 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
// select inside the sheet from being eaten).
|
||||
// - Long press: key still held → keep flag so KeyRepeat/KeyUp events
|
||||
// from the long press are correctly suppressed.
|
||||
if (!HardwareKeyboard.instance.logicalKeysPressed.any((k) => k.isSelectKey)) {
|
||||
if (clearSelectSuppression && !HardwareKeyboard.instance.logicalKeysPressed.any((k) => k.isSelectKey)) {
|
||||
SelectKeyUpSuppressor.clearSuppression();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _refocus() {
|
||||
final focusDescendant = InputModeTracker.isKeyboardMode(context, listen: false);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_isOpen) return;
|
||||
_sheetFocusScopeNode.requestFocus();
|
||||
if (!focusDescendant) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_isOpen) return;
|
||||
final topEntry = _pageStack.isNotEmpty ? _pageStack.last : null;
|
||||
final initialNode = topEntry?.initialFocusNode;
|
||||
if (initialNode != null && initialNode.context != null) {
|
||||
initialNode.requestFocus();
|
||||
} else {
|
||||
_focusFirstDescendant();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _focusFirstDescendant() {
|
||||
final descendants = _sheetFocusScopeNode.traversalDescendants.toList();
|
||||
if (descendants.isNotEmpty) {
|
||||
@@ -634,8 +605,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
final isTV = PlatformDetector.isTV();
|
||||
final showHandle = _showDragHandle && !isTV && !isTop;
|
||||
|
||||
final effectiveConstraints =
|
||||
_constraints ?? BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75);
|
||||
final effectiveConstraints = _constraints ?? OverlaySheetController._defaultSheetConstraints(context);
|
||||
|
||||
// Slide direction depends on alignment: bottom sheets slide up, top sheets slide down.
|
||||
// Use a pixel transform instead of FractionalTranslation so mouse-tracker
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'app_icon.dart';
|
||||
import 'dialog_action_button.dart';
|
||||
import 'loading_indicator_box.dart';
|
||||
|
||||
/// Shell for the "waiting for out-of-band authorization" dialogs.
|
||||
///
|
||||
/// Shows [body], the service-specific [children], a button that launches [url]
|
||||
/// in the browser, and a "waiting for authorization…" spinner while the poll
|
||||
/// loop runs. Dismissing calls [onCancel] so the provider can abort the poll.
|
||||
class PendingAuthDialog extends StatelessWidget {
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
/// Sits between the body text and the launch button, and carries its own
|
||||
/// trailing spacing.
|
||||
final List<Widget> children;
|
||||
final String url;
|
||||
final String openLabel;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const PendingAuthDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.children,
|
||||
required this.url,
|
||||
required this.openLabel,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
Future<void> _open() async {
|
||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(body, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 16),
|
||||
...children,
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FocusableButton(
|
||||
onPressed: _open,
|
||||
useBackgroundFocus: true,
|
||||
child: FilledButton.icon(
|
||||
icon: const AppIcon(Symbols.open_in_new_rounded),
|
||||
label: Text(openLabel),
|
||||
onPressed: _open,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const LoadingIndicatorBox(size: 16),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(t.services.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(
|
||||
onPressed: () {
|
||||
onCancel();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
label: t.common.cancel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tap/D-pad target that copies the value it displays to the clipboard.
|
||||
class CopyTapRegion extends StatelessWidget {
|
||||
final VoidCallback onCopy;
|
||||
final String semanticLabel;
|
||||
|
||||
/// Announced after [semanticLabel] so screen readers read out the value that
|
||||
/// will be copied instead of just the action.
|
||||
final String? semanticValue;
|
||||
final Widget child;
|
||||
|
||||
const CopyTapRegion({
|
||||
super.key,
|
||||
required this.onCopy,
|
||||
required this.semanticLabel,
|
||||
this.semanticValue,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FocusableWrapper(
|
||||
onSelect: onCopy,
|
||||
semanticLabel: semanticLabel,
|
||||
semanticValue: semanticValue,
|
||||
descendantsAreFocusable: false,
|
||||
useBackgroundFocus: true,
|
||||
borderRadius: 8,
|
||||
child: InkWell(canRequestFocus: false, onTap: onCopy, borderRadius: BorderRadius.circular(8), child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -12,20 +11,18 @@ import '../i18n/strings.g.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../models/catalog/catalog_item.dart';
|
||||
import '../providers/trackers_provider.dart';
|
||||
import '../providers/trakt_account_provider.dart';
|
||||
import '../services/trackers/anilist/anilist_tracker.dart';
|
||||
import '../services/trackers/mal/mal_tracker.dart';
|
||||
import '../services/trackers/simkl/simkl_tracker.dart';
|
||||
import '../screens/settings/tracker_service_info.dart';
|
||||
import '../services/trackers/tracker.dart';
|
||||
import '../services/trackers/tracker_constants.dart';
|
||||
import '../services/trackers/tracker_id_resolver.dart';
|
||||
import '../services/trakt/trakt_scrobble_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'app_icon.dart';
|
||||
import 'backend_badge.dart';
|
||||
import 'bottom_sheet_header.dart';
|
||||
import 'catalog_source_logo.dart';
|
||||
import 'clickable_cursor.dart';
|
||||
|
||||
class RatingBottomSheet extends StatefulWidget {
|
||||
@@ -92,9 +89,10 @@ class _RatingBottomSheetState extends State<RatingBottomSheet> {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final maxHeight = size.height * (size.width > 600 ? 0.64 : 0.74);
|
||||
|
||||
return Consumer2<TraktAccountProvider, TrackersProvider>(
|
||||
builder: (context, trakt, trackers, _) {
|
||||
final allTrackerSources = _trackerSources(trakt, trackers);
|
||||
// Trakt's account provider is watched by [_trackerSources] via `context`.
|
||||
return Consumer<TrackersProvider>(
|
||||
builder: (context, trackers, _) {
|
||||
final allTrackerSources = _trackerSources(context);
|
||||
final trackerSources = allTrackerSources.where((source) => !_hiddenTrackers.contains(source.service)).toList();
|
||||
_updateTrackerSourceMap(trackerSources);
|
||||
_resolverNeedsFribb = trackers.isMalConnected || trackers.isAnilistConnected;
|
||||
@@ -227,7 +225,7 @@ class _RatingBottomSheetState extends State<RatingBottomSheet> {
|
||||
return _RatingRow(
|
||||
focusNode: focusNode,
|
||||
autofocus: autofocus,
|
||||
leading: _TrackerLogo(source.logoAsset),
|
||||
leading: CatalogSourceLogo(source.logoSource, size: 24),
|
||||
title: source.title,
|
||||
subtitle: source.username != null ? t.services.connectedAs(username: source.username!) : source.connectedLabel,
|
||||
loading: loading,
|
||||
@@ -247,58 +245,20 @@ class _RatingBottomSheetState extends State<RatingBottomSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
List<_TrackerRatingSource> _trackerSources(TraktAccountProvider trakt, TrackersProvider trackers) {
|
||||
final sources = <_TrackerRatingSource>[];
|
||||
if (trakt.isConnected) {
|
||||
sources.add(
|
||||
/// Snapshot of every connected tracker, in the shared display order. Must be
|
||||
/// called from a build so the provider reads register a dependency.
|
||||
List<_TrackerRatingSource> _trackerSources(BuildContext context) => [
|
||||
for (final info in TrackerServiceInfo.all)
|
||||
if (info.isConnected(context))
|
||||
_TrackerRatingSource(
|
||||
service: TrackerService.trakt,
|
||||
title: t.trakt.title,
|
||||
username: trakt.username,
|
||||
service: info.service,
|
||||
title: info.displayName,
|
||||
username: info.username(context),
|
||||
connectedLabel: t.trakt.connected,
|
||||
logoAsset: 'assets/trakt_circlemark.svg',
|
||||
ratingSource: TraktScrobbleService.instance,
|
||||
logoSource: info.logoSource,
|
||||
ratingSource: info.ratingSource,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (trackers.isMalConnected) {
|
||||
sources.add(
|
||||
_TrackerRatingSource(
|
||||
service: TrackerService.mal,
|
||||
title: t.services.names.mal,
|
||||
username: trackers.malUsername,
|
||||
connectedLabel: t.trakt.connected,
|
||||
logoAsset: 'assets/mal_mark.svg',
|
||||
ratingSource: MalTracker.instance,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (trackers.isAnilistConnected) {
|
||||
sources.add(
|
||||
_TrackerRatingSource(
|
||||
service: TrackerService.anilist,
|
||||
title: t.services.names.anilist,
|
||||
username: trackers.anilistUsername,
|
||||
connectedLabel: t.trakt.connected,
|
||||
logoAsset: 'assets/anilist_mark.svg',
|
||||
ratingSource: AnilistTracker.instance,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (trackers.isSimklConnected) {
|
||||
sources.add(
|
||||
_TrackerRatingSource(
|
||||
service: TrackerService.simkl,
|
||||
title: t.services.names.simkl,
|
||||
username: trackers.simklUsername,
|
||||
connectedLabel: t.trakt.connected,
|
||||
logoAsset: 'assets/simkl_mark.svg',
|
||||
ratingSource: SimklTracker.instance,
|
||||
),
|
||||
);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
];
|
||||
|
||||
void _updateTrackerSourceMap(List<_TrackerRatingSource> sources) {
|
||||
_trackerSourcesByKey
|
||||
@@ -618,7 +578,7 @@ class _TrackerRatingSource {
|
||||
final String title;
|
||||
final String? username;
|
||||
final String connectedLabel;
|
||||
final String logoAsset;
|
||||
final CatalogSourceId logoSource;
|
||||
final TrackerRatingSource ratingSource;
|
||||
|
||||
const _TrackerRatingSource({
|
||||
@@ -626,7 +586,7 @@ class _TrackerRatingSource {
|
||||
required this.title,
|
||||
required this.username,
|
||||
required this.connectedLabel,
|
||||
required this.logoAsset,
|
||||
required this.logoSource,
|
||||
required this.ratingSource,
|
||||
});
|
||||
}
|
||||
@@ -889,15 +849,3 @@ class _FavoriteControl extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackerLogo extends StatelessWidget {
|
||||
final String asset;
|
||||
|
||||
const _TrackerLogo(this.asset);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface;
|
||||
return SvgPicture.asset(asset, width: 24, height: 24, theme: SvgTheme(currentColor: color));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/focusable_text_field.dart';
|
||||
import 'app_icon.dart';
|
||||
import 'pill_input_decoration.dart';
|
||||
|
||||
/// The pill search field the search screens put above their results, with the
|
||||
/// clear affordance that appears once there is text: RIGHT out of the field
|
||||
/// lands on it, LEFT goes back, and both escape down into the results.
|
||||
///
|
||||
/// [onBack] stays null unless the host wants the back key — a pushed route
|
||||
/// needs it for its own pop.
|
||||
class SearchInputField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final String hintText;
|
||||
|
||||
/// Names the clear button's focus node.
|
||||
final String debugLabel;
|
||||
|
||||
final TvTextInputController? tvTextInputController;
|
||||
final VoidCallback? onNavigateLeft;
|
||||
final VoidCallback? onNavigateDown;
|
||||
final VoidCallback? onEditingComplete;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
const SearchInputField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.hintText,
|
||||
required this.debugLabel,
|
||||
this.tvTextInputController,
|
||||
this.onNavigateLeft,
|
||||
this.onNavigateDown,
|
||||
this.onEditingComplete,
|
||||
this.onBack,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SearchInputField> createState() => _SearchInputFieldState();
|
||||
}
|
||||
|
||||
class _SearchInputFieldState extends State<SearchInputField> {
|
||||
late final FocusNode _clearFocusNode = FocusNode(debugLabel: '${widget.debugLabel}.clear');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_clearFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _clearSearch() {
|
||||
widget.controller.clear();
|
||||
widget.focusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasText = widget.controller.text.isNotEmpty;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||
child: Stack(
|
||||
alignment: Alignment.centerRight,
|
||||
children: [
|
||||
FocusableTextField(
|
||||
controller: widget.controller,
|
||||
focusNode: widget.focusNode,
|
||||
tvTextInputController: widget.tvTextInputController,
|
||||
textInputAction: TextInputAction.search,
|
||||
onNavigateLeft: widget.onNavigateLeft,
|
||||
onNavigateRight: hasText ? _clearFocusNode.requestFocus : null,
|
||||
onNavigateDown: widget.onNavigateDown,
|
||||
onEditingComplete: widget.onEditingComplete,
|
||||
onBack: widget.onBack,
|
||||
decoration: pillInputDecoration(
|
||||
context,
|
||||
hintText: widget.hintText,
|
||||
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
|
||||
suffixIcon: hasText ? const SizedBox(width: 48) : null,
|
||||
),
|
||||
),
|
||||
if (hasText)
|
||||
FocusableButton(
|
||||
focusNode: _clearFocusNode,
|
||||
onPressed: _clearSearch,
|
||||
onNavigateLeft: widget.focusNode.requestFocus,
|
||||
onNavigateDown: widget.onNavigateDown,
|
||||
autoScroll: false,
|
||||
child: IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: _clearSearch),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+91
-114
@@ -13,8 +13,46 @@ import 'settings_section.dart';
|
||||
/// Eliminates the field-mirror + setState + manual reload pattern that used to
|
||||
/// surround every settings row.
|
||||
|
||||
class _TileBase {
|
||||
static SettingsService get _svc => SettingsService.instance;
|
||||
/// Shared commit path for every tile: persist [value] under [pref], then hand
|
||||
/// it to the tile's optional [onAfterWrite] callback.
|
||||
Future<void> _writeAndNotify<T>(Pref<T> pref, T value, FutureOr<void> Function(T)? onAfterWrite) async {
|
||||
await SettingsService.instance.write(pref, value);
|
||||
if (onAfterWrite != null) await onAfterWrite(value);
|
||||
}
|
||||
|
||||
/// Shared scaffold for the tiles that render a tappable settings row: same
|
||||
/// leading icon, title style and row density everywhere. [trailing] defaults
|
||||
/// to the chevron used by every row that opens a dialog.
|
||||
class _SettingRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final Widget? subtitle;
|
||||
final Widget? trailing;
|
||||
final VoidCallback onTap;
|
||||
final FocusNode? focusNode;
|
||||
|
||||
const _SettingRow({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
this.focusNode,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FocusableListTile(
|
||||
focusNode: focusNode,
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title, style: settingsOptionTitleStyle(context)),
|
||||
subtitle: subtitle,
|
||||
trailing: trailing ?? const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: onTap,
|
||||
dense: settingsRowDense(context),
|
||||
visualDensity: settingsRowVisualDensity(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// SwitchListTile bound to a [Pref<bool>].
|
||||
@@ -40,9 +78,8 @@ class SettingSwitchTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final svc = _TileBase._svc;
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: svc.listenable(pref),
|
||||
valueListenable: SettingsService.instance.listenable(pref),
|
||||
builder: (_, value, _) => FocusableSwitchListTile(
|
||||
focusNode: focusNode,
|
||||
secondary: AppIcon(icon, fill: 1),
|
||||
@@ -51,13 +88,7 @@ class SettingSwitchTile extends StatelessWidget {
|
||||
value: value,
|
||||
dense: settingsRowDense(context),
|
||||
visualDensity: settingsRowVisualDensity(context),
|
||||
onChanged: enabled
|
||||
? (v) async {
|
||||
await svc.write(pref, v);
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(v);
|
||||
}
|
||||
: null,
|
||||
onChanged: enabled ? (v) => _writeAndNotify(pref, v, onAfterWrite) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -86,15 +117,13 @@ class SettingNavigationTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FocusableListTile(
|
||||
return _SettingRow(
|
||||
focusNode: focusNode,
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title, style: settingsOptionTitleStyle(context)),
|
||||
icon: icon,
|
||||
title: title,
|
||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||
trailing: AppIcon(trailingIcon, fill: 1),
|
||||
onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)),
|
||||
dense: settingsRowDense(context),
|
||||
visualDensity: settingsRowVisualDensity(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -126,16 +155,12 @@ class SettingNumberTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final svc = _TileBase._svc;
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: svc.listenable(pref),
|
||||
builder: (_, value, _) => FocusableListTile(
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title, style: settingsOptionTitleStyle(context)),
|
||||
valueListenable: SettingsService.instance.listenable(pref),
|
||||
builder: (_, value, _) => _SettingRow(
|
||||
icon: icon,
|
||||
title: title,
|
||||
subtitle: Text(subtitleBuilder(value)),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
dense: settingsRowDense(context),
|
||||
visualDensity: settingsRowVisualDensity(context),
|
||||
onTap: () => showNumericInputDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
@@ -144,11 +169,7 @@ class SettingNumberTile extends StatelessWidget {
|
||||
min: min,
|
||||
max: max,
|
||||
currentValue: value,
|
||||
onSave: (v) async {
|
||||
await svc.write(pref, v);
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(v);
|
||||
},
|
||||
onSave: (v) => _writeAndNotify(pref, v, onAfterWrite),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -156,16 +177,12 @@ class SettingNumberTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// ListTile that opens [showSelectionDialog] and writes the chosen value.
|
||||
/// [encode]/[decode] map between the [Pref<S>] storage type and the option
|
||||
/// type [T] (e.g. enum-stored-as-string preset → [TranscodeQualityPreset]).
|
||||
class SettingSelectionTile<T, S> extends StatelessWidget {
|
||||
final Pref<S> pref;
|
||||
class SettingSelectionTile<T> extends StatelessWidget {
|
||||
final Pref<T> pref;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String Function(T) subtitleBuilder;
|
||||
final List<DialogOption<T>> options;
|
||||
final T Function(S) decode;
|
||||
final S Function(T) encode;
|
||||
final FutureOr<void> Function(T)? onAfterWrite;
|
||||
|
||||
const SettingSelectionTile({
|
||||
@@ -175,39 +192,28 @@ class SettingSelectionTile<T, S> extends StatelessWidget {
|
||||
required this.title,
|
||||
required this.subtitleBuilder,
|
||||
required this.options,
|
||||
required this.decode,
|
||||
required this.encode,
|
||||
this.onAfterWrite,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final svc = _TileBase._svc;
|
||||
return ValueListenableBuilder<S>(
|
||||
valueListenable: svc.listenable(pref),
|
||||
builder: (_, raw, _) {
|
||||
final value = decode(raw);
|
||||
return FocusableListTile(
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title, style: settingsOptionTitleStyle(context)),
|
||||
subtitle: Text(subtitleBuilder(value)),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
dense: settingsRowDense(context),
|
||||
visualDensity: settingsRowVisualDensity(context),
|
||||
onTap: () async {
|
||||
final picked = await showSelectionDialog<T>(
|
||||
context: context,
|
||||
title: title,
|
||||
options: options,
|
||||
currentValue: value,
|
||||
);
|
||||
if (picked == null) return;
|
||||
await svc.write(pref, encode(picked));
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(picked);
|
||||
},
|
||||
);
|
||||
},
|
||||
return ValueListenableBuilder<T>(
|
||||
valueListenable: SettingsService.instance.listenable(pref),
|
||||
builder: (_, value, _) => _SettingRow(
|
||||
icon: icon,
|
||||
title: title,
|
||||
subtitle: Text(subtitleBuilder(value)),
|
||||
onTap: () async {
|
||||
final picked = await showSelectionDialog<T>(
|
||||
context: context,
|
||||
title: title,
|
||||
options: options,
|
||||
currentValue: value,
|
||||
);
|
||||
if (picked == null) return;
|
||||
await _writeAndNotify(pref, picked, onAfterWrite);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -233,42 +239,30 @@ class SettingRegexTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final svc = _TileBase._svc;
|
||||
return ValueListenableBuilder<String>(
|
||||
valueListenable: svc.listenable(pref),
|
||||
builder: (_, value, _) => FocusableListTile(
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title, style: settingsOptionTitleStyle(context)),
|
||||
valueListenable: SettingsService.instance.listenable(pref),
|
||||
builder: (_, value, _) => _SettingRow(
|
||||
icon: icon,
|
||||
title: title,
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
dense: settingsRowDense(context),
|
||||
visualDensity: settingsRowVisualDensity(context),
|
||||
onTap: () => showRegexInputDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
currentValue: value,
|
||||
defaultValue: defaultValue,
|
||||
onSave: (v) async {
|
||||
await svc.write(pref, v);
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(v);
|
||||
},
|
||||
onSave: (v) => _writeAndNotify(pref, v, onAfterWrite),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// SegmentedSetting bound to a [Pref<T>]. Use [encode]/[decode] when the
|
||||
/// stored type differs from the segment type (e.g. bool stored, segments
|
||||
/// over enum).
|
||||
class SettingSegmentedTile<T, S> extends StatelessWidget {
|
||||
final Pref<S> pref;
|
||||
/// SegmentedSetting bound to a [Pref<T>].
|
||||
class SettingSegmentedTile<T> extends StatelessWidget {
|
||||
final Pref<T> pref;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final List<ButtonSegment<T>> segments;
|
||||
final T Function(S) decode;
|
||||
final S Function(T) encode;
|
||||
final FutureOr<void> Function(T)? onAfterWrite;
|
||||
|
||||
const SettingSegmentedTile({
|
||||
@@ -277,30 +271,20 @@ class SettingSegmentedTile<T, S> extends StatelessWidget {
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.segments,
|
||||
required this.decode,
|
||||
required this.encode,
|
||||
this.onAfterWrite,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final svc = _TileBase._svc;
|
||||
return ValueListenableBuilder<S>(
|
||||
valueListenable: svc.listenable(pref),
|
||||
builder: (_, raw, _) {
|
||||
final value = decode(raw);
|
||||
return SegmentedSetting<T>(
|
||||
icon: icon,
|
||||
title: title,
|
||||
segments: segments,
|
||||
selected: value,
|
||||
onChanged: (v) async {
|
||||
await svc.write(pref, encode(v));
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(v);
|
||||
},
|
||||
);
|
||||
},
|
||||
return ValueListenableBuilder<T>(
|
||||
valueListenable: SettingsService.instance.listenable(pref),
|
||||
builder: (_, value, _) => SegmentedSetting<T>(
|
||||
icon: icon,
|
||||
title: title,
|
||||
segments: segments,
|
||||
selected: value,
|
||||
onChanged: (v) => _writeAndNotify(pref, v, onAfterWrite),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -325,12 +309,11 @@ class SettingColorTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final svc = _TileBase._svc;
|
||||
return ValueListenableBuilder<String>(
|
||||
valueListenable: svc.listenable(pref),
|
||||
builder: (_, hex, _) => FocusableListTile(
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title, style: settingsOptionTitleStyle(context)),
|
||||
valueListenable: SettingsService.instance.listenable(pref),
|
||||
builder: (_, hex, _) => _SettingRow(
|
||||
icon: icon,
|
||||
title: title,
|
||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||
trailing: Container(
|
||||
width: 28,
|
||||
@@ -341,17 +324,11 @@ class SettingColorTile extends StatelessWidget {
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
dense: settingsRowDense(context),
|
||||
visualDensity: settingsRowVisualDensity(context),
|
||||
onTap: () => showColorInputDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
currentHex: hex,
|
||||
onSave: (v) async {
|
||||
await svc.write(pref, v);
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(v);
|
||||
},
|
||||
onSave: (v) => _writeAndNotify(pref, v, onAfterWrite),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -62,13 +62,6 @@ class SettingsGroup extends StatelessWidget {
|
||||
this.margin = const EdgeInsets.symmetric(horizontal: 16),
|
||||
});
|
||||
|
||||
BorderRadius _radiusFor(int i, MonoTokens t) {
|
||||
return BorderRadius.vertical(
|
||||
top: Radius.circular(i == 0 ? t.radiusLg : t.radiusXs),
|
||||
bottom: Radius.circular(i == children.length - 1 ? t.radiusLg : t.radiusXs),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = tokens(context);
|
||||
@@ -85,7 +78,7 @@ class SettingsGroup extends StatelessWidget {
|
||||
Material(
|
||||
color: t.surface,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(borderRadius: _radiusFor(i, t)),
|
||||
shape: RoundedRectangleBorder(borderRadius: groupItemRadii(context, i, children.length)),
|
||||
child: children[i],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -54,6 +54,20 @@ final class _LibraryItemRow extends _LibraryNavRow {
|
||||
const _LibraryItemRow({required super.section, required this.library, this.showServerName = false});
|
||||
}
|
||||
|
||||
/// SELECT activates the rail row, RIGHT hands off to the content area.
|
||||
KeyEventResult _handleRailItemKey(KeyEvent event, {required VoidCallback onSelect, VoidCallback? onNavigateRight}) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey.isSelectKey) {
|
||||
onSelect();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) {
|
||||
onNavigateRight();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Reusable navigation rail item widget that handles focus, selection, and interaction
|
||||
class NavigationRailItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
@@ -63,6 +77,9 @@ class NavigationRailItem extends StatelessWidget {
|
||||
/// Playing item's equalizer). Should be at most [iconSize] tall/wide.
|
||||
final Widget? iconWidget;
|
||||
final Widget label;
|
||||
|
||||
/// Widget rendered after the [label] (e.g. a section header's chevron).
|
||||
final Widget? trailing;
|
||||
final bool isSelected;
|
||||
final bool isCollapsed;
|
||||
final bool useSimpleLayout;
|
||||
@@ -74,6 +91,11 @@ class NavigationRailItem extends StatelessWidget {
|
||||
final double horizontalPadding;
|
||||
final bool suppressSelectedBackground;
|
||||
|
||||
/// Background tint while keyboard-focused, and its stronger variant used
|
||||
/// when the item also shows its selected background.
|
||||
final double focusAlpha;
|
||||
final double selectedFocusAlpha;
|
||||
|
||||
/// Called when RIGHT arrow is pressed to navigate to content area.
|
||||
final VoidCallback? onNavigateRight;
|
||||
|
||||
@@ -83,6 +105,7 @@ class NavigationRailItem extends StatelessWidget {
|
||||
this.selectedIcon,
|
||||
this.iconWidget,
|
||||
required this.label,
|
||||
this.trailing,
|
||||
required this.isSelected,
|
||||
this.isCollapsed = false,
|
||||
this.useSimpleLayout = false,
|
||||
@@ -93,6 +116,8 @@ class NavigationRailItem extends StatelessWidget {
|
||||
this.iconSize = 22,
|
||||
this.horizontalPadding = 17,
|
||||
this.suppressSelectedBackground = false,
|
||||
this.focusAlpha = 0.12,
|
||||
this.selectedFocusAlpha = 0.15,
|
||||
this.onNavigateRight,
|
||||
});
|
||||
|
||||
@@ -108,18 +133,7 @@ class NavigationRailItem extends StatelessWidget {
|
||||
return Focus(
|
||||
focusNode: focusNode,
|
||||
autofocus: autofocus,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey.isSelectKey) {
|
||||
onTap();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) {
|
||||
onNavigateRight!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
onKeyEvent: (node, event) => _handleRailItemKey(event, onSelect: onTap, onNavigateRight: onNavigateRight),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
@@ -129,8 +143,10 @@ class NavigationRailItem extends StatelessWidget {
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: () {
|
||||
if (isCollapsed) return focused ? t.text.withValues(alpha: 0.12) : null;
|
||||
if (focused) return t.text.withValues(alpha: showSelectedBackground ? 0.15 : 0.12);
|
||||
if (isCollapsed) return focused ? t.text.withValues(alpha: focusAlpha) : null;
|
||||
if (focused) {
|
||||
return t.text.withValues(alpha: showSelectedBackground ? selectedFocusAlpha : focusAlpha);
|
||||
}
|
||||
if (showSelectedBackground) return t.text.withValues(alpha: 0.1);
|
||||
return null;
|
||||
}(),
|
||||
@@ -162,6 +178,7 @@ class NavigationRailItem extends StatelessWidget {
|
||||
return AnimatedOpacity(opacity: opacity, duration: t.fast, child: label);
|
||||
}(),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -975,107 +992,44 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
}) {
|
||||
final librariesProvider = context.watch<LibrariesProvider>();
|
||||
final isLoading = librariesProvider.isLoading;
|
||||
final isLibrariesSelected = widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == null;
|
||||
final librariesFocusNode = _focusTracker.get(_kLibraries);
|
||||
final showLibrariesSelectedBackground = isLibrariesSelected && !widget.isSidebarFocused;
|
||||
final isLibrariesTabSelected = widget.selectedTab == NavigationTabId.libraries;
|
||||
final allEmpty = visibleRows.isEmpty && hiddenLibraryCount == 0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
ListenableBuilder(
|
||||
listenable: librariesFocusNode,
|
||||
builder: (context, _) => Focus(
|
||||
focusNode: librariesFocusNode,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey.isSelectKey) {
|
||||
setState(() {
|
||||
_librariesExpanded = !_librariesExpanded;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// RIGHT arrow navigates to content area
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) {
|
||||
widget.onNavigateToContent!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_librariesExpanded = !_librariesExpanded;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: () {
|
||||
final showFocus = librariesFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
|
||||
if (isCollapsed) return showFocus ? t.text.withValues(alpha: 0.08) : null;
|
||||
if (showLibrariesSelectedBackground) return t.text.withValues(alpha: 0.1);
|
||||
if (showFocus) return t.text.withValues(alpha: 0.08);
|
||||
return null;
|
||||
}(),
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: UnconstrainedBox(
|
||||
alignment: .centerLeft,
|
||||
constrainedAxis: Axis.vertical,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox(
|
||||
width: expandedWidth - 24,
|
||||
child: Padding(
|
||||
padding: .symmetric(vertical: 12, horizontal: itemHorizontalPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
AppIcon(
|
||||
Symbols.video_library_rounded,
|
||||
fill: 1,
|
||||
size: 22,
|
||||
color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted,
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: AnimatedOpacity(
|
||||
opacity: isCollapsed ? 0.0 : 1.0,
|
||||
duration: tokens(context).fast,
|
||||
child: Text(
|
||||
Translations.of(context).navigation.libraries,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: widget.selectedTab == NavigationTabId.libraries
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
opacity: isCollapsed ? 0.0 : 1.0,
|
||||
duration: tokens(context).fast,
|
||||
child: AppIcon(
|
||||
_librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
|
||||
fill: 1,
|
||||
size: 20,
|
||||
color: t.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavigationRailItem(
|
||||
icon: Symbols.video_library_rounded,
|
||||
label: Text(
|
||||
Translations.of(context).navigation.libraries,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isLibrariesTabSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isLibrariesTabSelected ? t.text : t.textMuted,
|
||||
),
|
||||
),
|
||||
trailing: AnimatedOpacity(
|
||||
opacity: isCollapsed ? 0.0 : 1.0,
|
||||
duration: tokens(context).fast,
|
||||
child: AppIcon(
|
||||
_librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
|
||||
fill: 1,
|
||||
size: 20,
|
||||
color: t.textMuted,
|
||||
),
|
||||
),
|
||||
isSelected: isLibrariesTabSelected,
|
||||
isCollapsed: isCollapsed,
|
||||
onTap: () => setState(() => _librariesExpanded = !_librariesExpanded),
|
||||
focusNode: _focusTracker.get(_kLibraries),
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
||||
horizontalPadding: itemHorizontalPadding,
|
||||
// A selected library owns the highlight; the header only shows it
|
||||
// for the bare Libraries tab.
|
||||
suppressSelectedBackground: widget.isSidebarFocused || widget.selectedLibraryKey != null,
|
||||
focusAlpha: 0.08,
|
||||
selectedFocusAlpha: 0.1,
|
||||
onNavigateRight: widget.onNavigateToContent,
|
||||
),
|
||||
|
||||
TweenAnimationBuilder<double>(
|
||||
@@ -1222,18 +1176,8 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
listenable: focusNode,
|
||||
builder: (context, _) => Focus(
|
||||
focusNode: focusNode,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey.isSelectKey) {
|
||||
onToggle();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) {
|
||||
widget.onNavigateToContent!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
onKeyEvent: (node, event) =>
|
||||
_handleRailItemKey(event, onSelect: onToggle, onNavigateRight: widget.onNavigateToContent),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'rasterized_gradient.dart';
|
||||
|
||||
/// Top-edge fade behind a toolbar that floats over content, keeping its
|
||||
/// glyphs legible against artwork without a solid chrome bar.
|
||||
///
|
||||
/// The fade is pure black on dark schemes — a tinted surface reads as haze
|
||||
/// over backdrop artwork — and the scheme surface otherwise. [child] is laid
|
||||
/// out below the status bar with the standard chrome insets.
|
||||
class ToolbarScrim extends StatelessWidget {
|
||||
const ToolbarScrim({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusBarHeight = MediaQuery.paddingOf(context).top;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface;
|
||||
return RasterizedGradient(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
overlayColor.withValues(alpha: 0.7),
|
||||
overlayColor.withValues(alpha: 0.5),
|
||||
overlayColor.withValues(alpha: 0.3),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.0, 0.3, 0.6, 1.0],
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: statusBarHeight + 8, left: 16, right: 16, bottom: 16),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,9 @@ import 'package:flutter/services.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focus_theme.dart';
|
||||
import '../focus/focusable_text_field.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_repeat_helper.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'app_icon.dart';
|
||||
import 'tv_number_spinner.dart';
|
||||
|
||||
/// A TV-friendly color picker using HSV sliders for D-pad navigation.
|
||||
///
|
||||
@@ -106,6 +101,31 @@ class _TvColorPickerState extends State<TvColorPicker> with ControllerDisposerMi
|
||||
widget.onColorChanged(color);
|
||||
}
|
||||
|
||||
Widget _channelRow({
|
||||
required String label,
|
||||
required String semanticLabel,
|
||||
required int value,
|
||||
required int max,
|
||||
required String suffix,
|
||||
required ValueChanged<int> onChanged,
|
||||
bool autofocus = false,
|
||||
}) {
|
||||
return TvNumberSpinner(
|
||||
label: label,
|
||||
semanticLabel: semanticLabel,
|
||||
value: value,
|
||||
min: 0,
|
||||
max: max,
|
||||
step: 5,
|
||||
suffix: suffix,
|
||||
autofocus: autofocus,
|
||||
onConfirm: widget.onConfirm,
|
||||
onChanged: onChanged,
|
||||
verticalKeysAdjustValue: false,
|
||||
density: TvNumberSpinnerDensity.compact,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentColor = _currentColor();
|
||||
@@ -123,46 +143,37 @@ class _TvColorPickerState extends State<TvColorPicker> with ControllerDisposerMi
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_ColorChannelRow(
|
||||
_channelRow(
|
||||
label: 'H',
|
||||
semanticLabel: Translations.of(context).accessibility.hue,
|
||||
value: _hue,
|
||||
min: 0,
|
||||
max: 360,
|
||||
step: 5,
|
||||
suffix: '°',
|
||||
autofocus: true,
|
||||
onConfirm: widget.onConfirm,
|
||||
onChanged: (v) {
|
||||
setState(() => _hue = v);
|
||||
_onChannelChanged();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_ColorChannelRow(
|
||||
_channelRow(
|
||||
label: 'S',
|
||||
semanticLabel: Translations.of(context).accessibility.saturation,
|
||||
value: _saturation,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 5,
|
||||
suffix: '%',
|
||||
onConfirm: widget.onConfirm,
|
||||
onChanged: (v) {
|
||||
setState(() => _saturation = v);
|
||||
_onChannelChanged();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_ColorChannelRow(
|
||||
_channelRow(
|
||||
label: 'V',
|
||||
semanticLabel: Translations.of(context).accessibility.brightness,
|
||||
value: _value,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 5,
|
||||
suffix: '%',
|
||||
onConfirm: widget.onConfirm,
|
||||
onChanged: (v) {
|
||||
setState(() => _value = v);
|
||||
_onChannelChanged();
|
||||
@@ -185,211 +196,3 @@ class _TvColorPickerState extends State<TvColorPicker> with ControllerDisposerMi
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A horizontal channel row for a single HSV component.
|
||||
///
|
||||
/// LEFT/RIGHT adjust the value (with repeat timer for held keys).
|
||||
/// UP/DOWN are ignored so focus traverses normally between rows.
|
||||
class _ColorChannelRow extends StatefulWidget {
|
||||
final String label;
|
||||
final String semanticLabel;
|
||||
final int value;
|
||||
final int min;
|
||||
final int max;
|
||||
final int step;
|
||||
final String suffix;
|
||||
final bool autofocus;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
/// Called when the user presses SELECT to confirm.
|
||||
final VoidCallback? onConfirm;
|
||||
|
||||
const _ColorChannelRow({
|
||||
required this.label,
|
||||
required this.semanticLabel,
|
||||
required this.value,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.step,
|
||||
required this.suffix,
|
||||
required this.onChanged,
|
||||
this.autofocus = false,
|
||||
this.onConfirm,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ColorChannelRow> createState() => _ColorChannelRowState();
|
||||
}
|
||||
|
||||
class _ColorChannelRowState extends State<_ColorChannelRow> with KeyRepeatHelper<_ColorChannelRow> {
|
||||
late FocusNode _focusNode;
|
||||
bool _isFocused = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode(debugLabel: 'ColorChannel_${widget.label}');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
stopRepeat();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _increment() {
|
||||
final newValue = widget.value + widget.step;
|
||||
if (newValue <= widget.max) {
|
||||
widget.onChanged(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
void _decrement() {
|
||||
final newValue = widget.value - widget.step;
|
||||
if (newValue >= widget.min) {
|
||||
widget.onChanged(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
// Let UP/DOWN pass through for focus traversal between rows
|
||||
if (key.isUpKey || key.isDownKey) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
if (event is KeyDownEvent) {
|
||||
if (key.isSelectKey && widget.onConfirm != null) {
|
||||
widget.onConfirm!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isRightKey) {
|
||||
startRepeat(_increment);
|
||||
return KeyEventResult.handled;
|
||||
} else if (key.isLeftKey) {
|
||||
startRepeat(_decrement);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else if (event is KeyRepeatEvent) {
|
||||
// Consume repeat events for LEFT/RIGHT so they don't escape
|
||||
// to the focus system as traversal actions. The repeat timer
|
||||
// from KeyDown already handles value repetition.
|
||||
if (key.isRightKey || key.isLeftKey) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else if (event is KeyUpEvent) {
|
||||
if (key.isRightKey || key.isLeftKey) {
|
||||
stopRepeat();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = theme.extension<MonoTokens>();
|
||||
final canDecrement = widget.value > widget.min;
|
||||
final canIncrement = widget.value < widget.max;
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: widget.autofocus,
|
||||
descendantsAreFocusable: false,
|
||||
onFocusChange: (hasFocus) {
|
||||
setState(() => _isFocused = hasFocus);
|
||||
if (!hasFocus) stopRepeat();
|
||||
},
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: AnimatedContainer(
|
||||
duration: tokens?.fast ?? const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(FocusTheme.defaultBorderRadius)),
|
||||
border: Border.fromBorderSide(
|
||||
BorderSide(
|
||||
color: _isFocused && isKeyboardMode ? FocusTheme.getFocusBorderColor(context) : Colors.transparent,
|
||||
width: FocusTheme.focusBorderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Text(widget.label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: .bold)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ChannelButton(
|
||||
icon: Symbols.remove_rounded,
|
||||
onPressed: canDecrement ? _decrement : null,
|
||||
semanticLabel: Translations.of(context).accessibility.decreaseValue(label: widget.semanticLabel),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
constraints: const BoxConstraints(minWidth: 56),
|
||||
alignment: .center,
|
||||
child: Text('${widget.value}${widget.suffix}', style: theme.textTheme.titleMedium),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ChannelButton(
|
||||
icon: Symbols.add_rounded,
|
||||
onPressed: canIncrement ? _increment : null,
|
||||
semanticLabel: Translations.of(context).accessibility.increaseValue(label: widget.semanticLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
final String semanticLabel;
|
||||
|
||||
const _ChannelButton({required this.icon, required this.onPressed, required this.semanticLabel});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isEnabled = onPressed != null;
|
||||
|
||||
return Semantics(
|
||||
label: semanticLabel,
|
||||
button: true,
|
||||
enabled: isEnabled,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
icon,
|
||||
size: 18,
|
||||
fill: 1,
|
||||
color: isEnabled
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,20 @@ import 'app_icon.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
/// Size variant for [TvNumberSpinner].
|
||||
enum TvNumberSpinnerDensity {
|
||||
/// Large buttons with long-press repeat, for a spinner that owns the dialog.
|
||||
standard,
|
||||
|
||||
/// Smaller buttons sized to sit in a stack of labelled rows.
|
||||
compact,
|
||||
}
|
||||
|
||||
/// A TV-friendly number spinner with +/- buttons for D-pad navigation.
|
||||
///
|
||||
/// Displays a value with decrement/increment buttons on either side.
|
||||
/// Supports keyboard repeat for faster value changes when holding arrows.
|
||||
/// Displays a value with decrement/increment buttons on either side, optionally
|
||||
/// behind a leading [label]. Supports keyboard repeat for faster value changes
|
||||
/// when holding arrows.
|
||||
class TvNumberSpinner extends StatefulWidget {
|
||||
final int value;
|
||||
|
||||
@@ -27,6 +37,13 @@ class TvNumberSpinner extends StatefulWidget {
|
||||
/// Optional suffix text (e.g., "s" for seconds).
|
||||
final String? suffix;
|
||||
|
||||
/// Optional leading label shown before the buttons (e.g., "H" for hue).
|
||||
final String? label;
|
||||
|
||||
/// When set, the +/- buttons announce themselves as adjusting this value
|
||||
/// instead of using the generic increase/decrease labels.
|
||||
final String? semanticLabel;
|
||||
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
/// Called when the user presses SELECT to confirm.
|
||||
@@ -39,6 +56,13 @@ class TvNumberSpinner extends StatefulWidget {
|
||||
|
||||
final bool autofocus;
|
||||
|
||||
/// When false, UP/DOWN are left alone so focus traverses between rows, and
|
||||
/// held LEFT/RIGHT repeat events are consumed so they don't escape to the
|
||||
/// focus system as traversal actions.
|
||||
final bool verticalKeysAdjustValue;
|
||||
|
||||
final TvNumberSpinnerDensity density;
|
||||
|
||||
const TvNumberSpinner({
|
||||
super.key,
|
||||
required this.value,
|
||||
@@ -47,9 +71,13 @@ class TvNumberSpinner extends StatefulWidget {
|
||||
required this.onChanged,
|
||||
this.step = 1,
|
||||
this.suffix,
|
||||
this.label,
|
||||
this.semanticLabel,
|
||||
this.autofocus = false,
|
||||
this.onConfirm,
|
||||
this.onCancel,
|
||||
this.verticalKeysAdjustValue = true,
|
||||
this.density = TvNumberSpinnerDensity.standard,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -63,7 +91,8 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode(debugLabel: 'TvNumberSpinner');
|
||||
final label = widget.label;
|
||||
_focusNode = FocusNode(debugLabel: label == null ? 'TvNumberSpinner' : 'TvNumberSpinner_$label');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -89,6 +118,7 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
final vertical = widget.verticalKeysAdjustValue;
|
||||
|
||||
if (widget.onCancel != null) {
|
||||
final backResult = handleBackKeyAction(event, widget.onCancel!);
|
||||
@@ -97,20 +127,32 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
|
||||
}
|
||||
}
|
||||
|
||||
// Let UP/DOWN pass through for focus traversal between rows.
|
||||
if (!vertical && (key.isUpKey || key.isDownKey)) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
if (event is KeyDownEvent) {
|
||||
if (key.isSelectKey && widget.onConfirm != null) {
|
||||
widget.onConfirm!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isUpKey || key.isRightKey) {
|
||||
if ((vertical && key.isUpKey) || key.isRightKey) {
|
||||
startRepeat(_increment);
|
||||
return KeyEventResult.handled;
|
||||
} else if (key.isDownKey || key.isLeftKey) {
|
||||
} else if ((vertical && key.isDownKey) || key.isLeftKey) {
|
||||
startRepeat(_decrement);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else if (event is KeyRepeatEvent) {
|
||||
// The repeat timer from KeyDown already handles value repetition, so
|
||||
// swallow the OS repeats that would otherwise traverse focus. Only
|
||||
// needed when UP/DOWN traverse — otherwise no direction escapes.
|
||||
if (!vertical && (key.isRightKey || key.isLeftKey)) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else if (event is KeyUpEvent) {
|
||||
if (key.isUpKey || key.isRightKey || key.isDownKey || key.isLeftKey) {
|
||||
if ((vertical && (key.isUpKey || key.isDownKey)) || key.isRightKey || key.isLeftKey) {
|
||||
stopRepeat();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -126,6 +168,11 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
|
||||
final canDecrement = widget.value > widget.min;
|
||||
final canIncrement = widget.value < widget.max;
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
final isCompact = widget.density == TvNumberSpinnerDensity.compact;
|
||||
final gap = isCompact ? const SizedBox(width: 8) : const SizedBox(width: 16);
|
||||
final label = widget.label;
|
||||
final semanticLabel = widget.semanticLabel;
|
||||
final a11y = Translations.of(context).accessibility;
|
||||
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
@@ -149,32 +196,43 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
mainAxisAlignment: .center,
|
||||
mainAxisSize: isCompact ? .max : .min,
|
||||
mainAxisAlignment: isCompact ? .start : .center,
|
||||
children: [
|
||||
if (label != null) ...[
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: .bold)),
|
||||
),
|
||||
gap,
|
||||
],
|
||||
_SpinnerButton(
|
||||
icon: Symbols.remove_rounded,
|
||||
onPressed: canDecrement ? _decrement : null,
|
||||
onLongPressStart: canDecrement ? () => startRepeat(_decrement) : null,
|
||||
onLongPressEnd: stopRepeat,
|
||||
semanticLabel: Translations.of(context).accessibility.decrease,
|
||||
onLongPressStart: !isCompact && canDecrement ? () => startRepeat(_decrement) : null,
|
||||
onLongPressEnd: isCompact ? null : stopRepeat,
|
||||
semanticLabel: semanticLabel != null ? a11y.decreaseValue(label: semanticLabel) : a11y.decrease,
|
||||
compact: isCompact,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
gap,
|
||||
Container(
|
||||
constraints: const BoxConstraints(minWidth: 60),
|
||||
constraints: BoxConstraints(minWidth: isCompact ? 56 : 60),
|
||||
alignment: .center,
|
||||
child: Text(
|
||||
widget.suffix != null ? '${widget.value}${widget.suffix}' : '${widget.value}',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: .bold),
|
||||
'${widget.value}${widget.suffix ?? ''}',
|
||||
style: isCompact
|
||||
? theme.textTheme.titleMedium
|
||||
: theme.textTheme.headlineMedium?.copyWith(fontWeight: .bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
gap,
|
||||
_SpinnerButton(
|
||||
icon: Symbols.add_rounded,
|
||||
onPressed: canIncrement ? _increment : null,
|
||||
onLongPressStart: canIncrement ? () => startRepeat(_increment) : null,
|
||||
onLongPressEnd: stopRepeat,
|
||||
semanticLabel: Translations.of(context).accessibility.increase,
|
||||
onLongPressStart: !isCompact && canIncrement ? () => startRepeat(_increment) : null,
|
||||
onLongPressEnd: isCompact ? null : stopRepeat,
|
||||
semanticLabel: semanticLabel != null ? a11y.increaseValue(label: semanticLabel) : a11y.increase,
|
||||
compact: isCompact,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -190,6 +248,7 @@ class _SpinnerButton extends StatelessWidget {
|
||||
final VoidCallback? onLongPressStart;
|
||||
final VoidCallback? onLongPressEnd;
|
||||
final String semanticLabel;
|
||||
final bool compact;
|
||||
|
||||
const _SpinnerButton({
|
||||
required this.icon,
|
||||
@@ -197,45 +256,49 @@ class _SpinnerButton extends StatelessWidget {
|
||||
this.onLongPressStart,
|
||||
this.onLongPressEnd,
|
||||
required this.semanticLabel,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isEnabled = onPressed != null;
|
||||
final size = compact ? 36.0 : 48.0;
|
||||
|
||||
return Semantics(
|
||||
label: semanticLabel,
|
||||
button: true,
|
||||
enabled: isEnabled,
|
||||
child: GestureDetector(
|
||||
onLongPressStart: onLongPressStart != null ? (_) => onLongPressStart!() : null,
|
||||
onLongPressEnd: onLongPressEnd != null ? (_) => onLongPressEnd!() : null,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
icon,
|
||||
fill: 1,
|
||||
color: isEnabled
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
Widget button = Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.all(Radius.circular(compact ? 20 : 24)),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
icon,
|
||||
size: compact ? 18 : null,
|
||||
fill: 1,
|
||||
color: isEnabled
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (onLongPressStart != null || onLongPressEnd != null) {
|
||||
button = GestureDetector(
|
||||
onLongPressStart: onLongPressStart != null ? (_) => onLongPressStart!() : null,
|
||||
onLongPressEnd: onLongPressEnd != null ? (_) => onLongPressEnd!() : null,
|
||||
child: button,
|
||||
);
|
||||
}
|
||||
|
||||
return Semantics(label: semanticLabel, button: true, enabled: isEnabled, child: button);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,3 +142,27 @@ class TvSpotlightScaffold extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pins a toolbar to the top of the viewport across the full bleed width,
|
||||
/// sliding with the sidebar so it stays put while the content box translates.
|
||||
///
|
||||
/// Excluded from default focus traversal so that initial/tab-switch focus
|
||||
/// lands on content (hero/rails) rather than the toolbar; its buttons stay
|
||||
/// reachable via explicit UP from the content. Reads the offset aspect from
|
||||
/// its own element, so a sidebar flip rebuilds only this overlay.
|
||||
class TvToolbarOverlay extends StatelessWidget {
|
||||
const TvToolbarOverlay({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
|
||||
return SideNavigationBleedBuilder(
|
||||
targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context),
|
||||
child: ExcludeFocusTraversal(child: child),
|
||||
builder: (context, animatedBleed, child) =>
|
||||
Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import '../../models/livetv_capture_buffer.dart';
|
||||
import 'models/track_controls_state.dart';
|
||||
import 'player_chrome_controller.dart';
|
||||
import 'widgets/content_strip.dart';
|
||||
import 'widgets/content_strip_panel.dart';
|
||||
import 'widgets/live_timeline_bar.dart';
|
||||
import 'widgets/first_frame_guard.dart';
|
||||
import 'widgets/play_pause_stream_builder.dart';
|
||||
@@ -614,51 +615,28 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
_buildBottomControlsContent(context, hasFrame: true),
|
||||
// Down arrow hint when strip content is available
|
||||
if (widget.useDpadNavigation && _hasStripContent)
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 12,
|
||||
child: AppIcon(Symbols.keyboard_arrow_down_rounded, color: Colors.white24, size: 24),
|
||||
),
|
||||
const ContentStripHint(Symbols.keyboard_arrow_down_rounded),
|
||||
],
|
||||
),
|
||||
// Content strip (TV/dpad only) — replaces normal controls
|
||||
if (_contentStripVisible && widget.useDpadNavigation)
|
||||
Container(
|
||||
ContentStripPanel(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8, top: 32),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.65),
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
],
|
||||
stops: const [0.0, 0.42, 1.0],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const AppIcon(Symbols.keyboard_arrow_up_rounded, color: Colors.white38, size: 20),
|
||||
const SizedBox(height: 4),
|
||||
ContentStrip(
|
||||
key: _contentStripKey,
|
||||
player: widget.player,
|
||||
chapters: widget.chapters,
|
||||
chaptersLoaded: widget.chaptersLoaded,
|
||||
serverId: widget.serverId,
|
||||
canControl: _canControl,
|
||||
showQueueTab: widget.showQueueTab,
|
||||
onQueueItemSelected: widget.onQueueItemSelected,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
useFocusNavigation: true,
|
||||
onNavigateUp: _onContentStripNavigateUp,
|
||||
onFocusActivity: widget.onFocusActivity,
|
||||
),
|
||||
],
|
||||
chevron: Symbols.keyboard_arrow_up_rounded,
|
||||
child: ContentStrip(
|
||||
key: _contentStripKey,
|
||||
player: widget.player,
|
||||
chapters: widget.chapters,
|
||||
chaptersLoaded: widget.chaptersLoaded,
|
||||
serverId: widget.serverId,
|
||||
canControl: _canControl,
|
||||
showQueueTab: widget.showQueueTab,
|
||||
onQueueItemSelected: widget.onQueueItemSelected,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
useFocusNavigation: true,
|
||||
onNavigateUp: _onContentStripNavigateUp,
|
||||
onFocusActivity: widget.onFocusActivity,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,41 +2,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../theme/mono_tokens.dart';
|
||||
import '../../../utils/track_label_builder.dart';
|
||||
import '../../../widgets/focusable_list_tile.dart';
|
||||
|
||||
class TrackSelectionHelper {
|
||||
/// Get the appropriate empty message based on track type
|
||||
static String getEmptyMessage<T>() {
|
||||
if (T == SubtitleTrack) {
|
||||
return t.videoControls.noSubtitlesAvailable;
|
||||
} else if (T == AudioTrack) {
|
||||
return t.videoControls.noAudioTracksAvailable;
|
||||
}
|
||||
return t.videoControls.noTracksAvailable;
|
||||
}
|
||||
|
||||
static Widget buildEmptyState<T>() {
|
||||
return Center(child: Text(getEmptyMessage<T>()));
|
||||
}
|
||||
|
||||
/// Check if "Off" is selected for a track
|
||||
static bool isOffSelected<T>(T? selectedTrack, bool Function(T track)? isOffTrack) {
|
||||
return selectedTrack == null || (isOffTrack?.call(selectedTrack) ?? false);
|
||||
}
|
||||
|
||||
static String getTrackId<T>(T track) {
|
||||
if (track is AudioTrack) {
|
||||
return track.id;
|
||||
} else if (track is SubtitleTrack) {
|
||||
return track.id;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
static Widget buildOffTile<T>({
|
||||
static Widget buildOffTile({
|
||||
required BuildContext context,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
@@ -59,7 +30,7 @@ class TrackSelectionHelper {
|
||||
);
|
||||
}
|
||||
|
||||
static Widget buildTrackTile<T>({
|
||||
static Widget buildTrackTile({
|
||||
required BuildContext context,
|
||||
required TrackLabel label,
|
||||
required bool isSelected,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../media/media_item.dart';
|
||||
import '../../mpv/mpv.dart';
|
||||
import '../../models/livetv_capture_buffer.dart';
|
||||
@@ -12,6 +11,7 @@ import '../../i18n/strings.g.dart';
|
||||
import 'player_chrome_controller.dart';
|
||||
import 'widgets/circular_control_button.dart';
|
||||
import 'widgets/content_strip.dart';
|
||||
import 'widgets/content_strip_panel.dart';
|
||||
import 'widgets/first_frame_guard.dart';
|
||||
import 'widgets/play_pause_stream_builder.dart';
|
||||
import 'widgets/live_timeline_bar.dart';
|
||||
@@ -270,12 +270,7 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
||||
_buildBottomBar(context),
|
||||
],
|
||||
),
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 12,
|
||||
child: AppIcon(Symbols.keyboard_arrow_up_rounded, color: Colors.white24, size: 24),
|
||||
),
|
||||
const ContentStripHint(Symbols.keyboard_arrow_up_rounded),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -290,37 +285,19 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
||||
ignoring: t < 0.5,
|
||||
child: Opacity(
|
||||
opacity: (t * 2).clamp(0.0, 1.0),
|
||||
child: Container(
|
||||
child: ContentStripPanel(
|
||||
padding: const EdgeInsets.only(top: 32),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.65),
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
],
|
||||
stops: const [0.0, 0.42, 1.0],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const AppIcon(Symbols.keyboard_arrow_down_rounded, color: Colors.white38, size: 20),
|
||||
const SizedBox(height: 4),
|
||||
ContentStrip(
|
||||
player: widget.player,
|
||||
chapters: widget.chapters,
|
||||
chaptersLoaded: widget.chaptersLoaded,
|
||||
canControl: widget.canControl,
|
||||
serverId: widget.serverId,
|
||||
showQueueTab: widget.showQueueTab,
|
||||
onQueueItemSelected: widget.onQueueItemSelected,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
),
|
||||
],
|
||||
chevron: Symbols.keyboard_arrow_down_rounded,
|
||||
child: ContentStrip(
|
||||
player: widget.player,
|
||||
chapters: widget.chapters,
|
||||
chaptersLoaded: widget.chaptersLoaded,
|
||||
canControl: widget.canControl,
|
||||
serverId: widget.serverId,
|
||||
showQueueTab: widget.showQueueTab,
|
||||
onQueueItemSelected: widget.onQueueItemSelected,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -35,7 +35,6 @@ class TrackControlsState {
|
||||
final int audioSyncOffset;
|
||||
final int subtitleSyncOffset;
|
||||
final bool isRotationLocked;
|
||||
final bool isScreenLocked;
|
||||
final bool isFullscreen;
|
||||
final bool isAlwaysOnTop;
|
||||
final VoidCallback? onTogglePIPMode;
|
||||
@@ -97,7 +96,6 @@ class TrackControlsState {
|
||||
this.audioSyncOffset = 0,
|
||||
this.subtitleSyncOffset = 0,
|
||||
this.isRotationLocked = false,
|
||||
this.isScreenLocked = false,
|
||||
this.isFullscreen = false,
|
||||
this.isAlwaysOnTop = false,
|
||||
this.onTogglePIPMode,
|
||||
|
||||
@@ -100,6 +100,35 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
KeyEventResult _dispatchShortcut(KeyEvent event, {VoidCallback? onSkipMarker}) {
|
||||
return _keyboardService!.handleVideoPlayerKeyEvent(
|
||||
event,
|
||||
widget.player,
|
||||
_toggleFullscreen,
|
||||
_toggleSubtitles,
|
||||
_nextAudioTrack,
|
||||
_nextSubtitleTrack,
|
||||
_nextChapter,
|
||||
_previousChapter,
|
||||
canControlPlayback: widget.canControl,
|
||||
canNavigateMediaItems: widget.canNavigateMediaItems,
|
||||
onPlayPause: () => unawaited(_playOrPause()),
|
||||
onToggleShader: _toggleShader,
|
||||
onSkipMarker: onSkipMarker,
|
||||
onNextEpisode: widget.onNext,
|
||||
onPreviousEpisode: widget.onPrevious,
|
||||
onScreenshot: _showScreenshotToast,
|
||||
onZoomIn: widget.onZoomIn,
|
||||
onZoomOut: widget.onZoomOut,
|
||||
onZoomReset: widget.onResetVideoZoom,
|
||||
onVolumeUp: () => widget.volumeController.adjust(10),
|
||||
onVolumeDown: () => widget.volumeController.adjust(-10),
|
||||
onToggleMute: widget.volumeController.toggleMute,
|
||||
onLiveSeekBy: widget.onLiveSeekBy,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
);
|
||||
}
|
||||
|
||||
/// Global key event handler for focus-independent shortcuts (desktop only)
|
||||
bool _handleGlobalKeyEvent(KeyEvent event) {
|
||||
if (!mounted) return false;
|
||||
@@ -140,33 +169,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
// (e.g. after controls auto-hide). The !hasFocus guard prevents
|
||||
// double-handling when the Focus onKeyEvent already processes the event.
|
||||
if (!_focusNode.hasFocus && _keyboardService != null) {
|
||||
final result = _keyboardService!.handleVideoPlayerKeyEvent(
|
||||
event,
|
||||
widget.player,
|
||||
_toggleFullscreen,
|
||||
_toggleSubtitles,
|
||||
_nextAudioTrack,
|
||||
_nextSubtitleTrack,
|
||||
_nextChapter,
|
||||
_previousChapter,
|
||||
canControlPlayback: widget.canControl,
|
||||
canNavigateMediaItems: widget.canNavigateMediaItems,
|
||||
onPlayPause: () => unawaited(_playOrPause()),
|
||||
onToggleShader: _toggleShader,
|
||||
onNextEpisode: widget.onNext,
|
||||
onPreviousEpisode: widget.onPrevious,
|
||||
onScreenshot: _showScreenshotToast,
|
||||
onZoomIn: widget.onZoomIn,
|
||||
onZoomOut: widget.onZoomOut,
|
||||
onZoomReset: widget.onResetVideoZoom,
|
||||
onVolumeUp: () => widget.volumeController.adjust(10),
|
||||
onVolumeDown: () => widget.volumeController.adjust(-10),
|
||||
onToggleMute: widget.volumeController.toggleMute,
|
||||
currentPositionEpoch: widget.currentPositionEpoch,
|
||||
onLiveSeek: widget.onLiveSeek,
|
||||
onLiveSeekBy: widget.onLiveSeekBy,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
);
|
||||
final result = _dispatchShortcut(event);
|
||||
if (result == KeyEventResult.handled) {
|
||||
_focusNode.requestFocus(); // self-heal focus
|
||||
return true;
|
||||
@@ -270,34 +273,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
return event.logicalKey.isNavigationKey ? KeyEventResult.handled : KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final result = _keyboardService!.handleVideoPlayerKeyEvent(
|
||||
event,
|
||||
widget.player,
|
||||
_toggleFullscreen,
|
||||
_toggleSubtitles,
|
||||
_nextAudioTrack,
|
||||
_nextSubtitleTrack,
|
||||
_nextChapter,
|
||||
_previousChapter,
|
||||
canControlPlayback: widget.canControl,
|
||||
canNavigateMediaItems: widget.canNavigateMediaItems,
|
||||
onPlayPause: () => unawaited(_playOrPause()),
|
||||
onToggleShader: _toggleShader,
|
||||
onSkipMarker: _performAutoSkip,
|
||||
onNextEpisode: widget.onNext,
|
||||
onPreviousEpisode: widget.onPrevious,
|
||||
onScreenshot: _showScreenshotToast,
|
||||
onZoomIn: widget.onZoomIn,
|
||||
onZoomOut: widget.onZoomOut,
|
||||
onZoomReset: widget.onResetVideoZoom,
|
||||
onVolumeUp: () => widget.volumeController.adjust(10),
|
||||
onVolumeDown: () => widget.volumeController.adjust(-10),
|
||||
onToggleMute: widget.volumeController.toggleMute,
|
||||
currentPositionEpoch: widget.currentPositionEpoch,
|
||||
onLiveSeek: widget.onLiveSeek,
|
||||
onLiveSeekBy: widget.onLiveSeekBy,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
);
|
||||
final result = _dispatchShortcut(event, onSkipMarker: _performAutoSkip);
|
||||
if (!event.logicalKey.isNavigationKey) return result;
|
||||
// Never return .ignored for navigation keys — prevent leaking to previous routes.
|
||||
return result == KeyEventResult.ignored ? KeyEventResult.handled : result;
|
||||
|
||||
@@ -144,7 +144,6 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
audioSyncOffset: _audioSyncOffset,
|
||||
subtitleSyncOffset: _subtitleSyncOffset,
|
||||
isRotationLocked: _isRotationLocked,
|
||||
isScreenLocked: _isScreenLocked,
|
||||
isFullscreen: _isFullscreen,
|
||||
isAlwaysOnTop: _isAlwaysOnTop,
|
||||
onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV()) ? widget.onTogglePIPMode : null,
|
||||
|
||||
@@ -34,7 +34,6 @@ class PlayerChromeController extends ChangeNotifier implements ValueListenable<b
|
||||
/// Whether controls may still be visibly rendered during their fade-out.
|
||||
bool get controlsPresented => _controlsPresented;
|
||||
bool get contentStripVisible => _contentStripVisible;
|
||||
bool get hasVisibleHold => _holds.isNotEmpty;
|
||||
bool isHeld(PlayerChromeHold hold) => _holds.contains(hold);
|
||||
PlayerChromeFocusTarget? get pendingFocusTarget => _pendingFocusTarget;
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../utils/scroll_utils.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import 'sheet_column_header.dart';
|
||||
|
||||
/// Per-row handle handed to [SheetSelectionColumn.itemBuilder].
|
||||
abstract class SheetSelectionColumnScope {
|
||||
/// Key for the row at [index]. Only the first row is keyed, so the one-time
|
||||
/// initial scroll can measure a real item height.
|
||||
Key? keyFor(int index);
|
||||
|
||||
/// Runs an async selection: re-entrant taps are ignored while one is in
|
||||
/// flight, a progress bar is shown meanwhile, and the sheet is closed once
|
||||
/// [action] completes.
|
||||
void runExclusive(Future<void> Function() action);
|
||||
}
|
||||
|
||||
/// Shared scaffold for the selectable columns inside the video control sheets:
|
||||
/// an optional header, a one-shot scroll to the selected row, the async
|
||||
/// selection guard, the scrolling list, and an optional footer.
|
||||
class SheetSelectionColumn extends StatefulWidget {
|
||||
/// Header text, or null to omit the header entirely.
|
||||
final String? headerLabel;
|
||||
final int itemCount;
|
||||
|
||||
/// Row to scroll into view on first build; ignored when null or <= 0.
|
||||
final int? initialIndex;
|
||||
final Widget Function(BuildContext context, int index, SheetSelectionColumnScope scope) itemBuilder;
|
||||
final List<Widget> footer;
|
||||
|
||||
const SheetSelectionColumn({
|
||||
super.key,
|
||||
this.headerLabel,
|
||||
required this.itemCount,
|
||||
required this.initialIndex,
|
||||
required this.itemBuilder,
|
||||
this.footer = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
State<SheetSelectionColumn> createState() => _SheetSelectionColumnState();
|
||||
}
|
||||
|
||||
class _SheetSelectionColumnState extends State<SheetSelectionColumn> implements SheetSelectionColumnScope {
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
bool _selectionPending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Key? keyFor(int index) => index == 0 ? _initialScroll.firstItemKey : null;
|
||||
|
||||
@override
|
||||
void runExclusive(Future<void> Function() action) => unawaited(_select(action));
|
||||
|
||||
Future<void> _select(Future<void> Function() action) async {
|
||||
if (_selectionPending) return;
|
||||
setState(() => _selectionPending = true);
|
||||
try {
|
||||
await action();
|
||||
if (mounted) OverlaySheetController.of(context).close();
|
||||
} finally {
|
||||
if (mounted) setState(() => _selectionPending = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_initialScroll.maybeScrollTo(widget.initialIndex);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.headerLabel != null) SheetColumnHeader(label: widget.headerLabel!),
|
||||
if (_selectionPending) const LinearProgressIndicator(minHeight: 2),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: widget.itemCount,
|
||||
itemBuilder: (context, index) => widget.itemBuilder(context, index, this),
|
||||
),
|
||||
),
|
||||
...widget.footer,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import '../../../focus/input_mode_tracker.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../../models/plex/plex_subtitle_search_result.dart';
|
||||
import '../../../services/plex_client.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../utils/language_codes.dart';
|
||||
import '../../../utils/provider_extensions.dart';
|
||||
@@ -103,8 +102,7 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
|
||||
});
|
||||
|
||||
try {
|
||||
final neutral = context.tryGetMediaClientForServer(ServerId(widget.serverId));
|
||||
final client = neutral is PlexClient ? neutral : null;
|
||||
final client = context.tryGetPlexClientForServer(ServerId(widget.serverId));
|
||||
if (client == null) {
|
||||
if (!mounted || generation != _searchGeneration) return;
|
||||
setState(() => _isSearching = false);
|
||||
@@ -185,10 +183,7 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
|
||||
setState(() => _downloadingKey = result.key);
|
||||
|
||||
try {
|
||||
// Same Plex-only guard as in [_search]. Don't throw if a Jellyfin
|
||||
// server somehow reaches the download path.
|
||||
final neutral = context.tryGetMediaClientForServer(ServerId(widget.serverId));
|
||||
final client = neutral is PlexClient ? neutral : null;
|
||||
final client = context.tryGetPlexClientForServer(ServerId(widget.serverId));
|
||||
if (client == null) {
|
||||
if (!mounted) return;
|
||||
setState(() => _downloadingKey = null);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
@@ -7,13 +5,12 @@ import '../../../media/media_source_info.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../services/playback_subtitle_resolver.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../utils/scroll_utils.dart';
|
||||
import '../../../utils/track_label_builder.dart';
|
||||
import '../../../widgets/app_icon.dart';
|
||||
import '../../../widgets/focusable_list_tile.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
import 'sheet_column_header.dart';
|
||||
import 'sheet_selection_column.dart';
|
||||
import 'subtitle_search_sheet.dart';
|
||||
import '../models/track_controls_state.dart';
|
||||
import '../helpers/track_filter_helper.dart';
|
||||
@@ -134,7 +131,7 @@ class TrackSheet extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SourceAudioColumn extends StatefulWidget {
|
||||
class _SourceAudioColumn extends StatelessWidget {
|
||||
final List<MediaAudioTrack> tracks;
|
||||
final int? selectedStreamId;
|
||||
final Future<void> Function(int) onSelected;
|
||||
@@ -147,157 +144,94 @@ class _SourceAudioColumn extends StatefulWidget {
|
||||
required this.showHeader,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SourceAudioColumn> createState() => _SourceAudioColumnState();
|
||||
}
|
||||
|
||||
class _SourceAudioColumnState extends State<_SourceAudioColumn> {
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
bool _selectionPending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _select(int streamId) async {
|
||||
if (_selectionPending) return;
|
||||
setState(() => _selectionPending = true);
|
||||
try {
|
||||
await widget.onSelected(streamId);
|
||||
if (mounted) OverlaySheetController.of(context).close();
|
||||
} finally {
|
||||
if (mounted) setState(() => _selectionPending = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedId = _effectiveSelectedStreamId();
|
||||
final selectedIndex = selectedId == null ? null : widget.tracks.indexWhere((t) => t.id == selectedId);
|
||||
_initialScroll.maybeScrollTo(selectedIndex);
|
||||
final selectedIndex = selectedId == null ? null : tracks.indexWhere((t) => t.id == selectedId);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.audioLabel),
|
||||
if (_selectionPending) const LinearProgressIndicator(minHeight: 2),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: widget.tracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final track = widget.tracks[index];
|
||||
final isSelected = track.id == selectedId;
|
||||
return TrackSelectionHelper.buildTrackTile<AudioTrack>(
|
||||
context: context,
|
||||
key: index == 0 ? _initialScroll.firstItemKey : null,
|
||||
label: track.label,
|
||||
isSelected: isSelected,
|
||||
onTap: () => unawaited(_select(track.id)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
return SheetSelectionColumn(
|
||||
headerLabel: showHeader ? t.videoControls.audioLabel : null,
|
||||
itemCount: tracks.length,
|
||||
initialIndex: selectedIndex,
|
||||
itemBuilder: (context, index, scope) {
|
||||
final track = tracks[index];
|
||||
return TrackSelectionHelper.buildTrackTile(
|
||||
context: context,
|
||||
key: scope.keyFor(index),
|
||||
label: track.label,
|
||||
isSelected: track.id == selectedId,
|
||||
onTap: () => scope.runExclusive(() => onSelected(track.id)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int? _effectiveSelectedStreamId() {
|
||||
final explicit = widget.selectedStreamId;
|
||||
if (explicit != null && widget.tracks.any((track) => track.id == explicit)) return explicit;
|
||||
for (final track in widget.tracks) {
|
||||
final explicit = selectedStreamId;
|
||||
if (explicit != null && tracks.any((track) => track.id == explicit)) return explicit;
|
||||
for (final track in tracks) {
|
||||
if (track.selected) return track.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _SourceSubtitleColumn extends StatefulWidget {
|
||||
class _SourceSubtitleColumn extends StatelessWidget {
|
||||
final List<MediaSubtitleTrack> tracks;
|
||||
final TrackControlsState trackControlsState;
|
||||
final bool showHeader;
|
||||
|
||||
const _SourceSubtitleColumn({required this.tracks, required this.trackControlsState, required this.showHeader});
|
||||
|
||||
@override
|
||||
State<_SourceSubtitleColumn> createState() => _SourceSubtitleColumnState();
|
||||
}
|
||||
|
||||
class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
bool _selectionPending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _select(PlaybackSourceSubtitleChoice choice) async {
|
||||
if (_selectionPending) return;
|
||||
setState(() => _selectionPending = true);
|
||||
try {
|
||||
await widget.trackControlsState.onSwitchSubtitle!(choice);
|
||||
if (mounted) OverlaySheetController.of(context).close();
|
||||
} finally {
|
||||
if (mounted) setState(() => _selectionPending = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedChoice = _effectiveSelectedChoice();
|
||||
final selectedId = selectedChoice.sourceStreamId;
|
||||
final selectedIndex = selectedChoice.isOff ? 0 : widget.tracks.indexWhere((t) => t.id == selectedId) + 1;
|
||||
_initialScroll.maybeScrollTo(selectedIndex);
|
||||
final selectedIndex = selectedChoice.isOff ? 0 : tracks.indexWhere((t) => t.id == selectedId) + 1;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.subtitlesLabel),
|
||||
if (_selectionPending) const LinearProgressIndicator(minHeight: 2),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: widget.tracks.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return TrackSelectionHelper.buildOffTile<SubtitleTrack>(
|
||||
context: context,
|
||||
key: _initialScroll.firstItemKey,
|
||||
isSelected: selectedChoice.isOff,
|
||||
onTap: () => unawaited(_select(const PlaybackSourceSubtitleChoice.off())),
|
||||
);
|
||||
}
|
||||
return SheetSelectionColumn(
|
||||
headerLabel: showHeader ? t.videoControls.subtitlesLabel : null,
|
||||
itemCount: tracks.length + 1,
|
||||
initialIndex: selectedIndex,
|
||||
footer: _buildSubtitleSearchFooter(context, trackControlsState),
|
||||
itemBuilder: (context, index, scope) {
|
||||
if (index == 0) {
|
||||
return TrackSelectionHelper.buildOffTile(
|
||||
context: context,
|
||||
key: scope.keyFor(index),
|
||||
isSelected: selectedChoice.isOff,
|
||||
onTap: () => scope.runExclusive(
|
||||
() => trackControlsState.onSwitchSubtitle!(const PlaybackSourceSubtitleChoice.off()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final track = widget.tracks[index - 1];
|
||||
return TrackSelectionHelper.buildTrackTile<SubtitleTrack>(
|
||||
context: context,
|
||||
label: track.labelForIndex(index - 1),
|
||||
isSelected: track.id == selectedId,
|
||||
onTap: () => unawaited(_select(PlaybackSourceSubtitleChoice.source(track.id))),
|
||||
);
|
||||
},
|
||||
final track = tracks[index - 1];
|
||||
return TrackSelectionHelper.buildTrackTile(
|
||||
context: context,
|
||||
label: track.labelForIndex(index - 1),
|
||||
isSelected: track.id == selectedId,
|
||||
onTap: () => scope.runExclusive(
|
||||
() => trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(track.id)),
|
||||
),
|
||||
),
|
||||
..._buildSubtitleSearchFooter(context, widget.trackControlsState),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
PlaybackSourceSubtitleChoice _effectiveSelectedChoice() {
|
||||
final explicit = widget.trackControlsState.selectedSubtitleChoice;
|
||||
if (explicit != null && (explicit.isOff || widget.tracks.any((track) => track.id == explicit.sourceStreamId))) {
|
||||
final explicit = trackControlsState.selectedSubtitleChoice;
|
||||
if (explicit != null && (explicit.isOff || tracks.any((track) => track.id == explicit.sourceStreamId))) {
|
||||
return explicit;
|
||||
}
|
||||
for (final track in widget.tracks) {
|
||||
for (final track in tracks) {
|
||||
if (track.selected) return PlaybackSourceSubtitleChoice.source(track.id);
|
||||
}
|
||||
return const PlaybackSourceSubtitleChoice.off();
|
||||
}
|
||||
}
|
||||
|
||||
class _AudioColumn extends StatefulWidget {
|
||||
class _AudioColumn extends StatelessWidget {
|
||||
final List<AudioTrack> tracks;
|
||||
final TrackSelection selection;
|
||||
final Player player;
|
||||
@@ -312,61 +246,41 @@ class _AudioColumn extends StatefulWidget {
|
||||
required this.showHeader,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AudioColumn> createState() => _AudioColumnState();
|
||||
}
|
||||
|
||||
class _AudioColumnState extends State<_AudioColumn> {
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedId = widget.selection.audio?.id ?? '';
|
||||
final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedId);
|
||||
_initialScroll.maybeScrollTo(selectedIndex);
|
||||
final selectedId = selection.audio?.id ?? '';
|
||||
final selectedIndex = tracks.indexWhere((t) => t.id == selectedId);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.audioLabel),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: widget.tracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final track = widget.tracks[index];
|
||||
final label = TrackLabelBuilder.audioLabel(
|
||||
title: track.title,
|
||||
language: track.language,
|
||||
codec: track.codec,
|
||||
channels: track.channelsCount,
|
||||
index: index,
|
||||
);
|
||||
return TrackSelectionHelper.buildTrackTile<AudioTrack>(
|
||||
context: context,
|
||||
key: index == 0 ? _initialScroll.firstItemKey : null,
|
||||
label: label,
|
||||
isSelected: track.id == selectedId,
|
||||
onTap: () {
|
||||
widget.player.selectAudioTrack(track);
|
||||
widget.onTrackChanged?.call(track);
|
||||
OverlaySheetController.of(context).close();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
return SheetSelectionColumn(
|
||||
headerLabel: showHeader ? t.videoControls.audioLabel : null,
|
||||
itemCount: tracks.length,
|
||||
initialIndex: selectedIndex,
|
||||
itemBuilder: (context, index, scope) {
|
||||
final track = tracks[index];
|
||||
final label = TrackLabelBuilder.audioLabel(
|
||||
title: track.title,
|
||||
language: track.language,
|
||||
codec: track.codec,
|
||||
channels: track.channelsCount,
|
||||
index: index,
|
||||
);
|
||||
return TrackSelectionHelper.buildTrackTile(
|
||||
context: context,
|
||||
key: scope.keyFor(index),
|
||||
label: label,
|
||||
isSelected: track.id == selectedId,
|
||||
onTap: () {
|
||||
player.selectAudioTrack(track);
|
||||
onTrackChanged?.call(track);
|
||||
OverlaySheetController.of(context).close();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubtitleColumn extends StatefulWidget {
|
||||
class _SubtitleColumn extends StatelessWidget {
|
||||
final List<SubtitleTrack> tracks;
|
||||
final TrackSelection selection;
|
||||
final Player player;
|
||||
@@ -385,49 +299,24 @@ class _SubtitleColumn extends StatefulWidget {
|
||||
this.sourceSidecars = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SubtitleColumn> createState() => _SubtitleColumnState();
|
||||
}
|
||||
|
||||
class _SubtitleColumnState extends State<_SubtitleColumn> {
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
bool _selectionPending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _selectSourceSidecar(int streamId) async {
|
||||
if (_selectionPending) return;
|
||||
setState(() => _selectionPending = true);
|
||||
try {
|
||||
await widget.trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(streamId));
|
||||
if (mounted) OverlaySheetController.of(context).close();
|
||||
} finally {
|
||||
if (mounted) setState(() => _selectionPending = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedSub = widget.selection.subtitle;
|
||||
final secondarySub = widget.selection.secondarySubtitle;
|
||||
final selectedSub = selection.subtitle;
|
||||
final secondarySub = selection.secondarySubtitle;
|
||||
final isOffSelected = selectedSub == null || selectedSub.id == 'no';
|
||||
final hasSecondary = widget.supportsSecondary && secondarySub != null;
|
||||
final selectedSourceId = widget.trackControlsState.selectedSubtitleChoice?.sourceStreamId;
|
||||
final selectedSecondarySourceId = widget.trackControlsState.selectedSecondarySubtitleStreamId;
|
||||
final hasSecondary = supportsSecondary && secondarySub != null;
|
||||
final selectedSourceId = trackControlsState.selectedSubtitleChoice?.sourceStreamId;
|
||||
final selectedSecondarySourceId = trackControlsState.selectedSecondarySubtitleStreamId;
|
||||
final attachedSourceSidecarIds = <int>{};
|
||||
for (final sidecar in widget.trackControlsState.sourceSubtitleSidecars) {
|
||||
for (final sidecar in trackControlsState.sourceSubtitleSidecars) {
|
||||
final sourceStreamId = sidecar.sourceStreamId;
|
||||
final uri = sidecar.track.uri;
|
||||
if (sourceStreamId == null || uri == null) continue;
|
||||
if (widget.tracks.any((track) => track.isExternal && track.uri == uri)) {
|
||||
if (tracks.any((track) => track.isExternal && track.uri == uri)) {
|
||||
attachedSourceSidecarIds.add(sourceStreamId);
|
||||
}
|
||||
}
|
||||
final unloadedSourceSidecars = widget.sourceSidecars
|
||||
final unloadedSourceSidecars = sourceSidecars
|
||||
.where(
|
||||
(track) =>
|
||||
!attachedSourceSidecarIds.contains(track.id) &&
|
||||
@@ -438,127 +327,121 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
|
||||
|
||||
// +1 for "Off". Source sidecars represented by native external tracks,
|
||||
// plus active source IDs awaiting native discovery, are not appended.
|
||||
final itemCount = widget.tracks.length + unloadedSourceSidecars.length + 1;
|
||||
final itemCount = tracks.length + unloadedSourceSidecars.length + 1;
|
||||
|
||||
final selectedIndex = isOffSelected ? null : widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 1;
|
||||
_initialScroll.maybeScrollTo(selectedIndex);
|
||||
final selectedIndex = isOffSelected ? null : tracks.indexWhere((t) => t.id == selectedSub.id) + 1;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.subtitlesLabel),
|
||||
if (_selectionPending) const LinearProgressIndicator(minHeight: 2),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return TrackSelectionHelper.buildOffTile<SubtitleTrack>(
|
||||
context: context,
|
||||
key: _initialScroll.firstItemKey,
|
||||
isSelected: isOffSelected,
|
||||
onTap: () {
|
||||
// Turning off primary also clears secondary
|
||||
if (hasSecondary) {
|
||||
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
}
|
||||
widget.player.selectSubtitleTrack(SubtitleTrack.off);
|
||||
widget.trackControlsState.onSubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
OverlaySheetController.of(context).close();
|
||||
},
|
||||
onLongPress: widget.supportsSecondary && hasSecondary
|
||||
? () {
|
||||
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
}
|
||||
: null,
|
||||
onSecondaryTap: widget.supportsSecondary && hasSecondary
|
||||
? () {
|
||||
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
return SheetSelectionColumn(
|
||||
headerLabel: showHeader ? t.videoControls.subtitlesLabel : null,
|
||||
itemCount: itemCount,
|
||||
initialIndex: selectedIndex,
|
||||
footer: _buildSubtitleSearchFooter(context, trackControlsState),
|
||||
itemBuilder: (context, index, scope) {
|
||||
if (index == 0) {
|
||||
return TrackSelectionHelper.buildOffTile(
|
||||
context: context,
|
||||
key: scope.keyFor(index),
|
||||
isSelected: isOffSelected,
|
||||
onTap: () {
|
||||
// Turning off primary also clears secondary
|
||||
if (hasSecondary) {
|
||||
player.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||
trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
}
|
||||
|
||||
final trackIndex = index - 1;
|
||||
if (trackIndex >= widget.tracks.length) {
|
||||
final sourceIndex = trackIndex - widget.tracks.length;
|
||||
final sourceTrack = unloadedSourceSidecars[sourceIndex];
|
||||
return TrackSelectionHelper.buildTrackTile<SubtitleTrack>(
|
||||
context: context,
|
||||
label: sourceTrack.labelForIndex(trackIndex),
|
||||
isSelected: false,
|
||||
onTap: () => unawaited(_selectSourceSidecar(sourceTrack.id)),
|
||||
);
|
||||
}
|
||||
|
||||
final track = widget.tracks[trackIndex];
|
||||
final isPrimary = !isOffSelected && track.id == selectedSub.id;
|
||||
final isSecondary = hasSecondary && track.id == secondarySub.id;
|
||||
final label = TrackLabelBuilder.subtitleLabel(
|
||||
title: track.title,
|
||||
language: track.language,
|
||||
codec: track.codec,
|
||||
forced: track.isForced,
|
||||
index: trackIndex,
|
||||
);
|
||||
|
||||
Widget? badge;
|
||||
if (widget.supportsSecondary && hasSecondary) {
|
||||
if (isPrimary) {
|
||||
badge = TrackSelectionHelper.buildTrackBadge(context, 1);
|
||||
} else if (isSecondary) {
|
||||
badge = TrackSelectionHelper.buildTrackBadge(context, 2);
|
||||
}
|
||||
}
|
||||
|
||||
return TrackSelectionHelper.buildTrackTile<SubtitleTrack>(
|
||||
context: context,
|
||||
label: label,
|
||||
isSelected: isPrimary,
|
||||
badge: badge,
|
||||
onTap: () {
|
||||
// If tapping a track that is currently the secondary, clear secondary first
|
||||
if (isSecondary) {
|
||||
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
}
|
||||
widget.player.selectSubtitleTrack(track);
|
||||
widget.trackControlsState.onSubtitleTrackChanged?.call(track);
|
||||
OverlaySheetController.of(context).close();
|
||||
},
|
||||
onLongPress: widget.supportsSecondary
|
||||
? () {
|
||||
if (isSecondary) {
|
||||
// Already secondary — clear it
|
||||
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
} else if (!isPrimary) {
|
||||
// Set as secondary (don't close sheet so user sees badge update)
|
||||
widget.player.selectSecondarySubtitleTrack(track);
|
||||
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(track);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onSecondaryTap: widget.supportsSecondary
|
||||
? () {
|
||||
if (isSecondary) {
|
||||
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
} else if (!isPrimary) {
|
||||
widget.player.selectSecondarySubtitleTrack(track);
|
||||
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(track);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
);
|
||||
player.selectSubtitleTrack(SubtitleTrack.off);
|
||||
trackControlsState.onSubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
OverlaySheetController.of(context).close();
|
||||
},
|
||||
),
|
||||
),
|
||||
..._buildSubtitleSearchFooter(context, widget.trackControlsState),
|
||||
],
|
||||
onLongPress: supportsSecondary && hasSecondary
|
||||
? () {
|
||||
player.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||
trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
}
|
||||
: null,
|
||||
onSecondaryTap: supportsSecondary && hasSecondary
|
||||
? () {
|
||||
player.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||
trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
final trackIndex = index - 1;
|
||||
if (trackIndex >= tracks.length) {
|
||||
final sourceIndex = trackIndex - tracks.length;
|
||||
final sourceTrack = unloadedSourceSidecars[sourceIndex];
|
||||
return TrackSelectionHelper.buildTrackTile(
|
||||
context: context,
|
||||
label: sourceTrack.labelForIndex(trackIndex),
|
||||
isSelected: false,
|
||||
onTap: () => scope.runExclusive(
|
||||
() => trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(sourceTrack.id)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final track = tracks[trackIndex];
|
||||
final isPrimary = !isOffSelected && track.id == selectedSub.id;
|
||||
final isSecondary = hasSecondary && track.id == secondarySub.id;
|
||||
final label = TrackLabelBuilder.subtitleLabel(
|
||||
title: track.title,
|
||||
language: track.language,
|
||||
codec: track.codec,
|
||||
forced: track.isForced,
|
||||
index: trackIndex,
|
||||
);
|
||||
|
||||
Widget? badge;
|
||||
if (supportsSecondary && hasSecondary) {
|
||||
if (isPrimary) {
|
||||
badge = TrackSelectionHelper.buildTrackBadge(context, 1);
|
||||
} else if (isSecondary) {
|
||||
badge = TrackSelectionHelper.buildTrackBadge(context, 2);
|
||||
}
|
||||
}
|
||||
|
||||
return TrackSelectionHelper.buildTrackTile(
|
||||
context: context,
|
||||
label: label,
|
||||
isSelected: isPrimary,
|
||||
badge: badge,
|
||||
onTap: () {
|
||||
// If tapping a track that is currently the secondary, clear secondary first
|
||||
if (isSecondary) {
|
||||
player.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||
trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
}
|
||||
player.selectSubtitleTrack(track);
|
||||
trackControlsState.onSubtitleTrackChanged?.call(track);
|
||||
OverlaySheetController.of(context).close();
|
||||
},
|
||||
onLongPress: supportsSecondary
|
||||
? () {
|
||||
if (isSecondary) {
|
||||
// Already secondary — clear it
|
||||
player.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||
trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
} else if (!isPrimary) {
|
||||
// Set as secondary (don't close sheet so user sees badge update)
|
||||
player.selectSecondarySubtitleTrack(track);
|
||||
trackControlsState.onSecondarySubtitleTrackChanged?.call(track);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onSecondaryTap: supportsSecondary
|
||||
? () {
|
||||
if (isSecondary) {
|
||||
player.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||
trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
|
||||
} else if (!isPrimary) {
|
||||
player.selectSecondarySubtitleTrack(track);
|
||||
trackControlsState.onSecondarySubtitleTrackChanged?.call(track);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,9 @@ import '../../../i18n/strings.g.dart';
|
||||
import '../../../media/media_version.dart';
|
||||
import '../../../models/transcode_quality_preset.dart';
|
||||
import '../../../utils/quality_preset_labels.dart';
|
||||
import '../../../utils/scroll_utils.dart';
|
||||
import '../../../widgets/focusable_list_tile.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import 'sheet_column_header.dart';
|
||||
import 'sheet_selection_column.dart';
|
||||
|
||||
String versionQualityPickerTitle({required bool showVersions, required bool showQuality}) {
|
||||
return showQuality
|
||||
@@ -114,7 +113,7 @@ class VersionQualityPicker extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _VersionColumn extends StatefulWidget {
|
||||
class _VersionColumn extends StatelessWidget {
|
||||
final List<MediaVersion> versions;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
@@ -127,48 +126,27 @@ class _VersionColumn extends StatefulWidget {
|
||||
required this.showHeader,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_VersionColumn> createState() => _VersionColumnState();
|
||||
}
|
||||
|
||||
class _VersionColumnState extends State<_VersionColumn> {
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_initialScroll.maybeScrollTo(widget.selectedIndex);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.versionColumnHeader),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: widget.versions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final version = widget.versions[index];
|
||||
final isSelected = index == widget.selectedIndex;
|
||||
return _SelectionTile(
|
||||
key: index == 0 ? _initialScroll.firstItemKey : null,
|
||||
label: version.displayLabel,
|
||||
isSelected: isSelected,
|
||||
onTap: () => widget.onSelected(index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
return SheetSelectionColumn(
|
||||
headerLabel: showHeader ? t.videoControls.versionColumnHeader : null,
|
||||
itemCount: versions.length,
|
||||
initialIndex: selectedIndex,
|
||||
itemBuilder: (context, index, scope) {
|
||||
final version = versions[index];
|
||||
final isSelected = index == selectedIndex;
|
||||
return _SelectionTile(
|
||||
key: scope.keyFor(index),
|
||||
label: version.displayLabel,
|
||||
isSelected: isSelected,
|
||||
onTap: () => onSelected(index),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityColumn extends StatefulWidget {
|
||||
class _QualityColumn extends StatelessWidget {
|
||||
final TranscodeQualityPreset selected;
|
||||
final bool enabledForTranscoding;
|
||||
final int? sourceBitrateKbps;
|
||||
@@ -187,58 +165,36 @@ class _QualityColumn extends StatefulWidget {
|
||||
required this.showHeader,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_QualityColumn> createState() => _QualityColumnState();
|
||||
}
|
||||
|
||||
class _QualityColumnState extends State<_QualityColumn> {
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final presets = TranscodeQualityPreset.displayOrder;
|
||||
final selectedIndex = presets.indexOf(widget.selected);
|
||||
|
||||
_initialScroll.maybeScrollTo(selectedIndex);
|
||||
return SheetSelectionColumn(
|
||||
headerLabel: showHeader ? t.videoControls.qualityColumnHeader : null,
|
||||
itemCount: presets.length,
|
||||
initialIndex: presets.indexOf(selected),
|
||||
itemBuilder: (context, index, scope) {
|
||||
final preset = presets[index];
|
||||
final isSelected = preset == selected;
|
||||
final isOriginal = preset.isOriginal;
|
||||
final enabled = isOriginal || enabledForTranscoding;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.qualityColumnHeader),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: presets.length,
|
||||
itemBuilder: (context, index) {
|
||||
final preset = presets[index];
|
||||
final isSelected = preset == widget.selected;
|
||||
final isOriginal = preset.isOriginal;
|
||||
final enabled = isOriginal || widget.enabledForTranscoding;
|
||||
final trailing = qualityPresetSizeEstimate(
|
||||
preset: preset,
|
||||
sourceBitrateKbps: sourceBitrateKbps,
|
||||
sourceDurationMs: sourceDurationMs,
|
||||
sourceSizeBytes: sourceSizeBytes,
|
||||
);
|
||||
|
||||
final trailing = qualityPresetSizeEstimate(
|
||||
preset: preset,
|
||||
sourceBitrateKbps: widget.sourceBitrateKbps,
|
||||
sourceDurationMs: widget.sourceDurationMs,
|
||||
sourceSizeBytes: widget.sourceSizeBytes,
|
||||
);
|
||||
|
||||
return _SelectionTile(
|
||||
key: index == 0 ? _initialScroll.firstItemKey : null,
|
||||
label: qualityPresetLabel(preset),
|
||||
trailingText: trailing,
|
||||
isSelected: isSelected,
|
||||
enabled: enabled,
|
||||
onTap: enabled ? () => widget.onSelected(preset) : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
return _SelectionTile(
|
||||
key: scope.keyFor(index),
|
||||
label: qualityPresetLabel(preset),
|
||||
trailingText: trailing,
|
||||
isSelected: isSelected,
|
||||
enabled: enabled,
|
||||
onTap: enabled ? () => onSelected(preset) : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,11 @@ import 'package:path/path.dart' as path;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../models/shader_preset.dart';
|
||||
import '../../../models/transcode_quality_preset.dart';
|
||||
import '../../../media/playback_rate.dart';
|
||||
import '../../../media/media_version.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../providers/shader_provider.dart';
|
||||
import '../../../services/file_picker_service.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../services/shader_service.dart';
|
||||
import '../../../services/sleep_timer_service.dart';
|
||||
import '../../../services/video_filter_manager.dart';
|
||||
import '../../../focus/focusable_wrapper.dart';
|
||||
@@ -33,6 +30,7 @@ import '../../../utils/snackbar_helper.dart';
|
||||
import '../../../theme/mono_tokens.dart';
|
||||
import '../../../widgets/focusable_list_tile.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import '../models/track_controls_state.dart';
|
||||
import '../widgets/sync_offset_control.dart';
|
||||
import '../widgets/sleep_timer_content.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
@@ -191,73 +189,17 @@ class VideoSettingsSheet extends StatefulWidget {
|
||||
/// Defaults to the native platform capability, but can be supplied by
|
||||
/// embedders whose capability is known independently of the host platform.
|
||||
final bool? supportsHdrControl;
|
||||
final int audioSyncOffset;
|
||||
final int subtitleSyncOffset;
|
||||
final double videoZoomScale;
|
||||
final ValueChanged<double>? onVideoZoomChanged;
|
||||
final VoidCallback? onResetVideoZoom;
|
||||
|
||||
/// Whether the user can control playback (false hides speed option in host-only mode).
|
||||
final bool canControl;
|
||||
|
||||
/// Whether this is a live TV stream (hides speed settings).
|
||||
final bool isLive;
|
||||
|
||||
/// Available media versions and quality controls shown inside playback settings.
|
||||
final List<MediaVersion> availableVersions;
|
||||
final int selectedMediaIndex;
|
||||
final TranscodeQualityPreset selectedQualityPreset;
|
||||
final bool serverSupportsTranscoding;
|
||||
final int? sourceDurationMs;
|
||||
final ValueChanged<int>? onVersionSelected;
|
||||
final ValueChanged<TranscodeQualityPreset>? onQualitySelected;
|
||||
|
||||
/// Optional shader service for MPV shader control
|
||||
final ShaderService? shaderService;
|
||||
|
||||
/// Called when shader preset changes
|
||||
final VoidCallback? onShaderChanged;
|
||||
|
||||
/// Whether ambient lighting is currently enabled
|
||||
final bool isAmbientLightingEnabled;
|
||||
|
||||
/// Called to toggle ambient lighting on/off (null if unsupported)
|
||||
final VoidCallback? onToggleAmbientLighting;
|
||||
|
||||
/// Called to cancel the video controls auto-hide timer.
|
||||
final VoidCallback? onCancelAutoHide;
|
||||
|
||||
/// Called to restart the video controls auto-hide timer.
|
||||
final VoidCallback? onStartAutoHide;
|
||||
|
||||
/// Called when a sync offset changes (so the parent can update its state).
|
||||
final void Function(String propertyName, int offset)? onSyncOffsetChanged;
|
||||
/// Shared player-control state. Every playback value and callback this sheet
|
||||
/// shows (sync offsets, zoom, versions/quality, shaders, ambient lighting,
|
||||
/// auto-hide) is read straight off it.
|
||||
final TrackControlsState trackControlsState;
|
||||
|
||||
const VideoSettingsSheet({
|
||||
super.key,
|
||||
required this.player,
|
||||
this.supportsHdrControl,
|
||||
required this.audioSyncOffset,
|
||||
required this.subtitleSyncOffset,
|
||||
this.videoZoomScale = 1.0,
|
||||
this.onVideoZoomChanged,
|
||||
this.onResetVideoZoom,
|
||||
this.canControl = true,
|
||||
this.isLive = false,
|
||||
this.availableVersions = const [],
|
||||
this.selectedMediaIndex = 0,
|
||||
this.selectedQualityPreset = TranscodeQualityPreset.original,
|
||||
this.serverSupportsTranscoding = false,
|
||||
this.sourceDurationMs,
|
||||
this.onVersionSelected,
|
||||
this.onQualitySelected,
|
||||
this.shaderService,
|
||||
this.onShaderChanged,
|
||||
this.isAmbientLightingEnabled = false,
|
||||
this.onToggleAmbientLighting,
|
||||
this.onCancelAutoHide,
|
||||
this.onStartAutoHide,
|
||||
this.onSyncOffsetChanged,
|
||||
required this.trackControlsState,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -272,6 +214,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
String _dvConversionMode = 'auto';
|
||||
int _dvConversionWriteGeneration = 0;
|
||||
|
||||
TrackControlsState get _state => widget.trackControlsState;
|
||||
|
||||
bool get _supportsHdrControl =>
|
||||
widget.supportsHdrControl ?? (Platform.isIOS || Platform.isMacOS || Platform.isWindows);
|
||||
|
||||
@@ -284,16 +228,16 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_audioSyncOffset = widget.audioSyncOffset;
|
||||
_subtitleSyncOffset = widget.subtitleSyncOffset;
|
||||
_zoomScale = VideoFilterManager.normalizeZoomScale(widget.videoZoomScale);
|
||||
_audioSyncOffset = _state.audioSyncOffset;
|
||||
_subtitleSyncOffset = _state.subtitleSyncOffset;
|
||||
_zoomScale = VideoFilterManager.normalizeZoomScale(_state.videoZoomScale);
|
||||
_loadDebugDvConversionMode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant VideoSettingsSheet oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final nextZoomScale = VideoFilterManager.normalizeZoomScale(widget.videoZoomScale);
|
||||
final nextZoomScale = VideoFilterManager.normalizeZoomScale(_state.videoZoomScale);
|
||||
if (_zoomScale != nextZoomScale) {
|
||||
_zoomScale = nextZoomScale;
|
||||
}
|
||||
@@ -373,19 +317,19 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
} else {
|
||||
await settings.write(SettingsService.audioSyncOffset, offset);
|
||||
}
|
||||
widget.onSyncOffsetChanged?.call(propertyName, offset);
|
||||
_state.onSyncOffsetChanged?.call(propertyName, offset);
|
||||
},
|
||||
),
|
||||
)
|
||||
.whenComplete(() {
|
||||
sliderFocusNode.dispose();
|
||||
widget.onStartAutoHide?.call();
|
||||
_state.onStartAutoHide?.call();
|
||||
});
|
||||
|
||||
// Cancel auto-hide after show() — the previous sheet's whenComplete
|
||||
// fires as a microtask and restarts the timer, so schedule our cancel
|
||||
// to run after that microtask.
|
||||
Future.microtask(() => widget.onCancelAutoHide?.call());
|
||||
Future.microtask(() => _state.onCancelAutoHide?.call());
|
||||
}
|
||||
|
||||
void _navigateBack() {
|
||||
@@ -477,44 +421,44 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
setState(() {
|
||||
_zoomScale = next;
|
||||
});
|
||||
widget.onVideoZoomChanged?.call(next);
|
||||
_state.onVideoZoomChanged?.call(next);
|
||||
}
|
||||
|
||||
void _resetZoomScale() {
|
||||
setState(() {
|
||||
_zoomScale = 1.0;
|
||||
});
|
||||
final reset = widget.onResetVideoZoom;
|
||||
final reset = _state.onResetVideoZoom;
|
||||
if (reset != null) {
|
||||
reset();
|
||||
} else {
|
||||
widget.onVideoZoomChanged?.call(1.0);
|
||||
_state.onVideoZoomChanged?.call(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _hasVersionQuality {
|
||||
return (widget.availableVersions.length > 1 || widget.serverSupportsTranscoding) &&
|
||||
(widget.onVersionSelected != null || widget.onQualitySelected != null);
|
||||
return (_state.availableVersions.length > 1 || _state.serverSupportsTranscoding) &&
|
||||
(_state.onSwitchVersion != null || _state.onSwitchQualityPreset != null);
|
||||
}
|
||||
|
||||
String _versionQualityTitle() {
|
||||
return versionQualityPickerTitle(
|
||||
showVersions: widget.availableVersions.length > 1,
|
||||
showQuality: widget.serverSupportsTranscoding,
|
||||
showVersions: _state.availableVersions.length > 1,
|
||||
showQuality: _state.serverSupportsTranscoding,
|
||||
);
|
||||
}
|
||||
|
||||
String _versionQualityValueText() {
|
||||
final values = <String>[];
|
||||
if (widget.availableVersions.length > 1) values.add(_selectedVersionLabel());
|
||||
if (widget.serverSupportsTranscoding) values.add(qualityPresetLabel(widget.selectedQualityPreset));
|
||||
if (_state.availableVersions.length > 1) values.add(_selectedVersionLabel());
|
||||
if (_state.serverSupportsTranscoding) values.add(qualityPresetLabel(_state.selectedQualityPreset));
|
||||
return values.join(' / ');
|
||||
}
|
||||
|
||||
String _selectedVersionLabel() {
|
||||
final index = widget.selectedMediaIndex;
|
||||
if (index >= 0 && index < widget.availableVersions.length) {
|
||||
return widget.availableVersions[index].displayLabel;
|
||||
final index = _state.selectedMediaIndex;
|
||||
if (index >= 0 && index < _state.availableVersions.length) {
|
||||
return _state.availableVersions[index].displayLabel;
|
||||
}
|
||||
return t.videoControls.versionColumnHeader;
|
||||
}
|
||||
@@ -526,7 +470,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
return ListView(
|
||||
children: [
|
||||
// Playback Speed - hidden for live TV and when user cannot control playback
|
||||
if (widget.canControl && !widget.isLive)
|
||||
if (_state.canControl && !_state.isLive)
|
||||
StreamBuilder<double>(
|
||||
stream: widget.player.streams.rate,
|
||||
initialData: widget.player.state.rate,
|
||||
@@ -541,7 +485,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
},
|
||||
),
|
||||
|
||||
if (widget.onVideoZoomChanged != null || widget.onResetVideoZoom != null)
|
||||
if (_state.onVideoZoomChanged != null || _state.onResetVideoZoom != null)
|
||||
_SettingsMenuItem(
|
||||
icon: Symbols.zoom_in_rounded,
|
||||
title: t.videoSettings.zoom,
|
||||
@@ -657,36 +601,36 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
),
|
||||
|
||||
// Shader Preset (MPV only)
|
||||
if (widget.shaderService != null && widget.shaderService!.isSupported)
|
||||
if (_state.shaderService != null && _state.shaderService!.isSupported)
|
||||
_SettingsMenuItem(
|
||||
icon: Symbols.auto_fix_high_rounded,
|
||||
title: t.shaders.title,
|
||||
valueText: widget.shaderService!.currentPreset.id == ShaderPreset.none.id
|
||||
valueText: _state.shaderService!.currentPreset.id == ShaderPreset.none.id
|
||||
? t.common.off
|
||||
: widget.shaderService!.currentPreset.name,
|
||||
isHighlighted: widget.shaderService!.currentPreset.isEnabled,
|
||||
: _state.shaderService!.currentPreset.name,
|
||||
isHighlighted: _state.shaderService!.currentPreset.isEnabled,
|
||||
onTap: () => _navigateTo(_SettingsView.shader),
|
||||
),
|
||||
|
||||
// Ambient Lighting (MPV only)
|
||||
if (widget.onToggleAmbientLighting != null)
|
||||
if (_state.onToggleAmbientLighting != null)
|
||||
FocusableListTile(
|
||||
leading: AppIcon(
|
||||
Symbols.blur_on_rounded,
|
||||
fill: 1,
|
||||
color: widget.isAmbientLightingEnabled ? Colors.amber : tokens(context).textMuted,
|
||||
color: _state.isAmbientLightingEnabled ? Colors.amber : tokens(context).textMuted,
|
||||
),
|
||||
title: Text(t.videoControls.ambientLighting),
|
||||
trailing: Switch(
|
||||
value: widget.isAmbientLightingEnabled,
|
||||
value: _state.isAmbientLightingEnabled,
|
||||
onChanged: (_) {
|
||||
widget.onToggleAmbientLighting?.call();
|
||||
_state.onToggleAmbientLighting?.call();
|
||||
OverlaySheetController.of(context).close();
|
||||
},
|
||||
activeThumbColor: Colors.amber,
|
||||
),
|
||||
onTap: () {
|
||||
widget.onToggleAmbientLighting?.call();
|
||||
_state.onToggleAmbientLighting?.call();
|
||||
OverlaySheetController.of(context).close();
|
||||
},
|
||||
),
|
||||
@@ -851,13 +795,13 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
|
||||
Widget _buildVersionQualityView() {
|
||||
return VersionQualityPicker(
|
||||
availableVersions: widget.availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
selectedQualityPreset: widget.selectedQualityPreset,
|
||||
serverSupportsTranscoding: widget.serverSupportsTranscoding,
|
||||
sourceDurationMs: widget.sourceDurationMs,
|
||||
onVersionSelected: (index) => widget.onVersionSelected?.call(index),
|
||||
onQualitySelected: (preset) => widget.onQualitySelected?.call(preset),
|
||||
availableVersions: _state.availableVersions,
|
||||
selectedMediaIndex: _state.selectedMediaIndex,
|
||||
selectedQualityPreset: _state.selectedQualityPreset,
|
||||
serverSupportsTranscoding: _state.serverSupportsTranscoding,
|
||||
sourceDurationMs: _state.sourceDurationMs,
|
||||
onVersionSelected: (index) => _state.onSwitchVersion?.call(index),
|
||||
onQualitySelected: (preset) => _state.onSwitchQualityPreset?.call(preset),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -962,11 +906,11 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
}
|
||||
|
||||
Widget _buildShaderView() {
|
||||
if (widget.shaderService == null) return const SizedBox.shrink();
|
||||
if (_state.shaderService == null) return const SizedBox.shrink();
|
||||
|
||||
return Consumer<ShaderProvider>(
|
||||
builder: (context, shaderProvider, _) {
|
||||
final currentPreset = widget.shaderService!.currentPreset;
|
||||
final currentPreset = _state.shaderService!.currentPreset;
|
||||
final presets = shaderProvider.allPresets;
|
||||
|
||||
// +1 for the import button at the end
|
||||
@@ -1006,13 +950,13 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
),
|
||||
onTap: () async {
|
||||
// Disable ambient lighting when selecting a shader
|
||||
if (preset.type != ShaderPresetType.none && widget.isAmbientLightingEnabled) {
|
||||
widget.onToggleAmbientLighting?.call();
|
||||
if (preset.type != ShaderPresetType.none && _state.isAmbientLightingEnabled) {
|
||||
_state.onToggleAmbientLighting?.call();
|
||||
}
|
||||
await widget.shaderService!.applyPreset(preset);
|
||||
await _state.shaderService!.applyPreset(preset);
|
||||
await shaderProvider.setPreset(preset);
|
||||
if (!context.mounted) return;
|
||||
widget.onShaderChanged?.call();
|
||||
_state.onShaderChanged?.call();
|
||||
OverlaySheetController.of(context).close();
|
||||
},
|
||||
);
|
||||
@@ -1034,14 +978,14 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
final displayName = path.basenameWithoutExtension(filePath);
|
||||
final preset = await shaderProvider.importCustomShader(filePath, displayName);
|
||||
|
||||
if (widget.shaderService != null && mounted) {
|
||||
if (preset.type != ShaderPresetType.none && widget.isAmbientLightingEnabled) {
|
||||
widget.onToggleAmbientLighting?.call();
|
||||
if (_state.shaderService != null && mounted) {
|
||||
if (preset.type != ShaderPresetType.none && _state.isAmbientLightingEnabled) {
|
||||
_state.onToggleAmbientLighting?.call();
|
||||
}
|
||||
await widget.shaderService!.applyPreset(preset);
|
||||
await _state.shaderService!.applyPreset(preset);
|
||||
await shaderProvider.setPreset(preset);
|
||||
if (!mounted) return;
|
||||
widget.onShaderChanged?.call();
|
||||
_state.onShaderChanged?.call();
|
||||
}
|
||||
|
||||
if (mounted) showSuccessSnackBar(context, t.shaders.shaderImported);
|
||||
@@ -1059,9 +1003,9 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
if (!confirmed || !mounted) return;
|
||||
|
||||
// If the deleted shader is active, clear it from the player first
|
||||
if (widget.shaderService!.currentPreset.id == preset.id) {
|
||||
await widget.shaderService!.applyPreset(ShaderPreset.none);
|
||||
if (mounted) widget.onShaderChanged?.call();
|
||||
if (_state.shaderService!.currentPreset.id == preset.id) {
|
||||
await _state.shaderService!.applyPreset(ShaderPreset.none);
|
||||
if (mounted) _state.onShaderChanged?.call();
|
||||
}
|
||||
|
||||
await shaderProvider.deleteCustomShader(preset);
|
||||
@@ -1100,7 +1044,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sleepTimer = SleepTimerService();
|
||||
final isShaderActive = widget.shaderService != null && widget.shaderService!.currentPreset.isEnabled;
|
||||
final isShaderActive = _state.shaderService != null && _state.shaderService!.currentPreset.isEnabled;
|
||||
final isZoomActive = (_zoomScale - 1.0).abs() > 0.0001;
|
||||
final isIconActive =
|
||||
_currentView == _SettingsView.menu &&
|
||||
|
||||
@@ -76,9 +76,7 @@ class ContentStripState extends State<ContentStrip> {
|
||||
late _StripTab _activeTab;
|
||||
final ScrollController _chapterScrollController = ScrollController();
|
||||
final ScrollController _queueScrollController = ScrollController();
|
||||
int? _lastAutoScrolledChapterIndex;
|
||||
int? _lastAutoScrolledQueueItemID;
|
||||
int? _lastAutoScrolledQueueIndex;
|
||||
final Map<_StripTab, Object?> _lastAutoScrolled = {};
|
||||
final Map<int, GlobalKey> _chapterItemKeys = {};
|
||||
final Map<int, GlobalKey> _queueItemKeys = {};
|
||||
late Stream<int?> _chapterIndexStream;
|
||||
@@ -110,11 +108,10 @@ class ContentStripState extends State<ContentStrip> {
|
||||
void _normalizeActiveTab() {
|
||||
if (_activeTab == _StripTab.chapters && !_hasChapters && _hasQueue) {
|
||||
_activeTab = _StripTab.queue;
|
||||
_lastAutoScrolledQueueItemID = null;
|
||||
_lastAutoScrolledQueueIndex = null;
|
||||
_lastAutoScrolled.remove(_StripTab.queue);
|
||||
} else if (_activeTab == _StripTab.queue && !_hasQueue && _hasChapters) {
|
||||
_activeTab = _StripTab.chapters;
|
||||
_lastAutoScrolledChapterIndex = null;
|
||||
_lastAutoScrolled.remove(_StripTab.chapters);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,12 +201,7 @@ class ContentStripState extends State<ContentStrip> {
|
||||
void _selectTab(_StripTab tab) {
|
||||
setState(() {
|
||||
_activeTab = tab;
|
||||
if (tab == _StripTab.chapters) {
|
||||
_lastAutoScrolledChapterIndex = null;
|
||||
} else {
|
||||
_lastAutoScrolledQueueItemID = null;
|
||||
_lastAutoScrolledQueueIndex = null;
|
||||
}
|
||||
_lastAutoScrolled.remove(tab);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -218,21 +210,12 @@ class ContentStripState extends State<ContentStrip> {
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) {
|
||||
final nodes = page == _StripTab.chapters ? _chapterFocusNodes : _queueFocusNodes;
|
||||
if (index > 0) {
|
||||
nodes[index - 1].requestFocus();
|
||||
_scrollToFocusedNode(nodes[index - 1]);
|
||||
widget.onFocusActivity?.call();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
final nodes = page == _StripTab.chapters ? _chapterFocusNodes : _queueFocusNodes;
|
||||
if (index < totalItems - 1) {
|
||||
nodes[index + 1].requestFocus();
|
||||
_scrollToFocusedNode(nodes[index + 1]);
|
||||
final target = index + (key == LogicalKeyboardKey.arrowLeft ? -1 : 1);
|
||||
if (target >= 0 && target < totalItems) {
|
||||
nodes[target].requestFocus();
|
||||
_scrollToFocusedNode(nodes[target]);
|
||||
widget.onFocusActivity?.call();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
@@ -243,7 +226,7 @@ class ContentStripState extends State<ContentStrip> {
|
||||
// Switch to chapters page and focus current chapter
|
||||
setState(() {
|
||||
_activeTab = _StripTab.chapters;
|
||||
_lastAutoScrolledChapterIndex = null;
|
||||
_lastAutoScrolled.remove(_StripTab.chapters);
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _chapterFocusNodes.isNotEmpty) {
|
||||
@@ -266,8 +249,7 @@ class ContentStripState extends State<ContentStrip> {
|
||||
// Switch to queue page and focus current queue item
|
||||
setState(() {
|
||||
_activeTab = _StripTab.queue;
|
||||
_lastAutoScrolledQueueItemID = null;
|
||||
_lastAutoScrolledQueueIndex = null;
|
||||
_lastAutoScrolled.remove(_StripTab.queue);
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _queueFocusNodes.isNotEmpty) {
|
||||
@@ -407,41 +389,86 @@ class ContentStripState extends State<ContentStrip> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChapterStrip(bool isTablet) {
|
||||
final thumbWidth = isTablet ? 200.0 : 120.0;
|
||||
final thumbHeight = isTablet ? 112.0 : 68.0;
|
||||
/// Horizontal list of strip items: auto-scrolls to [autoScrollIndex] whenever
|
||||
/// [autoScrollToken] changes, and wraps items for focus navigation.
|
||||
Widget _buildStrip({
|
||||
required _StripTab tab,
|
||||
required ScrollController controller,
|
||||
required Map<int, GlobalKey> keys,
|
||||
required List<FocusNode> nodes,
|
||||
required String focusPrefix,
|
||||
required int itemCount,
|
||||
required bool isTablet,
|
||||
required int? autoScrollIndex,
|
||||
required Object? autoScrollToken,
|
||||
required (Widget, VoidCallback?) Function(BuildContext context, int index, Key key) itemBuilder,
|
||||
}) {
|
||||
_trimItemKeys(keys, itemCount);
|
||||
|
||||
if (autoScrollIndex != null && _lastAutoScrolled[tab] != autoScrollToken) {
|
||||
_lastAutoScrolled[tab] = autoScrollToken;
|
||||
_autoScrollTo(
|
||||
controller,
|
||||
keys,
|
||||
autoScrollIndex,
|
||||
isTablet: isTablet,
|
||||
isCurrent: () => _lastAutoScrolled[tab] == autoScrollToken,
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.useFocusNavigation) {
|
||||
_ensureFocusNodes(nodes, itemCount, focusPrefix);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge,
|
||||
itemCount: itemCount,
|
||||
padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4),
|
||||
itemBuilder: (context, index) {
|
||||
final (item, onTap) = itemBuilder(context, index, _itemKeyFor(keys, index));
|
||||
|
||||
if (!widget.useFocusNavigation) return item;
|
||||
|
||||
return Align(
|
||||
alignment: .topCenter,
|
||||
child: FocusableWrapper(
|
||||
focusNode: nodes[index],
|
||||
onSelect: onTap,
|
||||
onKeyEvent: (_, event) => _handleFocusItemKeyEvent(event, index, itemCount, tab),
|
||||
onFocusChange: (hasFocus) {
|
||||
if (hasFocus) widget.onFocusActivity?.call();
|
||||
},
|
||||
borderRadius: 6,
|
||||
autoScroll: false,
|
||||
useBackgroundFocus: true,
|
||||
child: item,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChapterStrip(bool isTablet) {
|
||||
return StreamBuilder<int?>(
|
||||
stream: _chapterIndexStream,
|
||||
initialData: MediaChapter.indexAtPosition(widget.player.state.position, widget.chapters),
|
||||
builder: (context, chapterSnapshot) {
|
||||
final currentChapterIndex = chapterSnapshot.data;
|
||||
_trimItemKeys(_chapterItemKeys, widget.chapters.length);
|
||||
|
||||
if (currentChapterIndex != null && _lastAutoScrolledChapterIndex != currentChapterIndex) {
|
||||
_lastAutoScrolledChapterIndex = currentChapterIndex;
|
||||
_autoScrollTo(
|
||||
_chapterScrollController,
|
||||
_chapterItemKeys,
|
||||
currentChapterIndex,
|
||||
isTablet: isTablet,
|
||||
isCurrent: () => _lastAutoScrolledChapterIndex == currentChapterIndex,
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.useFocusNavigation) {
|
||||
_ensureFocusNodes(_chapterFocusNodes, widget.chapters.length, 'ChapterFocus');
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
return _buildStrip(
|
||||
tab: _StripTab.chapters,
|
||||
controller: _chapterScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge,
|
||||
keys: _chapterItemKeys,
|
||||
nodes: _chapterFocusNodes,
|
||||
focusPrefix: 'ChapterFocus',
|
||||
itemCount: widget.chapters.length,
|
||||
padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4),
|
||||
itemBuilder: (context, index) {
|
||||
isTablet: isTablet,
|
||||
autoScrollIndex: currentChapterIndex,
|
||||
autoScrollToken: currentChapterIndex,
|
||||
itemBuilder: (context, index, itemKey) {
|
||||
final chapter = widget.chapters[index];
|
||||
final isCurrent = currentChapterIndex == index;
|
||||
|
||||
final localThumbPath = widget.serverId != null && chapter.thumb != null
|
||||
? DownloadStorageService.instance.getArtworkPathSync(ServerId(widget.serverId!), chapter.thumb!)
|
||||
@@ -451,48 +478,25 @@ class ContentStripState extends State<ContentStrip> {
|
||||
? () => unawaited(_handleChapterTap(chapter.startTime))
|
||||
: null;
|
||||
|
||||
final itemKey = _itemKeyFor(_chapterItemKeys, index);
|
||||
final item = _buildStripItem(
|
||||
key: itemKey,
|
||||
isCurrent: isCurrent,
|
||||
isTablet: isTablet,
|
||||
thumbnail: chapter.thumb != null
|
||||
? OptimizedMediaImage.thumb(
|
||||
client: _tryGetClient(context, serverIdOrNull(widget.serverId)),
|
||||
imagePath: chapter.thumb,
|
||||
localFilePath: localThumbPath,
|
||||
width: thumbWidth,
|
||||
height: thumbHeight,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, _, _) =>
|
||||
const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34),
|
||||
)
|
||||
: null,
|
||||
title: chapter.label,
|
||||
subtitle: formatDurationTimestamp(chapter.startTime),
|
||||
onTap: onTap,
|
||||
return (
|
||||
_buildStripItem(
|
||||
key: itemKey,
|
||||
isCurrent: currentChapterIndex == index,
|
||||
isTablet: isTablet,
|
||||
thumbnail: chapter.thumb != null
|
||||
? _buildStripThumbnail(
|
||||
client: _tryGetClient(context, serverIdOrNull(widget.serverId)),
|
||||
imagePath: chapter.thumb,
|
||||
localFilePath: localThumbPath,
|
||||
isTablet: isTablet,
|
||||
)
|
||||
: null,
|
||||
title: chapter.label,
|
||||
subtitle: formatDurationTimestamp(chapter.startTime),
|
||||
onTap: onTap,
|
||||
),
|
||||
onTap,
|
||||
);
|
||||
|
||||
if (widget.useFocusNavigation) {
|
||||
return Align(
|
||||
alignment: .topCenter,
|
||||
child: FocusableWrapper(
|
||||
focusNode: _chapterFocusNodes[index],
|
||||
onSelect: onTap,
|
||||
onKeyEvent: (_, event) =>
|
||||
_handleFocusItemKeyEvent(event, index, widget.chapters.length, _StripTab.chapters),
|
||||
onFocusChange: (hasFocus) {
|
||||
if (hasFocus) widget.onFocusActivity?.call();
|
||||
},
|
||||
borderRadius: 6,
|
||||
autoScroll: false,
|
||||
useBackgroundFocus: true,
|
||||
child: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return item;
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -500,9 +504,6 @@ class ContentStripState extends State<ContentStrip> {
|
||||
}
|
||||
|
||||
Widget _buildQueueStrip(bool isTablet) {
|
||||
final thumbWidth = isTablet ? 200.0 : 120.0;
|
||||
final thumbHeight = isTablet ? 112.0 : 68.0;
|
||||
|
||||
return SettingValueBuilder<bool>(
|
||||
pref: SettingsService.hideSpoilers,
|
||||
builder: (context, hideSpoilers, _) => Consumer<PlaybackStateProvider>(
|
||||
@@ -513,35 +514,18 @@ class ContentStripState extends State<ContentStrip> {
|
||||
? -1
|
||||
: items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID);
|
||||
|
||||
_trimItemKeys(_queueItemKeys, items.length);
|
||||
|
||||
if (currentIndex >= 0 &&
|
||||
(_lastAutoScrolledQueueItemID != currentItemID || _lastAutoScrolledQueueIndex != currentIndex)) {
|
||||
_lastAutoScrolledQueueItemID = currentItemID;
|
||||
_lastAutoScrolledQueueIndex = currentIndex;
|
||||
_autoScrollTo(
|
||||
_queueScrollController,
|
||||
_queueItemKeys,
|
||||
currentIndex,
|
||||
isTablet: isTablet,
|
||||
isCurrent: () =>
|
||||
_lastAutoScrolledQueueItemID == currentItemID && _lastAutoScrolledQueueIndex == currentIndex,
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.useFocusNavigation) {
|
||||
_ensureFocusNodes(_queueFocusNodes, items.length, 'QueueFocus');
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
return _buildStrip(
|
||||
tab: _StripTab.queue,
|
||||
controller: _queueScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge,
|
||||
keys: _queueItemKeys,
|
||||
nodes: _queueFocusNodes,
|
||||
focusPrefix: 'QueueFocus',
|
||||
itemCount: items.length,
|
||||
padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4),
|
||||
itemBuilder: (context, index) {
|
||||
isTablet: isTablet,
|
||||
autoScrollIndex: currentIndex >= 0 ? currentIndex : null,
|
||||
autoScrollToken: (currentItemID, currentIndex),
|
||||
itemBuilder: (context, index, itemKey) {
|
||||
final item = items[index];
|
||||
final isCurrent = playbackState.playQueueItemIdFor(item) == currentItemID;
|
||||
|
||||
final client = item.serverId != null
|
||||
? context.tryGetMediaClientForServer(serverIdOrNull(item.serverId))
|
||||
@@ -549,47 +533,21 @@ class ContentStripState extends State<ContentStrip> {
|
||||
|
||||
void onTap() => widget.onQueueItemSelected?.call(item);
|
||||
|
||||
final itemKey = _itemKeyFor(_queueItemKeys, index);
|
||||
final stripItem = _buildStripItem(
|
||||
key: itemKey,
|
||||
isCurrent: isCurrent,
|
||||
isTablet: isTablet,
|
||||
thumbnail: item.thumbPath != null
|
||||
? OptimizedMediaImage.thumb(
|
||||
client: client,
|
||||
imagePath: item.thumbPath,
|
||||
width: thumbWidth,
|
||||
height: thumbHeight,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, _, _) =>
|
||||
const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34),
|
||||
)
|
||||
: null,
|
||||
blurThumbnail: hideSpoilers && item.shouldHideSpoiler,
|
||||
title: item.title ?? '',
|
||||
subtitle: formatQueueItemSubtitle(item),
|
||||
onTap: onTap,
|
||||
return (
|
||||
_buildStripItem(
|
||||
key: itemKey,
|
||||
isCurrent: playbackState.playQueueItemIdFor(item) == currentItemID,
|
||||
isTablet: isTablet,
|
||||
thumbnail: item.thumbPath != null
|
||||
? _buildStripThumbnail(client: client, imagePath: item.thumbPath, isTablet: isTablet)
|
||||
: null,
|
||||
blurThumbnail: hideSpoilers && item.shouldHideSpoiler,
|
||||
title: item.title ?? '',
|
||||
subtitle: formatQueueItemSubtitle(item),
|
||||
onTap: onTap,
|
||||
),
|
||||
onTap,
|
||||
);
|
||||
|
||||
if (widget.useFocusNavigation) {
|
||||
return Align(
|
||||
alignment: .topCenter,
|
||||
child: FocusableWrapper(
|
||||
focusNode: _queueFocusNodes[index],
|
||||
onSelect: onTap,
|
||||
onKeyEvent: (_, event) => _handleFocusItemKeyEvent(event, index, items.length, _StripTab.queue),
|
||||
onFocusChange: (hasFocus) {
|
||||
if (hasFocus) widget.onFocusActivity?.call();
|
||||
},
|
||||
borderRadius: 6,
|
||||
autoScroll: false,
|
||||
useBackgroundFocus: true,
|
||||
child: stripItem,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return stripItem;
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -597,6 +555,23 @@ class ContentStripState extends State<ContentStrip> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStripThumbnail({
|
||||
required MediaServerClient? client,
|
||||
required String? imagePath,
|
||||
required bool isTablet,
|
||||
String? localFilePath,
|
||||
}) {
|
||||
return OptimizedMediaImage.thumb(
|
||||
client: client,
|
||||
imagePath: imagePath,
|
||||
localFilePath: localFilePath,
|
||||
width: isTablet ? 200.0 : 120.0,
|
||||
height: isTablet ? 112.0 : 68.0,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, _, _) => const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStripItem({
|
||||
Key? key,
|
||||
required bool isCurrent,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app_icon.dart';
|
||||
|
||||
/// Gradient scrim that hosts the content strip once it is on screen.
|
||||
///
|
||||
/// [chevron] points back at the controls the strip replaced — down for the
|
||||
/// mobile swipe, up for D-pad focus. [padding] compensates for the strip's
|
||||
/// own horizontal padding, which differs between touch and focus navigation.
|
||||
class ContentStripPanel extends StatelessWidget {
|
||||
final EdgeInsetsGeometry padding;
|
||||
final IconData chevron;
|
||||
final Widget child;
|
||||
|
||||
const ContentStripPanel({super.key, required this.padding, required this.chevron, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.65), Colors.black.withValues(alpha: 0.7)],
|
||||
stops: const [0.0, 0.42, 1.0],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
AppIcon(chevron, color: Colors.white38, size: 20),
|
||||
const SizedBox(height: 4),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chevron pinned to the bottom of the controls hinting that the content
|
||||
/// strip can be pulled into view. Must be placed directly in a [Stack].
|
||||
class ContentStripHint extends StatelessWidget {
|
||||
final IconData chevron;
|
||||
|
||||
const ContentStripHint(this.chevron, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(left: 0, right: 0, bottom: 12, child: AppIcon(chevron, color: Colors.white24, size: 24));
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../focus/dpad_navigator.dart';
|
||||
import '../../../media/media_item.dart';
|
||||
import '../../../media/media_version.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../media/media_source_info.dart';
|
||||
import '../../../services/sleep_timer_service.dart';
|
||||
@@ -13,12 +11,10 @@ import '../../../utils/quality_preset_labels.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import '../models/track_controls_state.dart';
|
||||
import '../../../models/transcode_quality_preset.dart';
|
||||
import '../sheets/chapter_sheet.dart';
|
||||
import '../sheets/queue_sheet.dart';
|
||||
import '../sheets/track_sheet.dart';
|
||||
import '../sheets/video_settings_sheet.dart';
|
||||
import '../../../services/shader_service.dart';
|
||||
import '../../../utils/track_label_builder.dart';
|
||||
import '../video_control_button.dart';
|
||||
|
||||
@@ -65,43 +61,6 @@ class TrackChapterControls extends StatelessWidget {
|
||||
this.hideChaptersAndQueue = false,
|
||||
});
|
||||
|
||||
List<MediaVersion> get availableVersions => trackControlsState.availableVersions;
|
||||
int get selectedMediaIndex => trackControlsState.selectedMediaIndex;
|
||||
TranscodeQualityPreset get selectedQualityPreset => trackControlsState.selectedQualityPreset;
|
||||
bool get serverSupportsTranscoding => trackControlsState.serverSupportsTranscoding;
|
||||
ValueChanged<TranscodeQualityPreset>? get onSwitchQualityPreset => trackControlsState.onSwitchQualityPreset;
|
||||
int get boxFitMode => trackControlsState.boxFitMode;
|
||||
double get videoZoomScale => trackControlsState.videoZoomScale;
|
||||
int get audioSyncOffset => trackControlsState.audioSyncOffset;
|
||||
int get subtitleSyncOffset => trackControlsState.subtitleSyncOffset;
|
||||
bool get isRotationLocked => trackControlsState.isRotationLocked;
|
||||
bool get isScreenLocked => trackControlsState.isScreenLocked;
|
||||
bool get isFullscreen => trackControlsState.isFullscreen;
|
||||
bool get isAlwaysOnTop => trackControlsState.isAlwaysOnTop;
|
||||
VoidCallback? get onTogglePIPMode => trackControlsState.onTogglePIPMode;
|
||||
VoidCallback? get onCycleBoxFitMode => trackControlsState.onCycleBoxFitMode;
|
||||
ValueChanged<double>? get onVideoZoomChanged => trackControlsState.onVideoZoomChanged;
|
||||
VoidCallback? get onResetVideoZoom => trackControlsState.onResetVideoZoom;
|
||||
VoidCallback? get onToggleRotationLock => trackControlsState.onToggleRotationLock;
|
||||
VoidCallback? get onToggleScreenLock => trackControlsState.onToggleScreenLock;
|
||||
VoidCallback? get onToggleFullscreen => trackControlsState.onToggleFullscreen;
|
||||
VoidCallback? get onToggleAlwaysOnTop => trackControlsState.onToggleAlwaysOnTop;
|
||||
Function(int)? get onSwitchVersion => trackControlsState.onSwitchVersion;
|
||||
VoidCallback? get onLoadSeekTimes => trackControlsState.onLoadSeekTimes;
|
||||
VoidCallback? get onCancelAutoHide => trackControlsState.onCancelAutoHide;
|
||||
VoidCallback? get onStartAutoHide => trackControlsState.onStartAutoHide;
|
||||
void Function(String propertyName, int offset)? get onSyncOffsetChanged => trackControlsState.onSyncOffsetChanged;
|
||||
String? get serverId => trackControlsState.serverId;
|
||||
ShaderService? get shaderService => trackControlsState.shaderService;
|
||||
VoidCallback? get onShaderChanged => trackControlsState.onShaderChanged;
|
||||
bool get isAmbientLightingEnabled => trackControlsState.isAmbientLightingEnabled;
|
||||
VoidCallback? get onToggleAmbientLighting => trackControlsState.onToggleAmbientLighting;
|
||||
bool get canControl => trackControlsState.canControl;
|
||||
bool get isLive => trackControlsState.isLive;
|
||||
bool get subtitlesVisible => trackControlsState.subtitlesVisible;
|
||||
bool get showQueueButton => trackControlsState.showQueueButton;
|
||||
Function(MediaItem)? get onQueueItemSelected => trackControlsState.onQueueItemSelected;
|
||||
|
||||
/// Handle key event for button navigation
|
||||
KeyEventResult _handleButtonKeyEvent(FocusNode _, KeyEvent event, int index, int totalButtons) {
|
||||
if (!event.isActionable) {
|
||||
@@ -183,6 +142,7 @@ class TrackChapterControls extends StatelessWidget {
|
||||
initialData: player.state.tracks,
|
||||
builder: (context, snapshot) {
|
||||
final tracks = snapshot.data;
|
||||
final state = trackControlsState;
|
||||
final isMobile = PlatformDetector.isMobile(context);
|
||||
final isDesktop = PlatformDetector.isDesktopOS();
|
||||
|
||||
@@ -196,13 +156,14 @@ class TrackChapterControls extends StatelessWidget {
|
||||
listenable: SleepTimerService(),
|
||||
builder: (context, _) {
|
||||
final sleepTimer = SleepTimerService();
|
||||
final shaderService = state.shaderService;
|
||||
final isShaderActive =
|
||||
shaderService != null && shaderService!.isSupported && shaderService!.currentPreset.isEnabled;
|
||||
final isZoomActive = (videoZoomScale - 1.0).abs() > 0.0001;
|
||||
shaderService != null && shaderService.isSupported && shaderService.currentPreset.isEnabled;
|
||||
final isZoomActive = (state.videoZoomScale - 1.0).abs() > 0.0001;
|
||||
final isActive =
|
||||
sleepTimer.isActive ||
|
||||
audioSyncOffset != 0 ||
|
||||
subtitleSyncOffset != 0 ||
|
||||
state.audioSyncOffset != 0 ||
|
||||
state.subtitleSyncOffset != 0 ||
|
||||
isShaderActive ||
|
||||
isZoomActive;
|
||||
return _buildTrackButton(
|
||||
@@ -216,37 +177,14 @@ class TrackChapterControls extends StatelessWidget {
|
||||
isMobile: isMobile,
|
||||
isDesktop: isDesktop,
|
||||
onPressed: () {
|
||||
onCancelAutoHide?.call();
|
||||
state.onCancelAutoHide?.call();
|
||||
OverlaySheetController.of(context)
|
||||
.show(
|
||||
builder: (_) => VideoSettingsSheet(
|
||||
player: player,
|
||||
audioSyncOffset: audioSyncOffset,
|
||||
subtitleSyncOffset: subtitleSyncOffset,
|
||||
videoZoomScale: videoZoomScale,
|
||||
onVideoZoomChanged: onVideoZoomChanged,
|
||||
onResetVideoZoom: onResetVideoZoom,
|
||||
canControl: canControl,
|
||||
isLive: isLive,
|
||||
availableVersions: availableVersions,
|
||||
selectedMediaIndex: selectedMediaIndex,
|
||||
selectedQualityPreset: selectedQualityPreset,
|
||||
serverSupportsTranscoding: serverSupportsTranscoding,
|
||||
sourceDurationMs: trackControlsState.sourceDurationMs,
|
||||
onVersionSelected: onSwitchVersion == null ? null : (i) => onSwitchVersion!(i),
|
||||
onQualitySelected: onSwitchQualityPreset,
|
||||
shaderService: shaderService,
|
||||
onShaderChanged: onShaderChanged,
|
||||
isAmbientLightingEnabled: isAmbientLightingEnabled,
|
||||
onToggleAmbientLighting: onToggleAmbientLighting,
|
||||
onCancelAutoHide: onCancelAutoHide,
|
||||
onStartAutoHide: onStartAutoHide,
|
||||
onSyncOffsetChanged: onSyncOffsetChanged,
|
||||
),
|
||||
builder: (_) => VideoSettingsSheet(player: player, trackControlsState: state),
|
||||
)
|
||||
.whenComplete(() {
|
||||
onStartAutoHide?.call();
|
||||
onLoadSeekTimes?.call();
|
||||
state.onStartAutoHide?.call();
|
||||
state.onLoadSeekTimes?.call();
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -264,10 +202,10 @@ class TrackChapterControls extends StatelessWidget {
|
||||
initialData: player.state.track,
|
||||
builder: (context, selectionSnapshot) {
|
||||
final selection = selectionSnapshot.data ?? player.state.track;
|
||||
final hasSubtitleControls = trackControlsState.hasSubtitleControls(tracks);
|
||||
final hasSubtitleControls = state.hasSubtitleControls(tracks);
|
||||
final selectedSub = selection.subtitle;
|
||||
final hasActiveSubtitle = selectedSub != null && selectedSub.id != SubtitleTrack.off.id;
|
||||
final isHidden = hasSubtitleControls && hasActiveSubtitle && !subtitlesVisible;
|
||||
final isHidden = hasSubtitleControls && hasActiveSubtitle && !state.subtitlesVisible;
|
||||
final icon = hasSubtitleControls
|
||||
? (isHidden ? Symbols.subtitles_off_rounded : Symbols.subtitles_rounded)
|
||||
: Symbols.audiotrack_rounded;
|
||||
@@ -280,12 +218,12 @@ class TrackChapterControls extends StatelessWidget {
|
||||
isMobile: isMobile,
|
||||
isDesktop: isDesktop,
|
||||
onPressed: () {
|
||||
onCancelAutoHide?.call();
|
||||
state.onCancelAutoHide?.call();
|
||||
OverlaySheetController.of(context)
|
||||
.show(
|
||||
builder: (_) => TrackSheet(player: player, trackControlsState: trackControlsState),
|
||||
builder: (_) => TrackSheet(player: player, trackControlsState: state),
|
||||
)
|
||||
.whenComplete(() => onStartAutoHide?.call());
|
||||
.whenComplete(() => state.onStartAutoHide?.call());
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -306,20 +244,20 @@ class TrackChapterControls extends StatelessWidget {
|
||||
isMobile: isMobile,
|
||||
isDesktop: isDesktop,
|
||||
onPressed: () {
|
||||
onCancelAutoHide?.call();
|
||||
state.onCancelAutoHide?.call();
|
||||
OverlaySheetController.of(context)
|
||||
.show(
|
||||
builder: (_) => ChapterSheet(
|
||||
player: player,
|
||||
chapters: chapters,
|
||||
chaptersLoaded: chaptersLoaded,
|
||||
canControl: canControl,
|
||||
serverId: serverId,
|
||||
canControl: state.canControl,
|
||||
serverId: state.serverId,
|
||||
onSeekRequested: onSeekRequested,
|
||||
onSeekCompleted: onSeekCompleted,
|
||||
),
|
||||
)
|
||||
.whenComplete(() => onStartAutoHide?.call());
|
||||
.whenComplete(() => state.onStartAutoHide?.call());
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -327,7 +265,7 @@ class TrackChapterControls extends StatelessWidget {
|
||||
}
|
||||
|
||||
// Queue button (hidden on mobile when content strip is available)
|
||||
if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) {
|
||||
if (state.showQueueButton && state.onQueueItemSelected != null && !hideChaptersAndQueue) {
|
||||
final currentIndex = buttonIndex;
|
||||
buttons.add(
|
||||
_buildTrackButton(
|
||||
@@ -338,10 +276,10 @@ class TrackChapterControls extends StatelessWidget {
|
||||
isMobile: isMobile,
|
||||
isDesktop: isDesktop,
|
||||
onPressed: () {
|
||||
onCancelAutoHide?.call();
|
||||
state.onCancelAutoHide?.call();
|
||||
OverlaySheetController.of(context)
|
||||
.show(builder: (_) => QueueSheet(onItemSelected: onQueueItemSelected!))
|
||||
.whenComplete(() => onStartAutoHide?.call());
|
||||
.show(builder: (_) => QueueSheet(onItemSelected: state.onQueueItemSelected!))
|
||||
.whenComplete(() => state.onStartAutoHide?.call());
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -349,7 +287,7 @@ class TrackChapterControls extends StatelessWidget {
|
||||
}
|
||||
|
||||
// Picture-in-Picture mode
|
||||
if (onTogglePIPMode != null) {
|
||||
if (state.onTogglePIPMode != null) {
|
||||
final currentIndex = buttonIndex;
|
||||
buttons.add(
|
||||
_buildTrackButton(
|
||||
@@ -359,25 +297,25 @@ class TrackChapterControls extends StatelessWidget {
|
||||
semanticLabel: t.videoControls.pipButton,
|
||||
isMobile: isMobile,
|
||||
isDesktop: isDesktop,
|
||||
onPressed: onTogglePIPMode,
|
||||
onPressed: state.onTogglePIPMode,
|
||||
),
|
||||
);
|
||||
buttonIndex++;
|
||||
}
|
||||
|
||||
// BoxFit mode button
|
||||
if (onCycleBoxFitMode != null) {
|
||||
if (state.onCycleBoxFitMode != null) {
|
||||
final currentIndex = buttonIndex;
|
||||
buttons.add(
|
||||
_buildTrackButton(
|
||||
buttonIndex: currentIndex,
|
||||
icon: _getBoxFitIcon(boxFitMode),
|
||||
tooltip: _getBoxFitTooltip(boxFitMode),
|
||||
icon: _getBoxFitIcon(state.boxFitMode),
|
||||
tooltip: _getBoxFitTooltip(state.boxFitMode),
|
||||
semanticLabel: t.videoControls.aspectRatioButton,
|
||||
semanticValue: _getBoxFitTooltip(boxFitMode),
|
||||
semanticValue: _getBoxFitTooltip(state.boxFitMode),
|
||||
isMobile: isMobile,
|
||||
isDesktop: isDesktop,
|
||||
onPressed: onCycleBoxFitMode,
|
||||
onPressed: state.onCycleBoxFitMode,
|
||||
),
|
||||
);
|
||||
buttonIndex++;
|
||||
@@ -389,13 +327,13 @@ class TrackChapterControls extends StatelessWidget {
|
||||
buttons.add(
|
||||
_buildTrackButton(
|
||||
buttonIndex: currentIndex,
|
||||
icon: isRotationLocked ? Symbols.screen_lock_rotation_rounded : Symbols.screen_rotation_rounded,
|
||||
tooltip: isRotationLocked ? t.videoControls.unlockRotation : t.videoControls.lockRotation,
|
||||
icon: state.isRotationLocked ? Symbols.screen_lock_rotation_rounded : Symbols.screen_rotation_rounded,
|
||||
tooltip: state.isRotationLocked ? t.videoControls.unlockRotation : t.videoControls.lockRotation,
|
||||
semanticLabel: t.videoControls.rotationLockButton,
|
||||
checked: isRotationLocked,
|
||||
checked: state.isRotationLocked,
|
||||
isMobile: isMobile,
|
||||
isDesktop: isDesktop,
|
||||
onPressed: onToggleRotationLock,
|
||||
onPressed: state.onToggleRotationLock,
|
||||
),
|
||||
);
|
||||
buttonIndex++;
|
||||
@@ -412,14 +350,14 @@ class TrackChapterControls extends StatelessWidget {
|
||||
semanticLabel: t.videoControls.screenLockButton,
|
||||
isMobile: isMobile,
|
||||
isDesktop: isDesktop,
|
||||
onPressed: onToggleScreenLock,
|
||||
onPressed: state.onToggleScreenLock,
|
||||
),
|
||||
);
|
||||
buttonIndex++;
|
||||
}
|
||||
|
||||
// Always on top button (desktop only, not TV)
|
||||
if (isDesktop && onToggleAlwaysOnTop != null) {
|
||||
if (isDesktop && state.onToggleAlwaysOnTop != null) {
|
||||
final currentIndex = buttonIndex;
|
||||
buttons.add(
|
||||
_buildTrackButton(
|
||||
@@ -427,11 +365,11 @@ class TrackChapterControls extends StatelessWidget {
|
||||
icon: Symbols.layers_rounded,
|
||||
tooltip: t.videoControls.alwaysOnTopButton,
|
||||
semanticLabel: t.videoControls.alwaysOnTopButton,
|
||||
isActive: isAlwaysOnTop,
|
||||
checked: isAlwaysOnTop,
|
||||
isActive: state.isAlwaysOnTop,
|
||||
checked: state.isAlwaysOnTop,
|
||||
isMobile: isMobile,
|
||||
isDesktop: isDesktop,
|
||||
onPressed: onToggleAlwaysOnTop,
|
||||
onPressed: state.onToggleAlwaysOnTop,
|
||||
),
|
||||
);
|
||||
buttonIndex++;
|
||||
@@ -443,13 +381,15 @@ class TrackChapterControls extends StatelessWidget {
|
||||
buttons.add(
|
||||
_buildTrackButton(
|
||||
buttonIndex: currentIndex,
|
||||
icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded,
|
||||
tooltip: isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton,
|
||||
semanticLabel: isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton,
|
||||
checked: isFullscreen,
|
||||
icon: state.isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded,
|
||||
tooltip: state.isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton,
|
||||
semanticLabel: state.isFullscreen
|
||||
? t.videoControls.exitFullscreenButton
|
||||
: t.videoControls.fullscreenButton,
|
||||
checked: state.isFullscreen,
|
||||
isMobile: isMobile,
|
||||
isDesktop: isDesktop,
|
||||
onPressed: onToggleFullscreen,
|
||||
onPressed: state.onToggleFullscreen,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -462,15 +402,16 @@ class TrackChapterControls extends StatelessWidget {
|
||||
}
|
||||
|
||||
String? _versionQualitySemanticValue() {
|
||||
final state = trackControlsState;
|
||||
final values = <String>[];
|
||||
if (availableVersions.length > 1) {
|
||||
final index = selectedMediaIndex;
|
||||
if (index >= 0 && index < availableVersions.length) {
|
||||
values.add(availableVersions[index].displayLabel);
|
||||
if (state.availableVersions.length > 1) {
|
||||
final index = state.selectedMediaIndex;
|
||||
if (index >= 0 && index < state.availableVersions.length) {
|
||||
values.add(state.availableVersions[index].displayLabel);
|
||||
}
|
||||
}
|
||||
if (serverSupportsTranscoding) {
|
||||
values.add(qualityPresetLabel(selectedQualityPreset));
|
||||
if (state.serverSupportsTranscoding) {
|
||||
values.add(qualityPresetLabel(state.selectedQualityPreset));
|
||||
}
|
||||
return values.isEmpty ? null : values.join(' / ');
|
||||
}
|
||||
@@ -519,14 +460,15 @@ class TrackChapterControls extends StatelessWidget {
|
||||
|
||||
/// Calculate total button count for navigation
|
||||
int _getButtonCount(bool isMobile, bool isDesktop) {
|
||||
final state = trackControlsState;
|
||||
int count = 1; // Settings button always shown
|
||||
count++; // Audio & subtitles button always shown
|
||||
if (chapters.isNotEmpty && !hideChaptersAndQueue) count++;
|
||||
if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) count++;
|
||||
if (onTogglePIPMode != null) count++;
|
||||
if (onCycleBoxFitMode != null) count++;
|
||||
if (state.showQueueButton && state.onQueueItemSelected != null && !hideChaptersAndQueue) count++;
|
||||
if (state.onTogglePIPMode != null) count++;
|
||||
if (state.onCycleBoxFitMode != null) count++;
|
||||
if (isMobile && !PlatformDetector.isTV()) count++; // Rotation lock (not on TV)
|
||||
if (isDesktop && onToggleAlwaysOnTop != null) count++; // Always on top
|
||||
if (isDesktop && state.onToggleAlwaysOnTop != null) count++; // Always on top
|
||||
if (isDesktop) count++; // Fullscreen
|
||||
return count;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user