From 04673a8c8a3fdfad6d044dd8ee0e2a76d35c7f5e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:44:08 +0200 Subject: [PATCH] fix(library): render Plex home videos with 16:9 thumbnails instead of cropped posters Plex "Other Videos" libraries returned items as type=movie with subtype=clip, which mapped to MediaKind.movie and rendered 2:3 poster cards that cropped the generated 16:9 video-frame thumbs. Items keep the movie kind so downloads, add-to, delete-from-server, and detail navigation stay available, but subtype=clip now renders wide with the thumb-first artwork clips already use. Sections marked subtype=clip in /media/providers map to the clip library kind, giving Plex home-video libraries the same folder-first grouping and wide grid cells as MediaBrowser homevideos views. Wide-only hubs also keep 16:9 cards in the poster episode modes, matching the TV rail's gate. close #2036 --- lib/media/media_item.dart | 19 +++++- .../libraries/tabs/library_browse_tab.dart | 10 ++- lib/services/plex_mappers.dart | 12 +++- lib/services/plex_mappers.g.dart | 1 + lib/widgets/hub_section.dart | 10 +-- test/media/media_item_test.dart | 29 +++++++++ test/services/plex_mappers_test.dart | 63 +++++++++++++++++++ test/widgets/hub_section_test.dart | 27 ++++++++ 8 files changed, 162 insertions(+), 9 deletions(-) diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 780c4b61b..35bd99cfa 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -504,6 +504,17 @@ sealed class MediaItem with _$MediaItem { /// Plex-only edition label. Jellyfin returns null. String? get editionTitle => null; + /// Plex marks unmatched home-video items ("Other Videos" libraries, agent + /// `tv.plex.agents.none`) as `type="movie"` with `subtype="clip"`. They keep + /// [MediaKind.movie] so movie-only actions (downloads, add-to, delete from + /// server, detail navigation) stay available, but they render like clips: + /// 16:9 cards showing the generated video-frame thumb instead of a cropped + /// 2:3 poster (#2036). + bool get _isPlexHomeVideo { + if (this case PlexMediaItem(subtype: 'clip', kind: MediaKind.movie)) return true; + return false; + } + /// Returns the appropriate poster path based on episode poster mode. String? posterThumb({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) { if (kind == MediaKind.episode) { @@ -524,14 +535,16 @@ sealed class MediaItem with _$MediaItem { } } + // Home videos and true clips identify by their generated 16:9 video-frame + // thumb; the movie branches below would prefer art that rarely exists. + if (kind == MediaKind.clip || _isPlexHomeVideo) return thumbPath ?? artPath; + if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail && (kind == MediaKind.movie || kind == MediaKind.show)) { return artPath ?? thumbPath; } - if (kind == MediaKind.clip) return thumbPath ?? artPath; - return thumbPath; } @@ -551,7 +564,7 @@ sealed class MediaItem with _$MediaItem { /// True when the item should render in 16:9. bool usesWideAspectRatio(EpisodePosterMode mode, {bool mixedHubContext = false}) { - if (kind == MediaKind.clip) return true; + if (kind == MediaKind.clip || _isPlexHomeVideo) return true; if (kind == MediaKind.episode && mode == EpisodePosterMode.episodeThumbnail) { return true; } diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 57ed077c7..1880307bf 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1861,7 +1861,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState json) => key: readStringField(json, 'key') as String? ?? '', title: json['title'] as String? ?? '', type: json['type'] as String? ?? '', + subtype: json['subtype'] as String?, agent: json['agent'] as String?, scanner: json['scanner'] as String?, language: json['language'] as String?, diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 1b1d01344..28bedcc3e 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -539,11 +539,13 @@ class HubSectionState extends State with MountedSetStateMixin, Skele final isMixedHub = hasEpisodes && hasNonEpisodes; - final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; - - // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) + // 16:9 when every item is wide (episode thumbnails, clips, + // home videos), or for mixed hubs in episode-thumbnail + // mode. Clip-only hubs stay wide in the poster modes — + // same gate as TvBrowseRail — since episodes already fold + // the mode into usesWideAspectRatio (#2036). final useWideLayout = - episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); + hasEpisodes && (!hasNonEpisodes || episodePosterMode == EpisodePosterMode.episodeThumbnail); // Music hubs render square album/artist artwork final isSquareHub = diff --git a/test/media/media_item_test.dart b/test/media/media_item_test.dart index d004d9754..857578602 100644 --- a/test/media/media_item_test.dart +++ b/test/media/media_item_test.dart @@ -7,6 +7,7 @@ import 'package:plezy/media/media_part.dart'; import 'package:plezy/media/media_rating.dart'; import 'package:plezy/media/media_role.dart'; import 'package:plezy/media/media_version.dart'; +import 'package:plezy/services/settings_service.dart'; import '../test_helpers/media_items.dart'; /// Backend-agnostic [MediaItem] tests. Existing coverage is split between @@ -670,6 +671,34 @@ void main() { }); }); + group('MediaItem card shape for Plex home videos (#2036)', () { + const homeVideo = PlexMediaItem( + id: 'hv1', + kind: MediaKind.movie, + subtype: 'clip', + thumbPath: '/thumb', + artPath: '/art', + ); + + test('movie with subtype=clip renders wide in every episode poster mode', () { + for (final mode in EpisodePosterMode.values) { + expect(homeVideo.usesWideAspectRatio(mode), isTrue, reason: mode.name); + expect(homeVideo.cardShape(mode), CardShape.wide, reason: mode.name); + } + }); + + test('posterThumb prefers the generated video-frame thumb over art, even in mixed hubs', () { + expect(homeVideo.posterThumb(), '/thumb'); + expect(homeVideo.posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true), '/thumb'); + }); + + test('other Plex subtypes do not widen a movie', () { + const trailer = PlexMediaItem(id: 't1', kind: MediaKind.movie, subtype: 'trailer', thumbPath: '/thumb'); + expect(trailer.usesWideAspectRatio(EpisodePosterMode.seriesPoster), isFalse); + expect(trailer.cardShape(EpisodePosterMode.seriesPoster), CardShape.poster); + }); + }); + group('MediaItem.displayTitle', () { test('episode prefers grandparent (show) title', () { final ep = testMediaItem( diff --git a/test/services/plex_mappers_test.dart b/test/services/plex_mappers_test.dart index 9643e2846..d012f0aec 100644 --- a/test/services/plex_mappers_test.dart +++ b/test/services/plex_mappers_test.dart @@ -12,6 +12,7 @@ import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_playlist.dart'; import 'package:plezy/media/media_stream.dart'; import 'package:plezy/services/plex_mappers.dart'; +import 'package:plezy/services/settings_service.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; const _serverId = 'plex-machine-1'; @@ -389,6 +390,53 @@ void main() { }); }); + group('PlexMappers.mediaItem (Plex home video: type=movie subtype=clip)', () { + test('keeps the movie kind but renders wide with the video-frame thumb in every poster mode', () { + // Wire shape of an "Other Videos"/unmatched-agent library item (#2036). + final json = { + 'ratingKey': '8964', + 'type': 'movie', + 'subtype': 'clip', + 'guid': 'tv.plex.agents.none://8964', + 'title': 'Holiday 2019', + 'thumb': '/library/metadata/8964/thumb/1', + }; + + final item = _mediaItemFromJson(json, serverId: ServerId(_serverId)); + // Movie kind is deliberate: downloads, add-to, delete-from-server, and + // detail navigation all gate on MediaKind.movie. + expect(item.kind, MediaKind.movie); + for (final mode in EpisodePosterMode.values) { + expect(item.usesWideAspectRatio(mode), isTrue, reason: mode.name); + expect(item.cardShape(mode), CardShape.wide, reason: mode.name); + expect(item.posterThumb(mode: mode), '/library/metadata/8964/thumb/1', reason: mode.name); + } + }); + + test('falls back to art when the generated thumb is missing', () { + final item = _mediaItemFromJson({ + 'ratingKey': '8506', + 'type': 'movie', + 'subtype': 'clip', + 'art': '/library/metadata/8506/art/1', + }, serverId: ServerId(_serverId)); + + expect(item.posterThumb(), '/library/metadata/8506/art/1'); + }); + + test('a movie without the clip subtype keeps the poster shape', () { + final item = _mediaItemFromJson({ + 'ratingKey': '1', + 'type': 'movie', + 'title': 'Matched', + 'thumb': '/t', + }, serverId: ServerId(_serverId)); + + expect(item.usesWideAspectRatio(EpisodePosterMode.seriesPoster), isFalse); + expect(item.cardShape(EpisodePosterMode.seriesPoster), CardShape.poster); + }); + }); + group('PlexMappers.mediaItem (show + season + episode)', () { test('show preserves leaf counts and child counts', () { final json = { @@ -685,6 +733,21 @@ void main() { expect(lib.serverName, _serverName); }); + test('home-video section (type=movie subtype=clip) maps to the clip kind', () { + // `/media/providers` marks "Other Videos" sections this way; the clip + // kind gives them the same folder-first grouping and wide grid cells as + // MediaBrowser `homevideos` views (#2036). + final json = { + 'key': '7', + 'title': 'Home Videos', + 'type': 'movie', + 'subtype': 'clip', + 'agent': 'tv.plex.agents.none', + }; + final lib = _mediaLibraryFromJson(json, serverId: ServerId(_serverId)); + expect(lib.kind, MediaKind.clip); + }); + test('shared library is marked isShared', () { final json = {'key': 'shared', 'title': 'Shared with you', 'type': 'movie'}; final lib = _mediaLibraryFromJson(json, serverId: ServerId(_serverId), isShared: true); diff --git a/test/widgets/hub_section_test.dart b/test/widgets/hub_section_test.dart index 20920d2d7..13cd0c149 100644 --- a/test/widgets/hub_section_test.dart +++ b/test/widgets/hub_section_test.dart @@ -135,6 +135,33 @@ void main() { expect(outerPadding.padding.resolve(TextDirection.ltr).bottom, 0); }); + testWidgets('clip-only hub keeps 16:9 cards in a poster episode mode', (tester) async { + // Clips (home videos) are wide in every mode; only episode/mixed hubs + // should fold the poster preference back to 2:3 (#2036). + final item = testMediaItem( + id: 'home_video', + backend: MediaBackend.plex, + kind: MediaKind.clip, + title: 'Home Video', + thumbPath: '/video-frame.jpg', + ); + + await tester.pumpWidget( + _TestApp( + child: HubSection( + hub: _hubWith(item), + focusMemory: HubFocusMemory(), + icon: Symbols.movie_rounded, + episodePosterModeOverride: EpisodePosterMode.seriesPoster, + ), + ), + ); + + final poster = find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipRRect)).first; + final posterSize = tester.getSize(poster); + expect(posterSize.width / posterSize.height, closeTo(16 / 9, 0.001)); + }); + testWidgets('shows a provider result count in the existing hub header only when supplied', (tester) async { final item = testMediaItem( id: 'counted_item',