chore: clean up code comments
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user