From 4f736c555bc38627bb5cef88ee329740d2b2b5ea Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 14 Mar 2026 05:40:14 +0100 Subject: [PATCH] fix: make PlexMetadata type/key/title nullable for folder items Plex API returns Directory entries without type field, causing TypeError crash in fromJson. Made fields nullable and migrated callers to use mediaType enum and displayTitle getter instead of raw string access. --- lib/models/plex_metadata.dart | 25 ++++++------ lib/models/plex_metadata.g.dart | 6 +-- lib/providers/download_provider.dart | 20 +++++----- lib/screens/collection_detail_screen.dart | 6 +-- lib/screens/discover_screen.dart | 2 +- lib/screens/hub_detail_screen.dart | 4 +- lib/screens/libraries/folder_tree_item.dart | 25 +++++------- lib/screens/libraries/folder_tree_view.dart | 28 ++++++------- lib/screens/livetv/tabs/whats_on_tab.dart | 5 ++- lib/screens/media_detail_screen.dart | 14 +++---- lib/screens/playlist/playlist_item_card.dart | 8 ++-- lib/screens/video_player_screen.dart | 4 +- lib/services/discord_rpc_service.dart | 12 +++--- lib/services/download_manager_service.dart | 39 +++++++++---------- lib/services/download_storage_service.dart | 6 +-- lib/services/media_controls_manager.dart | 2 +- lib/services/watch_next_service.dart | 4 +- lib/utils/content_utils.dart | 2 +- lib/utils/deletion_notifier.dart | 2 +- lib/utils/watch_state_notifier.dart | 4 +- lib/widgets/download_tree_view.dart | 4 +- lib/widgets/episode_card.dart | 2 +- lib/widgets/media_card.dart | 4 +- lib/widgets/media_context_menu.dart | 30 +++++++------- .../video_controls/sheets/queue_sheet.dart | 4 +- .../video_controls/widgets/content_strip.dart | 4 +- .../widgets/video_controls_header.dart | 8 ++-- 27 files changed, 134 insertions(+), 140 deletions(-) diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index a4a6cb4d..6074547f 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -62,11 +62,11 @@ enum PlexMediaType { class PlexMetadata with MultiServerFields { @JsonKey(readValue: _readRatingKey) final String ratingKey; - final String key; + final String? key; final String? guid; final String? studio; - final String type; - final String title; + final String? type; + final String? title; final String? titleSort; final String? contentRating; final String? summary; @@ -135,7 +135,8 @@ class PlexMetadata with MultiServerFields { /// Parsed media type enum for type-safe comparisons PlexMediaType get mediaType { - return switch (type.toLowerCase()) { + if (type == null) return PlexMediaType.unknown; + return switch (type!.toLowerCase()) { 'movie' => PlexMediaType.movie, 'show' => PlexMediaType.show, 'season' => PlexMediaType.season, @@ -153,11 +154,11 @@ class PlexMetadata with MultiServerFields { PlexMetadata({ required this.ratingKey, - required this.key, + this.key, this.guid, this.studio, - required this.type, - required this.title, + this.type, + this.title, this.titleSort, this.contentRating, this.summary, @@ -366,7 +367,7 @@ class PlexMetadata with MultiServerFields { // Helper to get the display title (show name for episodes/seasons, title otherwise) String get displayTitle { - final itemType = type.toLowerCase(); + final itemType = type?.toLowerCase(); // For episodes and seasons, prefer grandparent title (show name) if ((itemType == 'episode' || itemType == 'season') && grandparentTitle != null) { @@ -376,12 +377,12 @@ class PlexMetadata with MultiServerFields { if (itemType == 'season' && parentTitle != null) { return parentTitle!; } - return title; + return title ?? ''; } // Helper to get the subtitle (episode/season title) String? get displaySubtitle { - final itemType = type.toLowerCase(); + final itemType = type?.toLowerCase(); if (itemType == 'episode' || itemType == 'season') { // If we showed grandparent/parent as title, show this item's title as subtitle @@ -401,7 +402,7 @@ class PlexMetadata with MultiServerFields { /// For movies/shows/seasons in mixed hub context: returns art (16:9 background) /// For other types: returns thumb String? posterThumb({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) { - final itemType = type.toLowerCase(); + final itemType = type?.toLowerCase(); if (itemType == 'episode') { switch (mode) { @@ -436,7 +437,7 @@ class PlexMetadata with MultiServerFields { /// Clips (trailers, extras) always use 16:9. /// Movies, shows, and seasons use 16:9 in mixed hub context with episodeThumbnail mode. bool usesWideAspectRatio(EpisodePosterMode mode, {bool mixedHubContext = false}) { - final itemType = type.toLowerCase(); + final itemType = type?.toLowerCase(); // Clips (trailers, extras) are always 16:9 if (itemType == 'clip') return true; if (itemType == 'episode' && mode == EpisodePosterMode.episodeThumbnail) { diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index ecf8e4fd..8bfe8dcf 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -8,11 +8,11 @@ part of 'plex_metadata.dart'; PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( ratingKey: _readRatingKey(json, 'ratingKey') as String, - key: json['key'] as String, + key: json['key'] as String?, guid: json['guid'] as String?, studio: json['studio'] as String?, - type: json['type'] as String, - title: json['title'] as String, + type: json['type'] as String?, + title: json['title'] as String?, titleSort: json['titleSort'] as String?, contentRating: json['contentRating'] as String?, summary: json['summary'] as String?, diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index a2bb243a..6c79437c 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -515,10 +515,10 @@ class DownloadProvider extends ChangeNotifier { } // We have metadata, check type - final type = meta.type.toLowerCase(); - if (type == 'show') { + final mt = meta.mediaType; + if (mt == PlexMediaType.show) { return getAggregateProgressForShow(serverId, ratingKey); - } else if (type == 'season') { + } else if (mt == PlexMediaType.season) { return getAggregateProgressForSeason(serverId, ratingKey); } @@ -606,19 +606,19 @@ class DownloadProvider extends ChangeNotifier { _queueing.add(globalKey); notifyListeners(); - final type = metadata.type.toLowerCase(); + final mt = metadata.mediaType; - if (type == 'movie' || type == 'episode') { + if (mt == PlexMediaType.movie || mt == PlexMediaType.episode) { // Direct download of a single item await _queueSingleDownload(metadata, client); return 1; - } else if (type == 'show') { + } else if (mt == PlexMediaType.show) { // Store show metadata so getProgress() can identify it as a show _metadata[globalKey] = metadata; // Download all episodes from all seasons return await _queueShowDownload(metadata, client); - } else if (type == 'season') { + } else if (mt == PlexMediaType.season) { // Store season metadata so getProgress() can identify it as a season _metadata[globalKey] = metadata; @@ -765,11 +765,11 @@ class DownloadProvider extends ChangeNotifier { /// Used for resuming partial downloads /// Returns the number of episodes queued Future queueMissingEpisodes(PlexMetadata metadata, PlexClient client) async { - final type = metadata.type.toLowerCase(); + final mt = metadata.mediaType; - if (type == 'show') { + if (mt == PlexMediaType.show) { return await _queueMissingShowEpisodes(metadata, client); - } else if (type == 'season') { + } else if (mt == PlexMediaType.season) { return await _queueMissingSeasonEpisodes(metadata, client); } else { throw Exception('queueMissingEpisodes only supports shows/seasons'); diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 4f9e4efe..0fd68677 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -31,7 +31,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen widget.collection; @override - String get title => widget.collection.title; + String get title => widget.collection.title!; @override String get emptyMessage => t.collections.empty; @@ -93,7 +93,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen Widget _buildHeroItem(PlexMetadata heroItem, double heroHeight) { final isEpisode = heroItem.isEpisode; - final showName = heroItem.grandparentTitle ?? heroItem.title; + final showName = heroItem.grandparentTitle ?? heroItem.displayTitle; final screenWidth = MediaQuery.of(context).size.width; final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth); diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index cd259f1c..f893168d 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -180,7 +180,7 @@ class _HubDetailScreenState extends State switch (sortKey) { case 'titleSort': case 'title': - comparison = a.title.compareTo(b.title); + comparison = (a.title ?? '').compareTo(b.title ?? ''); break; case 'addedAt': comparison = (a.addedAt ?? 0).compareTo(b.addedAt ?? 0); @@ -193,7 +193,7 @@ class _HubDetailScreenState extends State comparison = (a.rating ?? 0).compareTo(b.rating ?? 0); break; default: - comparison = a.title.compareTo(b.title); + comparison = (a.title ?? '').compareTo(b.title ?? ''); } return _isSortDescending ? -comparison : comparison; diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index 640c96a5..23c8d934 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -42,21 +42,14 @@ class FolderTreeItem extends StatelessWidget { } // File icons based on type - final type = item.type.toLowerCase(); - switch (type) { - case 'movie': - return Symbols.movie_rounded; - case 'show': - return Symbols.tv_rounded; - case 'season': - return Symbols.video_library_rounded; - case 'episode': - return Symbols.play_circle_rounded; - case 'collection': - return Symbols.collections_rounded; - default: - return Symbols.insert_drive_file_rounded; - } + return switch (item.mediaType) { + PlexMediaType.movie => Symbols.movie_rounded, + PlexMediaType.show => Symbols.tv_rounded, + PlexMediaType.season => Symbols.video_library_rounded, + PlexMediaType.episode => Symbols.play_circle_rounded, + PlexMediaType.collection => Symbols.collections_rounded, + _ => Symbols.insert_drive_file_rounded, + }; } void _handleTap() { @@ -104,7 +97,7 @@ class FolderTreeItem extends StatelessWidget { // Item title Expanded( child: Text( - item.title, + item.displayTitle, style: TextStyle(fontSize: 14, fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index 41ad612e..bb2a612e 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -87,32 +87,32 @@ class _FolderTreeViewState extends State { Future _loadFolderChildren(PlexMetadata folder) async { // Already loading this folder - if (_loadingFolders.contains(folder.key)) return; + if (_loadingFolders.contains(folder.key!)) return; // Already loaded and cached - if (_childrenCache.containsKey(folder.key)) { + if (_childrenCache.containsKey(folder.key!)) { setState(() { - _expandedFolders.add(folder.key); + _expandedFolders.add(folder.key!); }); return; } setState(() { - _loadingFolders.add(folder.key); + _loadingFolders.add(folder.key!); }); try { final client = context.getClientForServer(widget.serverId!); // Items are automatically tagged with server info by PlexClient - final children = await client.getFolderChildren(folder.key); + final children = await client.getFolderChildren(folder.key!); if (!mounted) return; setState(() { - _childrenCache[folder.key] = children; - _expandedFolders.add(folder.key); - _loadingFolders.remove(folder.key); + _childrenCache[folder.key!] = children; + _expandedFolders.add(folder.key!); + _loadingFolders.remove(folder.key!); }); appLogger.d('Loaded ${children.length} children for folder: ${folder.title}'); @@ -121,7 +121,7 @@ class _FolderTreeViewState extends State { appLogger.e('Failed to load folder children', error: e); setState(() { - _loadingFolders.remove(folder.key); + _loadingFolders.remove(folder.key!); }); if (mounted) { @@ -131,9 +131,9 @@ class _FolderTreeViewState extends State { } void _toggleFolder(PlexMetadata folder) { - if (_expandedFolders.contains(folder.key)) { + if (_expandedFolders.contains(folder.key!)) { setState(() { - _expandedFolders.remove(folder.key); + _expandedFolders.remove(folder.key!); }); } else { _loadFolderChildren(folder); @@ -147,19 +147,19 @@ class _FolderTreeViewState extends State { Future _handleFolderPlay(PlexMetadata folder) async { final client = context.getClientForServer(widget.serverId!); final launcher = PlayQueueLauncher(context: context, client: client, serverId: widget.serverId); - await launcher.launchFromFolder(folderKey: folder.key, shuffle: false); + await launcher.launchFromFolder(folderKey: folder.key!, shuffle: false); } Future _handleFolderShuffle(PlexMetadata folder) async { final client = context.getClientForServer(widget.serverId!); final launcher = PlayQueueLauncher(context: context, client: client, serverId: widget.serverId); - await launcher.launchFromFolder(folderKey: folder.key, shuffle: true); + await launcher.launchFromFolder(folderKey: folder.key!, shuffle: true); } bool _isFolder(PlexMetadata item) { // Folders typically don't have a specific type or might have special indicators // Check for common folder indicators - return item.key.contains('/folder') || item.type.isEmpty || item.type.toLowerCase() == 'folder'; + return item.key?.contains('/folder') == true || item.type == null || item.type!.isEmpty || item.mediaType == PlexMediaType.unknown; } List _buildTreeItems(List items, int depth, [String parentPath = '']) { diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index e888fee2..1641d17d 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -11,6 +11,7 @@ import '../../../focus/locked_hub_controller.dart'; import '../../../i18n/strings.g.dart'; import '../../../models/livetv_channel.dart'; import '../../../models/livetv_hub_result.dart'; +import '../../../models/plex_metadata.dart'; import '../../../providers/multi_server_provider.dart'; import '../../../providers/settings_provider.dart'; import '../../../services/settings_service.dart' show LibraryDensity; @@ -168,12 +169,12 @@ class WhatsOnTabState extends State { if (entry.program.isCurrentlyAiring && channel != null) { // Live → play directly _tuneChannel(channel); - } else if (entry.metadata.type.toLowerCase() == 'show') { + } else if (entry.metadata.mediaType == PlexMediaType.show) { // Show with upcoming episodes → show full schedule Navigator.of(context).push( MaterialPageRoute( builder: (_) => LiveTvShowScheduleScreen( - showTitle: entry.metadata.title, + showTitle: entry.metadata.displayTitle, serverId: entry.metadata.serverId ?? '', channels: widget.channels, ), diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index a53d3d49..c94db2f1 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -740,7 +740,7 @@ class _MediaDetailScreenState extends State final confirmed = await showDeleteConfirmation( context, title: t.downloads.deleteDownload, - message: t.downloads.deleteConfirm(title: metadata.title), + message: t.downloads.deleteConfirm(title: metadata.displayTitle), ); if (confirmed && context.mounted) { @@ -1579,7 +1579,7 @@ class _MediaDetailScreenState extends State onSecondaryTapDown: (details) => tapPosition = details.globalPosition, onSecondaryTap: () => _showSeasonTabContextMenu(index, position: tapPosition), child: FocusableTabChip( - label: season.title, + label: season.title!, isSelected: index == _selectedSeasonIndex, focusNode: _seasonTabFocusNodes.length > index ? _seasonTabFocusNodes[index] : null, onSelect: () { @@ -2251,11 +2251,11 @@ class _MediaDetailScreenState extends State fit: BoxFit.contain, alignment: Alignment.centerLeft, errorBuilder: (context, error, stackTrace) => - _buildTitleText(context, metadata.title), + _buildTitleText(context, metadata.displayTitle), ); } // Offline but no local file - show title text - return _buildTitleText(context, metadata.title); + return _buildTitleText(context, metadata.displayTitle); } // Online - use network image @@ -2280,7 +2280,7 @@ class _MediaDetailScreenState extends State placeholder: (context, url) => Align( alignment: Alignment.centerLeft, child: Text( - metadata.title, + metadata.displayTitle, style: Theme.of(context).textTheme.displaySmall?.copyWith( color: Colors.white.withValues(alpha: 0.3), fontWeight: FontWeight.bold, @@ -2293,7 +2293,7 @@ class _MediaDetailScreenState extends State ), ), errorWidget: (context, url, error) { - return _buildTitleText(context, metadata.title); + return _buildTitleText(context, metadata.displayTitle); }, ), sigma: 10, @@ -2304,7 +2304,7 @@ class _MediaDetailScreenState extends State ) else Text( - metadata.title, + metadata.displayTitle, style: Theme.of(context).textTheme.displaySmall?.copyWith( color: Colors.white, fontWeight: FontWeight.bold, diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index c10d61c0..7b676356 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -237,9 +237,9 @@ class _PlaylistItemCardState extends State { } String _buildSubtitle() { - final itemType = widget.item.type.toLowerCase(); + final itemType = widget.item.mediaType; - if (itemType == 'episode') { + if (itemType == PlexMediaType.episode) { // For episodes, show "S#E# - Episode Title" final season = widget.item.parentIndex; final episode = widget.item.index; @@ -247,7 +247,7 @@ class _PlaylistItemCardState extends State { return 'S${season}E$episode${widget.item.displaySubtitle != null ? ' - ${widget.item.displaySubtitle}' : ''}'; } return widget.item.displaySubtitle ?? t.discover.tvShow; - } else if (itemType == 'movie') { + } else if (itemType == PlexMediaType.movie) { // For movies, show year and edition final year = widget.item.year?.toString(); if (year != null && widget.item.editionTitle != null) { @@ -257,6 +257,6 @@ class _PlaylistItemCardState extends State { } // Default to type - return widget.item.type; + return widget.item.mediaType.name; } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index e67610af..8ba3e2de 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1485,7 +1485,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin watchTogether.setCurrentMedia( ratingKey: targetMetadata.ratingKey, serverId: targetMetadata.serverId!, - mediaTitle: targetMetadata.title, + mediaTitle: targetMetadata.displayTitle, ); } } catch (e) { @@ -2898,7 +2898,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin ) else Text( - _nextEpisode!.title, + _nextEpisode!.title!, style: const TextStyle( color: Colors.white, fontSize: 14, diff --git a/lib/services/discord_rpc_service.dart b/lib/services/discord_rpc_service.dart index e3fe6c61..b6e25597 100644 --- a/lib/services/discord_rpc_service.dart +++ b/lib/services/discord_rpc_service.dart @@ -351,7 +351,7 @@ class DiscordRPCService { timestamps: _buildTimestamps(), statusDisplayType: DiscordStatusDisplayType.details, largeAsset: _cachedThumbnailUrl != null - ? DiscordAsset(url: _cachedThumbnailUrl!, text: metadata.grandparentTitle ?? metadata.title) + ? DiscordAsset(url: _cachedThumbnailUrl!, text: metadata.grandparentTitle ?? metadata.title!) : null, ), ); @@ -392,14 +392,14 @@ class DiscordRPCService { switch (metadata.mediaType) { case PlexMediaType.movie: final year = metadata.year != null ? ' (${metadata.year})' : ''; - return metadata.title + year; + return metadata.title! + year; case PlexMediaType.episode: // Show: "Show Name" or just episode title if no show name - return metadata.grandparentTitle ?? metadata.title; + return metadata.grandparentTitle ?? metadata.title!; default: - return metadata.title; + return metadata.title!; } } @@ -411,9 +411,9 @@ class DiscordRPCService { final season = metadata.parentIndex; final episode = metadata.index; if (season != null && episode != null) { - return 'S$season E$episode - ${metadata.title}'; + return 'S$season E$episode - ${metadata.title!}'; } - return metadata.title; + return metadata.title!; case PlexMediaType.movie: return metadata.studio; diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 6245b873..079a608c 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -341,7 +341,7 @@ class DownloadManagerService { serverId: metadata.serverId!, ratingKey: metadata.ratingKey, globalKey: globalKey, - type: metadata.type, + type: metadata.type ?? '', parentRatingKey: metadata.parentRatingKey, grandparentRatingKey: metadata.grandparentRatingKey, status: DownloadStatus.queued.index, @@ -446,8 +446,8 @@ class DownloadManagerService { // Build display name for notifications final displayName = metadata.type == 'episode' - ? '${metadata.grandparentTitle ?? metadata.title} - ${metadata.title}' - : metadata.title; + ? '${metadata.grandparentTitle ?? metadata.displayTitle} - ${metadata.displayTitle}' + : metadata.displayTitle; // Get WiFi-only setting for native enforcement final settings = await SettingsService.getInstance(); @@ -1234,7 +1234,7 @@ class DownloadManagerService { // Emit initial progress _emitDeletionProgress( - DeletionProgress(globalKey: globalKey, itemTitle: metadata.title, currentItem: 0, totalItems: totalItems), + DeletionProgress(globalKey: globalKey, itemTitle: metadata.displayTitle, currentItem: 0, totalItems: totalItems), ); // Delete files from storage (with progress updates) @@ -1250,7 +1250,7 @@ class DownloadManagerService { _emitDeletionProgress( DeletionProgress( globalKey: globalKey, - itemTitle: metadata.title, + itemTitle: metadata.displayTitle, currentItem: totalItems, totalItems: totalItems, ), @@ -1264,17 +1264,14 @@ class DownloadManagerService { /// Calculate total items to delete (for progress tracking) Future _getTotalItemsToDelete(PlexMetadata metadata, String _) async { - switch (metadata.type.toLowerCase()) { - case 'episode': - return 1; // Single episode - case 'movie': - return 1; // Single movie - case 'season': - // Count episodes in season + switch (metadata.mediaType) { + case PlexMediaType.episode: + case PlexMediaType.movie: + return 1; + case PlexMediaType.season: final episodes = await _database.getEpisodesBySeason(metadata.ratingKey); return episodes.length; - case 'show': - // Count all episodes in show + case PlexMediaType.show: final episodes = await _database.getEpisodesByShow(metadata.ratingKey); return episodes.length; default: @@ -1301,17 +1298,17 @@ class DownloadManagerService { } // Delete based on type - switch (metadata.type.toLowerCase()) { - case 'episode': + switch (metadata.mediaType) { + case PlexMediaType.episode: await _deleteEpisodeFiles(metadata, serverId); break; - case 'season': + case PlexMediaType.season: await _deleteSeasonFiles(metadata, serverId); break; - case 'show': + case PlexMediaType.show: await _deleteShowFiles(metadata, serverId); break; - case 'movie': + case PlexMediaType.movie: await _deleteMovieFiles(metadata, serverId); break; default: @@ -1447,7 +1444,7 @@ class DownloadManagerService { episodes: episodesInSeason, serverId: serverId, parentKey: season.ratingKey, - parentTitle: season.title, + parentTitle: season.displayTitle, ); final seasonDir = await _storageService.getSeasonDirectory(season, showYear: showYear); @@ -1510,7 +1507,7 @@ class DownloadManagerService { episodes: episodesInShow, serverId: serverId, parentKey: show.ratingKey, - parentTitle: show.title, + parentTitle: show.displayTitle, ); final showDir = await _storageService.getShowDirectory(show); diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index b3e10bf3..7676929d 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -65,7 +65,7 @@ class DownloadStorageService { String _formatEpisodeFileName(PlexMetadata episode) { final season = padNumber(episode.parentIndex ?? 0, 2); final ep = padNumber(episode.index ?? 0, 2); - final episodeName = _sanitizeFileName(episode.title); + final episodeName = _sanitizeFileName(episode.title!); return 'S${season}E$ep - $episodeName'; } @@ -246,13 +246,13 @@ class DownloadStorageService { /// Get the folder name for a movie: "Movie Name (YYYY)" String _getMovieFolderName(PlexMetadata movie) { - return _formatTitleWithYear(movie.title, movie.year); + return _formatTitleWithYear(movie.title!, movie.year); } /// Get the folder name for a TV show: "Show Name (YYYY)" /// [showYear]: Pass explicitly for episodes (episode.year may differ from show's year) String _getShowFolderName(PlexMetadata metadata, {int? showYear}) { - final title = metadata.grandparentTitle ?? metadata.title; + final title = metadata.grandparentTitle ?? metadata.title!; final year = showYear ?? metadata.year; return _formatTitleWithYear(title, year); } diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index c07a6fb8..7248d156 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -53,7 +53,7 @@ class MediaControlsManager { // Update OS media controls await OsMediaControls.setMetadata( MediaMetadata( - title: metadata.title, + title: metadata.title!, artist: _buildArtist(metadata), artworkUrl: artworkUrl, duration: duration, diff --git a/lib/services/watch_next_service.dart b/lib/services/watch_next_service.dart index 4c17310a..e9957bd4 100644 --- a/lib/services/watch_next_service.dart +++ b/lib/services/watch_next_service.dart @@ -134,7 +134,7 @@ class WatchNextService { title = item.grandparentTitle!; episodeTitle = item.title; } else { - title = item.title; + title = item.title!; episodeTitle = null; } @@ -148,7 +148,7 @@ class WatchNextService { 'episodeTitle': episodeTitle, 'description': item.summary, 'posterUri': posterUri, - 'type': item.type.toLowerCase(), + 'type': item.mediaType.name, 'duration': item.duration ?? 0, 'lastPlaybackPosition': item.viewOffset ?? 0, 'lastEngagementTime': lastEngagementTime, diff --git a/lib/utils/content_utils.dart b/lib/utils/content_utils.dart index b5d8021b..153d2459 100644 --- a/lib/utils/content_utils.dart +++ b/lib/utils/content_utils.dart @@ -86,7 +86,7 @@ String formatContentRating(String? contentRating) { /// Extension on PlexMetadata for type checking convenience methods extension PlexMetadataType on PlexMetadata { - String get _lowerType => type.toLowerCase(); + String get _lowerType => type?.toLowerCase() ?? ''; bool get isShow => _lowerType == ContentTypes.show; bool get isMovie => _lowerType == ContentTypes.movie; diff --git a/lib/utils/deletion_notifier.dart b/lib/utils/deletion_notifier.dart index cd54bb9c..24dea4de 100644 --- a/lib/utils/deletion_notifier.dart +++ b/lib/utils/deletion_notifier.dart @@ -80,7 +80,7 @@ class DeletionNotifier extends BaseNotifier { ratingKey: metadata.ratingKey, serverId: metadata.serverId ?? '', parentChain: _buildParentChain(metadata), - mediaType: metadata.type, + mediaType: metadata.type ?? '', leafCount: metadata.leafCount ?? 1, isDownloadOnly: isDownloadOnly, ), diff --git a/lib/utils/watch_state_notifier.dart b/lib/utils/watch_state_notifier.dart index 207179ac..249affc1 100644 --- a/lib/utils/watch_state_notifier.dart +++ b/lib/utils/watch_state_notifier.dart @@ -86,7 +86,7 @@ class WatchStateNotifier extends BaseNotifier { serverId: metadata.serverId ?? '', changeType: isNowWatched ? WatchStateChangeType.watched : WatchStateChangeType.unwatched, parentChain: _buildParentChain(metadata), - mediaType: metadata.type, + mediaType: metadata.type ?? '', isNowWatched: isNowWatched, ), ); @@ -103,7 +103,7 @@ class WatchStateNotifier extends BaseNotifier { serverId: metadata.serverId ?? '', changeType: WatchStateChangeType.progressUpdate, parentChain: _buildParentChain(metadata), - mediaType: metadata.type, + mediaType: metadata.type ?? '', viewOffset: viewOffset, isNowWatched: isNowWatched, ), diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index c36c3683..3e0af6e0 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -145,7 +145,7 @@ class _DownloadTreeViewState extends State { movies.add( DownloadTreeNode( key: globalKey, - title: meta.title, + title: meta.displayTitle, type: DownloadNodeType.movie, progress: download.progressPercent, status: download.status, @@ -202,7 +202,7 @@ class _DownloadTreeViewState extends State { if (meta == null) continue; final episodeNumber = meta.index; - final episodeTitle = episodeNumber != null ? 'Episode $episodeNumber - ${meta.title}' : meta.title; + final episodeTitle = episodeNumber != null ? 'Episode $episodeNumber - ${meta.title!}' : meta.title!; episodeNodes.add( DownloadTreeNode( diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index e146dea6..2faecba5 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -357,7 +357,7 @@ class _EpisodeCardState extends State { // Episode title Expanded( child: Text( - widget.episode.title, + widget.episode.title!, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), maxLines: 2, overflow: TextOverflow.ellipsis, diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 24cb89e2..3384f36e 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -518,7 +518,7 @@ class _MediaCardList extends StatelessWidget { color: tokens(context).textMuted.withValues(alpha: 0.85), fontSize: _subtitleFontSize, ); - final episodeTitle = metadata.displaySubtitle ?? metadata.title; + final episodeTitle = metadata.displaySubtitle ?? metadata.displayTitle; final episodeNum = metadata.index != null ? ' E${metadata.index}' : ''; return Row( children: [ @@ -783,7 +783,7 @@ class _MediaCardHelpers { // For episodes, show "S# · Episode Title" with clickable season link if (metadata.isEpisode && metadata.parentIndex != null) { - final episodeTitle = metadata.displaySubtitle ?? metadata.title; + final episodeTitle = metadata.displaySubtitle ?? metadata.displayTitle; if (metadata.parentRatingKey != null) { return Row( children: [ diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index ca51beb5..eee4f8e5 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -327,7 +327,7 @@ class MediaContextMenuState extends State { context, showDragHandle: true, builder: (context) => _FocusableContextMenuSheet( - title: widget.item.title, + title: widget.item.displayTitle, actions: menuActions, focusFirstItem: openedFromKeyboard, ), @@ -597,7 +597,7 @@ class MediaContextMenuState extends State { await OverlaySheetController.showAdaptive( context, isScrollControlled: true, - builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title), + builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.displayTitle), ); } else if (context.mounted) { showErrorSnackBar(context, t.messages.fileInfoNotAvailable); @@ -667,7 +667,7 @@ class MediaContextMenuState extends State { try { final metadata = widget.item as PlexMetadata; - final itemType = metadata.type.toLowerCase(); + final itemType = metadata.mediaType.name; // Load playlists final playlists = await client.getPlaylists(playlistType: 'video'); @@ -752,7 +752,7 @@ class MediaContextMenuState extends State { try { final metadata = widget.item as PlexMetadata; - final itemType = metadata.type.toLowerCase(); + final itemType = metadata.mediaType; // Get the library section ID from the item // First try from the metadata itself @@ -776,8 +776,8 @@ class MediaContextMenuState extends State { } // If still not found, try to extract from the key field - if (sectionId == null) { - final keyMatch = RegExp(r'/library/sections/(\d+)').firstMatch(metadata.key); + if (sectionId == null && metadata.key != null) { + final keyMatch = RegExp(r'/library/sections/(\d+)').firstMatch(metadata.key!); if (keyMatch != null) { sectionId = int.tryParse(keyMatch.group(1)!); appLogger.d(' - Extracted from key: $sectionId'); @@ -841,18 +841,20 @@ class MediaContextMenuState extends State { // Determine the collection type based on the item type int? collectionType; switch (itemType) { - case 'movie': + case PlexMediaType.movie: collectionType = 1; break; - case 'show': + case PlexMediaType.show: collectionType = 2; break; - case 'season': + case PlexMediaType.season: collectionType = 3; break; - case 'episode': + case PlexMediaType.episode: collectionType = 4; break; + default: + break; } appLogger.d('Creating collection "$collectionName" with type $collectionType'); @@ -951,7 +953,7 @@ class MediaContextMenuState extends State { final confirmed = await showDeleteConfirmation( context, title: t.collections.removeFromCollection, - message: t.collections.removeFromCollectionConfirm(title: metadata.title), + message: t.collections.removeFromCollectionConfirm(title: metadata.displayTitle), ); if (!confirmed || !context.mounted) return; @@ -1008,7 +1010,7 @@ class MediaContextMenuState extends State { Future _handleDelete(BuildContext context, bool isCollection, bool isPlaylist) async { final client = _getClientForItem(); - final itemTitle = widget.item.title; + final itemTitle = widget.item.displayTitle; final itemTypeLabel = isCollection ? t.collections.collection : t.playlists.playlist; // Show confirmation dialog @@ -1110,7 +1112,7 @@ class MediaContextMenuState extends State { final confirmed = await showDeleteConfirmation( context, title: t.downloads.deleteDownload, - message: t.downloads.deleteConfirm(title: metadata.title), + message: t.downloads.deleteConfirm(title: metadata.displayTitle), ); if (!confirmed || !context.mounted) return; @@ -1264,7 +1266,7 @@ class _CollectionSelectionDialog extends StatelessWidget { final collection = collections[index - 1]; return ListTile( leading: const AppIcon(Symbols.collections_rounded, fill: 1), - title: Text(collection.title), + title: Text(collection.title!), subtitle: collection.childCount != null ? Text('${collection.childCount} items') : null, onTap: () => Navigator.pop(context, collection.ratingKey), ); diff --git a/lib/widgets/video_controls/sheets/queue_sheet.dart b/lib/widgets/video_controls/sheets/queue_sheet.dart index 972726b2..e24c6c8e 100644 --- a/lib/widgets/video_controls/sheets/queue_sheet.dart +++ b/lib/widgets/video_controls/sheets/queue_sheet.dart @@ -57,7 +57,7 @@ class QueueSheet extends StatelessWidget { return FocusableListTile( leading: _buildThumbnail(context, item, isCurrent, isTablet: isTablet), title: Text( - item.title, + item.title!, style: TextStyle( color: isCurrent ? primaryColor : null, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, @@ -139,7 +139,7 @@ class QueueSheet extends StatelessWidget { if (item.year != null) { return item.editionTitle != null ? '${item.year} · ${item.editionTitle}' : '${item.year}'; } - return item.type; + return item.mediaType.name; } static dynamic _tryGetClient(BuildContext context, PlexMetadata item) { diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index 830eb296..554969b2 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -474,7 +474,7 @@ class ContentStripState extends State { const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), ) : null, - title: item.title, + title: item.title!, subtitle: _buildQueueSubtitle(item), onTap: onTap, ); @@ -511,7 +511,7 @@ class ContentStripState extends State { } if (item.grandparentTitle != null) return item.grandparentTitle!; if (item.year != null) return item.editionTitle != null ? '${item.year} · ${item.editionTitle}' : '${item.year}'; - return item.type; + return item.mediaType.name; } Widget _buildStripItem({ diff --git a/lib/widgets/video_controls/widgets/video_controls_header.dart b/lib/widgets/video_controls/widgets/video_controls_header.dart index 088bc237..95ddb882 100644 --- a/lib/widgets/video_controls/widgets/video_controls_header.dart +++ b/lib/widgets/video_controls/widgets/video_controls_header.dart @@ -54,14 +54,14 @@ class VideoControlsHeader extends StatelessWidget { Widget _buildSingleLineTitle() { // Build single-line title combining series and episode info - final seriesName = metadata.grandparentTitle ?? metadata.title; + final seriesName = metadata.grandparentTitle ?? metadata.title!; final hasEpisodeInfo = metadata.parentIndex != null && metadata.index != null; List parts = [seriesName]; if (hasEpisodeInfo) { parts.add('S${metadata.parentIndex}E${metadata.index}'); - parts.add(metadata.title); + parts.add(metadata.title!); } return Text( @@ -78,7 +78,7 @@ class VideoControlsHeader extends StatelessWidget { if (metadata.parentIndex != null && metadata.index != null) { secondLineParts.add('S${metadata.parentIndex}'); secondLineParts.add('E${metadata.index}'); - secondLineParts.add(metadata.title); + secondLineParts.add(metadata.title!); } if (metadata.duration != null) { @@ -89,7 +89,7 @@ class VideoControlsHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - metadata.grandparentTitle ?? metadata.title, + metadata.grandparentTitle ?? metadata.title!, style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis,