chore: clean up code comments

This commit is contained in:
edde746
2026-08-10 20:28:41 +02:00
parent 5611c6785a
commit 69fadc220d
170 changed files with 324 additions and 1765 deletions
-1
View File
@@ -119,7 +119,6 @@ class AppDatabase extends _$AppDatabase {
// migrations while failures are still covered by this close/rethrow
// boundary and the caller's startup download-recovery decision.
await database.customSelect('SELECT 1').get();
// It deliberately does not claim capacity for a later write.
}
final outcome = await _tvosRecoveryQueue.run(
() => store.reconcile(
-2
View File
@@ -74,7 +74,6 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
final double viewportHeight = scrollController.position.viewportDimension;
final double viewportBottom = viewportTop + viewportHeight;
// Already fully visible — skip
if (targetTop >= viewportTop && targetBottom <= viewportBottom) return;
final double destination = (targetTop - viewportHeight * 0.25).clamp(
@@ -104,7 +103,6 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
final backResult = handleBackKeyAction(event, () {
if (movingIndex != null) {
// Cancel move - restore original position
setState(() {
final originalOrder = _originalOrder;
if (originalOrder != null) {
-3
View File
@@ -42,7 +42,6 @@ class FocusMemoryTracker {
/// Restore focus to the last focused item, or fallback if provided
/// Returns true if focus was successfully restored
bool restoreFocus({String? fallbackKey}) {
// Try to restore last focused item
if (_lastFocusedKey != null) {
final node = _nodes[_lastFocusedKey];
if (node != null) {
@@ -50,7 +49,6 @@ class FocusMemoryTracker {
return true;
}
}
// Fallback: focus the provided key if available
if (fallbackKey != null) {
final node = _nodes[fallbackKey];
if (node != null) {
@@ -69,7 +67,6 @@ class FocusMemoryTracker {
_nodes.remove(key);
_focused.remove(key);
}
// Clear last focused if it was pruned
if (_lastFocusedKey != null && !validKeys.contains(_lastFocusedKey)) {
_lastFocusedKey = null;
}
-1
View File
@@ -51,7 +51,6 @@ class _FocusableButtonState extends State<FocusableButton> {
final showFocus = _isFocused && isKeyboard;
final duration = FocusTheme.getAnimationDuration(context);
final enabled = widget.onPressed != null;
// In dpad mode: focused = full opacity, unfocused = dimmed
final opacity = isKeyboard && !_isFocused ? 0.6 : 1.0;
return FocusableWrapper(
+3 -22
View File
@@ -79,12 +79,6 @@ class _RenderPaintScale extends RenderProxyBox {
/// A wrapper widget that makes its child focusable with D-pad navigation support.
///
/// Provides:
/// - Visual focus indicator (border + scale animation)
/// - Keyboard/D-pad event handling (Enter/Select to activate)
/// - Optional auto-scroll to keep focused item visible
/// - Long-press detection for SELECT key
/// - Navigation callbacks (UP, BACK)
class FocusableWrapper extends StatefulWidget {
/// The child widget to wrap.
final Widget child;
@@ -287,12 +281,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
void didUpdateWidget(FocusableWrapper oldWidget) {
super.didUpdateWidget(oldWidget);
// Handle focusNode changes
if (widget.focusNode != oldWidget.focusNode) {
_bindFocusNode();
}
// Update canRequestFocus
if (widget.canRequestFocus != oldWidget.canRequestFocus) {
_focusNode.canRequestFocus = widget.canRequestFocus;
}
@@ -368,7 +360,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
final viewport = scrollable.context.findRenderObject() as RenderBox?;
if (viewport == null) return;
// Get item's position relative to viewport
final itemBox = renderObject as RenderBox;
final itemPosition = itemBox.localToGlobal(Offset.zero, ancestor: viewport);
@@ -376,17 +367,14 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
final itemHeight = itemBox.size.height;
final itemVerticalCenter = itemPosition.dy + itemHeight / 2;
// Account for focus decoration when checking item visibility
final itemTop = itemPosition.dy - _focusDecorationPadding;
final itemBottom = itemPosition.dy + itemHeight + _focusDecorationPadding;
if (widget.useComfortableZone) {
// Define comfortable zone - if item (including focus decoration) is within middle 60% of viewport, don't scroll
final comfortZoneTop = viewportHeight * 0.2;
final comfortZoneBottom = viewportHeight * 0.8;
if (itemTop >= comfortZoneTop && itemBottom <= comfortZoneBottom) {
// Item is in comfortable zone, no need to scroll
return;
}
} else {
@@ -394,22 +382,17 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
// close to target position (prevents jitter when navigating horizontally)
final targetY = viewportHeight * widget.scrollAlignment;
final distance = (itemVerticalCenter - targetY).abs();
// Skip scroll if within half the item height of target
if (distance < itemHeight / 2) {
return;
}
}
// Calculate target scroll offset for the immediate scrollable only.
// This avoids Scrollable.ensureVisible which scrolls ALL ancestor scrollables,
// which can cause issues with nested scroll views (e.g., chips bar scrolling
// out of view when focusing grid items in library browse tab).
// Avoid Scrollable.ensureVisible, which scrolls all ancestor scrollables and
// can move nested views (e.g. the chips bar) out of view when focusing grid items.
final position = scrollable.position;
final currentOffset = position.pixels;
// Target: item center should be at scrollAlignment of viewport
// Add padding to ensure focus decoration is fully visible
final targetViewportY = viewportHeight * widget.scrollAlignment;
var scrollDelta = itemVerticalCenter - targetViewportY;
// If item would be near the top edge, add extra scroll to show focus decoration
@@ -471,7 +454,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
}
}
// Handle SELECT key with optional long-press detection
if (key.isSelectKey) {
if (widget.enableLongPress) {
final result = _selectLongPress.handleKeyEvent(
@@ -547,7 +529,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
} else {
final duration = FocusTheme.getAnimationDuration(context);
final controller = _ensureAnimationController();
// Update animation duration if theme changes
if (controller.duration != duration) {
controller.duration = duration;
}
-2
View File
@@ -85,9 +85,7 @@ class _InputModeTrackerState extends State<InputModeTracker> {
// is identity-guarded — otherwise startup's bootstrap→app swap would leave
// the live registration cleared.
InputModeTracker._instance = this;
// Initialize focus highlight strategy based on starting mode
_updateFocusHighlightStrategy(_mode);
// Listen to hardware keyboard events globally
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
}
-13
View File
@@ -114,13 +114,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
DownloadProvider({required this._downloadManager, required this._database})
: _syncRuleExecutor = SyncRuleExecutor(database: _database) {
_metadataStore = _DownloadMetadataStore(_downloadManager, _database)..addListener(_onMetadataStoreChanged);
// Listen to progress updates from the download manager
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
// Listen to deletion progress updates
_deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate);
// Load persisted downloads from database
_initFuture = _loadPersistedDownloads();
// Lets the diagnostics service score whether downloads actually advance
@@ -400,7 +397,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
// Initialize artwork directory path for synchronous access
await storageService.getArtworkDirectory();
// Load all downloads from database
final downloads = await _downloadManager.getAllDownloads();
// Bulk-load all pinned metadata across every backend in a single pass
@@ -427,7 +423,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
}
// Load sync rules from database
await _loadSyncRules();
// Apply queued offline watch actions on top of the server-time metadata
@@ -844,7 +839,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
return null;
}
// Calculate aggregate statistics
int completedCount = 0;
int downloadingCount = 0;
int queuedCount = 0;
@@ -867,7 +861,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
}
// Determine overall status
final DownloadStatus overallStatus;
if (completedCount == totalEpisodes) {
overallStatus = DownloadStatus.completed;
@@ -912,22 +905,18 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// For shows/seasons, returns aggregate progress of all child episodes
/// For episodes/movies, returns direct progress
DownloadProgress? getProgress(String globalKey) {
// First check if we have direct progress (for episodes/movies)
final directProgress = _downloads[globalKey];
if (directProgress != null) {
if (!_ownsDownloadKey(globalKey)) return null;
return directProgress;
}
// If no direct progress, check if this is a show or season
// and calculate aggregate progress from episodes
final parsed = parseGlobalKey(globalKey);
if (parsed == null) return null;
final serverId = parsed.serverId;
final ratingKey = parsed.ratingKey;
// Try to get metadata to determine type
final meta = _metadata[globalKey];
if (meta == null) {
// No metadata stored yet, might be a container (show/season/artist/
@@ -1294,11 +1283,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
await _claimDownloadForProfile(globalKey, ownership, client);
if (!_isQueueOwnershipCurrent(ownership)) return false;
// Update local state immediately for UI feedback
_downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued);
safeNotifyListeners();
// Actually trigger download via DownloadManagerService
if (!_isQueueOwnershipCurrent(ownership)) return false;
await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex);
return true;
-3
View File
@@ -303,10 +303,8 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
return libraries;
}
// Create a map for quick lookup
final libraryMap = {for (final lib in libraries) lib.globalKey: lib};
// Build ordered list based on saved order
final orderedLibraries = <MediaLibrary>[];
for (final key in savedOrder) {
final lib = libraryMap.remove(key);
@@ -315,7 +313,6 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
}
}
// Add any new libraries that weren't in the saved order
orderedLibraries.addAll(libraryMap.values);
return orderedLibraries;
-1
View File
@@ -175,7 +175,6 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi
if (_isInitialized) return;
_isInitialized = true;
// Check initial connectivity
await _updateConnectionFlags();
// Monitor connectivity changes — runZonedGuarded catches async errors from
@@ -26,7 +26,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
final DownloadProvider _downloadProvider;
OfflineWatchProvider({required this._syncService, required this._downloadProvider}) {
// Listen to sync service changes to update UI
_syncService.addListener(_onSyncServiceChanged);
}
@@ -49,13 +48,11 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
///
/// Returns true if watched, false otherwise.
Future<bool> isWatched(String globalKey) async {
// First check local offline action
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
if (localStatus != null) {
return localStatus;
}
// Fall back to cached metadata
final metadata = _downloadProvider.getMetadata(globalKey);
if (metadata != null) {
return metadata.isWatched;
@@ -73,7 +70,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
/// Returns null if no position is available.
@visibleForTesting
Future<int?> getViewOffset(String globalKey) async {
// First check local offline progress
final localOffset = await _syncService.getLocalViewOffset(globalKey);
if (localOffset != null) {
return localOffset;
@@ -82,7 +78,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
if (localStatus == true) return null;
// Fall back to cached metadata
final metadata = _downloadProvider.getMetadata(globalKey);
return metadata?.viewOffsetMs;
}
@@ -127,7 +122,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
final watchStatuses = await _resolveEpisodeWatchStatuses(episodes);
// Find first unwatched episode
for (final episode in episodes) {
if (!watchStatuses[episode.globalKey]!) {
return episode;
-11
View File
@@ -103,18 +103,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final TvSpotlightController _spotlight = TvSpotlightController();
bool _isTabVisible = true;
// Track initial load so we can focus hero when content first appears
bool _initialLoadComplete = false;
bool _pendingTvBrowseRailFocus = false;
// Hub navigation keys
GlobalKey<HubSectionState>? _continueWatchingHubKey;
final Map<String, GlobalKey<HubSectionState>> _hubKeysByIdentity = {};
List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
final _hubFocusMemory = HubFocusMemory();
// Hero and app bar focus
late FocusNode _heroFocusNode;
final _actionBarKey = GlobalKey<FocusableActionBarState>();
final _serverActivitiesButtonKey = GlobalKey<ServerActivitiesButtonState>();
@@ -155,7 +152,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_continueWatchingHubKey ??= GlobalKey<HubSectionState>();
}
/// Get all hub states (continue watching + other hubs)
List<GlobalKey<HubSectionState>> get _allHubKeys {
final keys = <GlobalKey<HubSectionState>>[];
if (_continueWatchingHubKey != null && _onDeck.isNotEmpty) {
@@ -935,7 +931,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (_isLoading) LoadingIndicatorBox.sliver,
if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load),
if (!_isLoading && _errorMessage == null) ...[
// On Deck / Continue Watching
if (continueWatchingHub != null)
SliverToBoxAdapter(
child: HubSection(
@@ -1259,14 +1254,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)],
);
// Determine content type label for chip
final contentTypeLabel = heroItem.isMovie ? t.discover.movie : t.discover.tvShow;
// Spoiler protection
final hideSpoilers = SettingsService.instance.read(SettingsService.hideSpoilers);
final shouldHideSpoiler = hideSpoilers && heroItem.shouldHideSpoiler;
// Build semantic label for hero item
final heroLabel = isEpisode ? "${heroItem.grandparentTitle}, ${heroItem.title}" : heroItem.title;
return Semantics(
@@ -1420,10 +1412,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
),
],
// On small screens: show button before summary
if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
// Summary with episode info (Apple TV style)
if (heroItem.summary != null && !shouldHideSpoiler) ...[
const SizedBox(height: 12),
RichText(
@@ -1468,7 +1458,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
),
],
// On large screens: show button after summary
if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)],
],
),
@@ -42,7 +42,6 @@ class DownloadsScreen extends StatefulWidget {
class DownloadsScreenState extends State<DownloadsScreen>
with TickerProviderStateMixin, TabNavigationMixin, FocusableTab {
// Focus nodes for tab chips
final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue');
final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows');
final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies');
@@ -60,7 +59,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
@override
void initState() {
super.initState();
suppressAutoFocus = true; // Start suppressed
initTabNavigation();
}
@@ -92,7 +90,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
/// Focus the first item in the currently active tab
void _focusCurrentTab() {
// Re-enable auto-focus since user is navigating into tab content
setState(() {
suppressAutoFocus = false;
});
@@ -115,7 +112,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
/// Build the app bar title - either tabs on desktop or simple title on mobile
Widget _buildAppBarTitle() {
// On desktop/TV with side nav, show tabs in app bar
if (PlatformDetector.shouldUseSideNavigation(context)) {
return TabChipStrip(
children: [
@@ -130,7 +126,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
);
}
// On mobile, show simple title
return Text(t.downloads.title);
}
@@ -177,7 +172,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
builder: (context, hasPendingDownloads, _) =>
BackgroundDownloadWarningBanner(hasPendingDownloads: hasPendingDownloads),
),
// Tab selector chips (only on mobile - desktop has them in app bar)
if (!PlatformDetector.shouldUseSideNavigation(context))
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -197,7 +191,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
),
),
),
// Tab content
Expanded(
child: TabBarView(
controller: tabController,
-6
View File
@@ -202,7 +202,6 @@ class _HubDetailScreenState extends State<HubDetailScreen>
setState(() {
_filteredItems = List.from(_items);
// Apply sorting
if (_selectedSort != null) {
final sortKey = _selectedSort!.key;
_filteredItems.sort((a, b) {
@@ -540,21 +539,16 @@ class _HubDetailScreenState extends State<HubDetailScreen>
final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
// Determine hub content type for layout decisions
final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode));
final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode));
// Mixed hub = has both episodes AND non-episodes
final isMixedHub = hasEpisodes && hasNonEpisodes;
// Episode-only = all items are episodes with thumbnails
final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes;
// Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode)
final useWideLayout =
episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub);
// Music hubs render square album/artist artwork
final isSquareHub =
_filteredItems.isNotEmpty &&
_filteredItems.every((item) => item.cardShape(episodePosterMode) == CardShape.square);
@@ -29,7 +29,6 @@ class AlphaJumpHelper {
AlphaJumpHelper._(this.letters, this.letterToIndex, this.letterSizes, this.totalItemCount);
factory AlphaJumpHelper(List<LibraryFirstCharacter> firstCharacters, {bool descending = false}) {
// Collect characters with their sizes.
final entries = <({String letter, int size})>[];
final letterSizes = <String, int>{};
@@ -41,13 +40,11 @@ class AlphaJumpHelper {
}
}
// Re-sort by DUCET collation to match the content endpoint's ICU sort order.
entries.sort((a, b) => ducetCompare(a.letter, b.letter));
if (descending) {
entries.setAll(0, entries.reversed.toList());
}
// Build cumulative index map in the corrected order.
final letters = <String>[];
final letterToIndex = <String, int>{};
int cumulative = 0;
@@ -176,12 +176,10 @@ class ContentStateBuilder<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Loading state (only show loading indicator if items list is empty)
if (isLoading && items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
// Error state (only show error if items list is empty)
if (errorMessage != null && items.isEmpty) {
return ErrorStateWidget(
message: errorMessage!,
@@ -191,12 +189,10 @@ class ContentStateBuilder<T> extends StatelessWidget {
);
}
// Empty state
if (items.isEmpty) {
return EmptyStateWidget(message: emptyMessage, icon: emptyIcon);
}
// Content state - delegate to builder
return builder(items);
}
}
@@ -62,7 +62,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
ItemUpdatable,
TickerProviderStateMixin,
TabNavigationMixin {
// GlobalKeys for tabs to enable refresh
final _recommendedTabKey = GlobalKey();
final _browseTabKey = GlobalKey();
final _collectionsTabKey = GlobalKey();
@@ -84,7 +83,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
/// Key for the library dropdown menu button.
final _libraryDropdownKey = GlobalKey<AppMenuButtonState<String>>();
// Dynamic visible tabs and their focus nodes
List<LibraryTabType> _visibleTabs = LibraryTabType.values;
List<FocusNode> _tabFocusNodes = List.generate(
LibraryTabType.values.length,
@@ -94,10 +92,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
@override
List<FocusNode> get tabChipFocusNodes => _tabFocusNodes;
// App bar action bar
final _actionBarKey = GlobalKey<FocusableActionBarState>();
// Scroll controller for the outer CustomScrollView
final ScrollController _outerScrollController = ScrollController();
/// Reveal the floating header by jumping the outer NestedScrollView back
@@ -153,25 +149,20 @@ class _LibrariesScreenState extends State<LibrariesScreen>
return;
}
// Compute visible libraries for initial load
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
// Load saved preferences
final storage = await StorageService.getInstance();
final savedLibraryKey = storage.getSelectedLibraryKey();
// Find the library by key in visible libraries
String? libraryGlobalKeyToLoad;
if (savedLibraryKey != null) {
// Check if saved library exists and is visible
final libraryExists = visibleLibraries.any((lib) => lib.globalKey == savedLibraryKey);
if (libraryExists) {
libraryGlobalKeyToLoad = savedLibraryKey;
}
}
// Fallback to first visible library if saved key not found
if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) {
libraryGlobalKeyToLoad = visibleLibraries.first.globalKey;
}
@@ -183,16 +174,12 @@ class _LibrariesScreenState extends State<LibrariesScreen>
@override
void onTabChanged() {
// Save tab name when changed (but not when restoring from storage)
if (_selectedLibraryGlobalKey != null && !tabController.indexIsChanging) {
// Only save if this was a user-initiated tab change, not a restore
if (!_isRestoringTab) {
StorageService.getInstance().then((storage) {
storage.saveLibraryTab(_selectedLibraryGlobalKey!, _visibleTabs[tabController.index].name);
});
// Focus first item in the current tab (only for user-initiated changes)
// But not when navigating via tab bar (suppressAutoFocus is true)
if (!suppressAutoFocus) {
_focusCurrentTab();
}
@@ -306,15 +293,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
/// Handle when a tab's data has finished loading
void _handleTabDataLoaded(int tabIndex) {
// Track that this tab has loaded
_loadedTabs.add(tabIndex);
// Don't auto-focus if suppressed (e.g., when navigating via tab bar)
if (suppressAutoFocus) return;
// Only focus if this is the currently active tab
if (tabController.index == tabIndex && mounted) {
// Use post-frame callback to ensure the widget tree is fully built
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && tabController.index == tabIndex && !suppressAutoFocus) {
_focusCurrentTab();
@@ -352,21 +335,17 @@ class _LibrariesScreenState extends State<LibrariesScreen>
void _updateVisibleTabs(List<LibraryTabType> newTabs) {
if (listEquals(_visibleTabs, newTabs)) return;
// Save current tab type before changing
final currentTabType = _visibleTabs.length > tabController.index ? _visibleTabs[tabController.index] : null;
// Dispose old focus nodes and controller
for (final node in _tabFocusNodes) {
node.dispose();
}
disposeTabNavigation();
// Build new
_visibleTabs = newTabs;
_tabFocusNodes = List.generate(newTabs.length, (i) => FocusNode(debugLabel: 'tab_chip_${newTabs[i].name}'));
initTabNavigation();
// Restore tab position: find current tab type in new set, default to first
final newIndex = currentTabType != null ? newTabs.indexOf(currentTabType) : -1;
if (newIndex > 0) {
tabController.index = newIndex;
@@ -49,7 +49,6 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
_currentDescending = widget.isSortDescending;
_initialFocusNode = FocusNode(debugLabel: 'SortBottomSheetInitialFocus');
// Scroll to selected item, then handle focus
final selectedIndex = widget.selectedSort != null
? widget.sortOptions.indexWhere((s) => s.key == widget.selectedSort!.key)
: -1;
+3 -14
View File
@@ -23,12 +23,11 @@ class PlaylistItemCard extends StatefulWidget {
final VoidCallback? onRemove;
final VoidCallback? onTap;
final void Function(MediaItem source)? onRefresh;
final bool canReorder; // Whether drag handle should be shown
final bool canReorder;
// Focus state for keyboard/D-pad navigation
final bool isFocused;
final int? focusedColumn; // 0=row, 1=drag handle, 2=remove button
final bool isMoving; // Whether this item is being moved/reordered
final int? focusedColumn;
final bool isMoving;
const PlaylistItemCard({
super.key,
@@ -56,20 +55,16 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
final colorScheme = Theme.of(context).colorScheme;
final textMuted = tokens(context).textMuted;
// Determine if row is focused (main content area)
final isRowFocused = widget.isFocused && widget.focusedColumn == 0;
// Focus states for individual elements
final isDragHandleFocused = widget.isFocused && widget.focusedColumn == 1;
final isRemoveButtonFocused = widget.isFocused && widget.focusedColumn == 2;
// Determine card styling based on focus/move state
Color? cardColor;
ShapeBorder? cardShape;
if (widget.isMoving) {
cardColor = colorScheme.primaryContainer;
} else if (isRowFocused) {
// Row is focused - use visible border like FocusableWrapper
cardColor = colorScheme.surfaceContainerHighest;
cardShape = RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
@@ -126,18 +121,15 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
),
),
// Poster thumbnail
_buildPosterImage(context, item),
const SizedBox(width: 12),
// Title and metadata
Expanded(
child: Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
// Title
Text(
item.displayTitle,
style: const TextStyle(fontSize: 15, fontWeight: .w500),
@@ -147,7 +139,6 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
const SizedBox(height: 4),
// Subtitle (episode info or type)
Text(
_buildSubtitle(item),
style: TextStyle(fontSize: 13, color: textMuted),
@@ -160,13 +151,11 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
const SizedBox(width: 12),
// Duration
if (item.durationMs != null)
Text(formatDurationTextual(item.durationMs!), style: TextStyle(fontSize: 13, color: textMuted)),
const SizedBox(width: 8),
// Remove button
Container(
decoration: isRemoveButtonFocused
? BoxDecoration(
-2
View File
@@ -29,7 +29,6 @@ class AboutScreen extends StatelessWidget {
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
// App Icon and Name
Center(
child: Column(
children: [
@@ -54,7 +53,6 @@ class AboutScreen extends StatelessWidget {
const SizedBox(height: 40),
// Open Source Licenses
SettingsGroup(
margin: EdgeInsets.zero,
children: [
@@ -114,7 +114,6 @@ class _LicenseDetailScreen extends StatelessWidget {
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
// Package info card
if (mergedLicense.allPackageNames.length > 1)
Card(
child: Padding(
@@ -134,7 +133,6 @@ class _LicenseDetailScreen extends StatelessWidget {
),
if (mergedLicense.allPackageNames.length > 1) const SizedBox(height: 16),
// License cards
...licenseEntries.asMap().entries.map((entry) {
final index = entry.key;
final license = entry.value;
@@ -43,10 +43,8 @@ class AmbientLightingService {
appLogger.d('AmbientLightingService: Shader path: $_shaderPath');
// Set video-aspect-override to fill the entire output area
await _player.setProperty('video-aspect-override', outputAspect.toString());
// Append ambient lighting shader
await _player.command(['change-list', 'glsl-shaders', 'append', _shaderPath!]);
_enabled = true;
-8
View File
@@ -139,7 +139,6 @@ class DiscordRPCService {
_playbackSpeed = 1.0;
if (_isEnabled && _isConnected) {
// Upload thumbnail in background, don't block playback
unawaited(_uploadThumbnailAndUpdatePresence(revision, metadata, client));
}
}
@@ -148,9 +147,7 @@ class DiscordRPCService {
void updatePosition(Duration position) {
final isSeek = _timeline.updatePosition(position);
// Update presence if position jumped significantly (seek detected)
if (_isEnabled && _isConnected && _playbackStartTime != null && isSeek) {
// Throttle updates to max once per second
final now = DateTime.now();
if (_lastPresenceUpdate == null || now.difference(_lastPresenceUpdate!) > const Duration(seconds: 1)) {
_lastPresenceUpdate = now;
@@ -172,7 +169,6 @@ class DiscordRPCService {
Future<void> resumePlayback() async {
if (_currentMetadata == null) return;
// Reset start time for elapsed time display
_playbackStartTime = DateTime.now();
if (_isEnabled && _isConnected) {
@@ -182,7 +178,6 @@ class DiscordRPCService {
/// Pause - clear timestamp but keep showing what's playing
Future<void> pausePlayback() async {
// Clear start time so Discord stops counting
_playbackStartTime = null;
if (_isEnabled && _isConnected) {
@@ -309,12 +304,9 @@ class DiscordRPCService {
Future<String?> _uploadThumbnail(MediaItem metadata, MediaServerClient client) async {
try {
// Get the thumbnail path (prefer show poster for episodes)
final thumbPath = metadata.grandparentThumbPath ?? metadata.thumbPath;
if (thumbPath == null || thumbPath.isEmpty) return null;
// Check cache first (with expiry check). Key by backend so the same
// path on Plex and Jellyfin doesn't collide.
final cacheKey = '${client.backend.id}:$thumbPath';
final cached = _posterUrlCache[cacheKey];
if (cached != null && !cached.isExpired) {
-5
View File
@@ -192,25 +192,20 @@ class GamepadService with WindowListener {
_tabNavigationHandlers.clear();
}
// Deadzone for analog sticks (0.0 to 1.0)
static const double _stickDeadzone = 0.5;
// Auto-repeat timing for held directional inputs (D-pad / stick)
static const Duration _repeatInitialDelay = Duration(milliseconds: 400);
static const Duration _repeatInterval = Duration(milliseconds: 80);
key_sim.KeyEventSimulatorController? _keyEventSimulator;
// Track stick state to detect deadzone crossings
bool _leftStickUp = false;
bool _leftStickDown = false;
bool _leftStickLeft = false;
bool _leftStickRight = false;
// Track button states to prevent repeated events from button holds
final Set<GamepadButton> _pressedButtons = {};
final Set<GamepadButton> _suppressedButtons = {};
// Whether the app window is currently focused — ignore gamepad input when false
bool _windowFocused = true;
bool _nativeKeyHandlerRegistered = false;
bool _nativeTextInputFocused = false;
@@ -250,16 +250,13 @@ class KeyboardShortcutsService extends ChangeNotifier {
if (!isMetaPressed) modifiersMatch = false;
break;
case HotKeyModifier.capsLock:
// CapsLock is typically not used for shortcuts, ignore for now
break;
case HotKeyModifier.fn:
// Fn key is typically not used for shortcuts, ignore for now
break;
}
if (!modifiersMatch) break;
}
// Check that no extra modifiers are pressed
if (modifiersMatch) {
final hasShift = requiredModifiers.contains(HotKeyModifier.shift);
final hasControl = requiredModifiers.contains(HotKeyModifier.control);
@@ -372,7 +369,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
return shortcut.label(seekTimeSmall: _seekTimeSmall, seekTimeLarge: _seekTimeLarge);
}
// Check if a hotkey is already assigned to another action
String? getActionForHotkey(HotKey hotkey) {
for (final entry in _hotkeys.entries) {
final assignedHotkey = entry.value;
@@ -383,7 +379,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
return null;
}
// Helper method to compare two HotKey objects
bool _hotkeyEquals(HotKey a, HotKey b) {
if (a.key != b.key) return false;
-2
View File
@@ -50,8 +50,6 @@ String formatContentRating(String? contentRating) {
return '';
}
// Remove common country prefixes like "gb/", "us/", "de/", etc.
// The pattern matches: lowercase letters followed by a forward slash
final regex = RegExp(r'^[a-z]{2,3}/(.+)$', caseSensitive: false);
final match = regex.firstMatch(contentRating);
-1
View File
@@ -68,7 +68,6 @@ class BottomSheetHeader extends StatelessWidget {
Widget build(BuildContext context) {
final usesBackButton = leading == null && onBack != null;
// Determine the leading widget based on priority: leading > onBack > icon
Widget? resolvedLeading;
if (leading != null) {
resolvedLeading = leading;
-33
View File
@@ -92,7 +92,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
@override
void didUpdateWidget(DownloadTreeView oldWidget) {
super.didUpdateWidget(oldWidget);
// When suppressAutoFocus changes from true to false, focus the first item
if (oldWidget.suppressAutoFocus && !widget.suppressAutoFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _firstItemFocusNode.canRequestFocus) {
@@ -121,13 +120,11 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
);
}
/// Build the download tree from flat download list
List<DownloadTreeNode> _buildTree() {
final Map<String, List<MapEntry<String, DownloadProgress>>> showGroups = {};
final Map<String, List<MapEntry<String, DownloadProgress>>> albumGroups = {};
final List<DownloadTreeNode> movies = [];
// Group downloads
for (final entry in widget.downloads.entries) {
final globalKey = entry.key;
final download = entry.value;
@@ -136,17 +133,14 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
if (meta == null) continue;
if (meta.isEpisode) {
// Group episodes by show
final showKey = meta.grandparentId ?? 'unknown';
showGroups.putIfAbsent(showKey, () => []);
showGroups[showKey]!.add(entry);
} else if (meta.kind == MediaKind.track) {
// Group tracks by album (single level — no per-disc tier)
final albumKey = meta.parentId ?? 'unknown';
albumGroups.putIfAbsent(albumKey, () => []);
albumGroups[albumKey]!.add(entry);
} else if (meta.isMovie) {
// Movies go at top level
movies.add(
DownloadTreeNode(
key: globalKey,
@@ -161,7 +155,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
}
}
// Build show nodes
final List<DownloadTreeNode> shows = [];
for (final showEntry in showGroups.entries) {
final showKey = showEntry.key;
@@ -169,11 +162,9 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
if (episodes.isEmpty) continue;
// Get show metadata from first episode
final firstEpisode = widget.metadata[episodes.first.key];
final showTitle = firstEpisode?.grandparentTitle ?? t.downloads.unknownShow;
// Group episodes by season
final Map<String, List<MapEntry<String, DownloadProgress>>> seasonGroups = {};
for (final episode in episodes) {
final meta = widget.metadata[episode.key];
@@ -184,7 +175,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
seasonGroups[seasonKey]!.add(episode);
}
// Build season nodes
final List<DownloadTreeNode> seasons = [];
for (final seasonEntry in seasonGroups.entries) {
final seasonKey = seasonEntry.key;
@@ -192,7 +182,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
if (seasonEpisodes.isEmpty) continue;
// Get season metadata from first episode
final firstEpisode = widget.metadata[seasonEpisodes.first.key];
final seasonNumber = firstEpisode?.parentIndex;
final seasonTitle = firstEpisode?.parentTitle?.isNotEmpty == true
@@ -201,7 +190,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
? t.common.seasonNumber(number: seasonNumber)
: t.downloads.unknownSeason;
// Build episode nodes
final List<DownloadTreeNode> episodeNodes = [];
for (final episodeEntry in seasonEpisodes) {
final globalKey = episodeEntry.key;
@@ -228,14 +216,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
);
}
// Sort episodes by episode number only (not by status)
episodeNodes.sort((a, b) {
final aIndex = a.metadata?.index ?? 0;
final bIndex = b.metadata?.index ?? 0;
return aIndex.compareTo(bIndex);
});
// Calculate aggregate season progress
final seasonProgress = episodeNodes.isEmpty
? 0.0
: episodeNodes.map((e) => e.progress).reduce((a, b) => a + b) / episodeNodes.length;
@@ -255,14 +241,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
seasons.removeWhere((s) => s.children.isEmpty);
// Sort seasons by season number
seasons.sort((a, b) {
final aSeasonNum = widget.metadata[a.children.first.key]?.parentIndex ?? 0;
final bSeasonNum = widget.metadata[b.children.first.key]?.parentIndex ?? 0;
return aSeasonNum.compareTo(bSeasonNum);
});
// Calculate aggregate show progress
final showProgress = seasons.isEmpty
? 0.0
: seasons.map((s) => s.progress).reduce((a, b) => a + b) / seasons.length;
@@ -280,14 +264,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
);
}
// Build album nodes (album -> tracks)
final List<DownloadTreeNode> albums = [];
for (final albumEntry in albumGroups.entries) {
final albumKey = albumEntry.key;
final tracks = albumEntry.value;
if (tracks.isEmpty) continue;
// Album/artist names from any track's parent fields
final firstTrack = widget.metadata[tracks.first.key];
final albumTitle = firstTrack?.albumTitle ?? t.downloads.unknownAlbum;
final artistTitle = firstTrack?.albumArtistTitle;
@@ -314,7 +296,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
}
if (trackNodes.isEmpty) continue;
// Sort tracks by disc then track number
trackNodes.sort((a, b) {
final byDisc = (a.metadata?.discNumber ?? 1).compareTo(b.metadata?.discNumber ?? 1);
if (byDisc != 0) return byDisc;
@@ -336,17 +317,13 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
);
}
// Sort shows, albums, and movies by status and title
_sortNodesByStatusAndTitle(shows);
_sortNodesByStatusAndTitle(albums);
_sortNodesByStatusAndTitle(movies);
// Combine movies, shows, and albums
return [...movies, ...shows, ...albums];
}
/// Determine aggregate status from child statuses
/// Priority: downloading > queued > paused > completed > failed
DownloadStatus _determineAggregateStatus(List<DownloadStatus> statuses) {
if (statuses.isEmpty) return DownloadStatus.queued;
@@ -365,7 +342,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
return DownloadStatus.completed;
}
/// Compare statuses for sorting (downloading first, then queued, etc.)
int _compareByStatus(DownloadStatus a, DownloadStatus b) {
const statusOrder = {
DownloadStatus.downloading: 0,
@@ -378,7 +354,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
return (statusOrder[a] ?? 99).compareTo(statusOrder[b] ?? 99);
}
/// Sort nodes by status (downloading first) then by title
void _sortNodesByStatusAndTitle(List<DownloadTreeNode> nodes) {
nodes.sort((a, b) {
final statusCompare = _compareByStatus(a.status, b.status);
@@ -414,7 +389,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
});
}
/// Build a tree item widget
Widget _buildTreeItem(DownloadTreeNode node, int depth, {bool isFirst = false}) {
return _DownloadTreeItem(
node: node,
@@ -590,9 +564,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
return widget.node.status;
}
// Focus node for row content (only created if not provided externally)
FocusNode? _ownedRowFocusNode;
// Focus nodes for action buttons (up to 3 buttons max)
final List<FocusNode> _buttonFocusNodes = [];
FocusNode get _rowFocusNode => widget.rowFocusNode ?? _ownedRowFocusNode!;
@@ -676,10 +648,8 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Row content
Expanded(child: _buildRowContent(theme, canExpand)),
// Action buttons
if (actions.isNotEmpty)
Row(
mainAxisSize: .min,
@@ -696,7 +666,6 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
Widget _buildRowContent(ThemeData theme, bool canExpand) {
return Row(
children: [
// Expand/collapse icon
if (canExpand)
AppIcon(widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, fill: 1, size: 20)
else
@@ -704,12 +673,10 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
const SizedBox(width: 8),
// Status icon
DownloadStatusIcon(status: _effectiveStatus, size: 20),
const SizedBox(width: 12),
// Title and info
Expanded(
child: Column(
crossAxisAlignment: .start,
-1
View File
@@ -275,7 +275,6 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
}
}
// Handle key down and repeat events
if (!event.isActionable) {
return KeyEventResult.ignored;
}
@@ -289,7 +289,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
final ScrollController _dialogScrollController = ScrollController();
final ScrollController _sheetScrollController = ScrollController();
// Keyboard navigation: column 0 = row, 1 = visibility button, 2 = options button.
@override
List<MediaLibrary> get reorderItems => _tempLibraries;
@@ -468,16 +467,13 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
final isHidden = hiddenLibraryKeys.contains(library.globalKey);
final colorScheme = Theme.of(context).colorScheme;
// Determine background color based on state
Color? tileColor;
if (isMoving) {
tileColor = colorScheme.primaryContainer;
} else if (isFocused && focusedColumn == 0) {
// Only highlight row when row itself is focused (column 0)
tileColor = colorScheme.surfaceContainerHighest;
}
// Button focus states
final isVisibilityButtonFocused = isFocused && focusedColumn == 1;
final isOptionsButtonFocused = isFocused && focusedColumn == 2;
-8
View File
@@ -615,13 +615,11 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
// Poster with overlay
if (posterHeight != null)
SizedBox(width: double.infinity, height: posterHeight, child: poster)
else
Expanded(child: poster),
const SizedBox(height: 2),
// Title (flattened — no inner Column)
if (widget.onTap == null && item is MediaItem && _hasClickableTitle(item))
_ClickableText(
text: item.displayTitle,
@@ -637,7 +635,6 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1),
),
),
// Subtitle
if (item is MediaPlaylist)
_MediaCardHelpers.buildPlaylistMeta(context, item)
else if (item is MediaItem)
@@ -1131,7 +1128,6 @@ class _MediaCardHelpers {
}
}
// For collections, show item count
if (mi.kind == MediaKind.collection) {
final count = mi.childCount ?? mi.leafCount;
if (count != null && count > 0) {
@@ -1146,14 +1142,12 @@ class _MediaCardHelpers {
}
}
// For albums, show the album artist
if (mi.kind == MediaKind.album && mi.albumArtistTitle != null) {
return ExcludeSemantics(
child: Text(mi.albumArtistTitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
);
}
// For tracks, show "Artist • duration"
if (mi.kind == MediaKind.track) {
final parts = [?mi.trackArtistTitle, if (mi.durationMs case final durationMs?) formatDurationTextual(durationMs)];
if (parts.isNotEmpty) {
@@ -1163,7 +1157,6 @@ class _MediaCardHelpers {
}
}
// For episodes, show "S# · Episode Title" with clickable season link
if (mi.isEpisode && mi.parentIndex != null) {
if (enableDetailLinks && mi.parentId != null) {
return _buildEpisodeSubtitleRow(
@@ -1185,7 +1178,6 @@ class _MediaCardHelpers {
);
}
// For other media types, show subtitle/parent/year
if (mi.displaySubtitle != null) {
return ExcludeSemantics(
child: Text(mi.displaySubtitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
+1 -1
View File
@@ -130,7 +130,7 @@ class MediaContextMenu extends StatefulWidget {
final Object item;
final void Function(MediaItem source)? onRefresh;
final VoidCallback? onRemoveFromContinueWatching;
final VoidCallback? onListRefresh; // For refreshing list after deletion
final VoidCallback? onListRefresh;
final VoidCallback? onTap;
/// Plays the item's trailer. When non-null a "Play trailer" item is added to
@@ -173,7 +173,6 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
bool get _canControl => _trackControlsState.canControl;
bool get _isLive => _trackControlsState.isLive;
// Focus nodes for playback control buttons
late final FocusNode _prevItemFocusNode;
late final FocusNode _prevChapterFocusNode;
late final FocusNode _skipBackFocusNode;
@@ -184,30 +183,23 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
late final FocusNode _goToLiveFocusNode;
late final FocusNode _timelineFocusNode;
// Focus node for volume control
late final FocusNode _volumeFocusNode;
// Focus nodes for track/chapter controls (max 8 buttons possible)
late final List<FocusNode> _trackControlFocusNodes;
// List of button focus nodes for horizontal navigation
late final List<FocusNode> _buttonFocusNodes;
// Progressive seek acceleration state
LogicalKeyboardKey? _seekDirection; // Current direction being held
int _seekRepeatCount = 0; // Consecutive key repeats for acceleration
// Preview thumbnail during sustained dpad/keyboard seeking
bool _showKeyRepeatThumbnail = false;
Timer? _keyRepeatThumbnailTimer;
late final DebouncedSeekAccumulator _timelineSeek;
static const _keyRepeatThumbnailTimeout = Duration(milliseconds: 400);
// Content strip state
bool _contentStripVisible = false;
final GlobalKey<ContentStripState> _contentStripKey = GlobalKey<ContentStripState>();
// Track which button was last focused (for returning from content strip)
FocusNode? _lastFocusedButtonNode;
/// Whether the content strip has any content to show
@@ -714,7 +706,6 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
children: [
// Row 1: Timeline (LiveTimelineBar for time-shifted live, VideoTimelineBar for VOD)
if (_isLive && widget.captureBuffer != null) ...[
LiveTimelineBar(
player: widget.player,
@@ -748,14 +739,12 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
previewPosition: _timelineSeek.pendingPosition,
),
],
// Row 2: Playback controls and options
Focus(
onFocusChange: _onButtonRowFocusChange,
skipTraversal: true,
child: Row(
children: [
if (!_isLive) ...[
// Previous item
Opacity(
opacity: _canControl ? 1.0 : 0.5,
child: _buildFocusableButton(
@@ -354,7 +354,6 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
mainAxisAlignment: .center,
children: [
if (!widget.isLive) ...[
// Previous episode button (greyed out when unavailable)
CircularControlButton(
semanticLabel: t.videoControls.previousButton,
icon: Symbols.skip_previous_rounded,
@@ -378,7 +377,6 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
),
if (!widget.isLive) ...[
const SizedBox(width: 24),
// Next episode button (greyed out when unavailable)
CircularControlButton(
semanticLabel: t.videoControls.nextButton,
icon: Symbols.skip_next_rounded,
@@ -24,7 +24,6 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
}
final currentTrack = widget.player.state.track.subtitle;
// Nothing to hide when no subtitle track is selected.
if (currentTrack == null || currentTrack.id == SubtitleTrack.off.id) return;
_setSubtitleVisibility(false);
@@ -51,7 +50,6 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
}
void _onSubtitleTrackChanged(SubtitleTrack track) {
// Reset visibility when user explicitly picks a new subtitle track
if (track.id != 'no' && !_subtitlesVisible) {
_setSubtitleVisibility(true);
}
@@ -770,7 +770,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
},
),
// Audio Sync
_SettingsMenuItem(
icon: Symbols.sync_rounded,
title: t.videoSettings.audioSync,
@@ -779,7 +778,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
onTap: () => _navigateTo(_SettingsView.audioSync),
),
// Subtitle Sync
_SettingsMenuItem(
icon: Symbols.subtitles_rounded,
title: t.videoSettings.subtitleSync,
@@ -788,7 +786,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
onTap: () => _navigateTo(_SettingsView.subtitleSync),
),
// HDR Toggle
if (_supportsHdrControl)
_SettingsToggleItem(
pref: SettingsService.enableHDR,
@@ -808,14 +805,12 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
onTap: () => _navigateTo(_SettingsView.hdrToneMapping),
),
// Auto-Play Next Episode Toggle
_SettingsToggleItem(
pref: SettingsService.autoPlayNextEpisode,
icon: Symbols.skip_next_rounded,
title: t.videoControls.autoPlayNext,
),
// Audio Output Device (Desktop only)
if (isDesktop)
StreamBuilder<AudioDevice>(
stream: widget.player.streams.audioDevice,
@@ -845,7 +840,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
// "not Dolby" when the system reports notApplicable.
if (PlatformDetector.isAppleTV()) _AudioRenderingModeItem(player: widget.player),
// Audio Normalization
_SettingsToggleItem(
pref: SettingsService.audioNormalization,
icon: Symbols.graphic_eq_rounded,
@@ -853,7 +847,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
onAfterWrite: widget.player.setAudioNormalization,
),
// Stereo Downmix
_SettingsToggleItem(
pref: SettingsService.audioDownmix,
icon: Symbols.headphones_rounded,
@@ -62,7 +62,6 @@ class VideoControlButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Determine the effective color: explicit color > active amber > default white
final effectiveColor = color ?? (isActive ? Colors.amber : Colors.white);
final button = IconButton(
@@ -85,9 +84,9 @@ class VideoControlButton extends StatelessWidget {
semanticLabel: effectiveSemanticLabel,
semanticValue: semanticValue,
checked: checked,
borderRadius: 20, // Circular for icon buttons
autoScroll: false, // Video controls don't scroll
useBackgroundFocus: true, // Use background highlight for video controls
borderRadius: 20,
autoScroll: false,
useBackgroundFocus: true,
child: result,
);
} else if (effectiveSemanticLabel != null) {
@@ -5,10 +5,8 @@ import '../../../../i18n/strings.g.dart';
/// Contains metrics queried from the video player (MPV or ExoPlayer)
/// including video/audio codec info, playback performance, and buffer state.
class PerformanceStats {
// Player info
final String playerType; // 'mpv' or 'exoplayer'
final String playerType;
// Video metrics
final String? videoCodec;
final int? videoWidth;
final int? videoHeight;
@@ -19,38 +17,32 @@ class PerformanceStats {
final int? rotate;
final String? videoDecoderName;
// Color/Format metrics
final String? pixelformat;
final String? hwPixelformat;
final String? colormatrix;
final String? primaries;
final String? gamma;
// HDR metadata
final double? maxLuma;
final double? minLuma;
final double? maxCll;
final double? maxFall;
// Audio metrics
final String? audioCodec;
final int? audioSamplerate;
final String? audioChannels;
final int? audioBitrate;
final String? audioDecoderName;
// Tunneling
final bool tunneledPlayback;
final String? tunnelingStatus;
// Performance metrics
final double? actualFps;
final double? avsyncChange;
final double? displayFps;
final int? frameDropCount;
final int? decoderFrameDropCount;
// Buffer metrics
final int? cacheUsed;
final int? cacheLimit;
final double? cacheSpeed;
@@ -58,9 +50,8 @@ class PerformanceStats {
final int? bufferTargetBytes;
final int? bufferMaxMs;
// DV conversion
final bool dvConversionActive;
final String dvConversionMode; // "DV81", "HEVC_STRIP", "DISABLED"
final String dvConversionMode;
final int? dvConvertedRpus;
final int? dvRpuConversionFailures;
final int? dvRpuOutputTooSmall;
@@ -70,7 +61,6 @@ class PerformanceStats {
final String? dvPlaybackPath;
final String? dvPlaybackReason;
// App metrics
final int? appMemoryBytes;
final double? uiFps;
@@ -69,7 +69,6 @@ class TrackChapterControls extends StatelessWidget {
final key = event.logicalKey;
// LEFT arrow - move to previous button or exit to volume
if (key == LogicalKeyboardKey.arrowLeft) {
if (index > 0 && focusNodes != null && focusNodes!.length > index - 1) {
focusNodes![index - 1].requestFocus();
@@ -81,23 +80,19 @@ class TrackChapterControls extends StatelessWidget {
return KeyEventResult.handled;
}
// RIGHT arrow - move to next button
if (key == LogicalKeyboardKey.arrowRight) {
if (index < totalButtons - 1 && focusNodes != null && focusNodes!.length > index + 1) {
focusNodes![index + 1].requestFocus();
return KeyEventResult.handled;
}
// At end, consume to prevent bubbling
return KeyEventResult.handled;
}
// UP arrow - navigate up (e.g., to timeline)
if (key == LogicalKeyboardKey.arrowUp) {
onNavigateUp?.call();
return KeyEventResult.handled;
}
// DOWN arrow - navigate down (e.g., to content strip)
if (key == LogicalKeyboardKey.arrowDown) {
onNavigateDown?.call();
return KeyEventResult.handled;
@@ -146,11 +141,9 @@ class TrackChapterControls extends StatelessWidget {
final isMobile = PlatformDetector.isMobile(context);
final isDesktop = PlatformDetector.isDesktopOS();
// Build list of buttons dynamically to track indices
final buttons = <Widget>[];
int buttonIndex = 0;
// Settings button (always shown)
buttons.add(
ListenableBuilder(
listenable: SleepTimerService(),
@@ -193,7 +186,6 @@ class TrackChapterControls extends StatelessWidget {
);
buttonIndex++;
// Combined audio & subtitles button
{
final currentIndex = buttonIndex;
buttons.add(
@@ -232,7 +224,6 @@ class TrackChapterControls extends StatelessWidget {
buttonIndex++;
}
// Chapters button (hidden on mobile when content strip is available)
if (chapters.isNotEmpty && !hideChaptersAndQueue) {
final currentIndex = buttonIndex;
buttons.add(
@@ -264,7 +255,6 @@ class TrackChapterControls extends StatelessWidget {
buttonIndex++;
}
// Queue button (hidden on mobile when content strip is available)
if (state.showQueueButton && state.onQueueItemSelected != null && !hideChaptersAndQueue) {
final currentIndex = buttonIndex;
buttons.add(
@@ -286,7 +276,6 @@ class TrackChapterControls extends StatelessWidget {
buttonIndex++;
}
// Picture-in-Picture mode
if (state.onTogglePIPMode != null) {
final currentIndex = buttonIndex;
buttons.add(