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
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1861,7 +1861,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
_selectedGrouping == browseGroupingArtists ||
|
||||
_selectedGrouping == browseGroupingAlbums ||
|
||||
_selectedGrouping == browseGroupingTracks;
|
||||
final browseShape = isMusicGrouping ? CardShape.square : null;
|
||||
// Clip libraries (MediaBrowser home videos, Plex "Other Videos") hold
|
||||
// homogeneous 16:9 items, so the flat grid uses wide cells; poster cells
|
||||
// would letterbox every card (#2036).
|
||||
final isClipLibrary = widget.library.kind == MediaKind.clip;
|
||||
final browseShape = isMusicGrouping
|
||||
? CardShape.square
|
||||
: isClipLibrary
|
||||
? CardShape.wide
|
||||
: null;
|
||||
// Full-bleed TV cards intentionally hide captions. Music artwork alone
|
||||
// is not a reliable identity, so artist/album/track grids always keep the
|
||||
// standard captioned card while preserving their circular/square artwork.
|
||||
|
||||
@@ -362,6 +362,11 @@ class PlexLibraryDto {
|
||||
final String title;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String type;
|
||||
|
||||
/// `"clip"` on home-video ("Other Videos") sections. Only present in the
|
||||
/// `/media/providers` listing; the legacy `/library/sections` fallback
|
||||
/// omits it, degrading those sections to [MediaKind.movie].
|
||||
final String? subtype;
|
||||
final String? agent;
|
||||
final String? scanner;
|
||||
final String? language;
|
||||
@@ -383,6 +388,7 @@ class PlexLibraryDto {
|
||||
required this.key,
|
||||
required this.title,
|
||||
required this.type,
|
||||
this.subtype,
|
||||
this.agent,
|
||||
this.scanner,
|
||||
this.language,
|
||||
@@ -402,6 +408,7 @@ class PlexLibraryDto {
|
||||
key: key,
|
||||
title: title,
|
||||
type: type,
|
||||
subtype: subtype,
|
||||
agent: agent,
|
||||
scanner: scanner,
|
||||
language: language,
|
||||
@@ -1231,7 +1238,10 @@ class PlexMappers {
|
||||
id: dto.key,
|
||||
backend: MediaBackend.plex,
|
||||
title: dto.title,
|
||||
kind: MediaKind.fromString(dto.type),
|
||||
// Home-video sections come back as `type="movie" subtype="clip"`; map
|
||||
// them to the clip kind MediaBrowser `homevideos` views already use so
|
||||
// they share the folder-first grouping and wide grid cells (#2036).
|
||||
kind: dto.type == 'movie' && dto.subtype == 'clip' ? MediaKind.clip : MediaKind.fromString(dto.type),
|
||||
language: dto.language,
|
||||
updatedAt: dto.updatedAt,
|
||||
createdAt: dto.createdAt,
|
||||
|
||||
@@ -45,6 +45,7 @@ PlexLibraryDto _$PlexLibraryDtoFromJson(Map<String, dynamic> 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?,
|
||||
|
||||
@@ -539,11 +539,13 @@ class HubSectionState extends State<HubSection> 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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user