diff --git a/lib/models/plex_metadata_extensions.dart b/lib/models/plex_metadata_extensions.dart new file mode 100644 index 00000000..07ddf4da --- /dev/null +++ b/lib/models/plex_metadata_extensions.dart @@ -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; +} diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index a1add47b..16ac2b77 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -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()), ); } } diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 18d58036..61f58873 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -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 } 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; diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index 4feb39a2..d4ee2a1b 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -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 { }); 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(), ), ); } diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index eb7dfc82..8e7f830d 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -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 // 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 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)); } } } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index a5aee3a9..8d8a1574 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -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 { 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 { ), 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 { 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 { ); 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 { 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 { 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 { 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 { 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 { 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 { ); } 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 { } 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 { } } 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 { // 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 { }); // 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 { _isLoadingMetadata = false; }); - if (widget.metadata.type.toLowerCase() == 'show') { + if (widget.metadata.isShow) { _loadSeasons(); } } catch (e) { @@ -869,7 +809,7 @@ class _MediaDetailScreenState extends State { _isLoadingMetadata = false; }); - if (widget.metadata.type.toLowerCase() == 'show') { + if (widget.metadata.isShow) { _loadSeasons(); } } @@ -1083,7 +1023,7 @@ class _MediaDetailScreenState extends State { // For shows, also refetch seasons to update their watch counts List? 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 { 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 { 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 { } } 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 { // 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 { if (client == null) return; final playbackState = context.read(); - final itemType = metadata.type.toLowerCase(); try { // Show loading indicator @@ -1234,9 +1168,9 @@ class _MediaDetailScreenState extends State { // 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 { 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 { } 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 { 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 { 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 { 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 diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index 1429b8f4..db05deb0 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -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 { 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( - Theme.of(context).colorScheme.primary, - ), + child: MediaProgressBar( + viewOffset: item.viewOffset!, + duration: item.duration!, minHeight: 3, ), ), diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index 42f0999d..896f3789 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -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), ); } } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 0462147e..1e9506db 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -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 setState(() { _isSearching = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.errors.searchFailed(error: e))), - ); + showErrorSnackBar(context, t.errors.searchFailed(error: e)); } } } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index cb4f01eb..c4489721 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -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 { .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 { 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 { 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 { 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 { _isCheckingForUpdate = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.update.checkFailed), - duration: Duration(seconds: 2), - ), - ); + showErrorSnackBar(context, t.update.checkFailed); } } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 1f9a4803..fa38f6eb 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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 // Set controls enabled based on content type final playbackState = context.read(); - 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 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 } } 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 } } - 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 } // 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; diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index 0b9fcd3f..8e33c8b3 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -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 = []; // 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) { diff --git a/lib/services/play_queue_launcher.dart b/lib/services/play_queue_launcher.dart index 29094b03..f2b140d9 100644 --- a/lib/services/play_queue_launcher.dart +++ b/lib/services/play_queue_launcher.dart @@ -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()), ); } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 3e01a41a..17dc171f 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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>( + 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( + 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 _fetchWithCacheFallback({ + required String cacheKey, + required Future 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? _getFirstMetadataJsonFromData(Map? 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 _wrapBoolApiCall( + Future 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> _wrapListApiCall( + Future Function() apiCall, + List 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 audio, List subtitles}) _parseStreams( List? 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 = {}; - if (audioLanguage != null) { - queryParams['audioLanguage'] = audioLanguage; - } - if (subtitleLanguage != null) { - queryParams['subtitleLanguage'] = subtitleLanguage; - } + final queryParams = {}; + 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 = {}; - if (audioStreamID != null) { - queryParams['audioStreamID'] = audioStreamID; + final queryParams = {}; + 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> 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>( + 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> 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( + () => _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> 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( + () => _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 = {'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 = {'playlistType': playlistType}; + if (smart != null) { + queryParams['smart'] = smart ? '1' : '0'; } + + return _wrapListApiCall( + () => _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 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 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 = { - 'type': 'playlist', - 'id': playlistId, - }; + final queryParams = { + '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> getLibraryCollections(String sectionId) async { - try { - final response = await _dio.get( + return _wrapListApiCall( + () => _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> 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( + () => _dio.get('/library/collections/$collectionId/children'), + _extractMetadataList, + 'Failed to get collection items', + ); } /// Delete a collection /// Deletes a library collection from the server Future 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 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) diff --git a/lib/utils/content_type_helper.dart b/lib/utils/content_type_helper.dart new file mode 100644 index 00000000..aaa6d546 --- /dev/null +++ b/lib/utils/content_type_helper.dart @@ -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 filterOutMusic(List items, String Function(T) getType) { + return items.where((item) => !isMusicContent(getType(item))).toList(); + } +} diff --git a/lib/utils/layout_constants.dart b/lib/utils/layout_constants.dart index c35550c3..fb2a7c83 100644 --- a/lib/utils/layout_constants.dart +++ b/lib/utils/layout_constants.dart @@ -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); +} diff --git a/lib/utils/snackbar_helper.dart b/lib/utils/snackbar_helper.dart new file mode 100644 index 00000000..502d7312 --- /dev/null +++ b/lib/utils/snackbar_helper.dart @@ -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), + ), + ); +} diff --git a/lib/widgets/bottom_sheet_header.dart b/lib/widgets/bottom_sheet_header.dart index 389fc453..78a28224 100644 --- a/lib/widgets/bottom_sheet_header.dart +++ b/lib/widgets/bottom_sheet_header.dart @@ -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), ), ], diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index 01af2b1d..f6393f24 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -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 { 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( diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 0bd8c442..7b34c4dc 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -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 { 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( - Theme.of(context).colorScheme.primary, - ), - minHeight: 4, + child: MediaProgressBar( + viewOffset: metadata.viewOffset!, + duration: metadata.duration!, ), ), ), diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index bfcecf30..0cc1e8e4 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -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 { 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 { 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 { 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 { } } 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 { 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 { } } 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 { 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 { } 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { ); 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()), ); } } diff --git a/lib/widgets/media_progress_bar.dart b/lib/widgets/media_progress_bar.dart new file mode 100644 index 00000000..5ddea659 --- /dev/null +++ b/lib/widgets/media_progress_bar.dart @@ -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( + valueColor ?? Theme.of(context).colorScheme.primary, + ), + minHeight: minHeight ?? 4, + ); + } +} diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 1ac5edcf..b3b0a2ed 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -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 { 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(); diff --git a/lib/widgets/video_controls/sheets/video_sheet_header.dart b/lib/widgets/video_controls/sheets/video_sheet_header.dart index 54b1b61b..f3b370ff 100644 --- a/lib/widgets/video_controls/sheets/video_sheet_header.dart +++ b/lib/widgets/video_controls/sheets/video_sheet_header.dart @@ -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, ); } } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 0d239724..556ad0b4 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -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 } } 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())); } } } diff --git a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart index 2603f1fe..96316b34 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart @@ -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)); }, ); },