refactor: deduplicate

This commit is contained in:
edde746
2025-12-14 20:18:36 +01:00
parent b1c44e639c
commit 5f78bbe93a
26 changed files with 797 additions and 756 deletions
+17
View File
@@ -0,0 +1,17 @@
import 'plex_metadata.dart';
/// Extension on PlexMetadata for type checking convenience methods
extension PlexMetadataType on PlexMetadata {
bool get isShow => type.toLowerCase() == 'show';
bool get isMovie => type.toLowerCase() == 'movie';
bool get isSeason => type.toLowerCase() == 'season';
bool get isEpisode => type.toLowerCase() == 'episode';
bool get isArtist => type.toLowerCase() == 'artist';
bool get isAlbum => type.toLowerCase() == 'album';
bool get isTrack => type.toLowerCase() == 'track';
bool get isCollection => type.toLowerCase() == 'collection';
bool get isPlaylist => type.toLowerCase() == 'playlist';
bool get isClip => type.toLowerCase() == 'clip';
bool get isMusicContent => isArtist || isAlbum || isTrack;
bool get isVideoContent => isShow || isMovie || isSeason || isEpisode;
}
+7 -15
View File
@@ -5,6 +5,7 @@ import '../widgets/focused_scroll_scaffold.dart';
import '../i18n/strings.g.dart';
import '../utils/dialogs.dart';
import '../utils/app_logger.dart';
import '../utils/snackbar_helper.dart';
import 'base_media_list_detail_screen.dart';
/// Screen to display the contents of a collection
@@ -55,9 +56,7 @@ class _CollectionDetailScreenState
if (sectionId == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.collections.unknownLibrarySection)),
);
showErrorSnackBar(context, t.collections.unknownLibrarySection);
}
return;
}
@@ -82,28 +81,21 @@ class _CollectionDetailScreenState
if (mounted) {
if (success) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.collections.deleted)));
showSuccessSnackBar(context, t.collections.deleted);
Navigator.pop(
context,
true,
); // Return true to indicate refresh needed
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.collections.deleteFailed)));
showErrorSnackBar(context, t.collections.deleteFailed);
}
}
} catch (e) {
appLogger.e('Failed to delete collection', error: e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
t.collections.deleteFailedWithError(error: e.toString()),
),
),
showErrorSnackBar(
context,
t.collections.deleteFailedWithError(error: e.toString()),
);
}
}
+3 -2
View File
@@ -8,6 +8,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../../services/plex_client.dart';
import '../utils/plex_image_helper.dart';
import '../models/plex_metadata.dart';
import '../models/plex_metadata_extensions.dart';
import '../models/plex_hub.dart';
import '../providers/multi_server_provider.dart';
import '../providers/server_state_provider.dart';
@@ -1187,13 +1188,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
Widget _buildHeroItem(PlexMetadata heroItem) {
final isEpisode = heroItem.type.toLowerCase() == 'episode';
final isEpisode = heroItem.isEpisode;
final showName = heroItem.grandparentTitle ?? heroItem.title;
final screenWidth = MediaQuery.of(context).size.width;
final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth);
// Determine content type label for chip
final contentTypeLabel = heroItem.type.toLowerCase() == 'movie'
final contentTypeLabel = heroItem.isMovie
? t.discover.movie
: t.discover.tvShow;
+6 -8
View File
@@ -4,6 +4,7 @@ import '../../models/plex_metadata.dart';
import '../../utils/app_logger.dart';
import '../../utils/media_navigation_helper.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart';
import '../../i18n/strings.g.dart';
import 'folder_tree_item.dart';
import 'empty_state_widget.dart';
@@ -125,14 +126,11 @@ class _FolderTreeViewState extends State<FolderTreeView> {
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
t.errors.failedToLoad(
context: t.libraries.folders,
error: e.toString(),
),
),
showErrorSnackBar(
context,
t.errors.failedToLoad(
context: t.libraries.folders,
error: e.toString(),
),
);
}
+9 -19
View File
@@ -16,6 +16,8 @@ import '../../providers/multi_server_provider.dart';
import '../../utils/app_logger.dart';
import '../../utils/platform_detector.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart';
import '../../utils/content_type_helper.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/focusable_tab_chip.dart';
import '../main_screen.dart';
@@ -351,7 +353,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// Filter out music libraries (type: 'artist') since music playback is not yet supported
// Only show movie and TV show libraries
final filteredLibraries = allLibraries
.where((lib) => lib.type.toLowerCase() != 'artist')
.where((lib) => !ContentTypeHelper.isMusicLibrary(lib))
.toList();
// Load saved library order and apply it
@@ -864,34 +866,22 @@ class _LibrariesScreenState extends State<LibrariesScreen>
final client = context.getClientForLibrary(library);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(progressMessage),
duration: const Duration(seconds: 2),
),
showAppSnackBar(
context,
progressMessage,
duration: const Duration(seconds: 2),
);
}
await action(client);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(successMessage),
duration: const Duration(seconds: 3),
),
);
showSuccessSnackBar(context, successMessage);
}
} catch (e) {
appLogger.e('Library action failed', error: e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(failureMessage(e)),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
showErrorSnackBar(context, failureMessage(e));
}
}
}
+52 -119
View File
@@ -13,6 +13,7 @@ import '../widgets/plex_optimized_image.dart';
import '../utils/plex_image_helper.dart';
import '../../services/plex_client.dart';
import '../models/plex_metadata.dart';
import '../models/plex_metadata_extensions.dart';
import '../models/download_status.dart';
import '../providers/playback_state_provider.dart';
import '../providers/download_provider.dart';
@@ -22,6 +23,7 @@ import '../utils/app_logger.dart';
import '../utils/content_rating_formatter.dart';
import '../utils/duration_formatter.dart';
import '../utils/provider_extensions.dart';
import '../utils/snackbar_helper.dart';
import '../utils/video_player_navigation.dart';
import '../widgets/app_bar_back_button.dart';
import '../utils/desktop_window_padding.dart';
@@ -139,7 +141,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
onPressed: () async {
// For TV shows, play the OnDeck episode if available
// Otherwise, play the first episode of the first season
if (metadata.type.toLowerCase() == 'show') {
if (metadata.isShow) {
if (_onDeckEpisode != null) {
appLogger.d(
'Playing on deck episode: ${_onDeckEpisode!.title}',
@@ -195,8 +197,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
),
const SizedBox(width: 12),
// Shuffle button (only for shows and seasons)
if (metadata.type.toLowerCase() == 'show' ||
metadata.type.toLowerCase() == 'season') ...[
if (metadata.isShow || metadata.isSeason) ...[
IconButton.filledTonal(
onPressed: () async {
await _handleShufflePlayWithQueue(
@@ -318,13 +319,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
client,
);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(
const SnackBar(
content: Text('Download resumed'),
),
);
showAppSnackBar(context, 'Download resumed');
}
},
icon: const AppIcon(
@@ -361,28 +356,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
);
if (context.mounted) {
ScaffoldMessenger.of(
showSuccessSnackBar(
context,
).showSnackBar(
SnackBar(
content: Text(
t.downloads.downloadQueued,
),
),
t.downloads.downloadQueued,
);
}
} on CellularDownloadBlockedException {
if (context.mounted) {
ScaffoldMessenger.of(
showErrorSnackBar(
context,
).showSnackBar(
SnackBar(
content: Text(
t
.settings
.cellularDownloadBlocked,
),
),
t.settings.cellularDownloadBlocked,
);
}
}
@@ -436,14 +419,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
globalKey,
);
if (context.mounted) {
ScaffoldMessenger.of(
showSuccessSnackBar(
context,
).showSnackBar(
SnackBar(
content: Text(
t.downloads.downloadDeleted,
),
),
t.downloads.downloadDeleted,
);
}
} else if (action == 'retry' &&
@@ -461,28 +439,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
client,
);
if (context.mounted) {
ScaffoldMessenger.of(
showSuccessSnackBar(
context,
).showSnackBar(
SnackBar(
content: Text(
t.downloads.downloadQueued,
),
),
t.downloads.downloadQueued,
);
}
} on CellularDownloadBlockedException {
if (context.mounted) {
ScaffoldMessenger.of(
showErrorSnackBar(
context,
).showSnackBar(
SnackBar(
content: Text(
t
.settings
.cellularDownloadBlocked,
),
),
t.settings.cellularDownloadBlocked,
);
}
}
@@ -526,11 +492,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
count: count,
)
: 'All episodes already downloaded';
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(content: Text(message)),
);
showAppSnackBar(context, message);
}
},
tooltip: tooltip,
@@ -584,14 +546,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
globalKey,
);
if (context.mounted) {
ScaffoldMessenger.of(
showSuccessSnackBar(
context,
).showSnackBar(
SnackBar(
content: Text(
t.downloads.downloadDeleted,
),
),
t.downloads.downloadDeleted,
);
}
}
@@ -623,9 +580,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
count: count,
)
: t.downloads.downloadQueued;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
showSuccessSnackBar(context, message);
}
},
icon: const AppIcon(
@@ -663,16 +618,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isWatched
? t
.messages
.markedAsUnwatchedOffline
: t.messages.markedAsWatchedOffline,
),
),
showAppSnackBar(
context,
isWatched
? t.messages.markedAsUnwatchedOffline
: t.messages.markedAsWatchedOffline,
);
// Refresh offline OnDeck
_loadOfflineOnDeckEpisode();
@@ -693,14 +643,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
}
if (context.mounted) {
_watchStateChanged = true;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isWatched
? t.messages.markedAsUnwatched
: t.messages.markedAsWatched,
),
),
showSuccessSnackBar(
context,
isWatched
? t.messages.markedAsUnwatched
: t.messages.markedAsWatched,
);
// Update watch state without full rebuild
_updateWatchState();
@@ -708,14 +655,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
t.messages.errorLoading(
error: e.toString(),
),
),
),
showErrorSnackBar(
context,
t.messages.errorLoading(error: e.toString()),
);
}
}
@@ -795,14 +737,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
// Offline mode: use passed metadata directly, load seasons from downloads
if (widget.isOffline) {
final type = widget.metadata.type.toLowerCase();
setState(() {
_fullMetadata = widget.metadata;
_isLoadingMetadata = false;
});
if (type == 'show') {
if (widget.metadata.isShow) {
_loadSeasonsFromDownloads();
// Get offline OnDeck episode
_loadOfflineOnDeckEpisode();
@@ -847,7 +787,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
});
// Load seasons if it's a show
if (metadata.type.toLowerCase() == 'show') {
if (metadata.isShow) {
_loadSeasons();
}
return;
@@ -859,7 +799,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
_isLoadingMetadata = false;
});
if (widget.metadata.type.toLowerCase() == 'show') {
if (widget.metadata.isShow) {
_loadSeasons();
}
} catch (e) {
@@ -869,7 +809,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
_isLoadingMetadata = false;
});
if (widget.metadata.type.toLowerCase() == 'show') {
if (widget.metadata.isShow) {
_loadSeasons();
}
}
@@ -1083,7 +1023,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
// For shows, also refetch seasons to update their watch counts
List<PlexMetadata>? updatedSeasons;
if (metadata.type.toLowerCase() == 'show') {
if (metadata.isShow) {
final seasons = await client.getChildren(widget.metadata.ratingKey);
// Preserve serverId for each season
updatedSeasons = seasons
@@ -1130,9 +1070,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
if (_seasons.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.noSeasonsFound)));
showErrorSnackBar(context, t.messages.noSeasonsFound);
}
return;
}
@@ -1163,9 +1101,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
if (episodes.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.noEpisodesFound)));
showErrorSnackBar(context, t.messages.noEpisodesFound);
}
return;
}
@@ -1192,8 +1128,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
showErrorSnackBar(
context,
t.messages.errorLoading(error: e.toString()),
);
}
}
@@ -1208,9 +1145,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
// Shuffle requires server connectivity
if (widget.isOffline) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Shuffle not available offline')),
);
showErrorSnackBar(context, 'Shuffle not available offline');
}
return;
}
@@ -1219,7 +1154,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
if (client == null) return;
final playbackState = context.read<PlaybackStateProvider>();
final itemType = metadata.type.toLowerCase();
try {
// Show loading indicator
@@ -1234,9 +1168,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
// Determine the rating key for the play queue
String showRatingKey;
if (itemType == 'show') {
if (metadata.isShow) {
showRatingKey = metadata.ratingKey;
} else if (itemType == 'season') {
} else if (metadata.isSeason) {
// For seasons, we need the show's rating key
// The season's parentRatingKey should point to the show
if (metadata.parentRatingKey == null) {
@@ -1262,9 +1196,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
playQueue.items == null ||
playQueue.items!.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.noEpisodesFound)));
showErrorSnackBar(context, t.messages.noEpisodesFound);
}
return;
}
@@ -1298,8 +1230,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
showErrorSnackBar(
context,
t.messages.errorLoading(error: e.toString()),
);
}
}
@@ -1309,7 +1242,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
Widget build(BuildContext context) {
// Use full metadata if loaded, otherwise use passed metadata
final metadata = _fullMetadata ?? widget.metadata;
final isShow = metadata.type.toLowerCase() == 'show';
final isShow = metadata.isShow;
KeyEventResult handleBack(FocusNode _, KeyEvent event) =>
handleBackKeyNavigation(context, event, result: _watchStateChanged);
@@ -1886,7 +1819,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
String _getPlayButtonLabel(PlexMetadata metadata) {
// For TV shows - use compact S1E1 format
if (metadata.type.toLowerCase() == 'show') {
if (metadata.isShow) {
if (_onDeckEpisode != null) {
final episode = _onDeckEpisode!;
final seasonNum = episode.parentIndex ?? 0;
@@ -1910,7 +1843,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
IconData _getPlayButtonIcon(PlexMetadata metadata) {
// For TV shows
if (metadata.type.toLowerCase() == 'show') {
if (metadata.isShow) {
if (_onDeckEpisode != null) {
final episode = _onDeckEpisode!;
// Check if episode has been partially watched
+4 -6
View File
@@ -7,6 +7,7 @@ import '../../utils/duration_formatter.dart';
import '../../utils/provider_extensions.dart';
import '../../i18n/strings.g.dart';
import '../../widgets/media_context_menu.dart';
import '../../widgets/media_progress_bar.dart';
import '../../widgets/plex_optimized_image.dart';
/// Custom list item widget for playlist items
@@ -102,12 +103,9 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
if (item.viewOffset != null && item.duration != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: LinearProgressIndicator(
value: item.viewOffset! / item.duration!,
backgroundColor: Colors.grey[800],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
child: MediaProgressBar(
viewOffset: item.viewOffset!,
duration: item.duration!,
minHeight: 3,
),
),
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../../models/plex_home_user.dart';
import '../../providers/user_profile_provider.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart';
import 'profile_list_tile.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../i18n/strings.g.dart';
@@ -117,13 +118,9 @@ class ProfileSwitchScreen extends StatelessWidget {
if (success && context.mounted) {
Navigator.of(context).pop();
} else if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
t.errors.failedToSwitchProfile(displayName: user.displayName),
),
backgroundColor: Theme.of(context).colorScheme.error,
),
showErrorSnackBar(
context,
t.errors.failedToSwitchProfile(displayName: user.displayName),
);
}
}
+2 -3
View File
@@ -11,6 +11,7 @@ import '../providers/multi_server_provider.dart';
import '../providers/settings_provider.dart';
import '../utils/app_logger.dart';
import '../utils/sliver_adaptive_media_builder.dart';
import '../utils/snackbar_helper.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/media_card.dart';
@@ -115,9 +116,7 @@ class _SearchScreenState extends State<SearchScreen>
setState(() {
_isSearching = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.errors.searchFailed(error: e))),
);
showErrorSnackBar(context, t.errors.searchFailed(error: e));
}
}
}
+7 -24
View File
@@ -16,6 +16,7 @@ import '../../providers/theme_provider.dart';
import '../../services/keyboard_shortcuts_service.dart';
import '../../services/settings_service.dart' as settings;
import '../../services/update_service.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/desktop_app_bar.dart';
import 'hotkey_recorder_widget.dart';
import 'about_screen.dart';
@@ -493,9 +494,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
.isDirectoryWritable(dir);
if (!isWritable) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.settings.downloadLocationInvalid)),
);
showErrorSnackBar(context, t.settings.downloadLocationInvalid);
}
return;
}
@@ -510,16 +509,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) {
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.settings.downloadLocationChanged)),
);
showSuccessSnackBar(context, t.settings.downloadLocationChanged);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.settings.downloadLocationSelectError)),
);
showErrorSnackBar(context, t.settings.downloadLocationSelectError);
}
}
}
@@ -530,9 +525,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) {
setState(() {});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.settings.downloadLocationReset)));
showAppSnackBar(context, t.settings.downloadLocationReset);
}
}
@@ -1254,12 +1247,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (updateInfo == null || updateInfo['hasUpdate'] != true) {
// Show "no updates" message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.update.latestVersion),
duration: Duration(seconds: 2),
),
);
showAppSnackBar(context, t.update.latestVersion);
}
}
} catch (e) {
@@ -1268,12 +1256,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_isCheckingForUpdate = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.update.checkFailed),
duration: Duration(seconds: 2),
),
);
showErrorSnackBar(context, t.update.checkFailed);
}
}
}
+7 -7
View File
@@ -15,6 +15,7 @@ import '../../services/plex_client.dart';
import '../services/plex_api_cache.dart';
import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
import '../models/plex_metadata_extensions.dart';
import '../models/plex_media_info.dart';
import '../providers/download_provider.dart';
import '../providers/playback_state_provider.dart';
@@ -32,6 +33,7 @@ import '../utils/orientation_helper.dart';
import '../utils/platform_detector.dart';
import '../utils/provider_extensions.dart';
import '../utils/language_codes.dart';
import '../utils/snackbar_helper.dart';
import '../utils/video_player_navigation.dart';
import '../widgets/video_controls/video_controls.dart';
import '../i18n/strings.g.dart';
@@ -545,7 +547,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
// Set controls enabled based on content type
final playbackState = context.read<PlaybackStateProvider>();
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
final isEpisode = widget.metadata.isEpisode;
final isInPlaylist = playbackState.isPlaylistActive;
await _mediaControlsManager!.setControlsEnabled(
@@ -576,7 +578,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
if (widget.isOffline) return;
// Only create play queues for episodes
if (widget.metadata.type.toLowerCase() != 'episode') {
if (!widget.metadata.isEpisode) {
return;
}
@@ -748,9 +750,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
}
@@ -1022,7 +1022,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
}
}
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
final isEpisode = widget.metadata.isEpisode;
final languagePrefRatingKey = isEpisode
? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey)
: widget.metadata.ratingKey;
@@ -1145,7 +1145,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
}
// Determine ratingKeys
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
final isEpisode = widget.metadata.isEpisode;
final languagePrefRatingKey = isEpisode
? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey)
: widget.metadata.ratingKey;
+3 -2
View File
@@ -3,6 +3,7 @@ import 'package:rate_limiter/rate_limiter.dart';
import 'plex_client.dart';
import '../models/plex_metadata.dart';
import '../models/plex_metadata_extensions.dart';
import '../utils/app_logger.dart';
/// Manages OS media controls integration for video playback.
@@ -175,7 +176,7 @@ class MediaControlsManager {
/// For movies: Director or studio
/// For other content: Fallback to year or empty
String _buildArtist(PlexMetadata metadata) {
if (metadata.type.toLowerCase() == 'episode') {
if (metadata.isEpisode) {
final parts = <String>[];
// Add show name
@@ -191,7 +192,7 @@ class MediaControlsManager {
}
return parts.join('');
} else if (metadata.type.toLowerCase() == 'movie') {
} else if (metadata.isMovie) {
// For movies, use director or studio
// Note: These fields may need to be added to PlexMetadata model
if (metadata.year != null) {
+5 -9
View File
@@ -6,6 +6,7 @@ import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../providers/playback_state_provider.dart';
import '../utils/app_logger.dart';
import '../utils/snackbar_helper.dart';
import '../utils/video_player_navigation.dart';
import '../i18n/strings.g.dart';
import 'plex_client.dart';
@@ -291,9 +292,7 @@ class PlayQueueLauncher {
// Handle empty queue result
if (result is PlayQueueEmpty && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.failedToCreatePlayQueueNoItems)),
);
showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems);
}
await dismissLoading();
@@ -302,12 +301,9 @@ class PlayQueueLauncher {
appLogger.e('Failed to $action', error: e);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
t.messages.failedPlayback(action: action, error: e.toString()),
),
),
showErrorSnackBar(
context,
t.messages.failedPlayback(action: action, error: e.toString()),
);
}
+297 -340
View File
@@ -11,6 +11,7 @@ import '../models/plex_library.dart';
import '../models/plex_media_info.dart';
import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
import '../models/plex_metadata_extensions.dart';
import '../models/plex_playlist.dart';
import '../models/plex_sort.dart';
import '../models/plex_video_playback_data.dart';
@@ -19,6 +20,13 @@ import '../utils/app_logger.dart';
import '../utils/log_redaction_manager.dart';
import 'plex_api_cache.dart';
/// Constants for Plex stream types
class PlexStreamType {
static const int video = 1;
static const int audio = 2;
static const int subtitle = 3;
}
/// Result of testing a connection, including success status and latency
class ConnectionTestResult {
final bool success;
@@ -402,74 +410,53 @@ class PlexClient {
// Cache key is always the base endpoint (no query params)
final cacheKey = '/library/metadata/$ratingKey';
// If offline mode, return from cache only (no OnDeck in offline)
if (_offlineMode) {
final cached = await _cache.get(serverId, cacheKey);
if (cached != null) {
final metadata = _parseMetadataWithImagesFromCachedResponse(cached);
return {'metadata': metadata, 'onDeckEpisode': null};
}
return {'metadata': null, 'onDeckEpisode': null};
}
// Online: try network first
try {
// Fetch with chapters/markers/onDeck
final response = await _dio.get(
// Special handling needed for OnDeck - can't use simple _fetchWithCacheFallback
// because OnDeck is only available from network response, not cache
return await _fetchWithCacheFallback<Map<String, dynamic>>(
cacheKey: cacheKey,
networkCall: () => _dio.get(
'/library/metadata/$ratingKey',
queryParameters: {
'includeChapters': 1,
'includeMarkers': 1,
'includeOnDeck': 1,
},
);
),
parseCache: (cachedData) {
final metadata = _parseMetadataWithImagesFromCachedResponse(cachedData);
return {'metadata': metadata, 'onDeckEpisode': null};
},
parseResponse: (response) {
PlexMetadata? metadata;
PlexMetadata? onDeckEpisode;
// Cache at base endpoint (single cache entry per item)
if (response.data != null) {
await _cache.put(serverId, cacheKey, response.data);
}
final metadataJson = _getFirstMetadataJson(response);
PlexMetadata? metadata;
PlexMetadata? onDeckEpisode;
if (metadataJson != null) {
metadata = PlexMetadata.fromJsonWithImages(
metadataJson,
).copyWith(serverId: serverId, serverName: serverName);
final metadataJson = _getFirstMetadataJson(response);
// Check if OnDeck is nested inside Metadata
if (metadataJson.containsKey('OnDeck') &&
metadataJson['OnDeck'] != null) {
final onDeckData = metadataJson['OnDeck'];
if (metadataJson != null) {
metadata = PlexMetadata.fromJsonWithImages(
metadataJson,
).copyWith(serverId: serverId, serverName: serverName);
// Check if OnDeck is nested inside Metadata
if (metadataJson.containsKey('OnDeck') &&
metadataJson['OnDeck'] != null) {
final onDeckData = metadataJson['OnDeck'];
// OnDeck can be either a Map with 'Metadata' key or direct metadata
if (onDeckData is Map && onDeckData.containsKey('Metadata')) {
final onDeckMetadata = onDeckData['Metadata'];
if (onDeckMetadata != null) {
onDeckEpisode = PlexMetadata.fromJson(
onDeckMetadata,
).copyWith(serverId: serverId, serverName: serverName);
// OnDeck can be either a Map with 'Metadata' key or direct metadata
if (onDeckData is Map && onDeckData.containsKey('Metadata')) {
final onDeckMetadata = onDeckData['Metadata'];
if (onDeckMetadata != null) {
onDeckEpisode = PlexMetadata.fromJson(
onDeckMetadata,
).copyWith(serverId: serverId, serverName: serverName);
}
}
}
}
}
return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode};
} catch (e) {
// Network failed - try cache as fallback (no OnDeck)
appLogger.w(
'Network request failed for metadata with OnDeck, trying cache',
error: e,
);
final cached = await _cache.get(serverId, cacheKey);
if (cached != null) {
final metadata = _parseMetadataWithImagesFromCachedResponse(cached);
return {'metadata': metadata, 'onDeckEpisode': null};
}
rethrow;
}
return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode};
},
) ?? {'metadata': null, 'onDeckEpisode': null};
}
/// Get metadata by rating key with images (includes clearLogo)
@@ -479,46 +466,23 @@ class PlexClient {
// Cache key is always the base endpoint (no query params)
final cacheKey = '/library/metadata/$ratingKey';
// If offline mode, return from cache only
if (_offlineMode) {
final cached = await _cache.get(serverId, cacheKey);
if (cached != null) {
return _parseMetadataWithImagesFromCachedResponse(cached);
}
return null;
}
// Online: try network first
try {
// Always fetch with chapters/markers - they're included in cached response
final response = await _dio.get(
return _fetchWithCacheFallback<PlexMetadata>(
cacheKey: cacheKey,
networkCall: () => _dio.get(
'/library/metadata/$ratingKey',
queryParameters: {'includeChapters': 1, 'includeMarkers': 1},
);
// Cache at base endpoint (single cache entry per item)
if (response.data != null) {
await _cache.put(serverId, cacheKey, response.data);
}
final metadataJson = _getFirstMetadataJson(response);
return metadataJson != null
? PlexMetadata.fromJsonWithImages(
metadataJson,
).copyWith(serverId: serverId, serverName: serverName)
: null;
} catch (e) {
// Network failed - try cache as fallback
appLogger.w(
'Network request failed for metadata with images, trying cache',
error: e,
);
final cached = await _cache.get(serverId, cacheKey);
if (cached != null) {
return _parseMetadataWithImagesFromCachedResponse(cached);
}
rethrow;
}
),
parseCache: (cachedData) =>
_parseMetadataWithImagesFromCachedResponse(cachedData),
parseResponse: (response) {
final metadataJson = _getFirstMetadataJson(response);
return metadataJson != null
? PlexMetadata.fromJsonWithImages(
metadataJson,
).copyWith(serverId: serverId, serverName: serverName)
: null;
},
);
}
/// Parse PlexMetadata with images from a cached response
@@ -570,6 +534,40 @@ class PlexClient {
}
}
/// Generic cache-network-fallback helper for fetching data
///
/// This method implements the standard pattern used throughout the client:
/// 1. If offline mode is enabled, return cached data only
/// 2. Otherwise, try network request first
/// 3. If network succeeds and cacheResponse is true, cache the response
/// 4. If network fails, fall back to cached data
/// 5. If no cached data available, rethrow the network error
Future<T?> _fetchWithCacheFallback<T>({
required String cacheKey,
required Future<Response> Function() networkCall,
required T? Function(dynamic cachedData) parseCache,
required T? Function(Response response) parseResponse,
bool cacheResponse = true,
}) async {
if (_offlineMode) {
final cached = await _cache.get(serverId, cacheKey);
if (cached != null) return parseCache(cached);
return null;
}
try {
final response = await networkCall();
if (cacheResponse && response.data != null) {
await _cache.put(serverId, cacheKey, response.data);
}
return parseResponse(response);
} catch (e) {
appLogger.w('Network request failed for $cacheKey, trying cache', error: e);
final cached = await _cache.get(serverId, cacheKey);
if (cached != null) return parseCache(cached);
rethrow;
}
}
/// Get first metadata JSON from response data
Map<String, dynamic>? _getFirstMetadataJsonFromData(Map<String, dynamic>? data) {
if (data == null) return null;
@@ -582,6 +580,35 @@ class PlexClient {
return null;
}
/// Wraps an API call that returns a boolean success status
Future<bool> _wrapBoolApiCall(
Future<Response> Function() apiCall,
String errorMessage,
) async {
try {
final response = await apiCall();
return response.statusCode == 200;
} catch (e) {
appLogger.e(errorMessage, error: e);
return false;
}
}
/// Wraps an API call that returns a list, returning empty list on error
Future<List<T>> _wrapListApiCall<T>(
Future<Response> Function() apiCall,
List<T> Function(Response response) parseResponse,
String errorMessage,
) async {
try {
final response = await apiCall();
return parseResponse(response);
} catch (e) {
appLogger.e(errorMessage, error: e);
return [];
}
}
/// Parse audio and subtitle tracks from a stream list
({List<PlexAudioTrack> audio, List<PlexSubtitleTrack> subtitles}) _parseStreams(
List<dynamic>? streams,
@@ -594,7 +621,7 @@ class PlexClient {
for (var stream in streams) {
final streamType = stream['streamType'] as int?;
if (streamType == 2) {
if (streamType == PlexStreamType.audio) {
// Audio track
audioTracks.add(
PlexAudioTrack(
@@ -609,7 +636,7 @@ class PlexClient {
selected: stream['selected'] == 1,
),
);
} else if (streamType == 3) {
} else if (streamType == PlexStreamType.subtitle) {
// Subtitle track
subtitleTracks.add(
PlexSubtitleTrack(
@@ -658,30 +685,26 @@ class PlexClient {
String? audioLanguage,
String? subtitleLanguage,
}) async {
try {
final queryParams = <String, dynamic>{};
if (audioLanguage != null) {
queryParams['audioLanguage'] = audioLanguage;
}
if (subtitleLanguage != null) {
queryParams['subtitleLanguage'] = subtitleLanguage;
}
final queryParams = <String, dynamic>{};
if (audioLanguage != null) {
queryParams['audioLanguage'] = audioLanguage;
}
if (subtitleLanguage != null) {
queryParams['subtitleLanguage'] = subtitleLanguage;
}
// If no preferences to set, return early
if (queryParams.isEmpty) {
return true;
}
// If no preferences to set, return early
if (queryParams.isEmpty) {
return true;
}
final response = await _dio.put(
return _wrapBoolApiCall(
() => _dio.put(
'/library/metadata/$ratingKey/prefs',
queryParameters: queryParams,
);
return response.statusCode == 200;
} catch (e) {
appLogger.e('Failed to set metadata preferences', error: e);
return false;
}
),
'Failed to set metadata preferences',
);
}
/// Select specific audio and subtitle streams for playback
@@ -693,35 +716,31 @@ class PlexClient {
int? subtitleStreamID,
bool allParts = true,
}) async {
try {
final queryParams = <String, dynamic>{};
if (audioStreamID != null) {
queryParams['audioStreamID'] = audioStreamID;
final queryParams = <String, dynamic>{};
if (audioStreamID != null) {
queryParams['audioStreamID'] = audioStreamID;
}
if (subtitleStreamID != null) {
queryParams['subtitleStreamID'] = subtitleStreamID;
}
if (allParts) {
// If no streams to select, return early
if (queryParams.isEmpty) {
return true;
}
if (subtitleStreamID != null) {
queryParams['subtitleStreamID'] = subtitleStreamID;
}
if (allParts) {
// If no streams to select, return early
if (queryParams.isEmpty) {
return true;
}
// Use PUT request on /library/parts/{partId}
final response = await _dio.put(
// Use PUT request on /library/parts/{partId}
return _wrapBoolApiCall(
() => _dio.put(
'/library/parts/$partId',
queryParameters: queryParams,
);
return response.statusCode == 200;
}
// Si allParts est false, retourner true ou false explicitement (selon la logique souhaitée)
// Ici, on retourne true par défaut si rien n'est fait
return true;
} catch (e) {
appLogger.e('Failed to select streams', error: e);
return false;
),
'Failed to select streams',
);
}
// Si allParts est false, retourner true ou false explicitement (selon la logique souhaitée)
// Ici, on retourne true par défaut si rien n'est fait
return true;
}
/// Search across all libraries using the hub search endpoint
@@ -797,10 +816,7 @@ class PlexClient {
final allItems = _extractMetadataList(response);
// Filter out music content (artists, albums, tracks)
return allItems.where((item) {
final type = item.type.toLowerCase();
return type != 'artist' && type != 'album' && type != 'track';
}).toList();
return allItems.where((item) => !item.isMusicContent).toList();
}
/// Get on deck items (continue watching, filtered to video content only)
@@ -817,10 +833,7 @@ class PlexClient {
.toList();
// Filter out music content (artists, albums, tracks)
return allItems.where((item) {
final type = item.type.toLowerCase();
return type != 'artist' && type != 'album' && type != 'track';
}).toList();
return allItems.where((item) => !item.isMusicContent).toList();
}
return [];
}
@@ -840,37 +853,12 @@ class PlexClient {
Future<List<PlexMetadata>> getChildren(String ratingKey) async {
final endpoint = '/library/metadata/$ratingKey/children';
// If offline mode, return from cache only
if (_offlineMode) {
final cached = await _cache.get(serverId, endpoint);
if (cached != null) {
return _parseMetadataListFromCachedResponse(cached);
}
return [];
}
// Online: try network first
try {
final response = await _dio.get(endpoint);
// Cache the successful response
if (response.data != null) {
await _cache.put(serverId, endpoint, response.data);
}
return _extractMetadataList(response);
} catch (e) {
// Network failed - try cache as fallback
appLogger.w(
'Network request failed for children, trying cache',
error: e,
);
final cached = await _cache.get(serverId, endpoint);
if (cached != null) {
return _parseMetadataListFromCachedResponse(cached);
}
rethrow;
}
return await _fetchWithCacheFallback<List<PlexMetadata>>(
cacheKey: endpoint,
networkCall: () => _dio.get(endpoint),
parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData),
parseResponse: (response) => _extractMetadataList(response),
) ?? [];
}
/// Get all unwatched episodes for a TV show across all seasons
@@ -884,12 +872,12 @@ class PlexClient {
// Get episodes from each season
for (final season in seasons) {
if (season.type == 'season') {
if (season.isSeason) {
final episodes = await getChildren(season.ratingKey);
// Filter for unwatched episodes
final unwatchedEpisodes = episodes
.where((ep) => ep.type == 'episode' && (ep.viewCount ?? 0) == 0)
.where((ep) => ep.isEpisode && (ep.viewCount ?? 0) == 0)
.toList();
allEpisodes.addAll(unwatchedEpisodes);
@@ -907,7 +895,7 @@ class PlexClient {
// Filter for unwatched episodes
return episodes
.where((ep) => ep.type == 'episode' && (ep.viewCount ?? 0) == 0)
.where((ep) => ep.isEpisode && (ep.viewCount ?? 0) == 0)
.toList();
}
@@ -1268,9 +1256,9 @@ class PlexClient {
for (var stream in streams) {
final streamType = stream['streamType'] as int?;
if (streamType == 1 && videoStream == null) {
if (streamType == PlexStreamType.video && videoStream == null) {
videoStream = stream;
} else if (streamType == 2 && audioStream == null) {
} else if (streamType == PlexStreamType.audio && audioStream == null) {
audioStream = stream;
}
}
@@ -1488,8 +1476,7 @@ class PlexClient {
// Filter out non-video content types and tag with server info
final videoItems = hub.items
.where((item) {
final type = item.type.toLowerCase();
return type == 'movie' || type == 'show';
return item.isMovie || item.isShow;
})
.map(
(item) => item.copyWith(
@@ -1530,31 +1517,27 @@ class PlexClient {
/// Get full content from a hub using its hub key
/// Returns the complete list of metadata items in the hub
Future<List<PlexMetadata>> getHubContent(String hubKey) async {
try {
final response = await _dio.get(hubKey);
final allItems = _extractMetadataList(response);
// Filter out non-video content types
return allItems.where((item) {
final type = item.type.toLowerCase();
return type == 'movie' || type == 'show';
}).toList();
} catch (e) {
appLogger.e('Failed to get hub content: $e');
return [];
}
return _wrapListApiCall<PlexMetadata>(
() => _dio.get(hubKey),
(response) {
final allItems = _extractMetadataList(response);
// Filter out non-video content types
return allItems.where((item) {
return item.isMovie || item.isShow;
}).toList();
},
'Failed to get hub content',
);
}
/// Get playlist content by playlist ID
/// Returns the list of metadata items in the playlist
Future<List<PlexMetadata>> getPlaylist(String playlistId) async {
try {
final response = await _dio.get('/playlists/$playlistId/items');
return _extractMetadataList(response);
} catch (e) {
appLogger.e('Failed to get playlist: $e');
return [];
}
return _wrapListApiCall<PlexMetadata>(
() => _dio.get('/playlists/$playlistId/items'),
_extractMetadataList,
'Failed to get playlist',
);
}
/// Get all playlists
@@ -1564,22 +1547,16 @@ class PlexClient {
String playlistType = 'video',
bool? smart,
}) async {
try {
final queryParams = <String, dynamic>{'playlistType': playlistType};
if (smart != null) {
queryParams['smart'] = smart ? '1' : '0';
}
final response = await _dio.get(
'/playlists',
queryParameters: queryParams,
);
return _extractPlaylistList(response);
} catch (e) {
appLogger.e('Failed to get playlists: $e');
return [];
final queryParams = <String, dynamic>{'playlistType': playlistType};
if (smart != null) {
queryParams['smart'] = smart ? '1' : '0';
}
return _wrapListApiCall<PlexPlaylist>(
() => _dio.get('/playlists', queryParameters: queryParams),
_extractPlaylistList,
'Failed to get playlists',
);
}
/// Get playlist metadata by playlist ID
@@ -1654,13 +1631,10 @@ class PlexClient {
/// Delete a playlist
Future<bool> deletePlaylist(String playlistId) async {
try {
await _dio.delete('/playlists/$playlistId');
return true;
} catch (e) {
appLogger.e('Failed to delete playlist: $e');
return false;
}
return _wrapBoolApiCall(
() => _dio.delete('/playlists/$playlistId'),
'Failed to delete playlist',
);
}
/// Add items to a playlist
@@ -1670,20 +1644,20 @@ class PlexClient {
required String playlistId,
required String uri,
}) async {
try {
appLogger.d(
'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}',
);
final response = await _dio.put(
appLogger.d(
'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}',
);
final result = await _wrapBoolApiCall(
() => _dio.put(
'/playlists/$playlistId/items',
queryParameters: {'uri': uri},
);
appLogger.d('Add to playlist response status: ${response.statusCode}');
return response.statusCode == 200;
} catch (e) {
appLogger.e('Failed to add to playlist', error: e);
return false;
),
'Failed to add to playlist',
);
if (result) {
appLogger.d('Add to playlist response status: 200');
}
return result;
}
/// Remove an item from a playlist
@@ -1693,13 +1667,10 @@ class PlexClient {
required String playlistId,
required String playlistItemId,
}) async {
try {
await _dio.delete('/playlists/$playlistId/items/$playlistItemId');
return true;
} catch (e) {
appLogger.e('Failed to remove from playlist: $e');
return false;
}
return _wrapBoolApiCall(
() => _dio.delete('/playlists/$playlistId/items/$playlistItemId'),
'Failed to remove from playlist',
);
}
/// Move a playlist item to a new position
@@ -1712,31 +1683,28 @@ class PlexClient {
required int playlistItemId,
required int afterPlaylistItemId,
}) async {
try {
appLogger.d(
'Moving playlist item $playlistItemId after $afterPlaylistItemId in playlist $playlistId',
);
await _dio.put(
appLogger.d(
'Moving playlist item $playlistItemId after $afterPlaylistItemId in playlist $playlistId',
);
final result = await _wrapBoolApiCall(
() => _dio.put(
'/playlists/$playlistId/items/$playlistItemId/move',
queryParameters: {'after': afterPlaylistItemId},
);
),
'Failed to move playlist item',
);
if (result) {
appLogger.d('Successfully moved playlist item');
return true;
} catch (e) {
appLogger.e('Failed to move playlist item', error: e);
return false;
}
return result;
}
/// Clear all items from a playlist
Future<bool> clearPlaylist(String playlistId) async {
try {
await _dio.delete('/playlists/$playlistId/items');
return true;
} catch (e) {
appLogger.e('Failed to clear playlist: $e');
return false;
}
return _wrapBoolApiCall(
() => _dio.delete('/playlists/$playlistId/items'),
'Failed to clear playlist',
);
}
/// Update playlist metadata (e.g., title, summary)
@@ -1746,30 +1714,27 @@ class PlexClient {
String? title,
String? summary,
}) async {
try {
final queryParams = <String, dynamic>{
'type': 'playlist',
'id': playlistId,
};
final queryParams = <String, dynamic>{
'type': 'playlist',
'id': playlistId,
};
if (title != null) {
queryParams['title.value'] = title;
queryParams['title.locked'] = '1';
}
if (summary != null) {
queryParams['summary.value'] = summary;
queryParams['summary.locked'] = '1';
}
if (title != null) {
queryParams['title.value'] = title;
queryParams['title.locked'] = '1';
}
if (summary != null) {
queryParams['summary.value'] = summary;
queryParams['summary.locked'] = '1';
}
await _dio.put(
return _wrapBoolApiCall(
() => _dio.put(
'/library/metadata/$playlistId',
queryParameters: queryParams,
);
return true;
} catch (e) {
appLogger.e('Failed to update playlist: $e');
return false;
}
),
'Failed to update playlist',
);
}
// ============================================================================
@@ -1779,51 +1744,46 @@ class PlexClient {
/// Get all collections for a library section
/// Returns collections as PlexMetadata objects with type="collection"
Future<List<PlexMetadata>> getLibraryCollections(String sectionId) async {
try {
final response = await _dio.get(
return _wrapListApiCall<PlexMetadata>(
() => _dio.get(
'/library/sections/$sectionId/collections',
queryParameters: {'includeGuids': 1},
);
final allItems = _extractMetadataList(response);
// Collections should have type="collection"
return allItems.where((item) {
return item.type.toLowerCase() == 'collection';
}).toList();
} catch (e) {
appLogger.e('Failed to get library collections: $e');
return [];
}
),
(response) {
final allItems = _extractMetadataList(response);
// Collections should have type="collection"
return allItems.where((item) {
return item.isCollection;
}).toList();
},
'Failed to get library collections',
);
}
/// Get items in a collection
/// Returns the list of metadata items in the collection
Future<List<PlexMetadata>> getCollectionItems(String collectionId) async {
try {
final response = await _dio.get(
'/library/collections/$collectionId/children',
);
return _extractMetadataList(response);
} catch (e) {
appLogger.e('Failed to get collection items: $e');
return [];
}
return _wrapListApiCall<PlexMetadata>(
() => _dio.get('/library/collections/$collectionId/children'),
_extractMetadataList,
'Failed to get collection items',
);
}
/// Delete a collection
/// Deletes a library collection from the server
Future<bool> deleteCollection(String sectionId, String collectionId) async {
try {
appLogger.d(
'Deleting collection: sectionId=$sectionId, collectionId=$collectionId',
);
final response = await _dio.delete('/library/collections/$collectionId');
appLogger.d('Delete collection response: ${response.statusCode}');
return true;
} catch (e) {
appLogger.e('Failed to delete collection', error: e);
return false;
appLogger.d(
'Deleting collection: sectionId=$sectionId, collectionId=$collectionId',
);
final result = await _wrapBoolApiCall(
() => _dio.delete('/library/collections/$collectionId'),
'Failed to delete collection',
);
if (result) {
appLogger.d('Delete collection response: 200');
}
return result;
}
/// Create a new collection
@@ -1875,18 +1835,18 @@ class PlexClient {
required String collectionId,
required String uri,
}) async {
try {
appLogger.d('Adding items to collection: collectionId=$collectionId');
final response = await _dio.put(
appLogger.d('Adding items to collection: collectionId=$collectionId');
final result = await _wrapBoolApiCall(
() => _dio.put(
'/library/collections/$collectionId/items',
queryParameters: {'uri': uri},
);
appLogger.d('Add to collection response: ${response.statusCode}');
return true;
} catch (e) {
appLogger.e('Failed to add items to collection', error: e);
return false;
),
'Failed to add items to collection',
);
if (result) {
appLogger.d('Add to collection response: 200');
}
return result;
}
/// Remove an item from a collection
@@ -1895,19 +1855,19 @@ class PlexClient {
required String collectionId,
required String itemId,
}) async {
try {
appLogger.d(
'Removing item from collection: collectionId=$collectionId, itemId=$itemId',
);
final response = await _dio.delete(
appLogger.d(
'Removing item from collection: collectionId=$collectionId, itemId=$itemId',
);
final result = await _wrapBoolApiCall(
() => _dio.delete(
'/library/collections/$collectionId/items/$itemId',
);
appLogger.d('Remove from collection response: ${response.statusCode}');
return true;
} catch (e) {
appLogger.e('Failed to remove item from collection', error: e);
return false;
),
'Failed to remove item from collection',
);
if (result) {
appLogger.d('Remove from collection response: 200');
}
return result;
}
// ============================================================================
@@ -2009,13 +1969,10 @@ class PlexClient {
/// Clear all items from a play queue
Future<bool> clearPlayQueue(int playQueueId) async {
try {
await _dio.delete('/playQueues/$playQueueId/items');
return true;
} catch (e) {
appLogger.e('Failed to clear play queue: $e');
return false;
}
return _wrapBoolApiCall(
() => _dio.delete('/playQueues/$playQueueId/items'),
'Failed to clear play queue',
);
}
/// Create a play queue for a TV show (all episodes)
+56
View File
@@ -0,0 +1,56 @@
/// Utility class for content type checking and filtering
class ContentTypeHelper {
/// Checks if the given type is music content (artist, album, or track)
///
/// [type] The content type string to check
/// Returns true if the type is artist, album, or track (case-insensitive)
static bool isMusicContent(String type) {
final lowerType = type.toLowerCase();
return lowerType == 'artist' ||
lowerType == 'album' ||
lowerType == 'track';
}
/// Checks if the given library is a music library
///
/// [lib] The library object to check (must have a 'type' property)
/// Returns true if the library type is 'artist' (case-insensitive)
static bool isMusicLibrary(dynamic lib) {
if (lib == null) return false;
try {
final type = (lib as dynamic).type as String?;
return type?.toLowerCase() == 'artist';
} catch (e) {
return false;
}
}
/// Checks if the given type is video content (movie, show, episode, or season)
///
/// [type] The content type string to check
/// Returns true if the type is movie, show, episode, or season (case-insensitive)
static bool isVideoContent(String type) {
final lowerType = type.toLowerCase();
return lowerType == 'movie' ||
lowerType == 'show' ||
lowerType == 'episode' ||
lowerType == 'season';
}
/// Filters out music content from a list of items
///
/// [items] The list of items to filter
/// [getType] A function that extracts the type string from each item
/// Returns a new list with music content removed
///
/// Example:
/// ```dart
/// final filtered = ContentTypeHelper.filterOutMusic(
/// items,
/// (item) => item.type,
/// );
/// ```
static List<T> filterOutMusic<T>(List<T> items, String Function(T) getType) {
return items.where((item) => !isMusicContent(getType(item))).toList();
}
}
+58 -2
View File
@@ -27,10 +27,12 @@ class ScreenBreakpoints {
/// Whether width is wide tablet (900px - 1199px)
/// Useful for layouts that need more columns than phone but less than desktop
static bool isWideTablet(double width) => width >= wideTablet && width < desktop;
static bool isWideTablet(double width) =>
width >= wideTablet && width < desktop;
/// Whether width is desktop-sized (1200px - 1599px)
static bool isDesktop(double width) => width >= desktop && width < largeDesktop;
static bool isDesktop(double width) =>
width >= desktop && width < largeDesktop;
/// Whether width is large desktop-sized (>= 1600px)
static bool isLargeDesktop(double width) => width >= largeDesktop;
@@ -69,3 +71,57 @@ class GridLayoutConstants {
/// Standard grid padding
static EdgeInsets get gridPadding => const EdgeInsets.fromLTRB(8, 0, 8, 8);
}
/// Layout constants for spacing and corner radius
class LayoutConstants {
/// Spacing constants
static const double spacingXSmall = 4.0;
static const double spacingSmall = 8.0;
static const double spacingMedium = 12.0;
static const double spacingLarge = 16.0;
static const double spacingXLarge = 24.0;
/// Corner radius constants
static const double cornerRadiusSmall = 6.0;
static const double cornerRadiusMedium = 8.0;
static const double cornerRadiusLarge = 12.0;
/// Convenience getters for EdgeInsets
static EdgeInsets get paddingXSmall => const EdgeInsets.all(spacingXSmall);
static EdgeInsets get paddingSmall => const EdgeInsets.all(spacingSmall);
static EdgeInsets get paddingMedium => const EdgeInsets.all(spacingMedium);
static EdgeInsets get paddingLarge => const EdgeInsets.all(spacingLarge);
static EdgeInsets get paddingXLarge => const EdgeInsets.all(spacingXLarge);
/// Convenience getters for horizontal EdgeInsets
static EdgeInsets get paddingHorizontalXSmall =>
const EdgeInsets.symmetric(horizontal: spacingXSmall);
static EdgeInsets get paddingHorizontalSmall =>
const EdgeInsets.symmetric(horizontal: spacingSmall);
static EdgeInsets get paddingHorizontalMedium =>
const EdgeInsets.symmetric(horizontal: spacingMedium);
static EdgeInsets get paddingHorizontalLarge =>
const EdgeInsets.symmetric(horizontal: spacingLarge);
static EdgeInsets get paddingHorizontalXLarge =>
const EdgeInsets.symmetric(horizontal: spacingXLarge);
/// Convenience getters for vertical EdgeInsets
static EdgeInsets get paddingVerticalXSmall =>
const EdgeInsets.symmetric(vertical: spacingXSmall);
static EdgeInsets get paddingVerticalSmall =>
const EdgeInsets.symmetric(vertical: spacingSmall);
static EdgeInsets get paddingVerticalMedium =>
const EdgeInsets.symmetric(vertical: spacingMedium);
static EdgeInsets get paddingVerticalLarge =>
const EdgeInsets.symmetric(vertical: spacingLarge);
static EdgeInsets get paddingVerticalXLarge =>
const EdgeInsets.symmetric(vertical: spacingXLarge);
/// Convenience getters for BorderRadius
static BorderRadius get borderRadiusSmall =>
BorderRadius.circular(cornerRadiusSmall);
static BorderRadius get borderRadiusMedium =>
BorderRadius.circular(cornerRadiusMedium);
static BorderRadius get borderRadiusLarge =>
BorderRadius.circular(cornerRadiusLarge);
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
/// Utility functions for showing snackbars throughout the application
/// Shows a standard snackbar with a message
///
/// [context] The build context
/// [message] The message to display
/// [duration] Optional duration, defaults to 3 seconds
void showAppSnackBar(
BuildContext context,
String message, {
Duration? duration,
}) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: duration ?? const Duration(seconds: 3),
),
);
}
/// Shows an error snackbar with a message
///
/// [context] The build context
/// [message] The error message to display
void showErrorSnackBar(BuildContext context, String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
),
);
}
/// Shows a success snackbar with a message
///
/// [context] The build context
/// [message] The success message to display
void showSuccessSnackBar(BuildContext context, String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
duration: const Duration(seconds: 3),
),
);
}
+69 -8
View File
@@ -9,6 +9,7 @@ class BottomSheetHeader extends StatelessWidget {
final String title;
/// Optional leading widget (e.g., icon or back button)
/// Takes precedence over [icon] and [onBack]
final Widget? leading;
/// Optional action widget (e.g., clear button)
@@ -18,35 +19,95 @@ class BottomSheetHeader extends StatelessWidget {
/// Defaults to Navigator.pop(context)
final VoidCallback? onClose;
/// Optional icon to display as leading widget
/// Only used if [leading] and [onBack] are null
final IconData? icon;
/// Optional color for the icon
/// Only used when [icon] is provided
final Color? iconColor;
/// Optional callback for back button
/// When provided, displays a back button as the leading widget
/// Takes precedence over [icon]
final VoidCallback? onBack;
/// Optional text style for the title
final TextStyle? titleStyle;
/// Optional text color for the title
/// Only used if [titleStyle] is null
final Color? titleColor;
/// Whether to show the bottom border
/// Defaults to true
final bool showBorder;
const BottomSheetHeader({
super.key,
required this.title,
this.leading,
this.action,
this.onClose,
this.icon,
this.iconColor,
this.onBack,
this.titleStyle,
this.titleColor,
this.showBorder = true,
});
@override
Widget build(BuildContext context) {
// Determine the leading widget based on priority: leading > onBack > icon
Widget? resolvedLeading;
if (leading != null) {
resolvedLeading = leading;
} else if (onBack != null) {
resolvedLeading = IconButton(
icon: AppIcon(
Symbols.arrow_back_rounded,
fill: 1,
color: iconColor,
),
onPressed: onBack,
);
} else if (icon != null) {
resolvedLeading = AppIcon(icon!, fill: 1, color: iconColor);
}
// Determine the title style
final effectiveTitleStyle = titleStyle ??
TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: titleColor,
);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor),
),
),
decoration: showBorder
? BoxDecoration(
border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor),
),
)
: null,
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 8)],
if (resolvedLeading != null) ...[
resolvedLeading,
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
style: effectiveTitleStyle,
),
),
if (action != null) action!,
IconButton(
icon: const AppIcon(Symbols.close_rounded, fill: 1),
icon: AppIcon(Symbols.close_rounded, fill: 1, color: iconColor),
onPressed: onClose ?? () => Navigator.pop(context),
),
],
+3 -2
View File
@@ -5,6 +5,7 @@ import '../i18n/strings.g.dart';
import '../models/download_progress.dart';
import '../models/download_status.dart';
import '../models/plex_metadata.dart';
import '../models/plex_metadata_extensions.dart';
/// Represents a node in the download tree
class DownloadTreeNode {
@@ -111,12 +112,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
if (meta == null) continue;
if (meta.type.toLowerCase() == 'episode') {
if (meta.isEpisode) {
// Group episodes by show
final showKey = meta.grandparentRatingKey ?? 'unknown';
showGroups.putIfAbsent(showKey, () => []);
showGroups[showKey]!.add(entry);
} else if (meta.type.toLowerCase() == 'movie') {
} else if (meta.isMovie) {
// Movies go at top level
movies.add(
DownloadTreeNode(
+6 -13
View File
@@ -14,9 +14,11 @@ import '../utils/provider_extensions.dart';
import '../utils/content_rating_formatter.dart';
import '../utils/duration_formatter.dart';
import '../utils/media_navigation_helper.dart';
import '../utils/snackbar_helper.dart';
import '../theme/theme_helper.dart';
import '../i18n/strings.g.dart';
import 'media_context_menu.dart';
import 'media_progress_bar.dart';
import 'plex_optimized_image.dart';
class MediaCard extends StatefulWidget {
@@ -128,12 +130,7 @@ class MediaCardState extends State<MediaCard> {
switch (result) {
case MediaNavigationResult.unsupported:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.messages.musicNotSupported),
duration: const Duration(seconds: 2),
),
);
showAppSnackBar(context, t.messages.musicNotSupported);
case MediaNavigationResult.listRefreshNeeded:
widget.onListRefresh?.call();
case MediaNavigationResult.navigated:
@@ -802,13 +799,9 @@ class _MediaCardHelpers {
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
child: LinearProgressIndicator(
value: metadata.viewOffset! / metadata.duration!,
backgroundColor: tokens(context).outline,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
minHeight: 4,
child: MediaProgressBar(
viewOffset: metadata.viewOffset!,
duration: metadata.duration!,
),
),
),
+67 -117
View File
@@ -14,6 +14,7 @@ import '../providers/offline_watch_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../utils/library_refresh_notifier.dart';
import '../utils/snackbar_helper.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import '../utils/smart_deletion_handler.dart';
@@ -379,9 +380,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
ratingKey: metadata.ratingKey,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.markedAsWatchedOffline)),
);
showAppSnackBar(context, t.messages.markedAsWatchedOffline);
widget.onRefresh?.call(metadata.ratingKey);
}
} else {
@@ -402,9 +401,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
ratingKey: metadata.ratingKey,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.markedAsUnwatchedOffline)),
);
showAppSnackBar(context, t.messages.markedAsUnwatchedOffline);
widget.onRefresh?.call(metadata.ratingKey);
}
} else {
@@ -423,8 +420,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
try {
await client.removeFromOnDeck(metadata!.ratingKey);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.removedFromContinueWatching)),
showSuccessSnackBar(
context,
t.messages.removedFromContinueWatching,
);
// Use specific callback if provided, otherwise fallback to onRefresh
if (widget.onRemoveFromContinueWatching != null) {
@@ -435,10 +433,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.messages.errorLoading(error: e.toString())),
),
showErrorSnackBar(
context,
t.messages.errorLoading(error: e.toString()),
);
}
}
@@ -512,15 +509,14 @@ class MediaContextMenuState extends State<MediaContextMenu> {
try {
await action();
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(successMessage)));
showSuccessSnackBar(context, successMessage);
widget.onRefresh?.call(widget.item.ratingKey);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
showErrorSnackBar(
context,
t.messages.errorLoading(error: e.toString()),
);
}
}
@@ -548,9 +544,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('$errorPrefix: $e')));
showErrorSnackBar(context, '$errorPrefix: $e');
}
}
}
@@ -589,9 +583,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title),
);
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.fileInfoNotAvailable)),
);
showErrorSnackBar(context, t.messages.fileInfoNotAvailable);
}
} catch (e) {
// Close loading indicator if it's still open
@@ -600,10 +592,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.messages.errorLoadingFileInfo(error: e.toString())),
),
showErrorSnackBar(
context,
t.messages.errorLoadingFileInfo(error: e.toString()),
);
}
}
@@ -761,16 +752,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (context.mounted) {
if (newPlaylist != null) {
appLogger.d('Successfully created playlist: ${newPlaylist.title}');
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.created)));
showSuccessSnackBar(context, t.playlists.created);
// Trigger refresh of playlists tab
LibraryRefreshNotifier().notifyPlaylistsChanged();
} else {
appLogger.e('Failed to create playlist - API returned null');
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorCreating)));
showErrorSnackBar(context, t.playlists.errorCreating);
}
}
} else {
@@ -786,18 +773,14 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (context.mounted) {
if (success) {
appLogger.d('Successfully added item(s) to playlist $result');
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.itemAdded)));
showSuccessSnackBar(context, t.playlists.itemAdded);
// Trigger refresh of playlists tab
LibraryRefreshNotifier().notifyPlaylistsChanged();
} else {
appLogger.e(
'Failed to add item(s) to playlist $result - API returned false',
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorAdding)));
showErrorSnackBar(context, t.playlists.errorAdding);
}
}
}
@@ -808,11 +791,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
stackTrace: stackTrace,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${t.playlists.errorLoading}: ${e.toString()}'),
duration: const Duration(seconds: 5),
),
showErrorSnackBar(
context,
'${t.playlists.errorLoading}: ${e.toString()}',
);
}
}
@@ -880,12 +861,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (sectionId == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Unable to determine library section for this item',
),
),
showErrorSnackBar(
context,
'Unable to determine library section for this item',
);
}
return;
@@ -975,22 +953,19 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (addSuccess) {
appLogger.d('Successfully added item to new collection');
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.collections.created)));
showSuccessSnackBar(context, t.collections.created);
// Trigger refresh of collections tab
LibraryRefreshNotifier().notifyCollectionsChanged();
} else {
appLogger.e('Failed to add item to new collection');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.collections.errorAddingToCollection)),
showErrorSnackBar(
context,
t.collections.errorAddingToCollection,
);
}
} else {
appLogger.e('Failed to create collection - API returned null');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.collections.errorAddingToCollection)),
);
showErrorSnackBar(context, t.collections.errorAddingToCollection);
}
}
} else {
@@ -1006,18 +981,14 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (context.mounted) {
if (success) {
appLogger.d('Successfully added item(s) to collection $result');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.collections.addedToCollection)),
);
showSuccessSnackBar(context, t.collections.addedToCollection);
// Trigger refresh of collections tab
LibraryRefreshNotifier().notifyCollectionsChanged();
} else {
appLogger.e(
'Failed to add item(s) to collection $result - API returned false',
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.collections.errorAddingToCollection)),
);
showErrorSnackBar(context, t.collections.errorAddingToCollection);
}
}
}
@@ -1028,13 +999,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
stackTrace: stackTrace,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${t.collections.errorAddingToCollection}: ${e.toString()}',
),
duration: const Duration(seconds: 5),
),
showErrorSnackBar(
context,
'${t.collections.errorAddingToCollection}: ${e.toString()}',
);
}
}
@@ -1087,28 +1054,24 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (context.mounted) {
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.collections.removedFromCollection)),
);
showSuccessSnackBar(context, t.collections.removedFromCollection);
// Trigger refresh of collections tab
LibraryRefreshNotifier().notifyCollectionsChanged();
// Trigger list refresh to remove the item from the view
widget.onListRefresh?.call();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.collections.removeFromCollectionFailed)),
showErrorSnackBar(
context,
t.collections.removeFromCollectionFailed,
);
}
}
} catch (e) {
appLogger.e('Failed to remove from collection', error: e);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
t.collections.removeFromCollectionError(error: e.toString()),
),
),
showErrorSnackBar(
context,
t.collections.removeFromCollectionError(error: e.toString()),
);
}
}
@@ -1213,38 +1176,29 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (context.mounted) {
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isCollection ? t.collections.deleted : t.playlists.deleted,
),
),
showSuccessSnackBar(
context,
isCollection ? t.collections.deleted : t.playlists.deleted,
);
// Trigger list refresh
widget.onListRefresh?.call();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isCollection
? t.collections.deleteFailed
: t.playlists.errorDeleting,
),
),
showErrorSnackBar(
context,
isCollection
? t.collections.deleteFailed
: t.playlists.errorDeleting,
);
}
}
} catch (e) {
appLogger.e('Failed to delete $itemTypeLabel', error: e);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isCollection
? t.collections.deleteFailedWithError(error: e.toString())
: t.playlists.errorDeleting,
),
),
showErrorSnackBar(
context,
isCollection
? t.collections.deleteFailedWithError(error: e.toString())
: t.playlists.errorDeleting,
);
}
}
@@ -1266,21 +1220,18 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final message = count > 1
? t.downloads.episodesQueued(count: count)
: t.downloads.downloadQueued;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
showSuccessSnackBar(context, message);
}
} on CellularDownloadBlockedException {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.settings.cellularDownloadBlocked)),
);
showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
}
} catch (e) {
appLogger.e('Failed to queue download', error: e);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
showErrorSnackBar(
context,
t.messages.errorLoading(error: e.toString()),
);
}
}
@@ -1326,17 +1277,16 @@ class MediaContextMenuState extends State<MediaContextMenu> {
);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.downloads.downloadDeleted)));
showSuccessSnackBar(context, t.downloads.downloadDeleted);
// Refresh the view if needed
widget.onRefresh?.call(metadata.ratingKey);
}
} catch (e) {
appLogger.e('Failed to delete download', error: e);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
showErrorSnackBar(
context,
t.messages.errorLoading(error: e.toString()),
);
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
/// Reusable media progress bar widget for displaying watch progress
///
/// Shows a linear progress indicator based on viewOffset and duration.
/// Uses theme defaults when colors are not provided.
class MediaProgressBar extends StatelessWidget {
final int viewOffset; // Progress position in milliseconds
final int duration; // Total duration in milliseconds
final Color? backgroundColor;
final Color? valueColor;
final double? minHeight;
const MediaProgressBar({
super.key,
required this.viewOffset,
required this.duration,
this.backgroundColor,
this.valueColor,
this.minHeight,
});
@override
Widget build(BuildContext context) {
// Calculate progress value (0.0 to 1.0)
final progress = duration > 0 ? viewOffset / duration : 0.0;
return LinearProgressIndicator(
value: progress.clamp(0.0, 1.0),
backgroundColor: backgroundColor ??
Theme.of(context).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
valueColor ?? Theme.of(context).colorScheme.primary,
),
minHeight: minHeight ?? 4,
);
}
}
+2 -1
View File
@@ -14,6 +14,7 @@ import '../providers/multi_server_provider.dart';
import '../services/fullscreen_state_manager.dart';
import '../services/storage_service.dart';
import '../theme/theme_helper.dart';
import '../utils/content_type_helper.dart';
import '../i18n/strings.g.dart';
/// Tracks focus state for a set of named items, avoiding repeated boilerplate
@@ -231,7 +232,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
await provider.aggregationService.getLibrariesFromAllServers();
// Filter out unsupported library types (music)
var filtered = libraries.where((lib) => lib.type != 'artist').toList();
var filtered = libraries.where((lib) => !ContentTypeHelper.isMusicLibrary(lib)).toList();
// Apply saved order
final savedOrder = storage.getLibraryOrder();
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/widgets/bottom_sheet_header.dart';
/// Shared header widget for video control sheets
///
/// This is now a thin wrapper around [BottomSheetHeader] for backward compatibility.
/// Consider using [BottomSheetHeader] directly for new implementations.
///
/// Provides a consistent header with an icon/back button, title, and close button
class VideoSheetHeader extends StatelessWidget {
final String title;
@@ -23,42 +25,18 @@ class VideoSheetHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Back button or icon
if (onBack != null)
IconButton(
icon: const AppIcon(
Symbols.arrow_back_rounded,
fill: 1,
color: Colors.white,
),
onPressed: onBack,
)
else if (icon != null)
AppIcon(icon, fill: 1, color: iconColor ?? Colors.white),
const SizedBox(width: 12),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const AppIcon(
Symbols.close_rounded,
fill: 1,
color: Colors.white,
),
onPressed: onClose ?? () => Navigator.pop(context),
),
],
return BottomSheetHeader(
title: title,
icon: icon,
iconColor: iconColor ?? Colors.white,
onBack: onBack,
onClose: onClose,
titleStyle: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
showBorder: false,
);
}
}
@@ -28,6 +28,7 @@ import '../../services/settings_service.dart';
import '../../utils/platform_detector.dart';
import '../../utils/player_utils.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart';
import '../../utils/video_control_icons.dart';
import '../../utils/app_logger.dart';
import '../../i18n/strings.g.dart';
@@ -1377,9 +1378,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
}
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../mpv/mpv.dart';
import '../../../services/sleep_timer_service.dart';
import '../../../utils/duration_formatter.dart';
import '../../../utils/snackbar_helper.dart';
import '../../../i18n/strings.g.dart';
/// Widget displaying list of sleep timer durations for selection
@@ -58,23 +59,13 @@ class SleepTimerDurationList extends StatelessWidget {
// Show a snackbar notification
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.videoControls.sleepTimerCompleted),
duration: const Duration(seconds: 3),
),
);
showSuccessSnackBar(context, t.videoControls.sleepTimerCompleted);
}
});
Navigator.pop(context);
// Show confirmation snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.messages.sleepTimerSet(label: label)),
duration: const Duration(seconds: 2),
),
);
showSuccessSnackBar(context, t.messages.sleepTimerSet(label: label));
},
);
},