From a2a5a5ae55ea24708f39a47a8dd9d66d21989600 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:03:11 +0100 Subject: [PATCH] refactor: simplify & deduplicate --- lib/focus/dpad_navigator.dart | 7 + lib/focus/focusable_chip_mixin.dart | 2 +- lib/focus/focusable_wrapper.dart | 4 +- lib/models/download_progress.dart | 21 +- lib/models/plex_file_info.dart | 29 +- lib/models/plex_media_info.dart | 34 +- lib/models/plex_media_version.dart | 8 +- lib/models/plex_metadata_extensions.dart | 27 +- lib/providers/download_provider.dart | 243 +++------- lib/providers/hidden_libraries_provider.dart | 25 +- lib/providers/settings_provider.dart | 32 +- lib/screens/auth_screen.dart | 3 +- .../base_media_list_detail_screen.dart | 46 +- lib/screens/discover_screen.dart | 45 +- lib/screens/downloads/downloads_screen.dart | 37 +- lib/screens/hub_detail_screen.dart | 24 +- lib/screens/libraries/empty_state_widget.dart | 10 + lib/screens/libraries/libraries_screen.dart | 77 +-- .../libraries/state_message_widget.dart | 39 +- lib/screens/media_detail_screen.dart | 65 +-- lib/screens/profile/profile_list_tile.dart | 3 +- .../profile/profile_switch_screen.dart | 33 +- lib/screens/profile/user_avatar_widget.dart | 3 +- lib/screens/search_screen.dart | 68 +-- lib/screens/season_detail_screen.dart | 47 +- lib/screens/settings/settings_screen.dart | 448 ++++++------------ lib/services/download_manager_service.dart | 366 ++++++-------- lib/services/download_storage_service.dart | 23 +- lib/services/plex_client.dart | 126 ++--- lib/theme/mono_theme.dart | 1 + lib/theme/mono_tokens.dart | 11 + lib/utils/byte_formatter.dart | 61 +++ lib/utils/codec_utils.dart | 91 ++++ lib/utils/content_type_helper.dart | 85 ++-- lib/utils/dialogs.dart | 165 +++++++ lib/utils/focus_utils.dart | 53 +++ lib/utils/number_formatter.dart | 32 ++ lib/utils/plex_cache_parser.dart | 57 +++ lib/utils/plex_url_helper.dart | 36 ++ lib/utils/provider_extensions.dart | 44 ++ lib/utils/snackbar_helper.dart | 70 ++- lib/utils/track_label_builder.dart | 54 +++ lib/utils/video_player_navigation.dart | 47 ++ lib/widgets/bottom_sheet_header.dart | 5 + lib/widgets/file_info_bottom_sheet.dart | 41 +- .../horizontal_scroll_with_arrows.dart | 5 +- lib/widgets/hub_section.dart | 5 +- lib/widgets/media_card.dart | 44 +- lib/widgets/media_context_menu.dart | 240 ++-------- lib/widgets/placeholder_container.dart | 43 ++ lib/widgets/side_navigation_rail.dart | 6 +- .../desktop_video_controls.dart | 5 +- .../sheets/audio_track_sheet.dart | 24 +- .../sheets/subtitle_track_sheet.dart | 31 +- .../video_controls/video_controls.dart | 112 ++--- .../widgets/track_chapter_controls.dart | 181 +++---- .../widgets/volume_control.dart | 2 +- 57 files changed, 1636 insertions(+), 1810 deletions(-) create mode 100644 lib/utils/byte_formatter.dart create mode 100644 lib/utils/codec_utils.dart create mode 100644 lib/utils/focus_utils.dart create mode 100644 lib/utils/number_formatter.dart create mode 100644 lib/utils/plex_cache_parser.dart create mode 100644 lib/utils/plex_url_helper.dart create mode 100644 lib/utils/track_label_builder.dart create mode 100644 lib/widgets/placeholder_container.dart diff --git a/lib/focus/dpad_navigator.dart b/lib/focus/dpad_navigator.dart index 676a1bb6..4ce70d93 100644 --- a/lib/focus/dpad_navigator.dart +++ b/lib/focus/dpad_navigator.dart @@ -1,5 +1,12 @@ import 'package:flutter/services.dart'; +/// Extension on KeyEvent for common event type checks. +extension KeyEventActionable on KeyEvent { + /// Whether this event should trigger an action (KeyDownEvent or KeyRepeatEvent). + /// Use this to filter out KeyUpEvents early in key handlers. + bool get isActionable => this is KeyDownEvent || this is KeyRepeatEvent; +} + /// Shared sets for keyboard key categories. final _dpadDirectionKeys = { LogicalKeyboardKey.arrowUp, diff --git a/lib/focus/focusable_chip_mixin.dart b/lib/focus/focusable_chip_mixin.dart index 0c96a245..68654737 100644 --- a/lib/focus/focusable_chip_mixin.dart +++ b/lib/focus/focusable_chip_mixin.dart @@ -108,7 +108,7 @@ mixin FocusableChipStateMixin on State { KeyEvent event, ChipKeyCallbacks callbacks, ) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + if (!event.isActionable) { return KeyEventResult.ignored; } diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index ef904cd2..95411443 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -304,8 +304,8 @@ class _FocusableWrapperState extends State } } - // Ignore key repeat events for other keys - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + // Ignore key up events for other keys + if (!event.isActionable) { return KeyEventResult.ignored; } diff --git a/lib/models/download_progress.dart b/lib/models/download_progress.dart index 82ffc7aa..4ead0da2 100644 --- a/lib/models/download_progress.dart +++ b/lib/models/download_progress.dart @@ -1,3 +1,4 @@ +import '../utils/byte_formatter.dart'; import 'download_status.dart'; class DownloadProgress { @@ -28,23 +29,9 @@ class DownloadProgress { double get progressPercent => progress / 100.0; - String get speedFormatted { - if (speed < 1024) return '${speed.toStringAsFixed(0)} B/s'; - if (speed < 1024 * 1024) return '${(speed / 1024).toStringAsFixed(1)} KB/s'; - return '${(speed / (1024 * 1024)).toStringAsFixed(1)} MB/s'; - } - - String get downloadedFormatted => _formatBytes(downloadedBytes); - String get totalFormatted => _formatBytes(totalBytes); - - String _formatBytes(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - if (bytes < 1024 * 1024 * 1024) { - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - } - return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; - } + String get speedFormatted => ByteFormatter.formatSpeed(speed); + String get downloadedFormatted => ByteFormatter.formatBytes(downloadedBytes); + String get totalFormatted => ByteFormatter.formatBytes(totalBytes); Duration? get estimatedTimeRemaining { if (speed <= 0 || totalBytes <= 0) return null; diff --git a/lib/models/plex_file_info.dart b/lib/models/plex_file_info.dart index 3ebf85cb..9fa02073 100644 --- a/lib/models/plex_file_info.dart +++ b/lib/models/plex_file_info.dart @@ -1,3 +1,5 @@ +import '../utils/byte_formatter.dart'; + class PlexFileInfo { // Media level properties final String? container; @@ -61,20 +63,7 @@ class PlexFileInfo { /// Format file size in human-readable format (GB, MB, KB, bytes) String get fileSizeFormatted { if (fileSize == null) return 'Unknown'; - - const kb = 1024; - const mb = kb * 1024; - const gb = mb * 1024; - - if (fileSize! >= gb) { - return '${(fileSize! / gb).toStringAsFixed(2)} GB'; - } else if (fileSize! >= mb) { - return '${(fileSize! / mb).toStringAsFixed(2)} MB'; - } else if (fileSize! >= kb) { - return '${(fileSize! / kb).toStringAsFixed(2)} KB'; - } else { - return '$fileSize bytes'; - } + return ByteFormatter.formatBytes(fileSize!, decimals: 2); } /// Format duration in HH:MM:SS or MM:SS format @@ -96,17 +85,7 @@ class PlexFileInfo { /// Format bitrate in Mbps or Kbps String get bitrateFormatted { if (bitrate == null) return 'Unknown'; - - const kbps = 1000; - const mbps = kbps * 1000; - - if (bitrate! >= mbps) { - return '${(bitrate! / mbps).toStringAsFixed(2)} Mbps'; - } else if (bitrate! >= kbps) { - return '${(bitrate! / kbps).toStringAsFixed(2)} Kbps'; - } else { - return '$bitrate bps'; - } + return ByteFormatter.formatBitrateBps(bitrate!); } /// Format resolution as widthxheight diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index 03b6d42d..70fb70ba 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -1,3 +1,5 @@ +import '../utils/codec_utils.dart'; + class PlexMediaInfo { final String videoUrl; final List audioTracks; @@ -67,7 +69,7 @@ class PlexAudioTrack with TrackLabelBuilder { String get label { final additionalParts = []; - if (codec != null) additionalParts.add(codec!.toUpperCase()); + if (codec != null) additionalParts.add(CodecUtils.formatAudioCodec(codec!)); if (channels != null) additionalParts.add('${channels!}ch'); return buildLabel(additionalParts); } @@ -118,39 +120,11 @@ class PlexSubtitleTrack with TrackLabelBuilder { if (!isExternal) return null; // Determine file extension based on codec - final ext = _getExtensionFromCodec(codec); + final ext = CodecUtils.getSubtitleExtension(codec); // Construct URL with authentication token return '$baseUrl$key.$ext?X-Plex-Token=$token'; } - - /// Maps Plex subtitle codec names to file extensions - String _getExtensionFromCodec(String? codec) { - if (codec == null) return 'srt'; - - switch (codec.toLowerCase()) { - case 'subrip': - case 'srt': - return 'srt'; - case 'ass': - return 'ass'; - case 'ssa': - return 'ssa'; - case 'webvtt': - case 'vtt': - return 'vtt'; - case 'mov_text': - return 'srt'; - case 'pgs': - case 'hdmv_pgs_subtitle': - return 'sup'; - case 'dvd_subtitle': - case 'dvdsub': - return 'sub'; - default: - return 'srt'; // Default to SRT for unknown codecs - } - } } class PlexChapter { diff --git a/lib/models/plex_media_version.dart b/lib/models/plex_media_version.dart index d165fc5c..3155faa5 100644 --- a/lib/models/plex_media_version.dart +++ b/lib/models/plex_media_version.dart @@ -1,3 +1,6 @@ +import '../utils/byte_formatter.dart'; +import '../utils/codec_utils.dart'; + class PlexMediaVersion { final int id; final String? videoResolution; @@ -52,7 +55,7 @@ class PlexMediaVersion { // Add codec if (videoCodec != null && videoCodec!.isNotEmpty) { - parts.add(videoCodec!.toUpperCase()); + parts.add(CodecUtils.formatVideoCodec(videoCodec!)); } // Add container @@ -65,8 +68,7 @@ class PlexMediaVersion { // Add bitrate in parentheses if (bitrate != null && bitrate! > 0) { - final bitrateInMbps = (bitrate! / 1000).toStringAsFixed(1); - label += ' ($bitrateInMbps Mbps)'; + label += ' (${ByteFormatter.formatBitrate(bitrate!)})'; } return label; diff --git a/lib/models/plex_metadata_extensions.dart b/lib/models/plex_metadata_extensions.dart index 07ddf4da..eae8e20f 100644 --- a/lib/models/plex_metadata_extensions.dart +++ b/lib/models/plex_metadata_extensions.dart @@ -1,17 +1,20 @@ +import '../utils/content_type_helper.dart'; 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; + String get _lowerType => type.toLowerCase(); + + bool get isShow => _lowerType == ContentTypes.show; + bool get isMovie => _lowerType == ContentTypes.movie; + bool get isSeason => _lowerType == ContentTypes.season; + bool get isEpisode => _lowerType == ContentTypes.episode; + bool get isArtist => _lowerType == ContentTypes.artist; + bool get isAlbum => _lowerType == ContentTypes.album; + bool get isTrack => _lowerType == ContentTypes.track; + bool get isCollection => _lowerType == ContentTypes.collection; + bool get isPlaylist => _lowerType == ContentTypes.playlist; + bool get isClip => _lowerType == ContentTypes.clip; + bool get isMusicContent => ContentTypes.musicTypes.contains(_lowerType); + bool get isVideoContent => ContentTypes.videoTypes.contains(_lowerType); } diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 2648a6b1..405ecb93 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:collection'; import 'package:flutter/foundation.dart'; +import 'package:plezy/models/plex_metadata_extensions.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/download_status.dart'; import '../models/download_progress.dart'; @@ -12,6 +13,7 @@ import '../services/download_storage_service.dart'; import '../services/plex_api_cache.dart'; import '../services/plex_client.dart'; import '../utils/app_logger.dart'; +import '../utils/plex_cache_parser.dart'; /// Holds Plex thumb path reference for downloaded artwork. /// The actual file path is computed from the hash of serverId + thumb path. @@ -104,18 +106,16 @@ class DownloadProvider extends ChangeNotifier { item.serverId, '/library/metadata/${item.ratingKey}', ); - if (cached != null) { - final metadataList = cached['MediaContainer']?['Metadata'] as List?; - if (metadataList != null && metadataList.isNotEmpty) { - final metadata = PlexMetadata.fromJson( - metadataList[0], - ).copyWith(serverId: item.serverId); - _metadata[item.globalKey] = metadata; + final firstMetadata = PlexCacheParser.extractFirstMetadata(cached); + if (firstMetadata != null) { + final metadata = PlexMetadata.fromJson( + firstMetadata, + ).copyWith(serverId: item.serverId); + _metadata[item.globalKey] = metadata; - // For episodes, also load parent (show and season) metadata - if (metadata.type == 'episode') { - await _loadParentMetadataFromCache(metadata, apiCache); - } + // For episodes, also load parent (show and season) metadata + if (metadata.isEpisode) { + await _loadParentMetadataFromCache(metadata, apiCache); } } } @@ -186,19 +186,17 @@ class DownloadProvider extends ChangeNotifier { serverId, '/library/metadata/$showRatingKey', ); - if (cached != null) { - final metadataList = cached['MediaContainer']?['Metadata'] as List?; - if (metadataList != null && metadataList.isNotEmpty) { - final showMetadata = PlexMetadata.fromJson( - metadataList[0], - ).copyWith(serverId: serverId); - _metadata[showGlobalKey] = showMetadata; - // Store artwork reference for offline display - if (showMetadata.thumb != null) { - _artworkPaths[showGlobalKey] = DownloadedArtwork( - thumbPath: showMetadata.thumb, - ); - } + final showJson = PlexCacheParser.extractFirstMetadata(cached); + if (showJson != null) { + final showMetadata = PlexMetadata.fromJson( + showJson, + ).copyWith(serverId: serverId); + _metadata[showGlobalKey] = showMetadata; + // Store artwork reference for offline display + if (showMetadata.thumb != null) { + _artworkPaths[showGlobalKey] = DownloadedArtwork( + thumbPath: showMetadata.thumb, + ); } } } @@ -213,19 +211,17 @@ class DownloadProvider extends ChangeNotifier { serverId, '/library/metadata/$seasonRatingKey', ); - if (cached != null) { - final metadataList = cached['MediaContainer']?['Metadata'] as List?; - if (metadataList != null && metadataList.isNotEmpty) { - final seasonMetadata = PlexMetadata.fromJson( - metadataList[0], - ).copyWith(serverId: serverId); - _metadata[seasonGlobalKey] = seasonMetadata; - // Store artwork reference for offline display - if (seasonMetadata.thumb != null) { - _artworkPaths[seasonGlobalKey] = DownloadedArtwork( - thumbPath: seasonMetadata.thumb, - ); - } + final seasonJson = PlexCacheParser.extractFirstMetadata(cached); + if (seasonJson != null) { + final seasonMetadata = PlexMetadata.fromJson( + seasonJson, + ).copyWith(serverId: serverId); + _metadata[seasonGlobalKey] = seasonMetadata; + // Store artwork reference for offline display + if (seasonMetadata.thumb != null) { + _artworkPaths[seasonGlobalKey] = DownloadedArtwork( + thumbPath: seasonMetadata.thumb, + ); } } } @@ -408,118 +404,11 @@ class DownloadProvider extends ChangeNotifier { String serverId, String showRatingKey, ) { - final globalKey = '$serverId:$showRatingKey'; - final episodes = _getEpisodeDownloadsForShow(showRatingKey); - - // DIAGNOSTIC: Check all sources of episode count - final showMeta = _metadata[globalKey]; - final metadataLeafCount = showMeta?.leafCount; - final storedCount = _totalEpisodeCounts[globalKey]; - final downloadedCount = episodes.length; - - appLogger.d( - '๐Ÿ“Š Episode count sources for show $showRatingKey:\n' - ' - Metadata leafCount: $metadataLeafCount\n' - ' - Stored count: $storedCount\n' - ' - Downloaded episodes: $downloadedCount\n' - ' - Show metadata exists: ${showMeta != null}\n' - ' - Show type: ${showMeta?.type}\n' - ' - Show title: ${showMeta?.title}', - ); - - // Get total episode count - FIXED: Use metadata.leafCount as primary source - int totalEpisodes; - String countSource; - - if (metadataLeafCount != null && metadataLeafCount > 0) { - totalEpisodes = metadataLeafCount; - countSource = 'metadata.leafCount'; - } else if (storedCount != null && storedCount > 0) { - totalEpisodes = storedCount; - countSource = 'stored count (SharedPreferences)'; - } else { - totalEpisodes = downloadedCount; - countSource = 'downloaded episodes (fallback)'; - } - - appLogger.d( - 'โœ… Using totalEpisodes=$totalEpisodes from [$countSource] for show $showRatingKey', - ); - - // If we have stored count but no downloads, check if it's a valid partial state - if (totalEpisodes == 0 || (episodes.isEmpty && totalEpisodes > 0)) { - // No episodes downloaded yet, but we have a count stored - // This means user hasn't downloaded anything or deleted all episodes - appLogger.d( - 'โš ๏ธ No valid downloads for show $showRatingKey, returning null', - ); - return null; - } - - // Calculate aggregate statistics - int completedCount = 0; - int downloadingCount = 0; - int queuedCount = 0; - int failedCount = 0; - - for (final ep in episodes) { - switch (ep.status) { - case DownloadStatus.completed: - completedCount++; - break; - case DownloadStatus.downloading: - downloadingCount++; - break; - case DownloadStatus.queued: - queuedCount++; - break; - case DownloadStatus.failed: - failedCount++; - break; - default: - break; - } - } - - // Determine overall status - final DownloadStatus overallStatus; - if (completedCount == totalEpisodes) { - // All episodes fully downloaded - overallStatus = DownloadStatus.completed; - } else if (completedCount > 0 && - downloadingCount == 0 && - queuedCount == 0 && - completedCount < totalEpisodes) { - // Some episodes downloaded, but not all, and nothing actively downloading - overallStatus = DownloadStatus.partial; - } else if (downloadingCount > 0) { - overallStatus = DownloadStatus.downloading; - } else if (queuedCount > 0) { - overallStatus = DownloadStatus.queued; - } else if (failedCount > 0) { - overallStatus = DownloadStatus.failed; - } else { - return null; - } - - // Calculate overall progress percentage based on TOTAL episodes - final int overallProgress = totalEpisodes > 0 - ? ((completedCount * 100) / totalEpisodes).round() - : 0; - - appLogger.d( - 'Aggregate progress for show $showRatingKey: $overallProgress% ' - '($completedCount completed, $downloadingCount downloading, ' - '$queuedCount queued of $totalEpisodes total) - Status: $overallStatus', - ); - - return DownloadProgress( - globalKey: globalKey, - status: overallStatus, - progress: overallProgress, - downloadedBytes: 0, // Not tracked at show level - totalBytes: 0, - currentFile: '$completedCount/$totalEpisodes episodes', + return _calculateAggregateProgress( + serverId: serverId, + ratingKey: showRatingKey, + episodes: _getEpisodeDownloadsForShow(showRatingKey), + entityType: 'show', ); } @@ -529,26 +418,40 @@ class DownloadProvider extends ChangeNotifier { String serverId, String seasonRatingKey, ) { - final globalKey = '$serverId:$seasonRatingKey'; - final episodes = _getEpisodeDownloadsForSeason(seasonRatingKey); + return _calculateAggregateProgress( + serverId: serverId, + ratingKey: seasonRatingKey, + episodes: _getEpisodeDownloadsForSeason(seasonRatingKey), + entityType: 'season', + ); + } + + /// Shared helper to calculate aggregate download progress for shows/seasons + DownloadProgress? _calculateAggregateProgress({ + required String serverId, + required String ratingKey, + required List episodes, + required String entityType, + }) { + final globalKey = '$serverId:$ratingKey'; // DIAGNOSTIC: Check all sources of episode count - final seasonMeta = _metadata[globalKey]; - final metadataLeafCount = seasonMeta?.leafCount; + final meta = _metadata[globalKey]; + final metadataLeafCount = meta?.leafCount; final storedCount = _totalEpisodeCounts[globalKey]; final downloadedCount = episodes.length; appLogger.d( - '๐Ÿ“Š Episode count sources for season $seasonRatingKey:\n' + '๐Ÿ“Š Episode count sources for $entityType $ratingKey:\n' ' - Metadata leafCount: $metadataLeafCount\n' ' - Stored count: $storedCount\n' ' - Downloaded episodes: $downloadedCount\n' - ' - Season metadata exists: ${seasonMeta != null}\n' - ' - Season type: ${seasonMeta?.type}\n' - ' - Season title: ${seasonMeta?.title}', + ' - Metadata exists: ${meta != null}\n' + ' - Type: ${meta?.type}\n' + ' - Title: ${meta?.title}', ); - // Get total episode count - FIXED: Use metadata.leafCount as primary source + // Get total episode count - Use metadata.leafCount as primary source int totalEpisodes; String countSource; @@ -564,13 +467,13 @@ class DownloadProvider extends ChangeNotifier { } appLogger.d( - 'โœ… Using totalEpisodes=$totalEpisodes from [$countSource] for season $seasonRatingKey', + 'โœ… Using totalEpisodes=$totalEpisodes from [$countSource] for $entityType $ratingKey', ); // If we have stored count but no downloads, check if it's a valid partial state if (totalEpisodes == 0 || (episodes.isEmpty && totalEpisodes > 0)) { appLogger.d( - 'โš ๏ธ No valid downloads for season $seasonRatingKey, returning null', + 'โš ๏ธ No valid downloads for $entityType $ratingKey, returning null', ); return null; } @@ -585,16 +488,12 @@ class DownloadProvider extends ChangeNotifier { switch (ep.status) { case DownloadStatus.completed: completedCount++; - break; case DownloadStatus.downloading: downloadingCount++; - break; case DownloadStatus.queued: queuedCount++; - break; case DownloadStatus.failed: failedCount++; - break; default: break; } @@ -603,13 +502,11 @@ class DownloadProvider extends ChangeNotifier { // Determine overall status final DownloadStatus overallStatus; if (completedCount == totalEpisodes) { - // All episodes fully downloaded overallStatus = DownloadStatus.completed; } else if (completedCount > 0 && downloadingCount == 0 && queuedCount == 0 && completedCount < totalEpisodes) { - // Some episodes downloaded, but not all, and nothing actively downloading overallStatus = DownloadStatus.partial; } else if (downloadingCount > 0) { overallStatus = DownloadStatus.downloading; @@ -627,7 +524,7 @@ class DownloadProvider extends ChangeNotifier { : 0; appLogger.d( - 'Aggregate progress for season $seasonRatingKey: $overallProgress% ' + 'Aggregate progress for $entityType $ratingKey: $overallProgress% ' '($completedCount completed, $downloadingCount downloading, ' '$queuedCount queued of $totalEpisodes total) - Status: $overallStatus', ); @@ -1248,13 +1145,11 @@ class DownloadProvider extends ChangeNotifier { '/library/metadata/$ratingKey', ); - if (cached != null) { - final metadataList = cached['MediaContainer']?['Metadata'] as List?; - if (metadataList != null && metadataList.isNotEmpty) { - final metadata = PlexMetadata.fromJson(metadataList.first); - _metadata[globalKey] = metadata.copyWith(serverId: serverId); - updatedCount++; - } + final firstMetadata = PlexCacheParser.extractFirstMetadata(cached); + if (firstMetadata != null) { + final metadata = PlexMetadata.fromJson(firstMetadata); + _metadata[globalKey] = metadata.copyWith(serverId: serverId); + updatedCount++; } } catch (e) { appLogger.d('Failed to refresh metadata for $globalKey: $e'); diff --git a/lib/providers/hidden_libraries_provider.dart b/lib/providers/hidden_libraries_provider.dart index 0db4b3de..dad177e4 100644 --- a/lib/providers/hidden_libraries_provider.dart +++ b/lib/providers/hidden_libraries_provider.dart @@ -8,23 +8,26 @@ class HiddenLibrariesProvider extends ChangeNotifier { late StorageService _storageService; Set _hiddenLibraryKeys = {}; bool _isInitialized = false; + Future? _initFuture; - /// Get an unmodifiable copy of hidden library keys - Set get hiddenLibraryKeys { - if (!_isInitialized) _initialize(); - return Set.unmodifiable(_hiddenLibraryKeys); + HiddenLibrariesProvider() { + // Start initialization eagerly to reduce race conditions + _initFuture = _initialize(); } + /// Ensures the provider is initialized. Call this before accessing hidden + /// libraries in contexts where you need the actual persisted values. + Future ensureInitialized() => _initFuture ?? _initialize(); + /// Check if the provider has completed initialization bool get isInitialized => _isInitialized; - HiddenLibrariesProvider() { - // Don't initialize immediately if lazy-loaded - // _initialize() will be called when first accessed - } + /// Get an unmodifiable copy of hidden library keys + Set get hiddenLibraryKeys => Set.unmodifiable(_hiddenLibraryKeys); /// Initialize the provider by loading hidden libraries from storage Future _initialize() async { + if (_isInitialized) return; _storageService = await StorageService.getInstance(); _hiddenLibraryKeys = _storageService.getHiddenLibraries(); _isInitialized = true; @@ -54,10 +57,8 @@ class HiddenLibrariesProvider extends ChangeNotifier { } /// Check if a specific library is hidden - bool isLibraryHidden(String libraryKey) { - if (!_isInitialized) _initialize(); - return _hiddenLibraryKeys.contains(libraryKey); - } + bool isLibraryHidden(String libraryKey) => + _hiddenLibraryKeys.contains(libraryKey); /// Refresh hidden libraries from storage /// Useful if storage was modified outside the provider diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 2f81d46d..ec7c9d82 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -8,12 +8,17 @@ class SettingsProvider extends ChangeNotifier { bool _useSeasonPoster = false; bool _showHeroSection = true; bool _isInitialized = false; + Future? _initFuture; SettingsProvider() { - // Don't initialize immediately if lazy-loaded - // _initializeSettings() will be called when first accessed + // Start initialization eagerly to reduce race conditions + _initFuture = _initializeSettings(); } + /// Ensures the provider is initialized. Call this before accessing settings + /// in contexts where you need the actual persisted values. + Future ensureInitialized() => _initFuture ?? _initializeSettings(); + Future _initializeSettings() async { if (_isInitialized) return; @@ -26,25 +31,16 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } - LibraryDensity get libraryDensity { - if (!_isInitialized) _initializeSettings(); - return _libraryDensity; - } + /// Whether the provider has completed initialization + bool get isInitialized => _isInitialized; - ViewMode get viewMode { - if (!_isInitialized) _initializeSettings(); - return _viewMode; - } + LibraryDensity get libraryDensity => _libraryDensity; - bool get useSeasonPoster { - if (!_isInitialized) _initializeSettings(); - return _useSeasonPoster; - } + ViewMode get viewMode => _viewMode; - bool get showHeroSection { - if (!_isInitialized) _initializeSettings(); - return _showHeroSection; - } + bool get useSeasonPoster => _useSeasonPoster; + + bool get showHeroSection => _showHeroSection; Future setLibraryDensity(LibraryDensity density) async { if (!_isInitialized) await _initializeSettings(); diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index f377bd98..00cfdd2b 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -10,6 +10,7 @@ import '../services/server_registry.dart'; import '../providers/multi_server_provider.dart'; import '../providers/plex_client_provider.dart'; import '../i18n/strings.g.dart'; +import '../theme/theme_helper.dart'; import '../utils/app_logger.dart'; import 'main_screen.dart'; @@ -507,7 +508,7 @@ class _AuthScreenState extends State { const SizedBox(height: 24), Center( child: ClipRRect( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(tokens(context).radiusMd), child: QrImageView( data: _qrAuthUrl!, size: qrSize, diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index 8f3f4ae9..9bdc6ab8 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -12,6 +12,8 @@ import '../utils/app_logger.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../i18n/strings.g.dart'; +import 'libraries/error_state_widget.dart'; +import 'libraries/empty_state_widget.dart'; /// Abstract base class for screens displaying media lists (collections/playlists) /// Provides common state management and playback functionality @@ -141,25 +143,10 @@ abstract class BaseMediaListDetailScreen if (errorMessage != null) { return [ SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const AppIcon( - Symbols.error_outline_rounded, - fill: 1, - size: 48, - color: Colors.red, - ), - const SizedBox(height: 16), - Text(errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: loadItems, - child: Text(t.common.retry), - ), - ], - ), + child: ErrorStateWidget( + message: errorMessage!, + icon: Symbols.error_outline_rounded, + onRetry: loadItems, ), ), ]; @@ -174,26 +161,11 @@ abstract class BaseMediaListDetailScreen } if (items.isEmpty) { - final icon = emptyIcon; return [ SliverFillRemaining( - child: Center( - child: icon != null - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppIcon(icon, fill: 1, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text( - emptyMessage, - style: const TextStyle( - fontSize: 16, - color: Colors.grey, - ), - ), - ], - ) - : Text(emptyMessage), + child: EmptyStateWidget( + message: emptyMessage, + icon: emptyIcon, ), ), ]; diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 61f58873..e5307f0c 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -4,6 +4,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../focus/dpad_navigator.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../../services/plex_client.dart'; import '../utils/plex_image_helper.dart'; @@ -28,8 +29,9 @@ import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../utils/content_rating_formatter.dart'; import '../utils/layout_constants.dart'; -import '../focus/dpad_navigator.dart'; +import '../theme/theme_helper.dart'; import 'auth_screen.dart'; +import 'libraries/error_state_widget.dart'; class DiscoverScreen extends StatefulWidget { final VoidCallback? onBecameVisible; @@ -197,7 +199,7 @@ class _DiscoverScreenState extends State /// Handle key events for the hero section KeyEventResult _handleHeroKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + if (!event.isActionable) { return KeyEventResult.ignored; } @@ -222,7 +224,7 @@ class _DiscoverScreenState extends State if (key.isLeftKey) { if (_currentHeroIndex > 0) { _heroController.previousPage( - duration: const Duration(milliseconds: 300), + duration: tokens(context).slow, curve: Curves.easeInOut, ); } @@ -233,7 +235,7 @@ class _DiscoverScreenState extends State if (key.isRightKey) { if (_currentHeroIndex < _onDeck.length - 1) { _heroController.nextPage( - duration: const Duration(milliseconds: 300), + duration: tokens(context).slow, curve: Curves.easeInOut, ); } @@ -253,7 +255,7 @@ class _DiscoverScreenState extends State /// Handle key events for the refresh button in app bar KeyEventResult _handleRefreshKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + if (!event.isActionable) { return KeyEventResult.ignored; } @@ -287,7 +289,7 @@ class _DiscoverScreenState extends State /// Handle key events for the user button in app bar KeyEventResult _handleUserKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + if (!event.isActionable) { return KeyEventResult.ignored; } @@ -901,25 +903,10 @@ class _DiscoverScreenState extends State ), if (_errorMessage != null) SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const AppIcon( - Symbols.error_outline_rounded, - fill: 1, - size: 48, - color: Colors.red, - ), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadContent, - child: Text(t.common.retry), - ), - ], - ), + child: ErrorStateWidget( + message: _errorMessage!, + icon: Symbols.error_outline_rounded, + onRetry: _loadContent, ), ), if (!_isLoading && _errorMessage == null) ...[ @@ -1007,7 +994,9 @@ class _DiscoverScreenState extends State color: Theme.of( context, ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular( + tokens(context).radiusSm, + ), ), ); }, @@ -1132,7 +1121,7 @@ class _DiscoverScreenState extends State ((maxWidth - dotSize) * _indicatorAnimationController.value); return AnimatedContainer( - duration: const Duration(milliseconds: 300), + duration: tokens(context).slow, curve: Curves.easeInOut, margin: const EdgeInsets.symmetric( horizontal: 4, @@ -1164,7 +1153,7 @@ class _DiscoverScreenState extends State } else { // Static indicator for inactive pages return AnimatedContainer( - duration: const Duration(milliseconds: 300), + duration: tokens(context).slow, curve: Curves.easeInOut, margin: const EdgeInsets.symmetric(horizontal: 4), width: dotSize, diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index 9bc2c565..e2de308b 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../models/plex_metadata.dart'; @@ -14,6 +13,7 @@ import '../../widgets/focusable_media_card.dart'; import '../../widgets/media_grid_delegate.dart'; import '../../widgets/download_tree_view.dart'; import '../main_screen.dart'; +import '../libraries/empty_state_widget.dart'; import '../../i18n/strings.g.dart'; class DownloadsScreen extends StatefulWidget { @@ -341,36 +341,11 @@ class _DownloadsGridContent extends StatelessWidget { } Widget _buildEmptyState(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppIcon( - Symbols.download_rounded, - fill: 1, - size: 80, - color: Theme.of(context).colorScheme.outline, - ), - const SizedBox(height: 24), - Text( - t.downloads.noDownloads, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 8), - Text( - t.downloads.noDownloadsDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), + return EmptyStateWidget( + message: t.downloads.noDownloads, + subtitle: t.downloads.noDownloadsDescription, + icon: Symbols.download_rounded, + iconSize: 80, ); } } diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 61aa23b9..f9d3d74a 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -10,6 +10,7 @@ import '../utils/app_logger.dart'; import '../widgets/media_grid_sliver.dart'; import '../widgets/focused_scroll_scaffold.dart'; import 'libraries/sort_bottom_sheet.dart'; +import 'libraries/error_state_widget.dart'; import '../mixins/refreshable.dart'; import '../i18n/strings.g.dart'; @@ -256,25 +257,10 @@ class _HubDetailScreenState extends State with Refreshable { slivers: [ if (_errorMessage != null) SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const AppIcon( - Symbols.error_outline_rounded, - fill: 1, - size: 48, - color: Colors.red, - ), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadMoreItems, - child: Text(t.common.retry), - ), - ], - ), + child: ErrorStateWidget( + message: _errorMessage!, + icon: Symbols.error_outline_rounded, + onRetry: _loadMoreItems, ), ) else if (_filteredItems.isEmpty && _isLoading) diff --git a/lib/screens/libraries/empty_state_widget.dart b/lib/screens/libraries/empty_state_widget.dart index 8e33bbd9..2b4717b9 100644 --- a/lib/screens/libraries/empty_state_widget.dart +++ b/lib/screens/libraries/empty_state_widget.dart @@ -7,9 +7,15 @@ class EmptyStateWidget extends StatelessWidget { /// The message to display final String message; + /// Optional subtitle/description below the message + final String? subtitle; + /// Optional icon to display above the message final IconData? icon; + /// Optional size for the icon + final double iconSize; + /// Optional callback for action button final VoidCallback? onAction; @@ -19,7 +25,9 @@ class EmptyStateWidget extends StatelessWidget { const EmptyStateWidget({ super.key, required this.message, + this.subtitle, this.icon, + this.iconSize = 64, this.onAction, this.actionLabel, }); @@ -28,7 +36,9 @@ class EmptyStateWidget extends StatelessWidget { Widget build(BuildContext context) { return StateMessageWidget( message: message, + subtitle: subtitle, icon: icon, + iconSize: iconSize, onAction: onAction, actionLabel: actionLabel, actionIcon: Symbols.add_rounded, diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 8e7f830d..fab900af 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -27,6 +27,8 @@ import '../../mixins/refreshable.dart'; import '../../mixins/item_updatable.dart'; import '../../i18n/strings.g.dart'; import '../../utils/error_message_utils.dart'; +import 'error_state_widget.dart'; +import 'empty_state_widget.dart'; import 'tabs/library_browse_tab.dart'; import 'tabs/library_recommended_tab.dart'; import 'tabs/library_collections_tab.dart'; @@ -957,7 +959,7 @@ class _LibrariesScreenState extends State child: Row( children: [ AppIcon( - _getLibraryIcon(library.type), + ContentTypeHelper.getLibraryIcon(library.type), fill: 1, size: 20, color: isSelected ? Theme.of(context).colorScheme.primary : null, @@ -1088,7 +1090,7 @@ class _LibrariesScreenState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - AppIcon(_getLibraryIcon(selectedLibrary.type), fill: 1, size: 20), + AppIcon(ContentTypeHelper.getLibraryIcon(selectedLibrary.type), fill: 1, size: 20), const SizedBox(width: 8), if (_hasMultipleServers && selectedLibrary.serverName != null) Column( @@ -1164,43 +1166,17 @@ class _LibrariesScreenState extends State ) else if (_errorMessage != null && visibleLibraries.isEmpty) SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const AppIcon( - Symbols.error_outline_rounded, - fill: 1, - size: 48, - color: Colors.red, - ), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadLibraries, - child: Text(t.common.retry), - ), - ], - ), + child: ErrorStateWidget( + message: _errorMessage!, + icon: Symbols.error_outline_rounded, + onRetry: _loadLibraries, ), ) else if (visibleLibraries.isEmpty) SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const AppIcon( - Symbols.video_library_rounded, - fill: 1, - size: 64, - color: Colors.grey, - ), - const SizedBox(height: 16), - Text(t.libraries.noLibrariesFound), - ], - ), + child: EmptyStateWidget( + message: t.libraries.noLibrariesFound, + icon: Symbols.video_library_rounded, ), ) else ...[ @@ -1286,20 +1262,6 @@ class _LibrariesScreenState extends State ); } - IconData _getLibraryIcon(String type) { - switch (type.toLowerCase()) { - case 'movie': - return Symbols.movie_rounded; - case 'show': - return Symbols.tv_rounded; - case 'artist': - return Symbols.music_note_rounded; - case 'photo': - return Symbols.photo_rounded; - default: - return Symbols.folder_rounded; - } - } } class _LibraryManagementSheet extends StatefulWidget { @@ -1533,21 +1495,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { } } - IconData _getLibraryIcon(String type) { - switch (type.toLowerCase()) { - case 'movie': - return Symbols.movie_rounded; - case 'show': - return Symbols.tv_rounded; - case 'artist': - return Symbols.music_note_rounded; - case 'photo': - return Symbols.photo_rounded; - default: - return Symbols.folder_rounded; - } - } - /// Get set of library names that appear more than once (not globally unique) Set _getNonUniqueLibraryNames() { final nameCounts = {}; @@ -1703,7 +1650,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { ), ), const SizedBox(width: 8), - AppIcon(_getLibraryIcon(library.type), fill: 1), + AppIcon(ContentTypeHelper.getLibraryIcon(library.type), fill: 1), ], ), title: Text(library.title), diff --git a/lib/screens/libraries/state_message_widget.dart b/lib/screens/libraries/state_message_widget.dart index 2309279c..094cdc85 100644 --- a/lib/screens/libraries/state_message_widget.dart +++ b/lib/screens/libraries/state_message_widget.dart @@ -5,18 +5,27 @@ import 'package:material_symbols_icons/symbols.dart'; /// Base widget for displaying state messages (empty, error, etc.) /// Provides a consistent UI pattern for showing icons, messages, and actions class StateMessageWidget extends StatelessWidget { - /// The message to display + /// The main message/title to display final String message; + /// Optional subtitle/description below the message + final String? subtitle; + /// Optional icon to display above the message final IconData? icon; + /// Optional size for the icon (default: 64) + final double iconSize; + /// Optional color for the icon final Color? iconColor; /// Optional color for the message text final Color? textColor; + /// Optional color for the subtitle text + final Color? subtitleColor; + /// Optional callback for action button final VoidCallback? onAction; @@ -29,9 +38,12 @@ class StateMessageWidget extends StatelessWidget { const StateMessageWidget({ super.key, required this.message, + this.subtitle, this.icon, + this.iconSize = 64, this.iconColor, this.textColor, + this.subtitleColor, this.onAction, this.actionLabel, this.actionIcon, @@ -39,6 +51,7 @@ class StateMessageWidget extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return Center( child: Padding( padding: const EdgeInsets.all(24.0), @@ -49,26 +62,34 @@ class StateMessageWidget extends StatelessWidget { AppIcon( icon, fill: 1, - size: 64, + size: iconSize, color: iconColor ?? - Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), + theme.colorScheme.onSurface.withValues(alpha: 0.4), ), const SizedBox(height: 16), ], Text( message, textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( + style: theme.textTheme.titleLarge?.copyWith( color: textColor ?? - Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), + theme.colorScheme.onSurface.withValues(alpha: 0.6), ), ), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text( + subtitle!, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: + subtitleColor ?? + theme.colorScheme.onSurfaceVariant, + ), + ), + ], if (onAction != null && actionLabel != null) ...[ const SizedBox(height: 24), FilledButton.icon( diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 8d8a1574..bbab81b8 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -30,6 +30,7 @@ import '../utils/desktop_window_padding.dart'; import '../widgets/horizontal_scroll_with_arrows.dart'; import '../widgets/media_card.dart'; import '../widgets/media_context_menu.dart'; +import '../widgets/placeholder_container.dart'; import 'season_detail_screen.dart'; class MediaDetailScreen extends StatefulWidget { @@ -146,18 +147,12 @@ class _MediaDetailScreenState extends State { appLogger.d( 'Playing on deck episode: ${_onDeckEpisode!.title}', ); - await navigateToVideoPlayer( + await navigateToVideoPlayerWithRefresh( context, metadata: _onDeckEpisode!, isOffline: widget.isOffline, + onRefresh: _loadFullMetadata, ); - appLogger.d( - 'Returned from playback, refreshing metadata', - ); - // Refresh metadata when returning from video player - if (!widget.isOffline) { - _loadFullMetadata(); - } } else { // No on deck episode, fetch first episode of first season await _playFirstEpisode(); @@ -165,18 +160,12 @@ class _MediaDetailScreenState extends State { } else { appLogger.d('Playing: ${metadata.title}'); // For movies or episodes, play directly - await navigateToVideoPlayer( + await navigateToVideoPlayerWithRefresh( context, metadata: metadata, isOffline: widget.isOffline, + onRefresh: _loadFullMetadata, ); - appLogger.d( - 'Returned from playback, refreshing metadata', - ); - // Refresh metadata when returning from video player - if (!widget.isOffline) { - _loadFullMetadata(); - } } }, icon: AppIcon( @@ -1115,16 +1104,12 @@ class _MediaDetailScreenState extends State { ); if (mounted) { appLogger.d('Playing first episode: ${episodeWithServerId.title}'); - await navigateToVideoPlayer( + await navigateToVideoPlayerWithRefresh( context, metadata: episodeWithServerId, isOffline: widget.isOffline, + onRefresh: _loadFullMetadata, ); - appLogger.d('Returned from playback, refreshing metadata'); - // Refresh metadata when returning from video player (skip if offline) - if (!widget.isOffline) { - _loadFullMetadata(); - } } } catch (e) { if (mounted) { @@ -1298,19 +1283,11 @@ class _MediaDetailScreenState extends State { fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => - Container( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - ), + const PlaceholderContainer(), ); } // Offline but no local file - show placeholder - return Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ); + return const PlaceholderContainer(); } // Online - use network image @@ -1330,24 +1307,14 @@ class _MediaDetailScreenState extends State { return CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), + placeholder: (context, url) => + const PlaceholderContainer(), + errorWidget: (context, url, error) => + const PlaceholderContainer(), ); }, ) - : Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), + : const PlaceholderContainer(), ), // Gradient overlay @@ -1631,7 +1598,9 @@ class _MediaDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular( + tokens(context).radiusSm, + ), child: actor.thumb != null ? CachedNetworkImage( imageUrl: actor.thumb!, diff --git a/lib/screens/profile/profile_list_tile.dart b/lib/screens/profile/profile_list_tile.dart index 932b2172..af6bbcdf 100644 --- a/lib/screens/profile/profile_list_tile.dart +++ b/lib/screens/profile/profile_list_tile.dart @@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../i18n/strings.g.dart'; import '../../models/plex_home_user.dart'; +import '../../theme/theme_helper.dart'; import 'user_avatar_widget.dart'; enum UserAttribute { admin, restricted, protected } @@ -36,7 +37,7 @@ class ProfileListTile extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: theme.colorScheme.primary, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(tokens(context).radiusMd), ), child: Text( t.userStatus.current, diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index 896f3789..16e7ff49 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../models/plex_home_user.dart'; @@ -8,6 +7,7 @@ import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import 'profile_list_tile.dart'; import '../../widgets/desktop_app_bar.dart'; +import '../libraries/empty_state_widget.dart'; import '../../i18n/strings.g.dart'; class ProfileSwitchScreen extends StatelessWidget { @@ -53,33 +53,10 @@ class ProfileSwitchScreen extends StatelessWidget { } if (users.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppIcon( - Symbols.person_off_rounded, - fill: 1, - size: 64, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - 'No profiles available', - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Text( - 'Contact your Plex administrator to add profiles', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ], - ), + return const EmptyStateWidget( + message: 'No profiles available', + subtitle: 'Contact your Plex administrator to add profiles', + icon: Symbols.person_off_rounded, ); } diff --git a/lib/screens/profile/user_avatar_widget.dart b/lib/screens/profile/user_avatar_widget.dart index 44118309..7147a8b4 100644 --- a/lib/screens/profile/user_avatar_widget.dart +++ b/lib/screens/profile/user_avatar_widget.dart @@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../../models/plex_home_user.dart'; +import '../../theme/theme_helper.dart'; import '../../i18n/strings.g.dart'; class UserAvatarWidget extends StatelessWidget { @@ -92,7 +93,7 @@ class UserAvatarWidget extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: backgroundColor, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), ), child: Text( text, diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 1e9506db..87b62630 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -14,6 +14,8 @@ import '../utils/sliver_adaptive_media_builder.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_card.dart'; +import '../utils/focus_utils.dart'; +import 'libraries/state_message_widget.dart'; class SearchScreen extends StatefulWidget { const SearchScreen({super.key}); @@ -41,9 +43,7 @@ class _SearchScreenState extends State ); _searchController.addListener(_onSearchChanged); // Focus the search input when the screen is shown - WidgetsBinding.instance.addPostFrameCallback((_) { - _searchFocusNode.requestFocus(); - }); + FocusUtils.requestFocusAfterBuild(this, _searchFocusNode); } @override @@ -132,9 +132,7 @@ class _SearchScreenState extends State /// Focus the search input field @override void focusSearchInput() { - WidgetsBinding.instance.addPostFrameCallback((_) { - _searchFocusNode.requestFocus(); - }); + FocusUtils.requestFocusAfterBuild(this, _searchFocusNode); } // Public method to fully reload all content (for profile switches) @@ -214,16 +212,22 @@ class _SearchScreenState extends State child: Center(child: CircularProgressIndicator()), ) else if (!_hasSearched) - _SearchEmptyState( - icon: Symbols.search_rounded, - title: t.search.searchYourMedia, - subtitle: t.search.enterTitleActorOrKeyword, + SliverFillRemaining( + child: StateMessageWidget( + message: t.search.searchYourMedia, + subtitle: t.search.enterTitleActorOrKeyword, + icon: Symbols.search_rounded, + iconSize: 80, + ), ) else if (_searchResults.isEmpty) - _SearchEmptyState( - icon: Symbols.search_off_rounded, - title: t.messages.noResultsFound, - subtitle: t.search.tryDifferentTerm, + SliverFillRemaining( + child: StateMessageWidget( + message: t.messages.noResultsFound, + subtitle: t.search.tryDifferentTerm, + icon: Symbols.search_off_rounded, + iconSize: 80, + ), ) else Consumer( @@ -253,39 +257,3 @@ class _SearchScreenState extends State ); } } - -/// Empty state widget for search screen with icon, title, and subtitle. -class _SearchEmptyState extends StatelessWidget { - final IconData icon; - final String title; - final String subtitle; - - const _SearchEmptyState({ - required this.icon, - required this.title, - required this.subtitle, - }); - - @override - Widget build(BuildContext context) { - return SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppIcon(icon, fill: 1, size: 80, color: Colors.grey.shade400), - const SizedBox(height: 16), - Text( - title, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(color: Colors.grey.shade600), - ), - const SizedBox(height: 8), - Text(subtitle, style: TextStyle(color: Colors.grey.shade600)), - ], - ), - ), - ); - } -} diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 78897efc..1d484c8f 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -17,6 +17,7 @@ import '../utils/video_player_navigation.dart'; import '../utils/duration_formatter.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_context_menu.dart'; +import '../widgets/placeholder_container.dart'; import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; @@ -193,15 +194,12 @@ class _SeasonDetailScreenState extends State autofocus: index == 0 && InputModeTracker.isKeyboardMode(context), onTap: () async { - await navigateToVideoPlayer( + await navigateToVideoPlayerWithRefresh( context, metadata: episode, isOffline: widget.isOffline, + onRefresh: _loadEpisodes, ); - // Refresh episodes when returning from video player (skip if offline) - if (!widget.isOffline) { - _loadEpisodes(); - } }, onRefresh: widget.isOffline ? null : updateItem, ); @@ -316,11 +314,8 @@ class _EpisodeCard extends StatelessWidget { File(localPosterPath!), fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => - Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const AppIcon( + const PlaceholderContainer( + child: AppIcon( Symbols.movie_rounded, fill: 1, size: 32, @@ -333,27 +328,19 @@ class _EpisodeCard extends StatelessWidget { imagePath: episode.thumb, filterQuality: FilterQuality.medium, fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const AppIcon( - Symbols.movie_rounded, - fill: 1, - size: 32, - ), - ), + placeholder: (context, url) => + const PlaceholderContainer(), + errorWidget: (context, url, error) => + const PlaceholderContainer( + child: AppIcon( + Symbols.movie_rounded, + fill: 1, + size: 32, + ), + ), ) - : Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const AppIcon( + : const PlaceholderContainer( + child: AppIcon( Symbols.movie_rounded, fill: 1, size: 32, diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index c4489721..699dfca3 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -23,6 +23,19 @@ import 'about_screen.dart'; import 'logs_screen.dart'; import 'subtitle_styling_screen.dart'; +/// Helper class for option selection dialog items +class _DialogOption { + final T value; + final String title; + final String? subtitle; + + const _DialogOption({ + required this.value, + required this.title, + this.subtitle, + }); +} + class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -803,8 +816,17 @@ class _SettingsScreenState extends State { ); } - void _showSeekTimeSmallDialog() { - final controller = TextEditingController(text: _seekTimeSmall.toString()); + /// Generic numeric input dialog to avoid duplication across settings + void _showNumericInputDialog({ + required String title, + required String labelText, + required String suffixText, + required int min, + required int max, + required int currentValue, + required Future Function(int value) onSave, + }) { + final controller = TextEditingController(text: currentValue.toString()); String? errorText; showDialog( @@ -813,15 +835,15 @@ class _SettingsScreenState extends State { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( - title: Text(t.settings.smallSkipDuration), + title: Text(title), content: TextField( controller: controller, keyboardType: TextInputType.number, decoration: InputDecoration( - labelText: t.settings.secondsLabel, - hintText: t.settings.durationHint(min: 1, max: 120), + labelText: labelText, + hintText: t.settings.durationHint(min: min, max: max), errorText: errorText, - suffixText: t.settings.secondsShort, + suffixText: suffixText, ), autofocus: true, onChanged: (value) { @@ -829,11 +851,11 @@ class _SettingsScreenState extends State { setDialogState(() { if (parsed == null) { errorText = t.settings.validationErrorEnterNumber; - } else if (parsed < 1 || parsed > 120) { + } else if (parsed < min || parsed > max) { errorText = t.settings.validationErrorDuration( - min: 1, - max: 120, - unit: t.settings.secondsLabel.toLowerCase(), + min: min, + max: max, + unit: labelText.toLowerCase(), ); } else { errorText = null; @@ -849,13 +871,8 @@ class _SettingsScreenState extends State { TextButton( onPressed: () async { final parsed = int.tryParse(controller.text); - if (parsed != null && parsed >= 1 && parsed <= 120) { - setState(() { - _seekTimeSmall = parsed; - _settingsService.setSeekTimeSmall(parsed); - }); - // Reload keyboard shortcuts service to use new settings - await _keyboardService?.refreshFromStorage(); + if (parsed != null && parsed >= min && parsed <= max) { + await onSave(parsed); if (dialogContext.mounted) { Navigator.pop(dialogContext); } @@ -871,204 +888,68 @@ class _SettingsScreenState extends State { ); } + void _showSeekTimeSmallDialog() { + _showNumericInputDialog( + title: t.settings.smallSkipDuration, + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 1, + max: 120, + currentValue: _seekTimeSmall, + onSave: (value) async { + setState(() { + _seekTimeSmall = value; + _settingsService.setSeekTimeSmall(value); + }); + await _keyboardService?.refreshFromStorage(); + }, + ); + } + void _showSeekTimeLargeDialog() { - final controller = TextEditingController(text: _seekTimeLarge.toString()); - String? errorText; - - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: Text(t.settings.largeSkipDuration), - content: TextField( - controller: controller, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: t.settings.secondsLabel, - hintText: t.settings.durationHint(min: 1, max: 120), - errorText: errorText, - suffixText: t.settings.secondsShort, - ), - autofocus: true, - onChanged: (value) { - final parsed = int.tryParse(value); - setDialogState(() { - if (parsed == null) { - errorText = t.settings.validationErrorEnterNumber; - } else if (parsed < 1 || parsed > 120) { - errorText = t.settings.validationErrorDuration( - min: 1, - max: 120, - unit: t.settings.secondsLabel.toLowerCase(), - ); - } else { - errorText = null; - } - }); - }, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () async { - final parsed = int.tryParse(controller.text); - if (parsed != null && parsed >= 1 && parsed <= 120) { - setState(() { - _seekTimeLarge = parsed; - _settingsService.setSeekTimeLarge(parsed); - }); - // Reload keyboard shortcuts service to use new settings - await _keyboardService?.refreshFromStorage(); - if (dialogContext.mounted) { - Navigator.pop(dialogContext); - } - } - }, - child: Text(t.common.save), - ), - ], - ); - }, - ); + _showNumericInputDialog( + title: t.settings.largeSkipDuration, + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 1, + max: 120, + currentValue: _seekTimeLarge, + onSave: (value) async { + setState(() { + _seekTimeLarge = value; + _settingsService.setSeekTimeLarge(value); + }); + await _keyboardService?.refreshFromStorage(); }, ); } void _showSleepTimerDurationDialog() { - final controller = TextEditingController( - text: _sleepTimerDuration.toString(), - ); - String? errorText; - - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: Text(t.settings.defaultSleepTimer), - content: TextField( - controller: controller, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: t.settings.minutesLabel, - hintText: t.settings.durationHint(min: 5, max: 240), - errorText: errorText, - suffixText: t.settings.minutesShort, - ), - autofocus: true, - onChanged: (value) { - final parsed = int.tryParse(value); - setDialogState(() { - if (parsed == null) { - errorText = t.settings.validationErrorEnterNumber; - } else if (parsed < 5 || parsed > 240) { - errorText = t.settings.validationErrorDuration( - min: 5, - max: 240, - unit: t.settings.minutesLabel.toLowerCase(), - ); - } else { - errorText = null; - } - }); - }, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () async { - final parsed = int.tryParse(controller.text); - if (parsed != null && parsed >= 5 && parsed <= 240) { - setState(() { - _sleepTimerDuration = parsed; - }); - await _settingsService.setSleepTimerDuration(parsed); - if (dialogContext.mounted) { - Navigator.pop(dialogContext); - } - } - }, - child: Text(t.common.save), - ), - ], - ); - }, - ); + _showNumericInputDialog( + title: t.settings.defaultSleepTimer, + labelText: t.settings.minutesLabel, + suffixText: t.settings.minutesShort, + min: 5, + max: 240, + currentValue: _sleepTimerDuration, + onSave: (value) async { + setState(() => _sleepTimerDuration = value); + await _settingsService.setSleepTimerDuration(value); }, ); } void _showAutoSkipDelayDialog() { - final controller = TextEditingController(text: _autoSkipDelay.toString()); - String? errorText; - - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: Text(t.settings.autoSkipDelay), - content: TextField( - controller: controller, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: t.settings.secondsLabel, - hintText: t.settings.durationHint(min: 1, max: 30), - errorText: errorText, - suffixText: t.settings.secondsShort, - ), - autofocus: true, - onChanged: (value) { - final parsed = int.tryParse(value); - setDialogState(() { - if (parsed == null) { - errorText = t.settings.validationErrorEnterNumber; - } else if (parsed < 1 || parsed > 30) { - errorText = t.settings.validationErrorDuration( - min: 1, - max: 30, - unit: t.settings.secondsLabel.toLowerCase(), - ); - } else { - errorText = null; - } - }); - }, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () async { - final parsed = int.tryParse(controller.text); - if (parsed != null && parsed >= 1 && parsed <= 30) { - setState(() { - _autoSkipDelay = parsed; - }); - await _settingsService.setAutoSkipDelay(parsed); - if (dialogContext.mounted) { - Navigator.pop(dialogContext); - } - } - }, - child: Text(t.common.save), - ), - ], - ); - }, - ); + _showNumericInputDialog( + title: t.settings.autoSkipDelay, + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 1, + max: 30, + currentValue: _autoSkipDelay, + onSave: (value) async { + setState(() => _autoSkipDelay = value); + await _settingsService.setAutoSkipDelay(value); }, ); } @@ -1309,68 +1190,41 @@ class _SettingsScreenState extends State { ); } - void _showLibraryDensityDialog() { + /// Generic option selection dialog for settings with SettingsProvider + void _showOptionSelectionDialog({ + required String title, + required List<_DialogOption> options, + required T Function(SettingsProvider) getCurrentValue, + required Future Function(T value, SettingsProvider provider) onSelect, + }) { final settingsProvider = context.read(); showDialog( context: context, builder: (BuildContext context) { return Consumer( builder: (context, provider, child) { + final currentValue = getCurrentValue(provider); return AlertDialog( - title: Text(t.settings.libraryDensity), + title: Text(title), content: Column( mainAxisSize: MainAxisSize.min, - children: [ - ListTile( + children: options.map((option) { + return ListTile( leading: AppIcon( - provider.libraryDensity == settings.LibraryDensity.compact + currentValue == option.value ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, fill: 1, ), - title: Text(t.settings.compact), - subtitle: Text(t.settings.compactDescription), + title: Text(option.title), + subtitle: + option.subtitle != null ? Text(option.subtitle!) : null, onTap: () async { - await settingsProvider.setLibraryDensity( - settings.LibraryDensity.compact, - ); + await onSelect(option.value, settingsProvider); if (context.mounted) Navigator.pop(context); }, - ), - ListTile( - leading: AppIcon( - provider.libraryDensity == settings.LibraryDensity.normal - ? Symbols.radio_button_checked_rounded - : Symbols.radio_button_unchecked_rounded, - fill: 1, - ), - title: Text(t.settings.normal), - subtitle: Text(t.settings.normalDescription), - onTap: () async { - await settingsProvider.setLibraryDensity( - settings.LibraryDensity.normal, - ); - if (context.mounted) Navigator.pop(context); - }, - ), - ListTile( - leading: AppIcon( - provider.libraryDensity == - settings.LibraryDensity.comfortable - ? Symbols.radio_button_checked_rounded - : Symbols.radio_button_unchecked_rounded, - fill: 1, - ), - title: Text(t.settings.comfortable), - subtitle: Text(t.settings.comfortableDescription), - onTap: () async { - await settingsProvider.setLibraryDensity( - settings.LibraryDensity.comfortable, - ); - if (context.mounted) Navigator.pop(context); - }, - ), - ], + ); + }).toList(), ), actions: [ TextButton( @@ -1385,62 +1239,48 @@ class _SettingsScreenState extends State { ); } + void _showLibraryDensityDialog() { + _showOptionSelectionDialog( + title: t.settings.libraryDensity, + options: [ + _DialogOption( + value: settings.LibraryDensity.compact, + title: t.settings.compact, + subtitle: t.settings.compactDescription, + ), + _DialogOption( + value: settings.LibraryDensity.normal, + title: t.settings.normal, + subtitle: t.settings.normalDescription, + ), + _DialogOption( + value: settings.LibraryDensity.comfortable, + title: t.settings.comfortable, + subtitle: t.settings.comfortableDescription, + ), + ], + getCurrentValue: (p) => p.libraryDensity, + onSelect: (value, provider) => provider.setLibraryDensity(value), + ); + } + void _showViewModeDialog() { - final settingsProvider = context.read(); - showDialog( - context: context, - builder: (BuildContext context) { - return Consumer( - builder: (context, provider, child) { - return AlertDialog( - title: Text(t.settings.viewMode), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: AppIcon( - provider.viewMode == settings.ViewMode.grid - ? Symbols.radio_button_checked_rounded - : Symbols.radio_button_unchecked_rounded, - fill: 1, - ), - title: Text(t.settings.gridView), - subtitle: Text(t.settings.gridViewDescription), - onTap: () async { - await settingsProvider.setViewMode( - settings.ViewMode.grid, - ); - if (context.mounted) Navigator.pop(context); - }, - ), - ListTile( - leading: AppIcon( - provider.viewMode == settings.ViewMode.list - ? Symbols.radio_button_checked_rounded - : Symbols.radio_button_unchecked_rounded, - fill: 1, - ), - title: Text(t.settings.listView), - subtitle: Text(t.settings.listViewDescription), - onTap: () async { - await settingsProvider.setViewMode( - settings.ViewMode.list, - ); - if (context.mounted) Navigator.pop(context); - }, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(t.common.cancel), - ), - ], - ); - }, - ); - }, + _showOptionSelectionDialog( + title: t.settings.viewMode, + options: [ + _DialogOption( + value: settings.ViewMode.grid, + title: t.settings.gridView, + subtitle: t.settings.gridViewDescription, + ), + _DialogOption( + value: settings.ViewMode.list, + title: t.settings.listView, + subtitle: t.settings.listViewDescription, + ), + ], + getCurrentValue: (p) => p.viewMode, + onSelect: (value, provider) => provider.setViewMode(value), ); } } diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 0adb0d06..2efdb482 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -4,6 +4,7 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'package:drift/drift.dart'; import 'package:path/path.dart' as path; +import 'package:plezy/models/plex_metadata_extensions.dart'; import '../database/app_database.dart'; import 'settings_service.dart'; import '../models/download_status.dart'; @@ -15,6 +16,8 @@ import '../services/plex_client.dart'; import '../services/download_storage_service.dart'; import '../services/plex_api_cache.dart'; import '../utils/app_logger.dart'; +import '../utils/codec_utils.dart'; +import '../utils/plex_cache_parser.dart'; /// Extension methods on AppDatabase for download operations extension DownloadDatabaseOperations on AppDatabase { @@ -186,6 +189,20 @@ extension DownloadDatabaseOperations on AppDatabase { downloadQueue, )..where((t) => t.mediaGlobalKey.equals(globalKey))).go(); } + + /// Get all downloaded episodes for a season + Future> getEpisodesBySeason(String seasonKey) { + return (select(downloadedMedia) + ..where((t) => t.parentRatingKey.equals(seasonKey))) + .get(); + } + + /// Get all downloaded episodes for a show + Future> getEpisodesByShow(String showKey) { + return (select(downloadedMedia) + ..where((t) => t.grandparentRatingKey.equals(showKey))) + .get(); + } } class DownloadManagerService { @@ -242,6 +259,17 @@ class DownloadManagerService { _storageService = storageService, _dio = dio ?? Dio(); + /// Delete a file if it exists and log the deletion + /// Returns true if file was deleted, false otherwise + Future _deleteFileIfExists(File file, String description) async { + if (await file.exists()) { + await file.delete(); + appLogger.i('Deleted $description: ${file.path}'); + return true; + } + return false; + } + /// Queue a download for a media item Future queueDownload({ required PlexMetadata metadata, @@ -355,12 +383,8 @@ class DownloadManagerService { appLogger.i('Starting download for $globalKey'); // Update status to downloading - await _database.updateDownloadStatus( - globalKey, - DownloadStatus.downloading.index, - ); - appLogger.d('Status updated to downloading, emitting initial progress'); - _emitProgress(globalKey, DownloadStatus.downloading, 0); + await _transitionStatus(globalKey, DownloadStatus.downloading); + appLogger.d('Status updated to downloading'); // Parse globalKey to get serverId and ratingKey final parts = globalKey.split(':'); @@ -377,13 +401,12 @@ class DownloadManagerService { } // Parse metadata from cached response - final metadataList = - cachedResponse['MediaContainer']?['Metadata'] as List?; - if (metadataList == null || metadataList.isEmpty) { + final firstMetadata = PlexCacheParser.extractFirstMetadata(cachedResponse); + if (firstMetadata == null) { throw Exception('Invalid cached metadata for $globalKey'); } final metadata = PlexMetadata.fromJson( - metadataList[0], + firstMetadata, ).copyWith(serverId: serverId); // Get video playback data (includes URL, streams, etc.) @@ -411,12 +434,10 @@ class DownloadManagerService { serverId, '/library/metadata/${metadataWithServer.grandparentRatingKey}', ); - if (showCached != null) { - final showList = showCached['MediaContainer']?['Metadata'] as List?; - if (showList != null && showList.isNotEmpty) { - final showMetadata = PlexMetadata.fromJson(showList[0]); - showYear = showMetadata.year; - } + final showJson = PlexCacheParser.extractFirstMetadata(showCached); + if (showJson != null) { + final showMetadata = PlexMetadata.fromJson(showJson); + showYear = showMetadata.year; } } @@ -552,12 +573,8 @@ class DownloadManagerService { } // Mark as completed - await _database.updateDownloadStatus( - globalKey, - DownloadStatus.completed.index, - ); + await _transitionStatus(globalKey, DownloadStatus.completed); await _database.removeFromQueue(globalKey); - _emitProgress(globalKey, DownloadStatus.completed, 100); _activeDownloads.remove(globalKey); @@ -573,19 +590,14 @@ class DownloadManagerService { } appLogger.e('Download failed for $globalKey', error: e); - await _database.updateDownloadStatus( + await _transitionStatus( globalKey, - DownloadStatus.failed.index, + DownloadStatus.failed, + errorMessage: e.toString(), ); await _database.updateDownloadError(globalKey, e.toString()); // Remove from queue to prevent endless retry loop await _database.removeFromQueue(globalKey); - _emitProgress( - globalKey, - DownloadStatus.failed, - 0, - errorMessage: e.toString(), - ); _activeDownloads.remove(globalKey); } } @@ -818,18 +830,18 @@ class DownloadManagerService { if (subtitleUrl == null) continue; // Determine file extension - final extension = _getExtensionFromCodec(subtitle.codec); + final extension = CodecUtils.getSubtitleExtension(subtitle.codec); // Get user-friendly subtitle path based on media type final String subtitlePath; - if (metadata.type == 'episode') { + if (metadata.isEpisode) { subtitlePath = await _storageService.getEpisodeSubtitlePath( metadata, subtitle.id, extension, showYear: showYear, ); - } else if (metadata.type == 'movie') { + } else if (metadata.isMovie) { subtitlePath = await _storageService.getMovieSubtitlePath( metadata, subtitle.id, @@ -867,33 +879,6 @@ class DownloadManagerService { return path.substring(lastDot + 1).split('?').first; } - String _getExtensionFromCodec(String? codec) { - if (codec == null) return 'srt'; - - switch (codec.toLowerCase()) { - case 'subrip': - case 'srt': - return 'srt'; - case 'ass': - return 'ass'; - case 'ssa': - return 'ssa'; - case 'webvtt': - case 'vtt': - return 'vtt'; - case 'mov_text': - return 'srt'; - case 'pgs': - case 'hdmv_pgs_subtitle': - return 'sup'; - case 'dvd_subtitle': - case 'dvdsub': - return 'sub'; - default: - return 'srt'; - } - } - void _emitProgress( String globalKey, DownloadStatus status, @@ -912,6 +897,28 @@ class DownloadManagerService { ); } + /// Update download status in database and emit progress notification. + /// + /// This helper combines two common operations: + /// 1. Update status in the database + /// 2. Emit progress to listeners + /// + /// Default progress is 0 for most statuses, 100 for completed. + Future _transitionStatus( + String globalKey, + DownloadStatus status, { + int? progress, + String? errorMessage, + }) async { + await _database.updateDownloadStatus(globalKey, status.index); + _emitProgress( + globalKey, + status, + progress ?? (status == DownloadStatus.completed ? 100 : 0), + errorMessage: errorMessage, + ); + } + /// Emit progress update with artwork paths so DownloadProvider can sync void _emitProgressWithArtwork(String globalKey, {String? thumbPath}) { // Emit a progress update containing artwork path @@ -935,25 +942,16 @@ class DownloadManagerService { cancelToken.cancel('Paused by user'); _activeDownloads.remove(globalKey); } - // Update status to paused - await _database.updateDownloadStatus( - globalKey, - DownloadStatus.paused.index, - ); - // Remove from queue so it doesn't restart + // Update status to paused and remove from queue so it doesn't restart + await _transitionStatus(globalKey, DownloadStatus.paused); await _database.removeFromQueue(globalKey); - _emitProgress(globalKey, DownloadStatus.paused, 0); } /// Resume a paused download Future resumeDownload(String globalKey, PlexClient client) async { - await _database.updateDownloadStatus( - globalKey, - DownloadStatus.queued.index, - ); + await _transitionStatus(globalKey, DownloadStatus.queued); // Re-add to queue (pauseDownload removes from queue) await _database.addToQueue(mediaGlobalKey: globalKey); - _emitProgress(globalKey, DownloadStatus.queued, 0); _processQueue(client); } @@ -962,13 +960,9 @@ class DownloadManagerService { // Clear error and reset retry count await _database.clearDownloadError(globalKey); // Reset status to queued - await _database.updateDownloadStatus( - globalKey, - DownloadStatus.queued.index, - ); + await _transitionStatus(globalKey, DownloadStatus.queued); // Re-add to queue await _database.addToQueue(mediaGlobalKey: globalKey); - _emitProgress(globalKey, DownloadStatus.queued, 0); _processQueue(client); } @@ -979,12 +973,8 @@ class DownloadManagerService { cancelToken.cancel('Cancelled by user'); _activeDownloads.remove(globalKey); } - await _database.updateDownloadStatus( - globalKey, - DownloadStatus.cancelled.index, - ); + await _transitionStatus(globalKey, DownloadStatus.cancelled); await _database.removeFromQueue(globalKey); - _emitProgress(globalKey, DownloadStatus.cancelled, 0); } /// Delete a downloaded item and its files @@ -1065,17 +1055,11 @@ class DownloadManagerService { return 1; // Single movie case 'season': // Count episodes in season - final seasonKey = metadata.ratingKey; - final episodes = await (_database.select( - _database.downloadedMedia, - )..where((t) => t.parentRatingKey.equals(seasonKey))).get(); + final episodes = await _database.getEpisodesBySeason(metadata.ratingKey); return episodes.length; case 'show': // Count all episodes in show - final showKey = metadata.ratingKey; - final episodes = await (_database.select( - _database.downloadedMedia, - )..where((t) => t.grandparentRatingKey.equals(showKey))).get(); + final episodes = await _database.getEpisodesByShow(metadata.ratingKey); return episodes.length; default: return 1; @@ -1135,13 +1119,9 @@ class DownloadManagerService { serverId, '/library/metadata/$ratingKey', ); - if (cachedData != null && cachedData['MediaContainer'] != null) { - final container = cachedData['MediaContainer'] as Map; - if (container['Metadata'] != null && - (container['Metadata'] as List).isNotEmpty) { - final metadataJson = container['Metadata'][0] as Map; - return PlexMetadata.fromJson(metadataJson).copyWith(serverId: serverId); - } + final metadataJson = PlexCacheParser.extractFirstMetadata(cachedData); + if (metadataJson != null) { + return PlexMetadata.fromJson(metadataJson).copyWith(serverId: serverId); } return null; } @@ -1156,34 +1136,14 @@ class DownloadManagerService { serverId, '/library/metadata/$ratingKey', ); + final chapters = PlexCacheParser.extractChapters(cachedData); + if (chapters == null) return []; - if (cachedData == null || cachedData['MediaContainer'] == null) { - return []; - } - - final container = cachedData['MediaContainer'] as Map; - final metadataList = container['Metadata'] as List?; - - if (metadataList == null || metadataList.isEmpty) { - return []; - } - - final metadata = metadataList[0] as Map; - final chapters = metadata['Chapter'] as List?; - - if (chapters == null) { - return []; - } - - final thumbPaths = []; - for (final chapter in chapters) { - final thumbPath = chapter['thumb'] as String?; - if (thumbPath != null && thumbPath.isNotEmpty) { - thumbPaths.add(thumbPath); - } - } - - return thumbPaths; + return chapters + .map((ch) => ch['thumb'] as String?) + .where((thumb) => thumb != null && thumb.isNotEmpty) + .cast() + .toList(); } catch (e) { appLogger.w('Error getting chapter thumb paths for $ratingKey', error: e); return []; @@ -1266,12 +1226,11 @@ class DownloadManagerService { serverId, thumbPath, ); - final file = File(artworkPath); - - if (await file.exists()) { - await file.delete(); + if (await _deleteFileIfExists( + File(artworkPath), + 'chapter thumbnail', + )) { deletedCount++; - appLogger.d('Deleted chapter thumbnail: $thumbPath'); } } catch (e) { appLogger.w( @@ -1321,9 +1280,8 @@ class DownloadManagerService { final actualVideoFile = await _findFileWithAnyExtension( videoPathWithoutExt, ); - if (actualVideoFile != null && await actualVideoFile.exists()) { - await actualVideoFile.delete(); - appLogger.i('Deleted episode video: ${actualVideoFile.path}'); + if (actualVideoFile != null) { + await _deleteFileIfExists(actualVideoFile, 'episode video'); } // Delete thumbnail @@ -1331,11 +1289,7 @@ class DownloadManagerService { episode, showYear: showYear, ); - final thumbFile = File(thumbPath); - if (await thumbFile.exists()) { - await thumbFile.delete(); - appLogger.i('Deleted episode thumbnail: $thumbPath'); - } + await _deleteFileIfExists(File(thumbPath), 'episode thumbnail'); // Delete subtitles directory final subsDir = await _storageService.getEpisodeSubtitlesDirectory( @@ -1366,42 +1320,18 @@ class DownloadManagerService { final showYear = parentMetadata?.year; // Get all episodes in this season - final seasonKey = season.ratingKey; - final episodesInSeason = await (_database.select( - _database.downloadedMedia, - )..where((t) => t.parentRatingKey.equals(seasonKey))).get(); + final episodesInSeason = + await _database.getEpisodesBySeason(season.ratingKey); appLogger.d( - 'Deleting ${episodesInSeason.length} episodes in season $seasonKey', + 'Deleting ${episodesInSeason.length} episodes in season ${season.ratingKey}', + ); + await _deleteEpisodesInCollection( + episodes: episodesInSeason, + serverId: serverId, + parentKey: season.ratingKey, + parentTitle: season.title, ); - for (int i = 0; i < episodesInSeason.length; i++) { - final episode = episodesInSeason[i]; - final episodeGlobalKey = '$serverId:${episode.ratingKey}'; - - // Emit progress update - _emitDeletionProgress( - DeletionProgress( - globalKey: '$serverId:$seasonKey', - itemTitle: season.title, - currentItem: i + 1, - totalItems: episodesInSeason.length, - currentOperation: - 'Deleting episode ${i + 1} of ${episodesInSeason.length}', - ), - ); - - // Delete chapter thumbnails - await _deleteChapterThumbnails(serverId, episode.ratingKey); - - // Delete episode files (video, subtitles) if stored outside season directory - await _deleteByFilePath(episode); - - // Delete episode from API cache - await _apiCache.deleteForItem(serverId, episode.ratingKey); - - // Delete episode DB entry - await _database.deleteDownload(episodeGlobalKey); - } final seasonDir = await _storageService.getSeasonDirectory( season, @@ -1418,46 +1348,59 @@ class DownloadManagerService { } } + /// Delete episodes in a collection (season or show) + /// Returns the number of episodes deleted + Future _deleteEpisodesInCollection({ + required List episodes, + required String serverId, + required String parentKey, + required String parentTitle, + }) async { + for (int i = 0; i < episodes.length; i++) { + final episode = episodes[i]; + final episodeGlobalKey = '$serverId:${episode.ratingKey}'; + + // Emit progress update + _emitDeletionProgress( + DeletionProgress( + globalKey: '$serverId:$parentKey', + itemTitle: parentTitle, + currentItem: i + 1, + totalItems: episodes.length, + currentOperation: + 'Deleting episode ${i + 1} of ${episodes.length}', + ), + ); + + // Delete chapter thumbnails + await _deleteChapterThumbnails(serverId, episode.ratingKey); + + // Delete episode files (video, subtitles) + await _deleteByFilePath(episode); + + // Delete episode from API cache + await _apiCache.deleteForItem(serverId, episode.ratingKey); + + // Delete episode DB entry + await _database.deleteDownload(episodeGlobalKey); + } + } + /// Delete show files Future _deleteShowFiles(PlexMetadata show, String serverId) async { try { // Get all episodes in this show - final showKey = show.ratingKey; - final episodesInShow = await (_database.select( - _database.downloadedMedia, - )..where((t) => t.grandparentRatingKey.equals(showKey))).get(); + final episodesInShow = await _database.getEpisodesByShow(show.ratingKey); appLogger.d( - 'Deleting ${episodesInShow.length} episodes in show $showKey', + 'Deleting ${episodesInShow.length} episodes in show ${show.ratingKey}', + ); + await _deleteEpisodesInCollection( + episodes: episodesInShow, + serverId: serverId, + parentKey: show.ratingKey, + parentTitle: show.title, ); - for (int i = 0; i < episodesInShow.length; i++) { - final episode = episodesInShow[i]; - final episodeGlobalKey = '$serverId:${episode.ratingKey}'; - - // Emit progress update - _emitDeletionProgress( - DeletionProgress( - globalKey: '$serverId:$showKey', - itemTitle: show.title, - currentItem: i + 1, - totalItems: episodesInShow.length, - currentOperation: - 'Deleting episode ${i + 1} of ${episodesInShow.length}', - ), - ); - - // Delete chapter thumbnails - await _deleteChapterThumbnails(serverId, episode.ratingKey); - - // Delete episode files (video, subtitles) if stored outside show directory - await _deleteByFilePath(episode); - - // Delete episode from API cache - await _apiCache.deleteForItem(serverId, episode.ratingKey); - - // Delete episode DB entry - await _database.deleteDownload(episodeGlobalKey); - } final showDir = await _storageService.getShowDirectory(show); if (await showDir.exists()) { @@ -1550,9 +1493,7 @@ class DownloadManagerService { final seasonKey = episode.parentRatingKey; if (seasonKey == null) return false; - final otherEpisodes = await (_database.select( - _database.downloadedMedia, - )..where((t) => t.parentRatingKey.equals(seasonKey))).get(); + final otherEpisodes = await _database.getEpisodesBySeason(seasonKey); // Check if any episodes besides this one return otherEpisodes.any( @@ -1608,12 +1549,11 @@ class DownloadManagerService { final videoPath = await _storageService.toAbsolutePath( record.videoFilePath!, ); - final videoFile = File(videoPath); - if (await videoFile.exists()) { - await videoFile.delete(); - appLogger.i('Deleted video file: $videoPath'); + final videoDeleted = + await _deleteFileIfExists(File(videoPath), 'video file'); - // Delete subtitle directory + // Delete subtitle directory if video was deleted + if (videoDeleted) { final subsPath = videoPath.replaceAll(RegExp(r'\.[^.]+$'), '_subs'); final subsDir = Directory(subsPath); if (await subsDir.exists()) { @@ -1627,11 +1567,7 @@ class DownloadManagerService { final thumbPath = await _storageService.toAbsolutePath( record.thumbPath!, ); - final thumbFile = File(thumbPath); - if (await thumbFile.exists()) { - await thumbFile.delete(); - appLogger.i('Deleted thumbnail: $thumbPath'); - } + await _deleteFileIfExists(File(thumbPath), 'thumbnail'); } } catch (e, stack) { appLogger.e('Error in fallback deletion', error: e, stackTrace: stack); diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index ada695e5..89d7efb0 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -5,6 +5,8 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../models/plex_metadata.dart'; +import '../utils/byte_formatter.dart'; +import '../utils/number_formatter.dart'; import 'settings_service.dart'; import 'saf_storage_service.dart'; @@ -65,10 +67,12 @@ class DownloadStorageService { /// Format episode filename base: S{XX}E{XX} - {Title} String _formatEpisodeFileName(PlexMetadata episode) { - final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0'); - final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0'); + final seCode = NumberFormatter.formatSeasonEpisode( + episode.parentIndex, + episode.index, + ); final episodeName = _sanitizeFileName(episode.title); - return 'S${seasonNum}E$episodeNum - $episodeName'; + return '$seCode - $episodeName'; } /// Check if using custom download path @@ -346,7 +350,7 @@ class DownloadStorageService { int? showYear, }) async { final showDir = await getShowDirectory(metadata, showYear: showYear); - final seasonNum = (metadata.parentIndex ?? 0).toString().padLeft(2, '0'); + final seasonNum = NumberFormatter.formatSeason(metadata.parentIndex); return _ensureDirectoryExists( Directory(path.join(showDir.path, 'Season $seasonNum')), ); @@ -526,14 +530,7 @@ class DownloadStorageService { } /// Format bytes to human readable string - static String formatBytes(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - if (bytes < 1024 * 1024 * 1024) { - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - } - return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; - } + static String formatBytes(int bytes) => ByteFormatter.formatBytes(bytes); // ============================================================ // SAF (Storage Access Framework) SUPPORT FOR ANDROID @@ -644,7 +641,7 @@ class DownloadStorageService { int? showYear, }) { final showFolder = _getShowFolderName(episode, showYear: showYear); - final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0'); + final seasonNum = NumberFormatter.formatSeason(episode.parentIndex); return ['TV Shows', showFolder, 'Season $seasonNum']; } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 17dc171f..0b7d37f0 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -18,6 +18,8 @@ import '../models/plex_video_playback_data.dart'; import '../utils/endpoint_failover_interceptor.dart'; import '../utils/app_logger.dart'; import '../utils/log_redaction_manager.dart'; +import '../utils/plex_cache_parser.dart'; +import '../utils/plex_url_helper.dart'; import 'plex_api_cache.dart'; /// Constants for Plex stream types @@ -245,17 +247,21 @@ class PlexClient { return null; } + /// Tag a PlexMetadata with this client's serverId and serverName + PlexMetadata _tagMetadata(PlexMetadata metadata) => + metadata.copyWith(serverId: serverId, serverName: serverName); + + /// Create and tag a PlexMetadata from JSON + PlexMetadata _createTaggedMetadata(Map json) => + _tagMetadata(PlexMetadata.fromJson(json)); + /// Extract list of PlexMetadata from response /// Automatically tags all items with this client's serverId and serverName List _extractMetadataList(Response response) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { return (container['Metadata'] as List) - .map( - (json) => PlexMetadata.fromJson( - json, - ).copyWith(serverId: serverId, serverName: serverName), - ) + .map((json) => _createTaggedMetadata(json)) .toList(); } return []; @@ -363,15 +369,9 @@ class PlexClient { List _parseMetadataListFromCachedResponse( Map cached, ) { - if (cached['MediaContainer'] != null && - cached['MediaContainer']['Metadata'] != null) { - return (cached['MediaContainer']['Metadata'] as List) - .map( - (json) => PlexMetadata.fromJson( - json, - ).copyWith(serverId: serverId, serverName: serverName), - ) - .toList(); + final metadataList = PlexCacheParser.extractMetadataList(cached); + if (metadataList != null) { + return metadataList.map((json) => _createTaggedMetadata(json)).toList(); } return []; } @@ -433,9 +433,7 @@ class PlexClient { final metadataJson = _getFirstMetadataJson(response); if (metadataJson != null) { - metadata = PlexMetadata.fromJsonWithImages( - metadataJson, - ).copyWith(serverId: serverId, serverName: serverName); + metadata = _tagMetadata(PlexMetadata.fromJsonWithImages(metadataJson)); // Check if OnDeck is nested inside Metadata if (metadataJson.containsKey('OnDeck') && @@ -446,9 +444,7 @@ class PlexClient { if (onDeckData is Map && onDeckData.containsKey('Metadata')) { final onDeckMetadata = onDeckData['Metadata']; if (onDeckMetadata != null) { - onDeckEpisode = PlexMetadata.fromJson( - onDeckMetadata, - ).copyWith(serverId: serverId, serverName: serverName); + onDeckEpisode = _createTaggedMetadata(onDeckMetadata); } } } @@ -477,9 +473,7 @@ class PlexClient { parseResponse: (response) { final metadataJson = _getFirstMetadataJson(response); return metadataJson != null - ? PlexMetadata.fromJsonWithImages( - metadataJson, - ).copyWith(serverId: serverId, serverName: serverName) + ? _tagMetadata(PlexMetadata.fromJsonWithImages(metadataJson)) : null; }, ); @@ -489,12 +483,9 @@ class PlexClient { PlexMetadata? _parseMetadataWithImagesFromCachedResponse( Map cached, ) { - if (cached['MediaContainer'] != null && - cached['MediaContainer']['Metadata'] != null && - (cached['MediaContainer']['Metadata'] as List).isNotEmpty) { - return PlexMetadata.fromJsonWithImages( - cached['MediaContainer']['Metadata'][0], - ).copyWith(serverId: serverId, serverName: serverName); + final firstMetadata = PlexCacheParser.extractFirstMetadata(cached); + if (firstMetadata != null) { + return _tagMetadata(PlexMetadata.fromJsonWithImages(firstMetadata)); } return null; } @@ -569,16 +560,10 @@ class PlexClient { } /// Get first metadata JSON from response data - Map? _getFirstMetadataJsonFromData(Map? data) { - if (data == null) return null; - final container = data['MediaContainer']; - if (container != null && - container['Metadata'] != null && - (container['Metadata'] as List).isNotEmpty) { - return container['Metadata'][0]; - } - return null; - } + Map? _getFirstMetadataJsonFromData( + Map? data, + ) => + PlexCacheParser.extractFirstMetadata(data); /// Wraps an API call that returns a boolean success status Future _wrapBoolApiCall( @@ -757,8 +742,8 @@ class PlexClient { final results = []; - if (response.data is Map && response.data.containsKey('MediaContainer')) { - final container = response.data['MediaContainer']; + final container = _getMediaContainer(response); + if (container != null) { if (container['Hub'] != null) { // Each hub contains results of a specific type (movies, shows, etc.) for (final hub in container['Hub'] as List) { @@ -773,11 +758,7 @@ class PlexClient { if (hub['Metadata'] != null) { for (final json in hub['Metadata'] as List) { try { - results.add( - PlexMetadata.fromJson( - json, - ).copyWith(serverId: serverId, serverName: serverName), - ); + results.add(_createTaggedMetadata(json)); } catch (e) { // Skip items that fail to parse appLogger.w('Failed to parse search result', error: e); @@ -788,11 +769,7 @@ class PlexClient { if (hub['Directory'] != null) { for (final json in hub['Directory'] as List) { try { - results.add( - PlexMetadata.fromJson( - json, - ).copyWith(serverId: serverId, serverName: serverName), - ); + results.add(_createTaggedMetadata(json)); } catch (e) { // Skip items that fail to parse appLogger.w('Failed to parse search result', error: e); @@ -825,11 +802,7 @@ class PlexClient { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { final allItems = (container['Metadata'] as List) - .map( - (json) => PlexMetadata.fromJsonWithImages( - json, - ).copyWith(serverId: serverId, serverName: serverName), - ) + .map((json) => _tagMetadata(PlexMetadata.fromJsonWithImages(json))) .toList(); // Filter out music content (artists, albums, tracks) @@ -906,10 +879,7 @@ class PlexClient { // Remove leading slash if present final path = thumbPath.startsWith('/') ? thumbPath.substring(1) : thumbPath; - // Check if path already has query parameters - final separator = path.contains('?') ? '&' : '?'; - - return '${config.baseUrl}/$path${separator}X-Plex-Token=${config.token}'; + return '${config.baseUrl}/$path'.withPlexToken(config.token); } /// Get video URL for direct playback @@ -936,7 +906,7 @@ class PlexClient { if (partKey != null) { // Return direct play URL - return '${config.baseUrl}$partKey?X-Plex-Token=${config.token}'; + return '${config.baseUrl}$partKey'.withPlexToken(config.token); } } } @@ -1011,8 +981,7 @@ class PlexClient { appLogger.d('getPlaybackExtras: serverId=$serverId, cacheKey=$cacheKey'); final cached = await _cache.get(serverId, cacheKey); if (cached != null) { - final chapters = - cached['MediaContainer']?['Metadata']?[0]?['Chapter'] as List?; + final chapters = PlexCacheParser.extractChapters(cached); appLogger.d( 'getPlaybackExtras: cache hit, ${chapters?.length ?? 0} chapters', ); @@ -1050,13 +1019,7 @@ class PlexClient { PlaybackExtras _parsePlaybackExtrasFromCachedResponse( Map cached, ) { - Map? metadataJson; - if (cached['MediaContainer'] != null && - cached['MediaContainer']['Metadata'] != null && - (cached['MediaContainer']['Metadata'] as List).isNotEmpty) { - metadataJson = - cached['MediaContainer']['Metadata'][0] as Map; - } + final metadataJson = PlexCacheParser.extractFirstMetadata(cached); return _parsePlaybackExtrasFromMetadataJson(metadataJson); } @@ -1136,7 +1099,7 @@ class PlexClient { final chapters = _parseChapters(metadataJson); return PlexMediaInfo( - videoUrl: '${config.baseUrl}$partKey?X-Plex-Token=${config.token}', + videoUrl: '${config.baseUrl}$partKey'.withPlexToken(config.token), audioTracks: streams.audio, subtitleTracks: streams.subtitles, chapters: chapters, @@ -1208,7 +1171,7 @@ class PlexClient { if (partKey != null) { // Get video URL - videoUrl = '${config.baseUrl}$partKey?X-Plex-Token=${config.token}'; + videoUrl = '${config.baseUrl}$partKey'.withPlexToken(config.token); // Parse streams using helper final streams = _parseStreams(part['Stream'] as List?); @@ -1813,9 +1776,10 @@ class PlexClient { // Extract the collection ID from the response // The response should contain the created collection metadata - if (response.data != null && response.data['MediaContainer'] != null) { - final metadata = response.data['MediaContainer']['Metadata']; - if (metadata != null && metadata.isNotEmpty) { + final container = _getMediaContainer(response); + if (container != null) { + final metadata = container['Metadata']; + if (metadata != null && (metadata as List).isNotEmpty) { final collectionId = metadata[0]['ratingKey']?.toString(); appLogger.d('Created collection with ID: $collectionId'); return collectionId; @@ -2031,11 +1995,7 @@ class PlexClient { for (final json in container['Metadata'] as List) { try { // Try to parse with full PlexMetadata.fromJson first - items.add( - PlexMetadata.fromJson( - json, - ).copyWith(serverId: serverId, serverName: serverName), - ); + items.add(_createTaggedMetadata(json)); } catch (e) { // If full parsing fails, use minimal safe parsing appLogger.d('Using minimal parsing for metadata item: $e'); @@ -2065,11 +2025,7 @@ class PlexClient { for (final json in container['Directory'] as List) { try { // Try to parse as PlexMetadata first - items.add( - PlexMetadata.fromJson( - json, - ).copyWith(serverId: serverId, serverName: serverName), - ); + items.add(_createTaggedMetadata(json)); } catch (e) { // If that fails, use minimal folder representation try { diff --git a/lib/theme/mono_theme.dart b/lib/theme/mono_theme.dart index 02e46b7e..dbe8604b 100644 --- a/lib/theme/mono_theme.dart +++ b/lib/theme/mono_theme.dart @@ -145,6 +145,7 @@ ThemeData monoTheme({required bool dark}) { space: 12, fast: const Duration(milliseconds: 120), normal: const Duration(milliseconds: 200), + slow: const Duration(milliseconds: 300), bg: c.bg, surface: c.surface, outline: c.outline, diff --git a/lib/theme/mono_tokens.dart b/lib/theme/mono_tokens.dart index 3604e944..a56b068b 100644 --- a/lib/theme/mono_tokens.dart +++ b/lib/theme/mono_tokens.dart @@ -8,6 +8,7 @@ class MonoTokens extends ThemeExtension { final double space; final Duration fast; final Duration normal; + final Duration slow; final Color bg; final Color surface; final Color outline; @@ -21,6 +22,7 @@ class MonoTokens extends ThemeExtension { required this.space, required this.fast, required this.normal, + required this.slow, required this.bg, required this.surface, required this.outline, @@ -36,6 +38,7 @@ class MonoTokens extends ThemeExtension { double? space, Duration? fast, Duration? normal, + Duration? slow, Color? bg, Color? surface, Color? outline, @@ -48,6 +51,7 @@ class MonoTokens extends ThemeExtension { space: space ?? this.space, fast: fast ?? this.fast, normal: normal ?? this.normal, + slow: slow ?? this.slow, bg: bg ?? this.bg, surface: surface ?? this.surface, outline: outline ?? this.outline, @@ -78,6 +82,13 @@ class MonoTokens extends ThemeExtension { t, )!.round(), ), + slow: Duration( + milliseconds: lerpDouble( + slow.inMilliseconds.toDouble(), + other.slow.inMilliseconds.toDouble(), + t, + )!.round(), + ), bg: lerpC(bg, other.bg), surface: lerpC(surface, other.surface), outline: lerpC(outline, other.outline), diff --git a/lib/utils/byte_formatter.dart b/lib/utils/byte_formatter.dart new file mode 100644 index 00000000..6482b255 --- /dev/null +++ b/lib/utils/byte_formatter.dart @@ -0,0 +1,61 @@ +/// Utility class for formatting byte sizes and speeds +class ByteFormatter { + ByteFormatter._(); + + static const int _kb = 1024; + static const int _mb = _kb * 1024; + static const int _gb = _mb * 1024; + + /// Format bytes to human-readable string (e.g., "1.5 GB", "256.3 MB") + /// + /// [bytes] The number of bytes to format + /// [decimals] Number of decimal places (default: 1 for KB/MB, 2 for GB) + static String formatBytes(int bytes, {int? decimals}) { + if (bytes < _kb) return '$bytes B'; + if (bytes < _mb) { + return '${(bytes / _kb).toStringAsFixed(decimals ?? 1)} KB'; + } + if (bytes < _gb) { + return '${(bytes / _mb).toStringAsFixed(decimals ?? 1)} MB'; + } + return '${(bytes / _gb).toStringAsFixed(decimals ?? 2)} GB'; + } + + /// Format speed in bytes per second to human-readable string + /// + /// [bytesPerSecond] The speed in bytes per second + static String formatSpeed(double bytesPerSecond) { + if (bytesPerSecond < _kb) { + return '${bytesPerSecond.toStringAsFixed(0)} B/s'; + } + if (bytesPerSecond < _mb) { + return '${(bytesPerSecond / _kb).toStringAsFixed(1)} KB/s'; + } + return '${(bytesPerSecond / _mb).toStringAsFixed(1)} MB/s'; + } + + /// Format bitrate in kbps to human-readable string + /// + /// [kbps] The bitrate in kilobits per second + static String formatBitrate(int kbps) { + if (kbps < 1000) return '$kbps kbps'; + return '${(kbps / 1000).toStringAsFixed(1)} Mbps'; + } + + /// Format bitrate in bps to human-readable string + /// + /// [bps] The bitrate in bits per second + /// Returns formatted string like "8.5 Mbps", "256 Kbps", or "128 bps" + static String formatBitrateBps(int bps) { + const kbps = 1000; + const mbps = kbps * 1000; + + if (bps >= mbps) { + return '${(bps / mbps).toStringAsFixed(2)} Mbps'; + } else if (bps >= kbps) { + return '${(bps / kbps).toStringAsFixed(2)} Kbps'; + } else { + return '$bps bps'; + } + } +} diff --git a/lib/utils/codec_utils.dart b/lib/utils/codec_utils.dart new file mode 100644 index 00000000..d3dda629 --- /dev/null +++ b/lib/utils/codec_utils.dart @@ -0,0 +1,91 @@ +/// Utility class for codec-related operations. +/// +/// Provides centralized codec name mappings, file extension lookups, +/// and display name formatting. +class CodecUtils { + CodecUtils._(); + + /// Maps Plex subtitle codec names to file extensions. + /// + /// Returns the appropriate file extension for a given subtitle codec. + /// Defaults to 'srt' for unknown or null codecs. + static String getSubtitleExtension(String? codec) { + if (codec == null) return 'srt'; + + switch (codec.toLowerCase()) { + case 'subrip': + case 'srt': + return 'srt'; + case 'ass': + return 'ass'; + case 'ssa': + return 'ssa'; + case 'webvtt': + case 'vtt': + return 'vtt'; + case 'mov_text': + return 'srt'; + case 'pgs': + case 'hdmv_pgs_subtitle': + return 'sup'; + case 'dvd_subtitle': + case 'dvdsub': + return 'sub'; + default: + return 'srt'; + } + } + + /// Formats a subtitle codec name to a user-friendly display format. + /// + /// Converts internal codec names like 'SUBRIP' to friendly names like 'SRT'. + static String formatSubtitleCodec(String codec) { + final upper = codec.toUpperCase(); + return switch (upper) { + 'SUBRIP' => 'SRT', + 'DVD_SUBTITLE' => 'DVD', + 'WEBVTT' => 'VTT', + 'HDMV_PGS_SUBTITLE' => 'PGS', + 'MOV_TEXT' => 'MOV', + _ => upper, + }; + } + + /// Formats a video codec name to a user-friendly display format. + /// + /// Converts internal codec names like 'hevc' to friendly names like 'HEVC'. + static String formatVideoCodec(String codec) { + final lower = codec.toLowerCase(); + return switch (lower) { + 'h264' || 'avc1' || 'avc' => 'H.264', + 'hevc' || 'h265' || 'hev1' => 'HEVC', + 'av1' => 'AV1', + 'vp8' => 'VP8', + 'vp9' => 'VP9', + 'mpeg2video' || 'mpeg2' => 'MPEG-2', + 'mpeg4' => 'MPEG-4', + 'vc1' => 'VC-1', + _ => codec.toUpperCase(), + }; + } + + /// Formats an audio codec name to a user-friendly display format. + static String formatAudioCodec(String codec) { + final lower = codec.toLowerCase(); + return switch (lower) { + 'aac' => 'AAC', + 'ac3' => 'AC3', + 'eac3' || 'ec3' => 'E-AC3', + 'truehd' => 'TrueHD', + 'dts' => 'DTS', + 'dca' => 'DTS', + 'dtshd' || 'dts-hd' => 'DTS-HD', + 'flac' => 'FLAC', + 'mp3' || 'mp3float' => 'MP3', + 'opus' => 'Opus', + 'vorbis' => 'Vorbis', + 'pcm_s16le' || 'pcm_s24le' || 'pcm' => 'PCM', + _ => codec.toUpperCase(), + }; + } +} diff --git a/lib/utils/content_type_helper.dart b/lib/utils/content_type_helper.dart index aaa6d546..c3cd729a 100644 --- a/lib/utils/content_type_helper.dart +++ b/lib/utils/content_type_helper.dart @@ -1,56 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +/// Content type constants used throughout the app +class ContentTypes { + ContentTypes._(); + + static const String movie = 'movie'; + static const String show = 'show'; + static const String season = 'season'; + static const String episode = 'episode'; + static const String artist = 'artist'; + static const String album = 'album'; + static const String track = 'track'; + static const String collection = 'collection'; + static const String playlist = 'playlist'; + static const String clip = 'clip'; + + static const Set musicTypes = {artist, album, track}; + static const Set videoTypes = {movie, show, season, episode}; + static const Set playableTypes = {movie, episode, clip, track}; +} + /// Utility class for content type checking and filtering class ContentTypeHelper { + 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'; - } + static bool isMusicContent(String type) => + ContentTypes.musicTypes.contains(type.toLowerCase()); + + /// Checks if the given type is video content (movie, show, episode, or season) + static bool isVideoContent(String type) => + ContentTypes.videoTypes.contains(type.toLowerCase()); /// 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'; + return type?.toLowerCase() == ContentTypes.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(); } + + /// Returns the appropriate icon for a given library type + static IconData getLibraryIcon(String type) { + switch (type.toLowerCase()) { + case ContentTypes.movie: + return Symbols.movie_rounded; + case ContentTypes.show: + return Symbols.tv_rounded; + case ContentTypes.artist: + return Symbols.music_note_rounded; + case 'photo': + return Symbols.photo_rounded; + default: + return Symbols.folder_rounded; + } + } } diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index ac1840a5..7f774ed6 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -3,6 +3,86 @@ import '../i18n/strings.g.dart'; /// Utility functions for showing common dialogs +/// Shows a loading dialog that cannot be dismissed by tapping outside. +/// +/// Returns a function to close the dialog when the operation completes. +/// Usage: +/// ```dart +/// final close = showLoadingDialog(context); +/// try { +/// await performOperation(); +/// } finally { +/// close(); +/// } +/// ``` +VoidCallback showLoadingDialog(BuildContext context, {String? message}) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => PopScope( + canPop: false, + child: Center( + child: message != null + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Material( + color: Colors.transparent, + child: Text( + message, + style: const TextStyle(color: Colors.white), + ), + ), + ], + ) + : const CircularProgressIndicator(), + ), + ), + ); + + return () { + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + }; +} + +/// Shows a generic confirmation dialog with customizable button text. +/// Returns true if confirmed, false if cancelled. +Future showConfirmation( + BuildContext context, { + required String title, + required String message, + String? confirmText, + String? cancelText, + Color? confirmColor, +}) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(cancelText ?? t.common.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: confirmColor != null + ? TextButton.styleFrom(foregroundColor: confirmColor) + : null, + child: Text(confirmText ?? t.common.confirm), + ), + ], + ), + ); + + return confirmed ?? false; +} + /// Shows a delete confirmation dialog /// Returns true if user confirmed, false if cancelled Future showDeleteConfirmation( @@ -31,3 +111,88 @@ Future showDeleteConfirmation( return confirmed ?? false; } + +/// Shows a text input dialog for creating/naming items +/// Returns the entered text, or null if cancelled +Future showTextInputDialog( + BuildContext context, { + required String title, + required String labelText, + required String hintText, + String? initialValue, +}) async { + return showDialog( + context: context, + builder: (context) => _TextInputDialog( + title: title, + labelText: labelText, + hintText: hintText, + initialValue: initialValue, + ), + ); +} + +class _TextInputDialog extends StatefulWidget { + final String title; + final String labelText; + final String hintText; + final String? initialValue; + + const _TextInputDialog({ + required this.title, + required this.labelText, + required this.hintText, + this.initialValue, + }); + + @override + State<_TextInputDialog> createState() => _TextInputDialogState(); +} + +class _TextInputDialogState extends State<_TextInputDialog> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + if (_controller.text.isNotEmpty) { + Navigator.pop(context, _controller.text); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + labelText: widget.labelText, + hintText: widget.hintText, + ), + onSubmitted: (_) => _submit(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: _submit, + child: Text(t.common.save), + ), + ], + ); + } +} diff --git a/lib/utils/focus_utils.dart b/lib/utils/focus_utils.dart new file mode 100644 index 00000000..6de0d5b8 --- /dev/null +++ b/lib/utils/focus_utils.dart @@ -0,0 +1,53 @@ +import 'package:flutter/widgets.dart'; + +/// Utility class for common focus operations +class FocusUtils { + FocusUtils._(); + + /// Request focus on a FocusNode after the current frame completes. + /// Safely checks if the State is still mounted before requesting focus. + /// + /// Usage: + /// ```dart + /// FocusUtils.requestFocusAfterBuild(this, _focusNode); + /// ``` + static void requestFocusAfterBuild(State state, FocusNode focusNode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (state.mounted) { + focusNode.requestFocus(); + } + }); + } + + /// Execute a callback after the current frame completes, with mounted check. + /// The callback will only execute if the State is still mounted. + /// + /// Usage: + /// ```dart + /// FocusUtils.afterBuildIfMounted(this, () { + /// // do something + /// }); + /// ``` + static void afterBuildIfMounted(State state, VoidCallback callback) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (state.mounted) { + callback(); + } + }); + } + + /// Execute a callback after the current frame completes, without mounted check. + /// Use this when you don't need the mounted check or are managing it yourself. + /// + /// Usage: + /// ```dart + /// FocusUtils.afterBuild(() { + /// // do something + /// }); + /// ``` + static void afterBuild(VoidCallback callback) { + WidgetsBinding.instance.addPostFrameCallback((_) { + callback(); + }); + } +} diff --git a/lib/utils/number_formatter.dart b/lib/utils/number_formatter.dart new file mode 100644 index 00000000..5bd63609 --- /dev/null +++ b/lib/utils/number_formatter.dart @@ -0,0 +1,32 @@ +/// Utility class for formatting numbers consistently across the app. +class NumberFormatter { + NumberFormatter._(); + + /// Formats a season number with leading zeros (e.g., "01", "02", "10"). + /// + /// Used for consistent season display in file names and UI. + static String formatSeason(int? seasonNumber) { + return (seasonNumber ?? 0).toString().padLeft(2, '0'); + } + + /// Formats an episode number with leading zeros (e.g., "01", "02", "10"). + /// + /// Used for consistent episode display in file names and UI. + static String formatEpisode(int? episodeNumber) { + return (episodeNumber ?? 0).toString().padLeft(2, '0'); + } + + /// Formats a season and episode as "SXXEXX" (e.g., "S01E05", "S12E23"). + /// + /// Commonly used for episode identifiers in file names. + static String formatSeasonEpisode(int? season, int? episode) { + return 'S${formatSeason(season)}E${formatEpisode(episode)}'; + } + + /// Formats a number with a minimum number of digits using leading zeros. + /// + /// Example: `padNumber(5, 3)` returns "005" + static String padNumber(int number, int width) { + return number.toString().padLeft(width, '0'); + } +} diff --git a/lib/utils/plex_cache_parser.dart b/lib/utils/plex_cache_parser.dart new file mode 100644 index 00000000..5108442f --- /dev/null +++ b/lib/utils/plex_cache_parser.dart @@ -0,0 +1,57 @@ +/// Utility class for parsing Plex API cache responses +/// +/// Provides consistent extraction of MediaContainer data across the codebase. +class PlexCacheParser { + PlexCacheParser._(); + + /// Extract the Metadata list from a cached response + /// + /// Returns null if MediaContainer or Metadata is not present + static List? extractMetadataList(Map? cached) { + if (cached == null) return null; + return cached['MediaContainer']?['Metadata'] as List?; + } + + /// Extract the first metadata item from a cached response + /// + /// Returns null if no metadata exists + static Map? extractFirstMetadata( + Map? cached, + ) { + final list = extractMetadataList(cached); + if (list == null || list.isEmpty) return null; + return list[0] as Map; + } + + /// Check if a cached response has valid metadata + static bool hasMetadata(Map? cached) { + final list = extractMetadataList(cached); + return list != null && list.isNotEmpty; + } + + /// Extract Directory list from a cached response (for libraries, playlists) + static List? extractDirectoryList(Map? cached) { + if (cached == null) return null; + return cached['MediaContainer']?['Directory'] as List?; + } + + /// Extract Hub list from a cached response + static List? extractHubList(Map? cached) { + if (cached == null) return null; + return cached['MediaContainer']?['Hub'] as List?; + } + + /// Extract Chapter list from the first metadata item + static List? extractChapters(Map? cached) { + final metadata = extractFirstMetadata(cached); + if (metadata == null) return null; + return metadata['Chapter'] as List?; + } + + /// Extract Marker list from the first metadata item + static List? extractMarkers(Map? cached) { + final metadata = extractFirstMetadata(cached); + if (metadata == null) return null; + return metadata['Marker'] as List?; + } +} diff --git a/lib/utils/plex_url_helper.dart b/lib/utils/plex_url_helper.dart new file mode 100644 index 00000000..74647e78 --- /dev/null +++ b/lib/utils/plex_url_helper.dart @@ -0,0 +1,36 @@ +/// Extension methods for appending Plex authentication tokens to URLs. +extension PlexUrlExtension on String { + /// Appends a Plex authentication token to this URL string. + /// + /// Automatically determines whether to use '?' or '&' as the separator + /// based on whether the URL already contains query parameters. + /// + /// If [token] is null or empty, returns the URL unchanged. + /// + /// Example: + /// ```dart + /// final url = '/library/metadata/123'.withPlexToken('abc123'); + /// // Result: '/library/metadata/123?X-Plex-Token=abc123' + /// + /// final urlWithParams = '/library/metadata/123?type=1'.withPlexToken('abc123'); + /// // Result: '/library/metadata/123?type=1&X-Plex-Token=abc123' + /// ``` + String withPlexToken(String? token) { + if (token == null || token.isEmpty) return this; + final separator = contains('?') ? '&' : '?'; + return '$this${separator}X-Plex-Token=$token'; + } + + /// Appends a base URL and Plex authentication token to this path string. + /// + /// If [token] is null or empty, returns the URL without a token parameter. + /// + /// Example: + /// ```dart + /// final fullUrl = '/library/metadata/123'.toPlexUrl('http://server:32400', 'abc123'); + /// // Result: 'http://server:32400/library/metadata/123?X-Plex-Token=abc123' + /// ``` + String toPlexUrl(String baseUrl, String? token) { + return '$baseUrl$this'.withPlexToken(token); + } +} diff --git a/lib/utils/provider_extensions.dart b/lib/utils/provider_extensions.dart index 99dd9382..0aef9a93 100644 --- a/lib/utils/provider_extensions.dart +++ b/lib/utils/provider_extensions.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../services/plex_client.dart'; import '../i18n/strings.g.dart'; import '../models/plex_library.dart'; +import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/multi_server_provider.dart'; @@ -66,4 +67,47 @@ extension ProviderExtensions on BuildContext { } return getClientForServer(library.serverId!); } + + /// Get PlexClient for metadata, with fallback to first available server + /// Throws an exception if no servers are available + PlexClient getClientForMetadata(PlexMetadata metadata) { + if (metadata.serverId != null) { + return getClientForServer(metadata.serverId!); + } + return getFirstAvailableClient(); + } + + /// Get PlexClient for metadata, or null if offline mode or no serverId + /// Use this for screens that support offline mode + PlexClient? getClientForMetadataOrNull( + PlexMetadata metadata, { + bool isOffline = false, + }) { + if (isOffline || metadata.serverId == null) { + return null; + } + return getClientForServer(metadata.serverId!); + } + + /// Get the first available client from connected servers + /// Throws an exception if no servers are available + PlexClient getFirstAvailableClient() { + final multiServerProvider = Provider.of( + this, + listen: false, + ); + if (!multiServerProvider.hasConnectedServers) { + throw Exception(t.errors.noClientAvailable); + } + return getClientForServer(multiServerProvider.onlineServerIds.first); + } + + /// Get client for a serverId with fallback to first available server + /// Useful for items that might not have a serverId + PlexClient getClientWithFallback(String? serverId) { + if (serverId != null) { + return getClientForServer(serverId); + } + return getFirstAvailableClient(); + } } diff --git a/lib/utils/snackbar_helper.dart b/lib/utils/snackbar_helper.dart index 502d7312..3b07f256 100644 --- a/lib/utils/snackbar_helper.dart +++ b/lib/utils/snackbar_helper.dart @@ -1,7 +1,48 @@ import 'package:flutter/material.dart'; +/// Types of snackbars available in the app +enum SnackBarType { + /// Standard informational snackbar + info, + + /// Success snackbar (green background) + success, + + /// Error snackbar (red background) + error, +} + /// Utility functions for showing snackbars throughout the application +/// Shows a snackbar with the specified type +/// +/// [context] The build context +/// [message] The message to display +/// [type] The type of snackbar (info, success, error) +/// [duration] Optional duration override +void showSnackBar( + BuildContext context, + String message, { + SnackBarType type = SnackBarType.info, + Duration? duration, +}) { + if (!context.mounted) return; + + final (backgroundColor, defaultDuration) = switch (type) { + SnackBarType.info => (null, const Duration(seconds: 3)), + SnackBarType.success => (Colors.green, const Duration(seconds: 3)), + SnackBarType.error => (Colors.red, const Duration(seconds: 4)), + }; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: duration ?? defaultDuration, + ), + ); +} + /// Shows a standard snackbar with a message /// /// [context] The build context @@ -12,14 +53,7 @@ void showAppSnackBar( String message, { Duration? duration, }) { - if (!context.mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - duration: duration ?? const Duration(seconds: 3), - ), - ); + showSnackBar(context, message, type: SnackBarType.info, duration: duration); } /// Shows an error snackbar with a message @@ -27,15 +61,7 @@ void showAppSnackBar( /// [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), - ), - ); + showSnackBar(context, message, type: SnackBarType.error); } /// Shows a success snackbar with a message @@ -43,13 +69,5 @@ void showErrorSnackBar(BuildContext context, String 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), - ), - ); + showSnackBar(context, message, type: SnackBarType.success); } diff --git a/lib/utils/track_label_builder.dart b/lib/utils/track_label_builder.dart new file mode 100644 index 00000000..9be55496 --- /dev/null +++ b/lib/utils/track_label_builder.dart @@ -0,0 +1,54 @@ +import 'codec_utils.dart'; + +/// Utility for building track labels for audio and subtitle tracks. +class TrackLabelBuilder { + TrackLabelBuilder._(); + + /// Build a label for an audio track. + /// + /// Combines title, language, codec, and channel count. + static String buildAudioLabel({ + String? title, + String? language, + String? codec, + int? channelsCount, + required int index, + }) { + final parts = []; + if (title != null && title.isNotEmpty) { + parts.add(title); + } + if (language != null && language.isNotEmpty) { + parts.add(language.toUpperCase()); + } + if (codec != null && codec.isNotEmpty) { + parts.add(CodecUtils.formatAudioCodec(codec)); + } + if (channelsCount != null) { + parts.add('${channelsCount}ch'); + } + return parts.isEmpty ? 'Audio Track ${index + 1}' : parts.join(' ยท '); + } + + /// Build a label for a subtitle track. + /// + /// Combines title, language, and codec (with friendly codec names). + static String buildSubtitleLabel({ + String? title, + String? language, + String? codec, + required int index, + }) { + final parts = []; + if (title != null && title.isNotEmpty) { + parts.add(title); + } + if (language != null && language.isNotEmpty) { + parts.add(language.toUpperCase()); + } + if (codec != null && codec.isNotEmpty) { + parts.add(CodecUtils.formatSubtitleCodec(codec)); + } + return parts.isEmpty ? 'Track ${index + 1}' : parts.join(' ยท '); + } +} diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 2f1b51a1..ba5ed3ba 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -4,6 +4,7 @@ import '../mpv/mpv.dart'; import '../models/plex_metadata.dart'; import '../screens/video_player_screen.dart'; import '../services/settings_service.dart'; +import 'app_logger.dart'; /// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash. /// @@ -74,3 +75,49 @@ Future navigateToVideoPlayer( return navigator.push(route); } } + +/// Navigates to the video player and optionally refreshes content when returning. +/// +/// This helper consolidates the common pattern of: +/// 1. Navigating to the video player +/// 2. Logging the return +/// 3. Calling a refresh callback if not offline +/// +/// Parameters: +/// - [context]: The build context for navigation +/// - [metadata]: The Plex metadata for the content to play +/// - [isOffline]: If true, plays from downloaded content +/// - [onRefresh]: Optional callback to refresh data when returning from playback +/// (only called when not offline) +/// - All other parameters are passed through to [navigateToVideoPlayer] +Future navigateToVideoPlayerWithRefresh( + BuildContext context, { + required PlexMetadata metadata, + bool isOffline = false, + VoidCallback? onRefresh, + AudioTrack? preferredAudioTrack, + SubtitleTrack? preferredSubtitleTrack, + double? preferredPlaybackRate, + int? selectedMediaIndex, + bool usePushReplacement = false, +}) async { + final result = await navigateToVideoPlayer( + context, + metadata: metadata, + isOffline: isOffline, + preferredAudioTrack: preferredAudioTrack, + preferredSubtitleTrack: preferredSubtitleTrack, + preferredPlaybackRate: preferredPlaybackRate, + selectedMediaIndex: selectedMediaIndex, + usePushReplacement: usePushReplacement, + ); + + appLogger.d('Returned from playback, refreshing metadata'); + + // Refresh data when returning from video player (skip if offline) + if (!isOffline && onRefresh != null) { + onRefresh(); + } + + return result; +} diff --git a/lib/widgets/bottom_sheet_header.dart b/lib/widgets/bottom_sheet_header.dart index 78a28224..57f2e2ec 100644 --- a/lib/widgets/bottom_sheet_header.dart +++ b/lib/widgets/bottom_sheet_header.dart @@ -43,6 +43,9 @@ class BottomSheetHeader extends StatelessWidget { /// Defaults to true final bool showBorder; + /// Optional focus node for the close button + final FocusNode? closeFocusNode; + const BottomSheetHeader({ super.key, required this.title, @@ -55,6 +58,7 @@ class BottomSheetHeader extends StatelessWidget { this.titleStyle, this.titleColor, this.showBorder = true, + this.closeFocusNode, }); @override @@ -107,6 +111,7 @@ class BottomSheetHeader extends StatelessWidget { ), if (action != null) action!, IconButton( + focusNode: closeFocusNode, icon: AppIcon(Symbols.close_rounded, fill: 1, color: iconColor), onPressed: onClose ?? () => Navigator.pop(context), ), diff --git a/lib/widgets/file_info_bottom_sheet.dart b/lib/widgets/file_info_bottom_sheet.dart index f71e53d5..c4d53d0d 100644 --- a/lib/widgets/file_info_bottom_sheet.dart +++ b/lib/widgets/file_info_bottom_sheet.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../models/plex_file_info.dart'; import '../i18n/strings.g.dart'; +import 'bottom_sheet_header.dart'; import 'focusable_bottom_sheet.dart'; class FileInfoBottomSheet extends StatefulWidget { @@ -54,40 +54,13 @@ class _FileInfoBottomSheetState extends State { child: Column( children: [ // Header - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const AppIcon( - Symbols.info_rounded, - fill: 1, - color: Colors.white, - size: 24, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - t.fileInfo.title, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - IconButton( - focusNode: _initialFocusNode, - icon: const AppIcon( - Symbols.close_rounded, - fill: 1, - color: Colors.white, - ), - onPressed: () => Navigator.pop(context), - ), - ], - ), + BottomSheetHeader( + title: t.fileInfo.title, + icon: Symbols.info_rounded, + iconColor: Colors.white, + titleColor: Colors.white, + closeFocusNode: _initialFocusNode, ), - const Divider(color: Colors.grey, height: 1), // Content Expanded( child: ListView( diff --git a/lib/widgets/horizontal_scroll_with_arrows.dart b/lib/widgets/horizontal_scroll_with_arrows.dart index 76fedd84..f9e4f012 100644 --- a/lib/widgets/horizontal_scroll_with_arrows.dart +++ b/lib/widgets/horizontal_scroll_with_arrows.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../theme/theme_helper.dart'; import '../utils/platform_detector.dart'; /// A wrapper widget that adds hover-activated navigation arrows to horizontal scrolling content. @@ -70,7 +71,7 @@ class _HorizontalScrollWithArrowsState _scrollController.animateTo( targetScroll, - duration: const Duration(milliseconds: 300), + duration: tokens(context).slow, curve: Curves.easeInOut, ); } @@ -83,7 +84,7 @@ class _HorizontalScrollWithArrowsState _scrollController.animateTo( targetScroll, - duration: const Duration(milliseconds: 300), + duration: tokens(context).slow, curve: Curves.easeInOut, ); } diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index a93f0d55..87986be0 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import '../focus/dpad_navigator.dart'; +import '../theme/theme_helper.dart'; import '../utils/layout_constants.dart'; import '../focus/locked_hub_controller.dart'; import '../models/plex_hub.dart'; @@ -165,7 +166,7 @@ class HubSectionState extends State { /// Handle ALL key events at the hub level KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { // Handle key down and repeat events - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + if (!event.isActionable) { return KeyEventResult.ignored; } @@ -270,7 +271,7 @@ class HubSectionState extends State { onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 7b34c4dc..ef0168a3 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:plezy/models/plex_metadata_extensions.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -6,7 +7,6 @@ import '../../services/plex_client.dart'; import '../models/plex_metadata.dart'; import '../models/plex_playlist.dart'; import '../providers/download_provider.dart'; -import '../providers/multi_server_provider.dart'; import '../services/download_storage_service.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; @@ -236,7 +236,7 @@ class _MediaCardGrid extends StatelessWidget { child: InkWell( canRequestFocus: false, // Keyboard handled by FocusableMediaCard onTap: onTap, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: Padding( padding: const EdgeInsets.all(8), child: Column( @@ -293,7 +293,7 @@ class _MediaCardGrid extends StatelessWidget { return Stack( children: [ ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: _buildPosterImage( context, item, @@ -490,7 +490,7 @@ class _MediaCardList extends StatelessWidget { child: InkWell( canRequestFocus: false, // Keyboard handled by FocusableMediaCard onTap: onTap, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: Padding( padding: const EdgeInsets.all(8), child: Row( @@ -503,7 +503,7 @@ class _MediaCardList extends StatelessWidget { child: Stack( children: [ ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: _buildPosterImage( context, item, @@ -591,31 +591,6 @@ class _MediaCardList extends StatelessWidget { } } -/// Helper to get the correct PlexClient for an item's server -PlexClient _getClientForItem(BuildContext context, dynamic item) { - String? serverId; - - if (item is PlexMetadata) { - serverId = item.serverId; - } else if (item is PlexPlaylist) { - serverId = item.serverId; - } - - // If serverId is null, fall back to first available server - if (serverId == null) { - final multiServerProvider = Provider.of( - context, - listen: false, - ); - if (!multiServerProvider.hasConnectedServers) { - throw Exception('No servers available'); - } - serverId = multiServerProvider.onlineServerIds.first; - } - - return context.getClientForServer(serverId); -} - Widget _buildPosterImage( BuildContext context, dynamic item, { @@ -630,7 +605,7 @@ Widget _buildPosterImage( fallbackIcon = Symbols.playlist_play_rounded; return PlexOptimizedImage.playlist( - client: isOffline ? null : _getClientForItem(context, item), + client: isOffline ? null : context.getClientWithFallback(item.serverId), imagePath: posterUrl, width: double.infinity, height: double.infinity, @@ -642,7 +617,7 @@ Widget _buildPosterImage( posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); return PlexOptimizedImage.poster( - client: isOffline ? null : _getClientForItem(context, item), + client: isOffline ? null : context.getClientWithFallback(item.serverId), imagePath: posterUrl, width: double.infinity, height: double.infinity, @@ -806,7 +781,7 @@ class _MediaCardHelpers { ), ), // Progress bar for seasons (viewedLeafCount / leafCount) - if (metadata.type == 'season' && + if (metadata.isSeason && metadata.viewedLeafCount != null && metadata.leafCount != null && metadata.leafCount! > 0 && @@ -883,7 +858,8 @@ class _SkeletonLoaderState extends State decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest .withValues(alpha: _animation.value), - borderRadius: widget.borderRadius ?? BorderRadius.circular(8), + borderRadius: widget.borderRadius ?? + BorderRadius.circular(tokens(context).radiusSm), ), child: widget.child, ), diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 0cc1e8e4..6a6bede4 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -7,7 +7,6 @@ import '../services/plex_client.dart'; import '../services/play_queue_launcher.dart'; import '../models/plex_metadata.dart'; import '../models/plex_playlist.dart'; -import '../providers/multi_server_provider.dart'; import '../providers/download_provider.dart'; import '../providers/offline_mode_provider.dart'; import '../providers/offline_watch_provider.dart'; @@ -15,9 +14,12 @@ import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; import '../utils/library_refresh_notifier.dart'; import '../utils/snackbar_helper.dart'; +import '../utils/dialogs.dart'; +import '../utils/focus_utils.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../utils/smart_deletion_handler.dart'; +import '../theme/theme_helper.dart'; import '../widgets/file_info_bottom_sheet.dart'; import '../widgets/focusable_bottom_sheet.dart'; import '../widgets/focusable_list_tile.dart'; @@ -95,32 +97,16 @@ class MediaContextMenuState extends State { _showContextMenu(menuContext); } - /// Get the correct PlexClient for this item's server - PlexClient _getClientForItem() { - String? serverId; - - // Get serverId from the item (could be PlexMetadata or PlexPlaylist) - if (widget.item is PlexMetadata) { - serverId = (widget.item as PlexMetadata).serverId; - } else if (widget.item is PlexPlaylist) { - serverId = (widget.item as PlexPlaylist).serverId; - } - - // If serverId is null, fall back to first available server - if (serverId == null) { - final multiServerProvider = Provider.of( - context, - listen: false, - ); - if (!multiServerProvider.hasConnectedServers) { - throw Exception('No servers available'); - } - serverId = multiServerProvider.onlineServerIds.first; - } - - return context.getClientForServer(serverId); + /// Get the serverId from the item (PlexMetadata or PlexPlaylist) + String? get _itemServerId { + if (widget.item is PlexMetadata) return (widget.item as PlexMetadata).serverId; + if (widget.item is PlexPlaylist) return (widget.item as PlexPlaylist).serverId; + return null; } + /// Get the correct PlexClient for this item's server + PlexClient _getClientForItem() => context.getClientWithFallback(_itemServerId); + void _handleTap() { if (_isContextMenuOpen) return; widget.onTap?.call(); @@ -729,9 +715,11 @@ class MediaContextMenuState extends State { if (result == '_create_new') { // Create new playlist flow - final playlistName = await showDialog( - context: context, - builder: (context) => _CreatePlaylistDialog(), + final playlistName = await showTextInputDialog( + context, + title: t.playlists.create, + labelText: t.playlists.playlistName, + hintText: t.playlists.enterPlaylistName, ); if (playlistName == null || playlistName.isEmpty || !context.mounted) { @@ -893,9 +881,11 @@ class MediaContextMenuState extends State { if (result == '_create_new') { // Create new collection flow - final collectionName = await showDialog( - context: context, - builder: (context) => _CreateCollectionDialog(), + final collectionName = await showTextInputDialog( + context, + title: t.collections.createNewCollection, + labelText: t.collections.collectionName, + hintText: t.collections.enterCollectionName, ); if (collectionName == null || @@ -1020,28 +1010,13 @@ class MediaContextMenuState extends State { } // Show confirmation dialog - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(t.collections.removeFromCollection), - content: Text( - t.collections.removeFromCollectionConfirm(title: metadata.title), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: Text(t.common.delete), - ), - ], - ), + final confirmed = await showDeleteConfirmation( + context, + title: t.collections.removeFromCollection, + message: t.collections.removeFromCollectionConfirm(title: metadata.title), ); - if (confirmed != true || !context.mounted) return; + if (!confirmed || !context.mounted) return; try { appLogger.d( @@ -1135,32 +1110,15 @@ class MediaContextMenuState extends State { : t.playlists.playlist; // Show confirmation dialog - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text( - isCollection ? t.collections.deleteCollection : t.playlists.delete, - ), - content: Text( - isCollection - ? t.collections.deleteConfirm(title: itemTitle) - : t.playlists.deleteMessage(name: itemTitle), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: Text(t.common.delete), - ), - ], - ), + final confirmed = await showDeleteConfirmation( + context, + title: isCollection ? t.collections.deleteCollection : t.playlists.delete, + message: isCollection + ? t.collections.deleteConfirm(title: itemTitle) + : t.playlists.deleteMessage(name: itemTitle), ); - if (confirmed != true || !context.mounted) return; + if (!confirmed || !context.mounted) return; try { bool success = false; @@ -1247,26 +1205,13 @@ class MediaContextMenuState extends State { final globalKey = '${metadata.serverId}:${metadata.ratingKey}'; // Show confirmation dialog - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(t.downloads.deleteDownload), - content: Text(t.downloads.deleteConfirm(title: metadata.title)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: Text(t.common.delete), - ), - ], - ), + final confirmed = await showDeleteConfirmation( + context, + title: t.downloads.deleteDownload, + message: t.downloads.deleteConfirm(title: metadata.title), ); - if (confirmed != true || !context.mounted) return; + if (!confirmed || !context.mounted) return; try { // Use smart deletion handler (shows progress only if >500ms) @@ -1361,56 +1306,6 @@ class _PlaylistSelectionDialog extends StatelessWidget { } } -/// Dialog to create a new playlist -class _CreatePlaylistDialog extends StatefulWidget { - @override - State<_CreatePlaylistDialog> createState() => _CreatePlaylistDialogState(); -} - -class _CreatePlaylistDialogState extends State<_CreatePlaylistDialog> { - final _controller = TextEditingController(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(t.playlists.create), - content: TextField( - controller: _controller, - autofocus: true, - decoration: InputDecoration( - labelText: t.playlists.playlistName, - hintText: t.playlists.enterPlaylistName, - ), - onSubmitted: (value) { - if (value.isNotEmpty) { - Navigator.pop(context, value); - } - }, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () { - if (_controller.text.isNotEmpty) { - Navigator.pop(context, _controller.text); - } - }, - child: Text(t.common.save), - ), - ], - ); - } -} - /// Dialog to select a collection or create a new one class _CollectionSelectionDialog extends StatelessWidget { final List collections; @@ -1458,57 +1353,6 @@ class _CollectionSelectionDialog extends StatelessWidget { } } -/// Dialog to create a new collection -class _CreateCollectionDialog extends StatefulWidget { - @override - State<_CreateCollectionDialog> createState() => - _CreateCollectionDialogState(); -} - -class _CreateCollectionDialogState extends State<_CreateCollectionDialog> { - final _controller = TextEditingController(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(t.collections.createNewCollection), - content: TextField( - controller: _controller, - autofocus: true, - decoration: InputDecoration( - labelText: t.collections.collectionName, - hintText: t.collections.enterCollectionName, - ), - onSubmitted: (value) { - if (value.isNotEmpty) { - Navigator.pop(context, value); - } - }, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () { - if (_controller.text.isNotEmpty) { - Navigator.pop(context, _controller.text); - } - }, - child: Text(t.common.save), - ), - ], - ); - } -} - /// Focusable context menu sheet for keyboard/gamepad navigation (mobile) class _FocusableContextMenuSheet extends StatefulWidget { final String title; @@ -1609,11 +1453,7 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { super.initState(); _initialFocusNode = FocusNode(debugLabel: 'PopupMenuInitialFocus'); if (widget.focusFirstItem) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _initialFocusNode.requestFocus(); - } - }); + FocusUtils.requestFocusAfterBuild(this, _initialFocusNode); } } @@ -1659,7 +1499,7 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { top: top, child: Material( elevation: 8, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), clipBehavior: Clip.antiAlias, child: ConstrainedBox( constraints: const BoxConstraints( diff --git a/lib/widgets/placeholder_container.dart b/lib/widgets/placeholder_container.dart new file mode 100644 index 00000000..a4f2d443 --- /dev/null +++ b/lib/widgets/placeholder_container.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +/// A standardized placeholder container used for loading states, +/// error states, and missing images throughout the app. +/// +/// Uses the theme's surfaceContainerHighest color by default. +class PlaceholderContainer extends StatelessWidget { + /// Optional child widget to display inside the placeholder + final Widget? child; + + /// Optional custom color (defaults to theme's surfaceContainerHighest) + final Color? color; + + /// Optional border radius + final BorderRadius? borderRadius; + + const PlaceholderContainer({ + super.key, + this.child, + this.color, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + final container = Container( + color: borderRadius != null + ? null + : (color ?? Theme.of(context).colorScheme.surfaceContainerHighest), + decoration: borderRadius != null + ? BoxDecoration( + color: + color ?? + Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: borderRadius, + ) + : null, + child: child != null ? Center(child: child) : null, + ); + + return container; + } +} diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index b3b0a2ed..78eab7da 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -496,7 +496,7 @@ class SideNavigationRailState extends State { _librariesExpanded = !_librariesExpanded; }); }, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(tokens(context).radiusMd), child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, @@ -508,7 +508,7 @@ class SideNavigationRailState extends State { : isLibrariesFocused ? t.text.withValues(alpha: 0.08) : null, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(tokens(context).radiusMd), ), child: Row( children: [ @@ -652,7 +652,7 @@ class SideNavigationRailState extends State { onTap: () => widget.onLibrarySelected(library.globalKey), focusNode: focusNode, padding: const EdgeInsets.only(left: 28, right: 12, top: 10, bottom: 10), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), iconSize: 18, ); } diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index abf40a4a..f3ea431a 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -5,6 +5,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; +import '../../focus/dpad_navigator.dart'; import '../../mpv/mpv.dart'; import '../../models/plex_media_info.dart'; import '../../models/plex_media_version.dart'; @@ -191,7 +192,7 @@ class DesktopVideoControlsState extends State { FocusNode? leftTarget, FocusNode? rightTarget, }) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + if (!event.isActionable) { return KeyEventResult.ignored; } @@ -249,7 +250,7 @@ class DesktopVideoControlsState extends State { /// Handle key events for timeline navigation KeyEventResult _handleTimelineKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + if (!event.isActionable) { return KeyEventResult.ignored; } diff --git a/lib/widgets/video_controls/sheets/audio_track_sheet.dart b/lib/widgets/video_controls/sheets/audio_track_sheet.dart index e35326d6..81669a53 100644 --- a/lib/widgets/video_controls/sheets/audio_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_track_sheet.dart @@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../mpv/mpv.dart'; import '../../../i18n/strings.g.dart'; +import '../../../utils/track_label_builder.dart'; import 'track_selection_sheet.dart'; /// Bottom sheet for selecting audio tracks @@ -21,22 +22,13 @@ class AudioTrackSheet { icon: Symbols.audiotrack_rounded, extractTracks: (tracks) => tracks?.audio ?? [], getCurrentTrack: (track) => track.audio, - buildLabel: (audioTrack, index) { - final parts = []; - if (audioTrack.title != null && audioTrack.title!.isNotEmpty) { - parts.add(audioTrack.title!); - } - if (audioTrack.language != null && audioTrack.language!.isNotEmpty) { - parts.add(audioTrack.language!.toUpperCase()); - } - if (audioTrack.codec != null && audioTrack.codec!.isNotEmpty) { - parts.add(audioTrack.codec!.toUpperCase()); - } - if (audioTrack.channelsCount != null) { - parts.add('${audioTrack.channelsCount}ch'); - } - return parts.isEmpty ? 'Audio Track ${index + 1}' : parts.join(' ยท '); - }, + buildLabel: (audioTrack, index) => TrackLabelBuilder.buildAudioLabel( + title: audioTrack.title, + language: audioTrack.language, + codec: audioTrack.codec, + channelsCount: audioTrack.channelsCount, + index: index, + ), setTrack: (track) => player.selectAudioTrack(track), onTrackChanged: onTrackChanged, onOpen: onOpen, diff --git a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart index ec4409ca..5b55495b 100644 --- a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart @@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../mpv/mpv.dart'; import '../../../i18n/strings.g.dart'; +import '../../../utils/track_label_builder.dart'; import 'track_selection_sheet.dart'; /// Bottom sheet for selecting subtitle tracks @@ -21,30 +22,12 @@ class SubtitleTrackSheet { icon: Symbols.subtitles_rounded, extractTracks: (tracks) => tracks?.subtitle ?? [], getCurrentTrack: (track) => track.subtitle, - buildLabel: (subtitle, index) { - final parts = []; - if (subtitle.title != null && subtitle.title!.isNotEmpty) { - parts.add(subtitle.title!); - } - if (subtitle.language != null && subtitle.language!.isNotEmpty) { - parts.add(subtitle.language!.toUpperCase()); - } - if (subtitle.codec != null && subtitle.codec!.isNotEmpty) { - // Format codec names nicely - String codecName = subtitle.codec!.toUpperCase(); - if (codecName == 'SUBRIP') { - codecName = 'SRT'; - } else if (codecName == 'DVD_SUBTITLE') { - codecName = 'DVD'; - } else if (codecName == 'ASS' || codecName == 'SSA') { - codecName = codecName; // Keep as-is - } else if (codecName == 'WEBVTT') { - codecName = 'VTT'; - } - parts.add(codecName); - } - return parts.isEmpty ? 'Track ${index + 1}' : parts.join(' ยท '); - }, + buildLabel: (subtitle, index) => TrackLabelBuilder.buildSubtitleLabel( + title: subtitle.title, + language: subtitle.language, + codec: subtitle.codec, + index: index, + ), setTrack: (track) => player.selectSubtitleTrack(track), onTrackChanged: onTrackChanged, showOffOption: true, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 556ad0b4..84522097 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -10,12 +10,14 @@ import 'package:flutter/services.dart' SystemChrome, DeviceOrientation, LogicalKeyboardKey, + KeyEvent, KeyDownEvent, KeyRepeatEvent; import 'package:macos_window_utils/macos_window_utils.dart'; import 'package:window_manager/window_manager.dart'; import '../../mpv/mpv.dart'; +import '../../focus/dpad_navigator.dart'; import '../../services/plex_client.dart'; import '../../services/plex_api_cache.dart'; @@ -26,7 +28,9 @@ import '../../screens/video_player_screen.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart'; import '../../utils/platform_detector.dart'; +import '../../utils/plex_cache_parser.dart'; import '../../utils/player_utils.dart'; +import '../../theme/theme_helper.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import '../../utils/video_control_icons.dart'; @@ -241,10 +245,7 @@ class _PlexVideoControlsState extends State _controlsFullyHidden = false; }); _hideTimer?.cancel(); - // On Linux, ensure Flutter view is visible - if (Platform.isLinux) { - widget.player.setControlsVisible(true); - } + _showLinuxControls(); } }); } @@ -455,7 +456,8 @@ class _PlexVideoControlsState extends State // Reset debounce timer - resume when resizing stops _resizeDebounceTimer?.cancel(); - _resizeDebounceTimer = Timer(const Duration(milliseconds: 300), () { + final slowDuration = tokens(context).slow; + _resizeDebounceTimer = Timer(slowDuration, () { if (_wasPlayingBeforeResize && mounted) { widget.player.play(); } @@ -477,18 +479,7 @@ class _PlexVideoControlsState extends State if (Platform.isMacOS) { _updateTrafficLightVisibility(); } - // On Linux, fully hide after animation completes (200ms) - if (Platform.isLinux) { - Future.delayed(const Duration(milliseconds: 250), () { - if (mounted && !_showControls) { - setState(() { - _controlsFullyHidden = true; - }); - // Hide Flutter view to show only video - widget.player.setControlsVisible(false); - } - }); - } + _hideLinuxControlsAfterAnimation(); } }); } @@ -501,32 +492,41 @@ class _PlexVideoControlsState extends State } } + /// Show controls immediately on Linux + void _showLinuxControls() { + if (Platform.isLinux) { + widget.player.setControlsVisible(true); + } + } + + /// Hide controls on Linux after animation completes (250ms delay) + void _hideLinuxControlsAfterAnimation() { + if (Platform.isLinux) { + Future.delayed(const Duration(milliseconds: 250), () { + if (mounted && !_showControls) { + setState(() { + _controlsFullyHidden = true; + }); + widget.player.setControlsVisible(false); + } + }); + } + } + void _toggleControls() { setState(() { _showControls = !_showControls; if (_showControls) { _controlsFullyHidden = false; - // On Linux, show Flutter view when controls are shown - if (Platform.isLinux) { - widget.player.setControlsVisible(true); - } + _showLinuxControls(); } }); if (_showControls) { _startHideTimer(); // Cancel auto-skip when user manually shows controls _cancelAutoSkipTimer(); - } else if (Platform.isLinux) { - // On Linux, fully hide after animation completes (200ms) - Future.delayed(const Duration(milliseconds: 250), () { - if (mounted && !_showControls) { - setState(() { - _controlsFullyHidden = true; - }); - // Hide Flutter view to show only video - widget.player.setControlsVisible(false); - } - }); + } else { + _hideLinuxControlsAfterAnimation(); } // On macOS, hide/show traffic lights with controls @@ -627,13 +627,7 @@ class _PlexVideoControlsState extends State final chapters = []; final markers = []; - Map? metadataJson; - if (cached['MediaContainer']?['Metadata'] != null && - (cached['MediaContainer']['Metadata'] as List).isNotEmpty) { - metadataJson = - cached['MediaContainer']['Metadata'][0] as Map; - } - + final metadataJson = PlexCacheParser.extractFirstMetadata(cached); if (metadataJson != null) { // Parse chapters if (metadataJson['Chapter'] != null) { @@ -771,6 +765,9 @@ class _PlexVideoControlsState extends State _doubleTapFeedbackOpacity = 1.0; }); + // Capture duration before timer to avoid context access in callback + final slowDuration = tokens(context).slow; + // Fade out after delay _feedbackTimer = Timer(const Duration(milliseconds: 500), () { if (mounted) { @@ -778,7 +775,7 @@ class _PlexVideoControlsState extends State _doubleTapFeedbackOpacity = 0.0; }); - Timer(const Duration(milliseconds: 300), () { + Timer(slowDuration, () { if (mounted) { setState(() { _showDoubleTapFeedback = false; @@ -871,9 +868,7 @@ class _PlexVideoControlsState extends State _showControls = true; _controlsFullyHidden = false; }); - if (Platform.isLinux) { - widget.player.setControlsVisible(true); - } + _showLinuxControls(); if (Platform.isMacOS) { _updateTrafficLightVisibility(); } @@ -897,16 +892,7 @@ class _PlexVideoControlsState extends State if (Platform.isMacOS) { _updateTrafficLightVisibility(); } - if (Platform.isLinux) { - Future.delayed(const Duration(milliseconds: 250), () { - if (mounted && !_showControls) { - setState(() { - _controlsFullyHidden = true; - }); - widget.player.setControlsVisible(false); - } - }); - } + _hideLinuxControlsAfterAnimation(); } } @@ -921,7 +907,7 @@ class _PlexVideoControlsState extends State autofocus: true, onKeyEvent: (node, event) { // Only handle KeyDown and KeyRepeat events - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + if (!event.isActionable) { return KeyEventResult.ignored; } @@ -992,10 +978,7 @@ class _PlexVideoControlsState extends State _showControls = true; _controlsFullyHidden = false; }); - // On Linux, show Flutter view when controls are shown - if (Platform.isLinux) { - widget.player.setControlsVisible(true); - } + _showLinuxControls(); _startHideTimer(); // On macOS, show traffic lights when controls appear if (Platform.isMacOS) { @@ -1207,7 +1190,7 @@ class _PlexVideoControlsState extends State child: IgnorePointer( child: AnimatedOpacity( opacity: _doubleTapFeedbackOpacity, - duration: const Duration(milliseconds: 300), + duration: tokens(context).slow, child: _buildDoubleTapFeedback(), ), ), @@ -1219,7 +1202,7 @@ class _PlexVideoControlsState extends State bottom: isMobile ? 80 : 115, child: AnimatedOpacity( opacity: 1.0, - duration: const Duration(milliseconds: 300), + duration: tokens(context).slow, child: _buildSkipMarkerButton(), ), ), @@ -1267,14 +1250,14 @@ class _PlexVideoControlsState extends State // Always perform the skip action when tapped _performAutoSkip(); }, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: Stack( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), @@ -1303,7 +1286,7 @@ class _PlexVideoControlsState extends State if (isAutoSkipActive && shouldShowAutoSkip) Positioned.fill( child: ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: Row( children: [ Expanded( @@ -1311,7 +1294,8 @@ class _PlexVideoControlsState extends State child: Container( decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), + borderRadius: + BorderRadius.circular(tokens(context).radiusSm), ), ), ), diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 6ba21a0b..262a8f41 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; +import '../../../focus/dpad_navigator.dart'; import '../../../mpv/mpv.dart'; import '../../../models/plex_media_info.dart'; import '../../../models/plex_media_version.dart'; @@ -84,7 +85,7 @@ class TrackChapterControls extends StatelessWidget { int index, int totalButtons, ) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + if (!event.isActionable) { return KeyEventResult.ignored; } @@ -117,6 +118,39 @@ class TrackChapterControls extends StatelessWidget { return KeyEventResult.ignored; } + /// Build a track control button with consistent focus handling + Widget _buildTrackButton({ + required int buttonIndex, + required IconData icon, + required String semanticLabel, + required VoidCallback? onPressed, + required Tracks? tracks, + required bool isMobile, + required bool isDesktop, + String? tooltip, + bool isActive = false, + }) { + return VideoControlButton( + icon: icon, + tooltip: tooltip, + semanticLabel: semanticLabel, + isActive: isActive, + focusNode: focusNodes != null && focusNodes!.length > buttonIndex + ? focusNodes![buttonIndex] + : null, + onKeyEvent: focusNodes != null + ? (node, event) => _handleButtonKeyEvent( + node, + event, + buttonIndex, + _getButtonCount(tracks, isMobile, isDesktop), + ) + : null, + onFocusChange: onFocusChange, + onPressed: onPressed, + ); + } + @override Widget build(BuildContext context) { return StreamBuilder( @@ -142,23 +176,14 @@ class TrackChapterControls extends StatelessWidget { sleepTimer.isActive || audioSyncOffset != 0 || subtitleSyncOffset != 0; - final currentIndex = 0; - return VideoControlButton( + return _buildTrackButton( + buttonIndex: 0, icon: Symbols.tune_rounded, isActive: isActive, semanticLabel: t.videoControls.settingsButton, - focusNode: focusNodes != null && focusNodes!.isNotEmpty - ? focusNodes![currentIndex] - : null, - onKeyEvent: focusNodes != null - ? (node, event) => _handleButtonKeyEvent( - node, - event, - currentIndex, - _getButtonCount(tracks, isMobile, isDesktop), - ) - : null, - onFocusChange: onFocusChange, + tracks: tracks, + isMobile: isMobile, + isDesktop: isDesktop, onPressed: () async { await VideoSettingsSheet.show( context, @@ -180,21 +205,13 @@ class TrackChapterControls extends StatelessWidget { if (_hasMultipleAudioTracks(tracks)) { final currentIndex = buttonIndex; buttons.add( - VideoControlButton( + _buildTrackButton( + buttonIndex: currentIndex, icon: Symbols.audiotrack_rounded, semanticLabel: t.videoControls.audioTrackButton, - focusNode: focusNodes != null && focusNodes!.length > currentIndex - ? focusNodes![currentIndex] - : null, - onKeyEvent: focusNodes != null - ? (node, event) => _handleButtonKeyEvent( - node, - event, - currentIndex, - _getButtonCount(tracks, isMobile, isDesktop), - ) - : null, - onFocusChange: onFocusChange, + tracks: tracks, + isMobile: isMobile, + isDesktop: isDesktop, onPressed: () => AudioTrackSheet.show( context, player, @@ -211,21 +228,13 @@ class TrackChapterControls extends StatelessWidget { if (_hasSubtitles(tracks)) { final currentIndex = buttonIndex; buttons.add( - VideoControlButton( + _buildTrackButton( + buttonIndex: currentIndex, icon: Symbols.subtitles_rounded, semanticLabel: t.videoControls.subtitlesButton, - focusNode: focusNodes != null && focusNodes!.length > currentIndex - ? focusNodes![currentIndex] - : null, - onKeyEvent: focusNodes != null - ? (node, event) => _handleButtonKeyEvent( - node, - event, - currentIndex, - _getButtonCount(tracks, isMobile, isDesktop), - ) - : null, - onFocusChange: onFocusChange, + tracks: tracks, + isMobile: isMobile, + isDesktop: isDesktop, onPressed: () => SubtitleTrackSheet.show( context, player, @@ -242,21 +251,13 @@ class TrackChapterControls extends StatelessWidget { if (chapters.isNotEmpty) { final currentIndex = buttonIndex; buttons.add( - VideoControlButton( + _buildTrackButton( + buttonIndex: currentIndex, icon: Symbols.video_library_rounded, semanticLabel: t.videoControls.chaptersButton, - focusNode: focusNodes != null && focusNodes!.length > currentIndex - ? focusNodes![currentIndex] - : null, - onKeyEvent: focusNodes != null - ? (node, event) => _handleButtonKeyEvent( - node, - event, - currentIndex, - _getButtonCount(tracks, isMobile, isDesktop), - ) - : null, - onFocusChange: onFocusChange, + tracks: tracks, + isMobile: isMobile, + isDesktop: isDesktop, onPressed: () => ChapterSheet.show( context, player, @@ -275,21 +276,13 @@ class TrackChapterControls extends StatelessWidget { if (availableVersions.length > 1 && onSwitchVersion != null) { final currentIndex = buttonIndex; buttons.add( - VideoControlButton( + _buildTrackButton( + buttonIndex: currentIndex, icon: Symbols.video_file_rounded, semanticLabel: t.videoControls.versionsButton, - focusNode: focusNodes != null && focusNodes!.length > currentIndex - ? focusNodes![currentIndex] - : null, - onKeyEvent: focusNodes != null - ? (node, event) => _handleButtonKeyEvent( - node, - event, - currentIndex, - _getButtonCount(tracks, isMobile, isDesktop), - ) - : null, - onFocusChange: onFocusChange, + tracks: tracks, + isMobile: isMobile, + isDesktop: isDesktop, onPressed: () => VersionSheet.show( context, availableVersions, @@ -307,22 +300,14 @@ class TrackChapterControls extends StatelessWidget { if (onCycleBoxFitMode != null) { final currentIndex = buttonIndex; buttons.add( - VideoControlButton( + _buildTrackButton( + buttonIndex: currentIndex, icon: _getBoxFitIcon(boxFitMode), tooltip: _getBoxFitTooltip(boxFitMode), semanticLabel: t.videoControls.aspectRatioButton, - focusNode: focusNodes != null && focusNodes!.length > currentIndex - ? focusNodes![currentIndex] - : null, - onKeyEvent: focusNodes != null - ? (node, event) => _handleButtonKeyEvent( - node, - event, - currentIndex, - _getButtonCount(tracks, isMobile, isDesktop), - ) - : null, - onFocusChange: onFocusChange, + tracks: tracks, + isMobile: isMobile, + isDesktop: isDesktop, onPressed: onCycleBoxFitMode, ), ); @@ -333,7 +318,8 @@ class TrackChapterControls extends StatelessWidget { if (isMobile) { final currentIndex = buttonIndex; buttons.add( - VideoControlButton( + _buildTrackButton( + buttonIndex: currentIndex, icon: isRotationLocked ? Symbols.screen_lock_rotation_rounded : Symbols.screen_rotation_rounded, @@ -341,18 +327,9 @@ class TrackChapterControls extends StatelessWidget { ? t.videoControls.unlockRotation : t.videoControls.lockRotation, semanticLabel: t.videoControls.rotationLockButton, - focusNode: focusNodes != null && focusNodes!.length > currentIndex - ? focusNodes![currentIndex] - : null, - onKeyEvent: focusNodes != null - ? (node, event) => _handleButtonKeyEvent( - node, - event, - currentIndex, - _getButtonCount(tracks, isMobile, isDesktop), - ) - : null, - onFocusChange: onFocusChange, + tracks: tracks, + isMobile: isMobile, + isDesktop: isDesktop, onPressed: onToggleRotationLock, ), ); @@ -363,25 +340,17 @@ class TrackChapterControls extends StatelessWidget { if (isDesktop) { final currentIndex = buttonIndex; buttons.add( - VideoControlButton( + _buildTrackButton( + buttonIndex: currentIndex, icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded, semanticLabel: isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton, - focusNode: focusNodes != null && focusNodes!.length > currentIndex - ? focusNodes![currentIndex] - : null, - onKeyEvent: focusNodes != null - ? (node, event) => _handleButtonKeyEvent( - node, - event, - currentIndex, - _getButtonCount(tracks, isMobile, isDesktop), - ) - : null, - onFocusChange: onFocusChange, + tracks: tracks, + isMobile: isMobile, + isDesktop: isDesktop, onPressed: onToggleFullscreen, ), ); diff --git a/lib/widgets/video_controls/widgets/volume_control.dart b/lib/widgets/video_controls/widgets/volume_control.dart index db1ab030..cbcba8a2 100644 --- a/lib/widgets/video_controls/widgets/volume_control.dart +++ b/lib/widgets/video_controls/widgets/volume_control.dart @@ -73,7 +73,7 @@ class _VolumeControlState extends State { } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + if (!event.isActionable) { return KeyEventResult.ignored; }