diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 4420c47a..03179e24 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -254,37 +254,45 @@ class PlayerNative implements Player { } void _updateSelectedAudioTrack(dynamic trackId) { + _updateSelectedTrack( + trackId, + _state.tracks.audio.cast().toList(), + (selection, track) => selection.copyWith(audio: track), + ); + } + + void _updateSelectedSubtitleTrack(dynamic trackId) { + _updateSelectedTrack( + trackId, + _state.tracks.subtitle.cast().toList(), + (selection, track) => selection.copyWith(subtitle: track), + ); + } + + void _updateSelectedTrack( + dynamic trackId, + List tracks, + TrackSelection Function(TrackSelection, T?) selectionSetter, + ) { final id = trackId?.toString(); - AudioTrack? selectedTrack; + T? selectedTrack; if (id != null && id != 'no') { - selectedTrack = _state.tracks.audio.cast().firstWhere( - (t) => t?.id == id, + selectedTrack = tracks.firstWhere( + (track) => _getTrackId(track) == id, orElse: () => null, ); } _state = _state.copyWith( - track: _state.track.copyWith(audio: selectedTrack), + track: selectionSetter(_state.track, selectedTrack), ); _trackController.add(_state.track); } - void _updateSelectedSubtitleTrack(dynamic trackId) { - final id = trackId?.toString(); - SubtitleTrack? selectedTrack; - - if (id != null && id != 'no') { - selectedTrack = _state.tracks.subtitle.cast().firstWhere( - (t) => t?.id == id, - orElse: () => null, - ); - } - - _state = _state.copyWith( - track: _state.track.copyWith(subtitle: selectedTrack), - ); - _trackController.add(_state.track); + String? _getTrackId(T? track) { + final dynamic t = track; + return t?.id?.toString(); } Future _ensureInitialized() async { diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index 3aab78bf..163f6481 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; import '../../../models/plex_metadata.dart'; import '../../../utils/library_refresh_notifier.dart'; -import '../../../mixins/library_tab_focus_mixin.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../i18n/strings.g.dart'; -import '../adaptive_media_grid.dart'; import 'base_library_tab.dart'; +import 'library_grid_tab_state.dart'; /// Collections tab for library screen /// Shows collections for the current library @@ -26,14 +25,10 @@ class LibraryCollectionsTab extends BaseLibraryTab { } class _LibraryCollectionsTabState - extends BaseLibraryTabState - with LibraryTabFocusMixin { + extends LibraryGridTabState { @override String get focusNodeDebugLabel => 'collections_first_item'; - @override - int get itemCount => items.length; - @override IconData get emptyIcon => Icons.collections; @@ -57,20 +52,13 @@ class _LibraryCollectionsTabState } @override - Widget buildContent(List items) { - return AdaptiveMediaGrid( - items: items, - itemBuilder: (context, item, index) { - return FocusableMediaCard( - key: Key(item.ratingKey), - item: item, - focusNode: index == 0 ? firstItemFocusNode : null, - onListRefresh: loadItems, - onBack: widget.onBack, - ); - }, - onRefresh: loadItems, - firstItemFocusNode: firstItemFocusNode, + @override + Widget buildGridItem(BuildContext context, PlexMetadata item, int index) { + return FocusableMediaCard( + key: Key(item.ratingKey), + item: item, + focusNode: index == 0 ? firstItemFocusNode : null, + onListRefresh: loadItems, onBack: widget.onBack, ); } diff --git a/lib/screens/libraries/tabs/library_grid_tab_state.dart b/lib/screens/libraries/tabs/library_grid_tab_state.dart new file mode 100644 index 00000000..602527b8 --- /dev/null +++ b/lib/screens/libraries/tabs/library_grid_tab_state.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import '../adaptive_media_grid.dart'; +import '../../../mixins/library_tab_focus_mixin.dart'; +import 'base_library_tab.dart'; + +/// Shared state implementation for simple grid-based library tabs. +/// +/// Handles focus, item counting, and grid wiring so individual tabs only +/// implement data loading and per-item rendering. +abstract class LibraryGridTabState> + extends BaseLibraryTabState + with LibraryTabFocusMixin { + /// Build a single grid item. + Widget buildGridItem(BuildContext context, T item, int index); + + @override + int get itemCount => items.length; + + @override + Widget buildContent(List items) { + return AdaptiveMediaGrid( + items: items, + itemBuilder: (context, item, index) => + buildGridItem(context, item, index), + onRefresh: loadItems, + firstItemFocusNode: firstItemFocusNode, + onBack: widget.onBack, + ); + } +} diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index 8066f9f6..6a5dd6fd 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; import '../../../models/plex_playlist.dart'; import '../../../utils/library_refresh_notifier.dart'; -import '../../../mixins/library_tab_focus_mixin.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../i18n/strings.g.dart'; -import '../adaptive_media_grid.dart'; import 'base_library_tab.dart'; +import 'library_grid_tab_state.dart'; /// Playlists tab for library screen /// Shows playlists that contain items from the current library @@ -26,14 +25,10 @@ class LibraryPlaylistsTab extends BaseLibraryTab { } class _LibraryPlaylistsTabState - extends BaseLibraryTabState - with LibraryTabFocusMixin { + extends LibraryGridTabState { @override String get focusNodeDebugLabel => 'playlists_first_item'; - @override - int get itemCount => items.length; - @override IconData get emptyIcon => Icons.playlist_play; @@ -59,20 +54,12 @@ class _LibraryPlaylistsTabState } @override - Widget buildContent(List items) { - return AdaptiveMediaGrid( - items: items, - itemBuilder: (context, playlist, index) { - return FocusableMediaCard( - key: Key(playlist.ratingKey), - item: playlist, - focusNode: index == 0 ? firstItemFocusNode : null, - onListRefresh: loadItems, - onBack: widget.onBack, - ); - }, - onRefresh: loadItems, - firstItemFocusNode: firstItemFocusNode, + Widget buildGridItem(BuildContext context, PlexPlaylist playlist, int index) { + return FocusableMediaCard( + key: Key(playlist.ratingKey), + item: playlist, + focusNode: index == 0 ? firstItemFocusNode : null, + onListRefresh: loadItems, onBack: widget.onBack, ); } diff --git a/lib/widgets/plex_optimized_image.dart b/lib/widgets/plex_optimized_image.dart index 4cc14e85..3cdbad7e 100644 --- a/lib/widgets/plex_optimized_image.dart +++ b/lib/widgets/plex_optimized_image.dart @@ -20,7 +20,7 @@ class PlexOptimizedImage extends StatelessWidget { final IconData? fallbackIcon; final ImageType imageType; - const PlexOptimizedImage({ + const PlexOptimizedImage._({ super.key, required this.client, required this.imagePath, @@ -38,59 +38,171 @@ class PlexOptimizedImage extends StatelessWidget { this.imageType = ImageType.poster, }); - /// Named constructor for poster images with default fallback icon - const PlexOptimizedImage.poster({ - super.key, - required this.client, - required this.imagePath, - this.width, - this.height, - this.fit = BoxFit.cover, - this.filterQuality = FilterQuality.medium, - this.placeholder, - this.errorWidget, - this.fadeInDuration = const Duration(milliseconds: 300), - this.enableTranscoding = true, - this.cacheKey, - this.alignment = Alignment.center, - }) : fallbackIcon = Icons.movie, - imageType = ImageType.poster; + /// Generic constructor for optimized images. + const factory PlexOptimizedImage({ + Key? key, + required PlexClient client, + required String? imagePath, + double? width, + double? height, + BoxFit fit, + FilterQuality filterQuality, + Widget Function(BuildContext, String)? placeholder, + Widget Function(BuildContext, String, dynamic)? errorWidget, + Duration fadeInDuration, + bool enableTranscoding, + String? cacheKey, + Alignment alignment, + IconData? fallbackIcon, + ImageType imageType, + }) = PlexOptimizedImage._; - /// Named constructor for episode thumbnails - const PlexOptimizedImage.thumb({ - super.key, - required this.client, - required this.imagePath, - this.width, - this.height, - this.fit = BoxFit.cover, - this.filterQuality = FilterQuality.medium, - this.placeholder, - this.errorWidget, - this.fadeInDuration = const Duration(milliseconds: 300), - this.enableTranscoding = true, - this.cacheKey, - this.alignment = Alignment.center, - }) : fallbackIcon = Icons.video_library, - imageType = ImageType.thumb; + /// Named constructor for poster images with default fallback icon. + const factory PlexOptimizedImage.poster({ + Key? key, + required PlexClient client, + required String? imagePath, + double? width, + double? height, + BoxFit fit, + FilterQuality filterQuality, + Widget Function(BuildContext, String)? placeholder, + Widget Function(BuildContext, String, dynamic)? errorWidget, + Duration fadeInDuration, + bool enableTranscoding, + String? cacheKey, + Alignment alignment, + }) = PlexOptimizedImage._poster; - /// Named constructor for playlist images - const PlexOptimizedImage.playlist({ - super.key, - required this.client, - required this.imagePath, - this.width, - this.height, - this.fit = BoxFit.cover, - this.filterQuality = FilterQuality.medium, - this.placeholder, - this.errorWidget, - this.fadeInDuration = const Duration(milliseconds: 300), - this.enableTranscoding = true, - this.cacheKey, - this.alignment = Alignment.center, - }) : fallbackIcon = Icons.playlist_play, - imageType = ImageType.poster; + /// Named constructor for episode thumbnails. + const factory PlexOptimizedImage.thumb({ + Key? key, + required PlexClient client, + required String? imagePath, + double? width, + double? height, + BoxFit fit, + FilterQuality filterQuality, + Widget Function(BuildContext, String)? placeholder, + Widget Function(BuildContext, String, dynamic)? errorWidget, + Duration fadeInDuration, + bool enableTranscoding, + String? cacheKey, + Alignment alignment, + }) = PlexOptimizedImage._thumb; + + /// Named constructor for playlist images. + const factory PlexOptimizedImage.playlist({ + Key? key, + required PlexClient client, + required String? imagePath, + double? width, + double? height, + BoxFit fit, + FilterQuality filterQuality, + Widget Function(BuildContext, String)? placeholder, + Widget Function(BuildContext, String, dynamic)? errorWidget, + Duration fadeInDuration, + bool enableTranscoding, + String? cacheKey, + Alignment alignment, + }) = PlexOptimizedImage._playlist; + + const PlexOptimizedImage._poster({ + Key? key, + required PlexClient client, + required String? imagePath, + double? width, + double? height, + BoxFit fit = BoxFit.cover, + FilterQuality filterQuality = FilterQuality.medium, + Widget Function(BuildContext, String)? placeholder, + Widget Function(BuildContext, String, dynamic)? errorWidget, + Duration fadeInDuration = const Duration(milliseconds: 300), + bool enableTranscoding = true, + String? cacheKey, + Alignment alignment = Alignment.center, + }) : this._( + key: key, + client: client, + imagePath: imagePath, + width: width, + height: height, + fit: fit, + filterQuality: filterQuality, + placeholder: placeholder, + errorWidget: errorWidget, + fadeInDuration: fadeInDuration, + enableTranscoding: enableTranscoding, + cacheKey: cacheKey, + alignment: alignment, + fallbackIcon: Icons.movie, + imageType: ImageType.poster, + ); + + const PlexOptimizedImage._thumb({ + Key? key, + required PlexClient client, + required String? imagePath, + double? width, + double? height, + BoxFit fit = BoxFit.cover, + FilterQuality filterQuality = FilterQuality.medium, + Widget Function(BuildContext, String)? placeholder, + Widget Function(BuildContext, String, dynamic)? errorWidget, + Duration fadeInDuration = const Duration(milliseconds: 300), + bool enableTranscoding = true, + String? cacheKey, + Alignment alignment = Alignment.center, + }) : this._( + key: key, + client: client, + imagePath: imagePath, + width: width, + height: height, + fit: fit, + filterQuality: filterQuality, + placeholder: placeholder, + errorWidget: errorWidget, + fadeInDuration: fadeInDuration, + enableTranscoding: enableTranscoding, + cacheKey: cacheKey, + alignment: alignment, + fallbackIcon: Icons.video_library, + imageType: ImageType.thumb, + ); + + const PlexOptimizedImage._playlist({ + Key? key, + required PlexClient client, + required String? imagePath, + double? width, + double? height, + BoxFit fit = BoxFit.cover, + FilterQuality filterQuality = FilterQuality.medium, + Widget Function(BuildContext, String)? placeholder, + Widget Function(BuildContext, String, dynamic)? errorWidget, + Duration fadeInDuration = const Duration(milliseconds: 300), + bool enableTranscoding = true, + String? cacheKey, + Alignment alignment = Alignment.center, + }) : this._( + key: key, + client: client, + imagePath: imagePath, + width: width, + height: height, + fit: fit, + filterQuality: filterQuality, + placeholder: placeholder, + errorWidget: errorWidget, + fadeInDuration: fadeInDuration, + enableTranscoding: enableTranscoding, + cacheKey: cacheKey, + alignment: alignment, + fallbackIcon: Icons.playlist_play, + imageType: ImageType.poster, + ); @override Widget build(BuildContext context) { diff --git a/lib/widgets/video_controls/helpers/track_filter_helper.dart b/lib/widgets/video_controls/helpers/track_filter_helper.dart index be9a62a7..6a8bda3e 100644 --- a/lib/widgets/video_controls/helpers/track_filter_helper.dart +++ b/lib/widgets/video_controls/helpers/track_filter_helper.dart @@ -4,28 +4,9 @@ import '../../../mpv/mpv.dart'; /// /// This keeps track-filter rules in one place and eliminates duplication. class TrackFilterHelper { - /// Filter out 'auto' and 'no' tracks from a list of audio tracks - static List filterAudioTracks(List tracks) { - return tracks - .where((track) => track.id != 'auto' && track.id != 'no') - .toList(); - } - - /// Filter out 'auto' and 'no' tracks from a list of subtitle tracks - static List filterSubtitleTracks(List tracks) { - return tracks - .where((track) => track.id != 'auto' && track.id != 'no') - .toList(); - } - /// Generic method to filter tracks based on type static List filterTracks(List tracks) { - if (T == AudioTrack) { - return filterAudioTracks(tracks as List) as List; - } else if (T == SubtitleTrack) { - return filterSubtitleTracks(tracks as List) as List; - } - return tracks; + return tracks.where(_isAllowedTrack).toList(); } /// Extract and filter tracks from Tracks object @@ -45,4 +26,14 @@ class TrackFilterHelper { static bool hasTracks(List tracks) { return filterTracks(tracks).isNotEmpty; } + + static bool _isAllowedTrack(T track) { + final id = switch (track) { + AudioTrack t => t.id, + SubtitleTrack t => t.id, + _ => '', + }; + + return id != 'auto' && id != 'no'; + } } diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart index 4afcd1fc..2515a0cb 100644 --- a/lib/widgets/video_controls/helpers/track_selection_helper.dart +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -48,12 +48,9 @@ class TrackSelectionHelper { required bool isSelected, required VoidCallback onTap, }) { - return ListTile( - title: Text( - 'Off', - style: TextStyle(color: isSelected ? Colors.blue : Colors.white), - ), - trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) : null, + return _buildSelectableTile( + label: 'Off', + isSelected: isSelected, onTap: onTap, ); } @@ -63,6 +60,18 @@ class TrackSelectionHelper { required String label, required bool isSelected, required VoidCallback onTap, + }) { + return _buildSelectableTile( + label: label, + isSelected: isSelected, + onTap: onTap, + ); + } + + static Widget _buildSelectableTile({ + required String label, + required bool isSelected, + required VoidCallback onTap, }) { return ListTile( title: Text(