refactor: deduplicate UI helpers & track handling
This commit is contained in:
@@ -254,37 +254,45 @@ class PlayerNative implements Player {
|
||||
}
|
||||
|
||||
void _updateSelectedAudioTrack(dynamic trackId) {
|
||||
_updateSelectedTrack<AudioTrack>(
|
||||
trackId,
|
||||
_state.tracks.audio.cast<AudioTrack?>().toList(),
|
||||
(selection, track) => selection.copyWith(audio: track),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateSelectedSubtitleTrack(dynamic trackId) {
|
||||
_updateSelectedTrack<SubtitleTrack>(
|
||||
trackId,
|
||||
_state.tracks.subtitle.cast<SubtitleTrack?>().toList(),
|
||||
(selection, track) => selection.copyWith(subtitle: track),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateSelectedTrack<T>(
|
||||
dynamic trackId,
|
||||
List<T?> tracks,
|
||||
TrackSelection Function(TrackSelection, T?) selectionSetter,
|
||||
) {
|
||||
final id = trackId?.toString();
|
||||
AudioTrack? selectedTrack;
|
||||
T? selectedTrack;
|
||||
|
||||
if (id != null && id != 'no') {
|
||||
selectedTrack = _state.tracks.audio.cast<AudioTrack?>().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<SubtitleTrack?>().firstWhere(
|
||||
(t) => t?.id == id,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
_state = _state.copyWith(
|
||||
track: _state.track.copyWith(subtitle: selectedTrack),
|
||||
);
|
||||
_trackController.add(_state.track);
|
||||
String? _getTrackId<T>(T? track) {
|
||||
final dynamic t = track;
|
||||
return t?.id?.toString();
|
||||
}
|
||||
|
||||
Future<void> _ensureInitialized() async {
|
||||
|
||||
@@ -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<PlexMetadata> {
|
||||
}
|
||||
|
||||
class _LibraryCollectionsTabState
|
||||
extends BaseLibraryTabState<PlexMetadata, LibraryCollectionsTab>
|
||||
with LibraryTabFocusMixin {
|
||||
extends LibraryGridTabState<PlexMetadata, LibraryCollectionsTab> {
|
||||
@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<PlexMetadata> items) {
|
||||
return AdaptiveMediaGrid<PlexMetadata>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<T, W extends BaseLibraryTab<T>>
|
||||
extends BaseLibraryTabState<T, W>
|
||||
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<T> items) {
|
||||
return AdaptiveMediaGrid<T>(
|
||||
items: items,
|
||||
itemBuilder: (context, item, index) =>
|
||||
buildGridItem(context, item, index),
|
||||
onRefresh: loadItems,
|
||||
firstItemFocusNode: firstItemFocusNode,
|
||||
onBack: widget.onBack,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PlexPlaylist> {
|
||||
}
|
||||
|
||||
class _LibraryPlaylistsTabState
|
||||
extends BaseLibraryTabState<PlexPlaylist, LibraryPlaylistsTab>
|
||||
with LibraryTabFocusMixin {
|
||||
extends LibraryGridTabState<PlexPlaylist, LibraryPlaylistsTab> {
|
||||
@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<PlexPlaylist> items) {
|
||||
return AdaptiveMediaGrid<PlexPlaylist>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<AudioTrack> filterAudioTracks(List<AudioTrack> 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<SubtitleTrack> filterSubtitleTracks(List<SubtitleTrack> tracks) {
|
||||
return tracks
|
||||
.where((track) => track.id != 'auto' && track.id != 'no')
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Generic method to filter tracks based on type
|
||||
static List<T> filterTracks<T>(List<T> tracks) {
|
||||
if (T == AudioTrack) {
|
||||
return filterAudioTracks(tracks as List<AudioTrack>) as List<T>;
|
||||
} else if (T == SubtitleTrack) {
|
||||
return filterSubtitleTracks(tracks as List<SubtitleTrack>) as List<T>;
|
||||
}
|
||||
return tracks;
|
||||
return tracks.where(_isAllowedTrack).toList();
|
||||
}
|
||||
|
||||
/// Extract and filter tracks from Tracks object
|
||||
@@ -45,4 +26,14 @@ class TrackFilterHelper {
|
||||
static bool hasTracks<T>(List<T> tracks) {
|
||||
return filterTracks<T>(tracks).isNotEmpty;
|
||||
}
|
||||
|
||||
static bool _isAllowedTrack<T>(T track) {
|
||||
final id = switch (track) {
|
||||
AudioTrack t => t.id,
|
||||
SubtitleTrack t => t.id,
|
||||
_ => '',
|
||||
};
|
||||
|
||||
return id != 'auto' && id != 'no';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user