refactor: deduplicate UI helpers & track handling

This commit is contained in:
edde746
2025-12-07 15:54:24 +01:00
parent 80d60077d7
commit 4d65607e59
7 changed files with 265 additions and 139 deletions
+27 -19
View File
@@ -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,
);
}
+164 -52
View File
@@ -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(