diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 40184290..ec0e4a55 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -431,6 +431,8 @@ sealed class MediaItem with _$MediaItem { return artPath ?? thumbPath; } + if (kind == MediaKind.clip) return thumbPath ?? artPath; + return thumbPath; } diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 0ea3b008..d3316178 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -237,6 +237,12 @@ abstract class MediaServerClient { /// "More like this" recommendations for [id]. Future> fetchRelatedHubs(String id, {int count = 10}); + /// Playable extras attached to [id] (trailers, featurettes, deleted scenes, + /// behind-the-scenes clips). Backends return only items that can be opened by + /// the normal video playback flow; external/remote trailer URLs are out of + /// scope for this neutral surface. + Future> fetchExtras(String id); + /// Media featuring a specific person/actor. Future> fetchPersonMedia(String personId); diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 20009ac5..13ed2d00 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -20,7 +20,6 @@ import '../focus/focusable_wrapper.dart'; import '../focus/key_event_utils.dart'; import '../focus/input_mode_tracker.dart'; import '../widgets/focus_builders.dart'; -import '../media/media_backend.dart'; import '../media/media_hub.dart'; import '../utils/provider_extensions.dart'; import '../utils/plex_season_display.dart'; @@ -1852,8 +1851,7 @@ class _MediaDetailScreenState extends State }); } - /// Load extras (trailers, behind-the-scenes, etc.). Plex-only — Jellyfin - /// has no equivalent of `fetchExtras`. + /// Load extras (trailers, featurettes, behind-the-scenes, etc.). Future _loadExtras() async { void markLoaded() { setStateIfMounted(() { @@ -1873,13 +1871,8 @@ class _MediaDetailScreenState extends State return; } - if (_metadata.backend != MediaBackend.plex) { - markLoaded(); - return; - } - try { - final client = getServerBoundPlexClient(context); + final client = getServerBoundMediaClient(context); if (client == null) { markLoaded(); return; @@ -3856,7 +3849,7 @@ class _MediaDetailScreenState extends State ); } - /// Get the primary trailer from the extras list + /// Get the primary trailer from the extras list. MediaItem? _getPrimaryTrailer() { if (_extras == null || _extras!.isEmpty) return null; @@ -3872,17 +3865,24 @@ class _MediaDetailScreenState extends State } } - // Otherwise, find the first item with subtype 'trailer'. Extras are - // always Plex-sourced so the cast is safe; non-Plex backends route - // around this method entirely. try { - return _extras!.firstWhere((extra) => extra is PlexMediaItem && extra.subtype == 'trailer'); + return _extras!.firstWhere(_isTrailerExtra); } catch (_) { // No trailer found, return null (button won't appear) return null; } } + bool _isTrailerExtra(MediaItem extra) { + if (extra case PlexMediaItem(:final subtype?)) { + return subtype.toLowerCase() == 'trailer'; + } + final raw = extra.raw; + final extraType = raw?['ExtraType'] as String?; + final type = raw?['Type'] as String?; + return extraType?.toLowerCase() == 'trailer' || type?.toLowerCase() == 'trailer'; + } + /// Build the cast section with locked focus pattern for D-pad navigation /// Uses same layout pattern as seasons/extras (ListView.builder + Padding(horizontal: 2)) Widget _buildCastSection(MediaItem metadata) { diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 52b7c584..723743fa 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -1133,6 +1133,40 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { ].where((h) => h.items.isNotEmpty).toList(); } + /// Jellyfin exposes local trailers separately from special features. Combine + /// both into Plezy's existing extras row, but keep remote/YouTube trailers + /// out of scope because they are external URLs, not playable Jellyfin items. + @override + Future> fetchExtras(String id) async { + if (isOfflineMode) return const []; + + final results = await Future.wait([ + _safeFetchItemsArray('/Items/${_segment(id)}/LocalTrailers', { + 'userId': connection.userId, + ...jellyfinImageQueryParameters, + }), + _safeFetchItemsArray('/Items/${_segment(id)}/SpecialFeatures', { + 'userId': connection.userId, + ...jellyfinImageQueryParameters, + }), + ]); + + return _playableExtrasFromRaw(results.expand((items) => items)); + } + + List _playableExtrasFromRaw(Iterable> rawExtras) { + final extras = []; + final seenIds = {}; + + for (final raw in rawExtras) { + final item = _mapItem(raw); + if (item == null || !item.kind.isVideo || !seenIds.add(item.id)) continue; + extras.add(item); + } + + return extras; + } + List _mergeContinueWatchingAndNextUp({ required List resume, required List nextUp, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index c5146730..ad8e7f43 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -3821,6 +3821,7 @@ class PlexClient } /// Plex-specific: extras (trailers, behind-the-scenes) for a media item. + @override Future> fetchExtras(String ratingKey) async { final raw = await _getExtras(ratingKey); return raw.map((m) => PlexMappers.mediaItem(m)).toList(); diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 4b9f64fb..69da2fb1 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -103,6 +103,75 @@ void main() { ]); }); + test('fetchExtras combines local trailers and special features as playable videos', () async { + const itemId = 'movie/id #1?x'; + final encodedItemId = Uri.encodeComponent(itemId); + final requests = []; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + requests.add(request.url); + if (request.url.path == '/Items/$encodedItemId/LocalTrailers') { + return http.Response( + jsonEncode([ + { + 'Id': 'trailer-1', + 'Name': 'Trailer', + 'Type': 'Trailer', + 'ExtraType': 'Trailer', + 'RunTimeTicks': 900000000, + 'ImageTags': {'Primary': 'trailer-tag'}, + }, + {'Id': 'theme-song', 'Name': 'Theme Song', 'Type': 'Audio', 'ExtraType': 'ThemeSong'}, + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/Items/$encodedItemId/SpecialFeatures') { + return http.Response( + jsonEncode([ + {'Id': 'trailer-1', 'Name': 'Trailer Duplicate', 'Type': 'Trailer', 'ExtraType': 'Trailer'}, + { + 'Id': 'featurette-1', + 'Name': 'Making Of', + 'Type': 'Video', + 'ExtraType': 'Featurette', + 'RunTimeTicks': 1800000000, + 'BackdropImageTags': ['featurette-backdrop'], + }, + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('unexpected ${request.url}', 500); + }), + ); + addTearDown(scoped.close); + + final extras = await scoped.fetchExtras(itemId); + + expect(requests.map((uri) => uri.path).toSet(), { + '/Items/$encodedItemId/LocalTrailers', + '/Items/$encodedItemId/SpecialFeatures', + }); + expect(requests.every((uri) => uri.queryParameters['userId'] == 'user-1'), isTrue); + expect(requests.every((uri) => uri.queryParameters['EnableImageTypes'] == 'Primary,Backdrop,Thumb,Logo'), isTrue); + expect(requests.every((uri) => uri.queryParameters['ImageTypeLimit'] == '1'), isTrue); + expect(extras.map((item) => item.id).toList(), ['trailer-1', 'featurette-1']); + expect(extras.every((item) => item.kind.isVideo), isTrue); + expect(extras.every((item) => item.serverId == 'srv-1'), isTrue); + expect(extras.every((item) => item.serverName == 'Home'), isTrue); + expect(extras[0].kind, MediaKind.clip); + expect(extras[0].raw?['ExtraType'], 'Trailer'); + expect(extras[1].kind, MediaKind.clip); + expect(extras[1].raw?['ExtraType'], 'Featurette'); + expect(extras[1].thumbPath, isNull); + expect(extras[1].artPath, isNotNull); + expect(extras[1].posterThumb(), extras[1].artPath); + }); + test('reportPlaybackProgress sends media source and stream indexes', () async { Uri? capturedUri; String? capturedBody;