refactor: extract shared mixins and helpers, drop dead abstractions

Introduces shared seams for paginated views, D-pad reorder, media control
routing, async singletons and the device method channel, then points the
open-coded copies at them.

Also removes unused models and duplicated provider/server plumbing, folds
the twice-implemented artifact store in the server, and factors the
repeated Flutter toolchain prologue in CI into a composite action.
This commit is contained in:
edde746
2026-07-26 06:09:48 +02:00
parent 61344f7862
commit 352b88109b
217 changed files with 6813 additions and 8773 deletions
+16 -21
View File
@@ -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;
+98 -207
View File
@@ -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)),
),
),
);
-18
View File
@@ -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,
-18
View File
@@ -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,
+58 -242
View File
@@ -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,
);
},
);
+11 -36
View File
@@ -1060,22 +1060,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,
@@ -1402,29 +1391,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,
);
}
+21 -73
View File
@@ -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));
}
}
+91 -114
View File
@@ -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),
),
),
);
+1 -8
View File
@@ -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],
),
],
@@ -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,
),
),
],
@@ -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,
),
),
),
@@ -34,7 +34,6 @@ class TrackControlsState {
final int audioSyncOffset;
final int subtitleSyncOffset;
final bool isRotationLocked;
final bool isScreenLocked;
final bool isFullscreen;
final bool isAlwaysOnTop;
final VoidCallback? onTogglePIPMode;
@@ -96,7 +95,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,37 @@ 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,
currentPositionEpoch: widget.currentPositionEpoch,
onLiveSeek: widget.onLiveSeek,
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 +171,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 +275,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;
@@ -142,7 +142,6 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
audioSyncOffset: _audioSyncOffset,
subtitleSyncOffset: _subtitleSyncOffset,
isRotationLocked: _isRotationLocked,
isScreenLocked: _isScreenLocked,
isFullscreen: _isFullscreen,
isAlwaysOnTop: _isAlwaysOnTop,
onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV()) ? widget.onTogglePIPMode : null,
@@ -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,
],
);
}
}
+196 -313
View File
@@ -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<AudioTrack>(
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<SubtitleTrack>(
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<SubtitleTrack>(
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<AudioTrack>(
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,166 +299,135 @@ 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 unloadedSourceSidecars = widget.sourceSidecars
final hasSecondary = supportsSecondary && secondarySub != null;
final selectedSourceId = trackControlsState.selectedSubtitleChoice?.sourceStreamId;
final selectedSecondarySourceId = trackControlsState.selectedSecondarySubtitleStreamId;
final unloadedSourceSidecars = sourceSidecars
.where((track) => track.id != selectedSourceId && track.id != selectedSecondarySourceId)
.toList(growable: false);
// +1 for "Off" row. The selected direct-play sidecar is already present
// in [tracks], so only the other server sidecars are 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<SubtitleTrack>(
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<SubtitleTrack>(
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<SubtitleTrack>(
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,13 +12,10 @@ 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/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';
@@ -32,6 +29,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';
@@ -190,73 +188,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
@@ -271,6 +213,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);
@@ -283,16 +227,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;
}
@@ -372,19 +316,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() {
@@ -476,44 +420,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;
}
@@ -525,7 +469,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,
@@ -540,7 +484,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,
@@ -656,36 +600,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();
},
),
@@ -831,13 +775,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),
);
}
@@ -942,11 +886,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
@@ -986,13 +930,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();
},
);
@@ -1014,14 +958,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);
@@ -1039,9 +983,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);
@@ -1080,7 +1024,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;
}