From abe0919324be34317349d2c315ea53e521a20348 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:42:49 +0100 Subject: [PATCH] feat: episode thumbnail display mode --- lib/i18n/strings.g.dart | 130 ++++++++++++++---- lib/i18n/strings.i18n.json | 9 +- lib/i18n/strings_de.i18n.json | 9 +- lib/i18n/strings_it.i18n.json | 9 +- lib/i18n/strings_ko.i18n.json | 9 +- lib/i18n/strings_nl.i18n.json | 9 +- lib/i18n/strings_sv.i18n.json | 9 +- lib/i18n/strings_zh.i18n.json | 9 +- lib/models/plex_metadata.dart | 48 +++++-- lib/providers/settings_provider.dart | 26 +++- .../libraries/tabs/library_browse_tab.dart | 11 +- lib/screens/settings/settings_screen.dart | 39 ++++-- lib/services/settings_service.dart | 30 ++-- lib/utils/layout_constants.dart | 9 +- lib/utils/plex_image_helper.dart | 6 +- lib/widgets/hub_section.dart | 42 ++++-- lib/widgets/media_card.dart | 56 ++++++-- lib/widgets/media_grid_delegate.dart | 16 ++- lib/widgets/plex_optimized_image.dart | 9 +- 19 files changed, 379 insertions(+), 106 deletions(-) diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 54225ec1..3107e3c5 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 7 -/// Strings: 3568 (509 per locale) +/// Strings: 3603 (514 per locale) /// -/// Built on 2026-01-06 at 22:09 UTC +/// Built on 2026-01-08 at 05:05 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -288,7 +288,13 @@ class _StringsSettingsEn { String get appearance => 'Appearance'; String get videoPlayback => 'Video Playback'; String get advanced => 'Advanced'; - String get useSeasonPostersDescription => 'Show season poster instead of series poster for episodes'; + String get episodePosterMode => 'Episode Poster Style'; + String get seriesPoster => 'Series Poster'; + String get seriesPosterDescription => 'Show the series poster for all episodes'; + String get seasonPoster => 'Season Poster'; + String get seasonPosterDescription => 'Show the season-specific poster for episodes'; + String get episodeThumbnail => 'Episode Thumbnail'; + String get episodeThumbnailDescription => 'Show 16:9 episode screenshot thumbnails'; String get showHeroSectionDescription => 'Display featured content carousel on home screen'; String get secondsLabel => 'Seconds'; String get minutesLabel => 'Minutes'; @@ -311,7 +317,6 @@ class _StringsSettingsEn { String get gridViewDescription => 'Display items in a grid layout'; String get listView => 'List'; String get listViewDescription => 'Display items in a list layout'; - String get useSeasonPosters => 'Use Season Posters'; String get showHeroSection => 'Show Hero Section'; String get useGlobalHubs => 'Use Plex Home Layout'; String get useGlobalHubsDescription => 'Show home page hubs like the official Plex client. When off, shows per-library recommendations instead.'; @@ -1163,7 +1168,13 @@ class _StringsSettingsDe implements _StringsSettingsEn { @override String get appearance => 'Darstellung'; @override String get videoPlayback => 'Videowiedergabe'; @override String get advanced => 'Erweitert'; - @override String get useSeasonPostersDescription => 'Staffelposter statt Serienposter für Episoden anzeigen'; + @override String get episodePosterMode => 'Episoden-Poster-Stil'; + @override String get seriesPoster => 'Serienposter'; + @override String get seriesPosterDescription => 'Zeige das Serienposter für alle Episoden'; + @override String get seasonPoster => 'Staffelposter'; + @override String get seasonPosterDescription => 'Zeige das staffelspezifische Poster für Episoden'; + @override String get episodeThumbnail => 'Episoden-Miniatur'; + @override String get episodeThumbnailDescription => 'Zeige 16:9 Episoden-Vorschaubilder'; @override String get showHeroSectionDescription => 'Bereich mit empfohlenen Inhalten auf der Startseite anzeigen'; @override String get secondsLabel => 'Sekunden'; @override String get minutesLabel => 'Minuten'; @@ -1186,7 +1197,6 @@ class _StringsSettingsDe implements _StringsSettingsEn { @override String get gridViewDescription => 'Elemente im Raster anzeigen'; @override String get listView => 'Liste'; @override String get listViewDescription => 'Elemente in Listenansicht anzeigen'; - @override String get useSeasonPosters => 'Staffelposter verwenden'; @override String get showHeroSection => 'Hero-Bereich anzeigen'; @override String get useGlobalHubs => 'Plex-Startseiten-Layout verwenden'; @override String get useGlobalHubsDescription => 'Zeigt Startseiten-Hubs wie der offizielle Plex-Client. Wenn deaktiviert, werden stattdessen Empfehlungen pro Bibliothek angezeigt.'; @@ -2039,7 +2049,13 @@ class _StringsSettingsIt implements _StringsSettingsEn { @override String get appearance => 'Aspetto'; @override String get videoPlayback => 'Riproduzione video'; @override String get advanced => 'Avanzate'; - @override String get useSeasonPostersDescription => 'Mostra il poster della stagione invece del poster della serie per gli episodi'; + @override String get episodePosterMode => 'Stile poster episodio'; + @override String get seriesPoster => 'Poster della serie'; + @override String get seriesPosterDescription => 'Mostra il poster della serie per tutti gli episodi'; + @override String get seasonPoster => 'Poster della stagione'; + @override String get seasonPosterDescription => 'Mostra il poster specifico della stagione per gli episodi'; + @override String get episodeThumbnail => 'Miniatura episodio'; + @override String get episodeThumbnailDescription => 'Mostra miniature 16:9 degli episodi'; @override String get showHeroSectionDescription => 'Visualizza il carosello dei contenuti in primo piano sulla schermata iniziale'; @override String get secondsLabel => 'Secondi'; @override String get minutesLabel => 'Minuti'; @@ -2062,7 +2078,6 @@ class _StringsSettingsIt implements _StringsSettingsEn { @override String get gridViewDescription => 'Visualizza gli elementi in un layout a griglia'; @override String get listView => 'Elenco'; @override String get listViewDescription => 'Visualizza gli elementi in un layout a elenco'; - @override String get useSeasonPosters => 'Usa poster delle stagioni'; @override String get showHeroSection => 'Mostra sezione principale'; @override String get useGlobalHubs => 'Usa layout Home di Plex'; @override String get useGlobalHubsDescription => 'Mostra gli hub della home page come il client Plex ufficiale. Se disattivato, mostra invece i suggerimenti per libreria.'; @@ -2915,7 +2930,13 @@ class _StringsSettingsKo implements _StringsSettingsEn { @override String get appearance => '외관'; @override String get videoPlayback => '비디오 재생'; @override String get advanced => '고급'; - @override String get useSeasonPostersDescription => '에피소드에 시리즈 포스터 대신 시즌 포스터 표시'; + @override String get episodePosterMode => '에피소드 포스터 스타일'; + @override String get seriesPoster => '시리즈 포스터'; + @override String get seriesPosterDescription => '모든 에피소드에 시리즈 포스터 표시'; + @override String get seasonPoster => '시즌 포스터'; + @override String get seasonPosterDescription => '에피소드에 시즌별 포스터 표시'; + @override String get episodeThumbnail => '에피소드 썸네일'; + @override String get episodeThumbnailDescription => '16:9 에피소드 스크린샷 썸네일 표시'; @override String get showHeroSectionDescription => '홈 화면에 주요 콘텐츠 캐러셀(슬라이드) 표시'; @override String get secondsLabel => '초'; @override String get minutesLabel => '분'; @@ -2938,7 +2959,6 @@ class _StringsSettingsKo implements _StringsSettingsEn { @override String get gridViewDescription => '항목을 그리드 레이아웃으로 표시합니다'; @override String get listView => '목록 보기'; @override String get listViewDescription => '항목을 목록 레이아웃으로 표시합니다'; - @override String get useSeasonPosters => '시즌 포스터 사용'; @override String get showHeroSection => '주요 추천 영역 표시'; @override String get useGlobalHubs => 'Plex 홈 레이아웃 사용'; @override String get useGlobalHubsDescription => '공식 Plex 클라이언트처럼 홈 페이지 허브를 표시합니다. 끄면 라이브러리별 추천이 대신 표시됩니다.'; @@ -3791,7 +3811,13 @@ class _StringsSettingsNl implements _StringsSettingsEn { @override String get appearance => 'Uiterlijk'; @override String get videoPlayback => 'Video afspelen'; @override String get advanced => 'Geavanceerd'; - @override String get useSeasonPostersDescription => 'Toon seizoenposter in plaats van serieposter voor afleveringen'; + @override String get episodePosterMode => 'Aflevering poster stijl'; + @override String get seriesPoster => 'Serie poster'; + @override String get seriesPosterDescription => 'Toon de serie poster voor alle afleveringen'; + @override String get seasonPoster => 'Seizoen poster'; + @override String get seasonPosterDescription => 'Toon de seizoensspecifieke poster voor afleveringen'; + @override String get episodeThumbnail => 'Aflevering miniatuur'; + @override String get episodeThumbnailDescription => 'Toon 16:9 aflevering miniaturen'; @override String get showHeroSectionDescription => 'Toon uitgelichte inhoud carrousel op startscherm'; @override String get secondsLabel => 'Seconden'; @override String get minutesLabel => 'Minuten'; @@ -3814,7 +3840,6 @@ class _StringsSettingsNl implements _StringsSettingsEn { @override String get gridViewDescription => 'Items weergeven in een rasterindeling'; @override String get listView => 'Lijst'; @override String get listViewDescription => 'Items weergeven in een lijstindeling'; - @override String get useSeasonPosters => 'Gebruik seizoenposters'; @override String get showHeroSection => 'Toon hoofdsectie'; @override String get useGlobalHubs => 'Plex Home-indeling gebruiken'; @override String get useGlobalHubsDescription => 'Toon startpagina-hubs zoals de officiële Plex-client. Indien uitgeschakeld, worden in plaats daarvan aanbevelingen per bibliotheek getoond.'; @@ -4667,7 +4692,13 @@ class _StringsSettingsSv implements _StringsSettingsEn { @override String get appearance => 'Utseende'; @override String get videoPlayback => 'Videouppspelning'; @override String get advanced => 'Avancerat'; - @override String get useSeasonPostersDescription => 'Visa säsongsaffisch istället för serieaffisch för avsnitt'; + @override String get episodePosterMode => 'Avsnittsaffisch-stil'; + @override String get seriesPoster => 'Serieaffisch'; + @override String get seriesPosterDescription => 'Visa seriens affisch för alla avsnitt'; + @override String get seasonPoster => 'Säsongsaffisch'; + @override String get seasonPosterDescription => 'Visa säsongens affisch för avsnitt'; + @override String get episodeThumbnail => 'Avsnittsminiatyr'; + @override String get episodeThumbnailDescription => 'Visa 16:9 skärmbild från avsnittet'; @override String get showHeroSectionDescription => 'Visa utvalda innehållskarusell på startsidan'; @override String get secondsLabel => 'Sekunder'; @override String get minutesLabel => 'Minuter'; @@ -4690,7 +4721,6 @@ class _StringsSettingsSv implements _StringsSettingsEn { @override String get gridViewDescription => 'Visa objekt i rutnätslayout'; @override String get listView => 'Lista'; @override String get listViewDescription => 'Visa objekt i listlayout'; - @override String get useSeasonPosters => 'Använd säsongsaffischer'; @override String get showHeroSection => 'Visa hjältesektion'; @override String get useGlobalHubs => 'Använd Plex hem-layout'; @override String get useGlobalHubsDescription => 'Visar startsidans hubbar som den officiella Plex-klienten. När av visas rekommendationer per bibliotek istället.'; @@ -5543,7 +5573,13 @@ class _StringsSettingsZh implements _StringsSettingsEn { @override String get appearance => '外观'; @override String get videoPlayback => '视频播放'; @override String get advanced => '高级'; - @override String get useSeasonPostersDescription => '为剧集显示季海报而非剧集海报'; + @override String get episodePosterMode => '剧集海报样式'; + @override String get seriesPoster => '剧集海报'; + @override String get seriesPosterDescription => '为所有剧集显示剧集海报'; + @override String get seasonPoster => '季海报'; + @override String get seasonPosterDescription => '为剧集显示特定季的海报'; + @override String get episodeThumbnail => '剧集缩略图'; + @override String get episodeThumbnailDescription => '显示16:9剧集截图缩略图'; @override String get showHeroSectionDescription => '在主屏幕上显示精选内容轮播区'; @override String get secondsLabel => '秒'; @override String get minutesLabel => '分钟'; @@ -5566,7 +5602,6 @@ class _StringsSettingsZh implements _StringsSettingsEn { @override String get gridViewDescription => '以网格布局显示项目'; @override String get listView => '列表视图'; @override String get listViewDescription => '以列表布局显示项目'; - @override String get useSeasonPosters => '使用季海报'; @override String get showHeroSection => '显示主要精选区'; @override String get useGlobalHubs => '使用 Plex 主页布局'; @override String get useGlobalHubsDescription => '显示与官方 Plex 客户端相同的主页推荐。关闭时将显示按媒体库分类的推荐。'; @@ -6314,7 +6349,13 @@ extension on Translations { case 'settings.appearance': return 'Appearance'; case 'settings.videoPlayback': return 'Video Playback'; case 'settings.advanced': return 'Advanced'; - case 'settings.useSeasonPostersDescription': return 'Show season poster instead of series poster for episodes'; + case 'settings.episodePosterMode': return 'Episode Poster Style'; + case 'settings.seriesPoster': return 'Series Poster'; + case 'settings.seriesPosterDescription': return 'Show the series poster for all episodes'; + case 'settings.seasonPoster': return 'Season Poster'; + case 'settings.seasonPosterDescription': return 'Show the season-specific poster for episodes'; + case 'settings.episodeThumbnail': return 'Episode Thumbnail'; + case 'settings.episodeThumbnailDescription': return 'Show 16:9 episode screenshot thumbnails'; case 'settings.showHeroSectionDescription': return 'Display featured content carousel on home screen'; case 'settings.secondsLabel': return 'Seconds'; case 'settings.minutesLabel': return 'Minutes'; @@ -6337,7 +6378,6 @@ extension on Translations { case 'settings.gridViewDescription': return 'Display items in a grid layout'; case 'settings.listView': return 'List'; case 'settings.listViewDescription': return 'Display items in a list layout'; - case 'settings.useSeasonPosters': return 'Use Season Posters'; case 'settings.showHeroSection': return 'Show Hero Section'; case 'settings.useGlobalHubs': return 'Use Plex Home Layout'; case 'settings.useGlobalHubsDescription': return 'Show home page hubs like the official Plex client. When off, shows per-library recommendations instead.'; @@ -6831,7 +6871,13 @@ extension on _StringsDe { case 'settings.appearance': return 'Darstellung'; case 'settings.videoPlayback': return 'Videowiedergabe'; case 'settings.advanced': return 'Erweitert'; - case 'settings.useSeasonPostersDescription': return 'Staffelposter statt Serienposter für Episoden anzeigen'; + case 'settings.episodePosterMode': return 'Episoden-Poster-Stil'; + case 'settings.seriesPoster': return 'Serienposter'; + case 'settings.seriesPosterDescription': return 'Zeige das Serienposter für alle Episoden'; + case 'settings.seasonPoster': return 'Staffelposter'; + case 'settings.seasonPosterDescription': return 'Zeige das staffelspezifische Poster für Episoden'; + case 'settings.episodeThumbnail': return 'Episoden-Miniatur'; + case 'settings.episodeThumbnailDescription': return 'Zeige 16:9 Episoden-Vorschaubilder'; case 'settings.showHeroSectionDescription': return 'Bereich mit empfohlenen Inhalten auf der Startseite anzeigen'; case 'settings.secondsLabel': return 'Sekunden'; case 'settings.minutesLabel': return 'Minuten'; @@ -6854,7 +6900,6 @@ extension on _StringsDe { case 'settings.gridViewDescription': return 'Elemente im Raster anzeigen'; case 'settings.listView': return 'Liste'; case 'settings.listViewDescription': return 'Elemente in Listenansicht anzeigen'; - case 'settings.useSeasonPosters': return 'Staffelposter verwenden'; case 'settings.showHeroSection': return 'Hero-Bereich anzeigen'; case 'settings.useGlobalHubs': return 'Plex-Startseiten-Layout verwenden'; case 'settings.useGlobalHubsDescription': return 'Zeigt Startseiten-Hubs wie der offizielle Plex-Client. Wenn deaktiviert, werden stattdessen Empfehlungen pro Bibliothek angezeigt.'; @@ -7349,7 +7394,13 @@ extension on _StringsIt { case 'settings.appearance': return 'Aspetto'; case 'settings.videoPlayback': return 'Riproduzione video'; case 'settings.advanced': return 'Avanzate'; - case 'settings.useSeasonPostersDescription': return 'Mostra il poster della stagione invece del poster della serie per gli episodi'; + case 'settings.episodePosterMode': return 'Stile poster episodio'; + case 'settings.seriesPoster': return 'Poster della serie'; + case 'settings.seriesPosterDescription': return 'Mostra il poster della serie per tutti gli episodi'; + case 'settings.seasonPoster': return 'Poster della stagione'; + case 'settings.seasonPosterDescription': return 'Mostra il poster specifico della stagione per gli episodi'; + case 'settings.episodeThumbnail': return 'Miniatura episodio'; + case 'settings.episodeThumbnailDescription': return 'Mostra miniature 16:9 degli episodi'; case 'settings.showHeroSectionDescription': return 'Visualizza il carosello dei contenuti in primo piano sulla schermata iniziale'; case 'settings.secondsLabel': return 'Secondi'; case 'settings.minutesLabel': return 'Minuti'; @@ -7372,7 +7423,6 @@ extension on _StringsIt { case 'settings.gridViewDescription': return 'Visualizza gli elementi in un layout a griglia'; case 'settings.listView': return 'Elenco'; case 'settings.listViewDescription': return 'Visualizza gli elementi in un layout a elenco'; - case 'settings.useSeasonPosters': return 'Usa poster delle stagioni'; case 'settings.showHeroSection': return 'Mostra sezione principale'; case 'settings.useGlobalHubs': return 'Usa layout Home di Plex'; case 'settings.useGlobalHubsDescription': return 'Mostra gli hub della home page come il client Plex ufficiale. Se disattivato, mostra invece i suggerimenti per libreria.'; @@ -7867,7 +7917,13 @@ extension on _StringsKo { case 'settings.appearance': return '외관'; case 'settings.videoPlayback': return '비디오 재생'; case 'settings.advanced': return '고급'; - case 'settings.useSeasonPostersDescription': return '에피소드에 시리즈 포스터 대신 시즌 포스터 표시'; + case 'settings.episodePosterMode': return '에피소드 포스터 스타일'; + case 'settings.seriesPoster': return '시리즈 포스터'; + case 'settings.seriesPosterDescription': return '모든 에피소드에 시리즈 포스터 표시'; + case 'settings.seasonPoster': return '시즌 포스터'; + case 'settings.seasonPosterDescription': return '에피소드에 시즌별 포스터 표시'; + case 'settings.episodeThumbnail': return '에피소드 썸네일'; + case 'settings.episodeThumbnailDescription': return '16:9 에피소드 스크린샷 썸네일 표시'; case 'settings.showHeroSectionDescription': return '홈 화면에 주요 콘텐츠 캐러셀(슬라이드) 표시'; case 'settings.secondsLabel': return '초'; case 'settings.minutesLabel': return '분'; @@ -7890,7 +7946,6 @@ extension on _StringsKo { case 'settings.gridViewDescription': return '항목을 그리드 레이아웃으로 표시합니다'; case 'settings.listView': return '목록 보기'; case 'settings.listViewDescription': return '항목을 목록 레이아웃으로 표시합니다'; - case 'settings.useSeasonPosters': return '시즌 포스터 사용'; case 'settings.showHeroSection': return '주요 추천 영역 표시'; case 'settings.useGlobalHubs': return 'Plex 홈 레이아웃 사용'; case 'settings.useGlobalHubsDescription': return '공식 Plex 클라이언트처럼 홈 페이지 허브를 표시합니다. 끄면 라이브러리별 추천이 대신 표시됩니다.'; @@ -8385,7 +8440,13 @@ extension on _StringsNl { case 'settings.appearance': return 'Uiterlijk'; case 'settings.videoPlayback': return 'Video afspelen'; case 'settings.advanced': return 'Geavanceerd'; - case 'settings.useSeasonPostersDescription': return 'Toon seizoenposter in plaats van serieposter voor afleveringen'; + case 'settings.episodePosterMode': return 'Aflevering poster stijl'; + case 'settings.seriesPoster': return 'Serie poster'; + case 'settings.seriesPosterDescription': return 'Toon de serie poster voor alle afleveringen'; + case 'settings.seasonPoster': return 'Seizoen poster'; + case 'settings.seasonPosterDescription': return 'Toon de seizoensspecifieke poster voor afleveringen'; + case 'settings.episodeThumbnail': return 'Aflevering miniatuur'; + case 'settings.episodeThumbnailDescription': return 'Toon 16:9 aflevering miniaturen'; case 'settings.showHeroSectionDescription': return 'Toon uitgelichte inhoud carrousel op startscherm'; case 'settings.secondsLabel': return 'Seconden'; case 'settings.minutesLabel': return 'Minuten'; @@ -8408,7 +8469,6 @@ extension on _StringsNl { case 'settings.gridViewDescription': return 'Items weergeven in een rasterindeling'; case 'settings.listView': return 'Lijst'; case 'settings.listViewDescription': return 'Items weergeven in een lijstindeling'; - case 'settings.useSeasonPosters': return 'Gebruik seizoenposters'; case 'settings.showHeroSection': return 'Toon hoofdsectie'; case 'settings.useGlobalHubs': return 'Plex Home-indeling gebruiken'; case 'settings.useGlobalHubsDescription': return 'Toon startpagina-hubs zoals de officiële Plex-client. Indien uitgeschakeld, worden in plaats daarvan aanbevelingen per bibliotheek getoond.'; @@ -8903,7 +8963,13 @@ extension on _StringsSv { case 'settings.appearance': return 'Utseende'; case 'settings.videoPlayback': return 'Videouppspelning'; case 'settings.advanced': return 'Avancerat'; - case 'settings.useSeasonPostersDescription': return 'Visa säsongsaffisch istället för serieaffisch för avsnitt'; + case 'settings.episodePosterMode': return 'Avsnittsaffisch-stil'; + case 'settings.seriesPoster': return 'Serieaffisch'; + case 'settings.seriesPosterDescription': return 'Visa seriens affisch för alla avsnitt'; + case 'settings.seasonPoster': return 'Säsongsaffisch'; + case 'settings.seasonPosterDescription': return 'Visa säsongens affisch för avsnitt'; + case 'settings.episodeThumbnail': return 'Avsnittsminiatyr'; + case 'settings.episodeThumbnailDescription': return 'Visa 16:9 skärmbild från avsnittet'; case 'settings.showHeroSectionDescription': return 'Visa utvalda innehållskarusell på startsidan'; case 'settings.secondsLabel': return 'Sekunder'; case 'settings.minutesLabel': return 'Minuter'; @@ -8926,7 +8992,6 @@ extension on _StringsSv { case 'settings.gridViewDescription': return 'Visa objekt i rutnätslayout'; case 'settings.listView': return 'Lista'; case 'settings.listViewDescription': return 'Visa objekt i listlayout'; - case 'settings.useSeasonPosters': return 'Använd säsongsaffischer'; case 'settings.showHeroSection': return 'Visa hjältesektion'; case 'settings.useGlobalHubs': return 'Använd Plex hem-layout'; case 'settings.useGlobalHubsDescription': return 'Visar startsidans hubbar som den officiella Plex-klienten. När av visas rekommendationer per bibliotek istället.'; @@ -9421,7 +9486,13 @@ extension on _StringsZh { case 'settings.appearance': return '外观'; case 'settings.videoPlayback': return '视频播放'; case 'settings.advanced': return '高级'; - case 'settings.useSeasonPostersDescription': return '为剧集显示季海报而非剧集海报'; + case 'settings.episodePosterMode': return '剧集海报样式'; + case 'settings.seriesPoster': return '剧集海报'; + case 'settings.seriesPosterDescription': return '为所有剧集显示剧集海报'; + case 'settings.seasonPoster': return '季海报'; + case 'settings.seasonPosterDescription': return '为剧集显示特定季的海报'; + case 'settings.episodeThumbnail': return '剧集缩略图'; + case 'settings.episodeThumbnailDescription': return '显示16:9剧集截图缩略图'; case 'settings.showHeroSectionDescription': return '在主屏幕上显示精选内容轮播区'; case 'settings.secondsLabel': return '秒'; case 'settings.minutesLabel': return '分钟'; @@ -9444,7 +9515,6 @@ extension on _StringsZh { case 'settings.gridViewDescription': return '以网格布局显示项目'; case 'settings.listView': return '列表视图'; case 'settings.listViewDescription': return '以列表布局显示项目'; - case 'settings.useSeasonPosters': return '使用季海报'; case 'settings.showHeroSection': return '显示主要精选区'; case 'settings.useGlobalHubs': return '使用 Plex 主页布局'; case 'settings.useGlobalHubsDescription': return '显示与官方 Plex 客户端相同的主页推荐。关闭时将显示按媒体库分类的推荐。'; diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index 14dee7a9..ff885723 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -59,7 +59,13 @@ "appearance": "Appearance", "videoPlayback": "Video Playback", "advanced": "Advanced", - "useSeasonPostersDescription": "Show season poster instead of series poster for episodes", + "episodePosterMode": "Episode Poster Style", + "seriesPoster": "Series Poster", + "seriesPosterDescription": "Show the series poster for all episodes", + "seasonPoster": "Season Poster", + "seasonPosterDescription": "Show the season-specific poster for episodes", + "episodeThumbnail": "Episode Thumbnail", + "episodeThumbnailDescription": "Show 16:9 episode screenshot thumbnails", "showHeroSectionDescription": "Display featured content carousel on home screen", "secondsLabel": "Seconds", "minutesLabel": "Minutes", @@ -82,7 +88,6 @@ "gridViewDescription": "Display items in a grid layout", "listView": "List", "listViewDescription": "Display items in a list layout", - "useSeasonPosters": "Use Season Posters", "showHeroSection": "Show Hero Section", "useGlobalHubs": "Use Plex Home Layout", "useGlobalHubsDescription": "Show home page hubs like the official Plex client. When off, shows per-library recommendations instead.", diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json index 874cbca3..ad3574f5 100644 --- a/lib/i18n/strings_de.i18n.json +++ b/lib/i18n/strings_de.i18n.json @@ -60,7 +60,13 @@ "appearance": "Darstellung", "videoPlayback": "Videowiedergabe", "advanced": "Erweitert", - "useSeasonPostersDescription": "Staffelposter statt Serienposter für Episoden anzeigen", + "episodePosterMode": "Episoden-Poster-Stil", + "seriesPoster": "Serienposter", + "seriesPosterDescription": "Zeige das Serienposter für alle Episoden", + "seasonPoster": "Staffelposter", + "seasonPosterDescription": "Zeige das staffelspezifische Poster für Episoden", + "episodeThumbnail": "Episoden-Miniatur", + "episodeThumbnailDescription": "Zeige 16:9 Episoden-Vorschaubilder", "showHeroSectionDescription": "Bereich mit empfohlenen Inhalten auf der Startseite anzeigen", "secondsLabel": "Sekunden", "minutesLabel": "Minuten", @@ -83,7 +89,6 @@ "gridViewDescription": "Elemente im Raster anzeigen", "listView": "Liste", "listViewDescription": "Elemente in Listenansicht anzeigen", - "useSeasonPosters": "Staffelposter verwenden", "showHeroSection": "Hero-Bereich anzeigen", "useGlobalHubs": "Plex-Startseiten-Layout verwenden", "useGlobalHubsDescription": "Zeigt Startseiten-Hubs wie der offizielle Plex-Client. Wenn deaktiviert, werden stattdessen Empfehlungen pro Bibliothek angezeigt.", diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index b7ea6220..ea832d5b 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -60,7 +60,13 @@ "appearance": "Aspetto", "videoPlayback": "Riproduzione video", "advanced": "Avanzate", - "useSeasonPostersDescription": "Mostra il poster della stagione invece del poster della serie per gli episodi", + "episodePosterMode": "Stile poster episodio", + "seriesPoster": "Poster della serie", + "seriesPosterDescription": "Mostra il poster della serie per tutti gli episodi", + "seasonPoster": "Poster della stagione", + "seasonPosterDescription": "Mostra il poster specifico della stagione per gli episodi", + "episodeThumbnail": "Miniatura episodio", + "episodeThumbnailDescription": "Mostra miniature 16:9 degli episodi", "showHeroSectionDescription": "Visualizza il carosello dei contenuti in primo piano sulla schermata iniziale", "secondsLabel": "Secondi", "minutesLabel": "Minuti", @@ -83,7 +89,6 @@ "gridViewDescription": "Visualizza gli elementi in un layout a griglia", "listView": "Elenco", "listViewDescription": "Visualizza gli elementi in un layout a elenco", - "useSeasonPosters": "Usa poster delle stagioni", "showHeroSection": "Mostra sezione principale", "useGlobalHubs": "Usa layout Home di Plex", "useGlobalHubsDescription": "Mostra gli hub della home page come il client Plex ufficiale. Se disattivato, mostra invece i suggerimenti per libreria.", diff --git a/lib/i18n/strings_ko.i18n.json b/lib/i18n/strings_ko.i18n.json index 50e2ccea..76ea192e 100644 --- a/lib/i18n/strings_ko.i18n.json +++ b/lib/i18n/strings_ko.i18n.json @@ -60,7 +60,13 @@ "appearance": "외관", "videoPlayback": "비디오 재생", "advanced": "고급", - "useSeasonPostersDescription": "에피소드에 시리즈 포스터 대신 시즌 포스터 표시", + "episodePosterMode": "에피소드 포스터 스타일", + "seriesPoster": "시리즈 포스터", + "seriesPosterDescription": "모든 에피소드에 시리즈 포스터 표시", + "seasonPoster": "시즌 포스터", + "seasonPosterDescription": "에피소드에 시즌별 포스터 표시", + "episodeThumbnail": "에피소드 썸네일", + "episodeThumbnailDescription": "16:9 에피소드 스크린샷 썸네일 표시", "showHeroSectionDescription": "홈 화면에 주요 콘텐츠 캐러셀(슬라이드) 표시", "secondsLabel": "초", "minutesLabel": "분", @@ -83,7 +89,6 @@ "gridViewDescription": "항목을 그리드 레이아웃으로 표시합니다", "listView": "목록 보기", "listViewDescription": "항목을 목록 레이아웃으로 표시합니다", - "useSeasonPosters": "시즌 포스터 사용", "showHeroSection": "주요 추천 영역 표시", "useGlobalHubs": "Plex 홈 레이아웃 사용", "useGlobalHubsDescription": "공식 Plex 클라이언트처럼 홈 페이지 허브를 표시합니다. 끄면 라이브러리별 추천이 대신 표시됩니다.", diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index 04370240..b38454be 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -60,7 +60,13 @@ "appearance": "Uiterlijk", "videoPlayback": "Video afspelen", "advanced": "Geavanceerd", - "useSeasonPostersDescription": "Toon seizoenposter in plaats van serieposter voor afleveringen", + "episodePosterMode": "Aflevering poster stijl", + "seriesPoster": "Serie poster", + "seriesPosterDescription": "Toon de serie poster voor alle afleveringen", + "seasonPoster": "Seizoen poster", + "seasonPosterDescription": "Toon de seizoensspecifieke poster voor afleveringen", + "episodeThumbnail": "Aflevering miniatuur", + "episodeThumbnailDescription": "Toon 16:9 aflevering miniaturen", "showHeroSectionDescription": "Toon uitgelichte inhoud carrousel op startscherm", "secondsLabel": "Seconden", "minutesLabel": "Minuten", @@ -83,7 +89,6 @@ "gridViewDescription": "Items weergeven in een rasterindeling", "listView": "Lijst", "listViewDescription": "Items weergeven in een lijstindeling", - "useSeasonPosters": "Gebruik seizoenposters", "showHeroSection": "Toon hoofdsectie", "useGlobalHubs": "Plex Home-indeling gebruiken", "useGlobalHubsDescription": "Toon startpagina-hubs zoals de officiële Plex-client. Indien uitgeschakeld, worden in plaats daarvan aanbevelingen per bibliotheek getoond.", diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index 2866267f..004a7986 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -60,7 +60,13 @@ "appearance": "Utseende", "videoPlayback": "Videouppspelning", "advanced": "Avancerat", - "useSeasonPostersDescription": "Visa säsongsaffisch istället för serieaffisch för avsnitt", + "episodePosterMode": "Avsnittsaffisch-stil", + "seriesPoster": "Serieaffisch", + "seriesPosterDescription": "Visa seriens affisch för alla avsnitt", + "seasonPoster": "Säsongsaffisch", + "seasonPosterDescription": "Visa säsongens affisch för avsnitt", + "episodeThumbnail": "Avsnittsminiatyr", + "episodeThumbnailDescription": "Visa 16:9 skärmbild från avsnittet", "showHeroSectionDescription": "Visa utvalda innehållskarusell på startsidan", "secondsLabel": "Sekunder", "minutesLabel": "Minuter", @@ -83,7 +89,6 @@ "gridViewDescription": "Visa objekt i rutnätslayout", "listView": "Lista", "listViewDescription": "Visa objekt i listlayout", - "useSeasonPosters": "Använd säsongsaffischer", "showHeroSection": "Visa hjältesektion", "useGlobalHubs": "Använd Plex hem-layout", "useGlobalHubsDescription": "Visar startsidans hubbar som den officiella Plex-klienten. När av visas rekommendationer per bibliotek istället.", diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 8bee6551..40379317 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -60,7 +60,13 @@ "appearance": "外观", "videoPlayback": "视频播放", "advanced": "高级", - "useSeasonPostersDescription": "为剧集显示季海报而非剧集海报", + "episodePosterMode": "剧集海报样式", + "seriesPoster": "剧集海报", + "seriesPosterDescription": "为所有剧集显示剧集海报", + "seasonPoster": "季海报", + "seasonPosterDescription": "为剧集显示特定季的海报", + "episodeThumbnail": "剧集缩略图", + "episodeThumbnailDescription": "显示16:9剧集截图缩略图", "showHeroSectionDescription": "在主屏幕上显示精选内容轮播区", "secondsLabel": "秒", "minutesLabel": "分钟", @@ -83,7 +89,6 @@ "gridViewDescription": "以网格布局显示项目", "listView": "列表视图", "listViewDescription": "以列表布局显示项目", - "useSeasonPosters": "使用季海报", "showHeroSection": "显示主要精选区", "useGlobalHubs": "使用 Plex 主页布局", "useGlobalHubsDescription": "显示与官方 Plex 客户端相同的主页推荐。关闭时将显示按媒体库分类的推荐。", diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 616a5697..a5dc78e3 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import '../services/settings_service.dart' show EpisodePosterMode; import 'mixins/multi_server_fields.dart'; import 'plex_role.dart'; @@ -294,27 +295,54 @@ class PlexMetadata with MultiServerFields { return null; } - // Helper to get the poster (show poster for episodes/seasons, thumb otherwise) - // If useSeasonPoster is true, episodes will use season poster instead of series poster - String? posterThumb({bool useSeasonPoster = false}) { + /// Returns the appropriate image path based on episode poster mode. + /// For episodes: + /// - seriesPoster: grandparentThumb (series poster) + /// - seasonPoster: parentThumb (season poster) + /// - episodeThumbnail: thumb (16:9 episode still) + /// For seasons: always returns grandparentThumb (series poster) + /// For movies 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(); if (itemType == 'episode') { - // If season poster is enabled and available, use it - if (useSeasonPoster && parentThumb != null) { - return parentThumb!; - } - // Otherwise fall back to series poster, then item thumb - if (grandparentThumb != null) { - return grandparentThumb!; + switch (mode) { + case EpisodePosterMode.episodeThumbnail: + return thumb; // 16:9 episode thumbnail + case EpisodePosterMode.seasonPoster: + return parentThumb ?? grandparentThumb ?? thumb; + case EpisodePosterMode.seriesPoster: + return grandparentThumb ?? thumb; } } else if (itemType == 'season' && grandparentThumb != null) { // For seasons, always use series poster return grandparentThumb!; } + + // For movies in mixed hub context with episode thumbnail mode, use art (16:9) + if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail && itemType == 'movie') { + return art ?? thumb; + } + return thumb; } + /// Returns true if this item should use 16:9 aspect ratio. + /// Episodes use 16:9 when in episodeThumbnail mode. + /// Movies use 16:9 when in mixed hub context with episodeThumbnail mode. + bool usesWideAspectRatio(EpisodePosterMode mode, {bool mixedHubContext = false}) { + final itemType = type.toLowerCase(); + if (itemType == 'episode' && mode == EpisodePosterMode.episodeThumbnail) { + return true; + } + // Movies use 16:9 art in mixed hubs with episode thumbnail mode + if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail && itemType == 'movie') { + return true; + } + return false; + } + // Helper to determine if content is watched bool get isWatched { // For series/seasons, check if all episodes are watched diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 4f114bcb..685bd9ac 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; +import '../i18n/strings.g.dart'; import '../services/settings_service.dart'; class SettingsProvider extends ChangeNotifier { SettingsService? _settingsService; LibraryDensity _libraryDensity = LibraryDensity.normal; ViewMode _viewMode = ViewMode.grid; - bool _useSeasonPoster = false; + EpisodePosterMode _episodePosterMode = EpisodePosterMode.seriesPoster; bool _showHeroSection = true; bool _useGlobalHubs = true; bool _isInitialized = false; @@ -26,7 +27,7 @@ class SettingsProvider extends ChangeNotifier { _settingsService = await SettingsService.getInstance(); _libraryDensity = _settingsService!.getLibraryDensity(); _viewMode = _settingsService!.getViewMode(); - _useSeasonPoster = _settingsService!.getUseSeasonPoster(); + _episodePosterMode = _settingsService!.getEpisodePosterMode(); _showHeroSection = _settingsService!.getShowHeroSection(); _useGlobalHubs = _settingsService!.getUseGlobalHubs(); _isInitialized = true; @@ -40,7 +41,7 @@ class SettingsProvider extends ChangeNotifier { ViewMode get viewMode => _viewMode; - bool get useSeasonPoster => _useSeasonPoster; + EpisodePosterMode get episodePosterMode => _episodePosterMode; bool get showHeroSection => _showHeroSection; @@ -64,11 +65,11 @@ class SettingsProvider extends ChangeNotifier { } } - Future setUseSeasonPoster(bool value) async { + Future setEpisodePosterMode(EpisodePosterMode mode) async { if (!_isInitialized) await _initializeSettings(); - if (_useSeasonPoster != value) { - _useSeasonPoster = value; - await _settingsService!.setUseSeasonPoster(value); + if (_episodePosterMode != mode) { + _episodePosterMode = mode; + await _settingsService!.setEpisodePosterMode(mode); notifyListeners(); } } @@ -101,4 +102,15 @@ class SettingsProvider extends ChangeNotifier { return 'Comfortable'; } } + + String get episodePosterModeDisplayName { + switch (_episodePosterMode) { + case EpisodePosterMode.seriesPoster: + return t.settings.seriesPoster; + case EpisodePosterMode.seasonPoster: + return t.settings.seasonPoster; + case EpisodePosterMode.episodeThumbnail: + return t.settings.episodeThumbnail; + } + } } diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 5c54890d..8bc6023b 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -18,7 +18,7 @@ import '../filters_bottom_sheet.dart'; import '../sort_bottom_sheet.dart'; import '../state_messages.dart'; import '../../../services/storage_service.dart'; -import '../../../services/settings_service.dart' show ViewMode; +import '../../../services/settings_service.dart' show ViewMode, EpisodePosterMode; import '../../../mixins/item_updatable.dart'; import '../../../i18n/strings.g.dart'; import 'base_library_tab.dart'; @@ -570,9 +570,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildMediaCardItem(index, isFirstRow: _isFirstRow(index, columnCount)), ); diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 67dcd6d5..a39bb25f 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -189,14 +189,12 @@ class _SettingsScreenState extends State { ), Consumer( builder: (context, settingsProvider, child) { - return SwitchListTile( - secondary: const AppIcon(Symbols.image_rounded, fill: 1), - title: Text(t.settings.useSeasonPosters), - subtitle: Text(t.settings.useSeasonPostersDescription), - value: settingsProvider.useSeasonPoster, - onChanged: (value) async { - await settingsProvider.setUseSeasonPoster(value); - }, + return ListTile( + leading: const AppIcon(Symbols.image_rounded, fill: 1), + title: Text(t.settings.episodePosterMode), + subtitle: Text(settingsProvider.episodePosterModeDisplayName), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => _showEpisodePosterModeDialog(), ); }, ), @@ -1308,6 +1306,31 @@ class _SettingsScreenState extends State { onSelect: (value, provider) => provider.setViewMode(value), ); } + + void _showEpisodePosterModeDialog() { + _showOptionSelectionDialog( + title: t.settings.episodePosterMode, + options: [ + _DialogOption( + value: settings.EpisodePosterMode.seriesPoster, + title: t.settings.seriesPoster, + subtitle: t.settings.seriesPosterDescription, + ), + _DialogOption( + value: settings.EpisodePosterMode.seasonPoster, + title: t.settings.seasonPoster, + subtitle: t.settings.seasonPosterDescription, + ), + _DialogOption( + value: settings.EpisodePosterMode.episodeThumbnail, + title: t.settings.episodeThumbnail, + subtitle: t.settings.episodeThumbnailDescription, + ), + ], + getCurrentValue: (p) => p.episodePosterMode, + onSelect: (value, provider) => provider.setEpisodePosterMode(value), + ); + } } class _KeyboardShortcutsScreen extends StatefulWidget { diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 8f40371b..e8df9750 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -13,6 +13,8 @@ enum LibraryDensity { compact, normal, comfortable } enum ViewMode { grid, list } +enum EpisodePosterMode { seriesPoster, seasonPoster, episodeThumbnail } + class SettingsService extends BaseSharedPreferencesService { static const String _keyThemeMode = 'theme_mode'; static const String _keyEnableDebugLogging = 'enable_debug_logging'; @@ -25,7 +27,8 @@ class SettingsService extends BaseSharedPreferencesService { static const String _keyPreferredAudioCodec = 'preferred_audio_codec'; static const String _keyLibraryDensity = 'library_density'; static const String _keyViewMode = 'view_mode'; - static const String _keyUseSeasonPoster = 'use_season_poster'; + static const String _keyUseSeasonPoster = 'use_season_poster'; // Legacy key for migration + static const String _keyEpisodePosterMode = 'episode_poster_mode'; static const String _keySeekTimeSmall = 'seek_time_small'; static const String _keySeekTimeLarge = 'seek_time_large'; static const String _keyMediaVersionPreferences = 'media_version_preferences'; @@ -153,13 +156,23 @@ class SettingsService extends BaseSharedPreferencesService { return _getEnumValue(_keyViewMode, ViewMode.values, ViewMode.grid); } - // Use Season Poster - Future setUseSeasonPoster(bool enabled) async { - await prefs.setBool(_keyUseSeasonPoster, enabled); + // Episode Poster Mode + Future setEpisodePosterMode(EpisodePosterMode mode) async { + await prefs.setString(_keyEpisodePosterMode, mode.name); } - bool getUseSeasonPoster() { - return prefs.getBool(_keyUseSeasonPoster) ?? false; // Default: false (use series poster) + EpisodePosterMode getEpisodePosterMode() { + // Migration: check old boolean key first + final legacyValue = prefs.getBool(_keyUseSeasonPoster); + if (legacyValue != null) { + // Migrate old setting: true = seasonPoster, false = seriesPoster + final migratedMode = legacyValue ? EpisodePosterMode.seasonPoster : EpisodePosterMode.seriesPoster; + // Clear old key and save new format (fire and forget) + prefs.remove(_keyUseSeasonPoster); + prefs.setString(_keyEpisodePosterMode, migratedMode.name); + return migratedMode; + } + return _getEnumValue(_keyEpisodePosterMode, EpisodePosterMode.values, EpisodePosterMode.episodeThumbnail); } // Show Hero Section @@ -928,7 +941,8 @@ class SettingsService extends BaseSharedPreferencesService { prefs.remove(_keyPreferredAudioCodec), prefs.remove(_keyLibraryDensity), prefs.remove(_keyViewMode), - prefs.remove(_keyUseSeasonPoster), + prefs.remove(_keyUseSeasonPoster), // Legacy key + prefs.remove(_keyEpisodePosterMode), prefs.remove(_keyShowHeroSection), prefs.remove(_keySeekTimeSmall), prefs.remove(_keySeekTimeLarge), @@ -978,7 +992,7 @@ class SettingsService extends BaseSharedPreferencesService { 'preferredAudioCodec': getPreferredAudioCodec(), 'libraryDensity': getLibraryDensity().name, 'viewMode': getViewMode().name, - 'useSeasonPoster': getUseSeasonPoster(), + 'episodePosterMode': getEpisodePosterMode().name, 'seekTimeSmall': getSeekTimeSmall(), 'seekTimeLarge': getSeekTimeLarge(), 'keyboardShortcuts': getKeyboardShortcuts(), diff --git a/lib/utils/layout_constants.dart b/lib/utils/layout_constants.dart index c35550c3..f2160b25 100644 --- a/lib/utils/layout_constants.dart +++ b/lib/utils/layout_constants.dart @@ -59,9 +59,16 @@ class GridLayoutConstants { static const double normalTablet = 200; static const double normalMobile = 170; - /// Default aspect ratio for media cards (poster) + /// Default aspect ratio for media card grid cells (poster + text) static const double posterAspectRatio = 2 / 3.3; + /// Aspect ratio for episode thumbnail image (16:9) + static const double episodeThumbnailAspectRatio = 16 / 9; + + /// Aspect ratio for episode thumbnail grid cells (16:9 image + text area) + /// This is wider than posterAspectRatio but accounts for ~60px text below + static const double episodeGridCellAspectRatio = 1.4; + /// Grid spacing (edge-to-edge cards) static const double crossAxisSpacing = 0; static const double mainAxisSpacing = 0; diff --git a/lib/utils/plex_image_helper.dart b/lib/utils/plex_image_helper.dart index 519a6a74..95163662 100644 --- a/lib/utils/plex_image_helper.dart +++ b/lib/utils/plex_image_helper.dart @@ -139,9 +139,9 @@ class PlexImageHelper { return ''; } - // For art/backgrounds and clear logos, prefer the original image to avoid - // any aspect ratio changes from Plex photo transcoding. - if (imageType == ImageType.art || imageType == ImageType.logo) { + // For art/backgrounds, clear logos, and episode thumbnails, prefer the + // original image to avoid any aspect ratio changes from Plex photo transcoding. + if (imageType == ImageType.art || imageType == ImageType.logo || imageType == ImageType.thumb) { return client.getThumbnailUrl(basePath); } diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index f7218e40..16b0d200 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -1,7 +1,10 @@ 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 '../focus/dpad_navigator.dart'; +import '../providers/settings_provider.dart'; +import '../services/settings_service.dart' show EpisodePosterMode; import '../theme/mono_tokens.dart'; import '../utils/layout_constants.dart'; import '../focus/locked_hub_controller.dart'; @@ -286,9 +289,9 @@ class HubSectionState extends State { onKeyEvent: _handleKeyEvent, child: LayoutBuilder( builder: (context, constraints) { - // Responsive card width based on screen size + // Responsive base card width for posters (2:3 aspect ratio) final screenWidth = constraints.maxWidth; - final cardWidth = ScreenBreakpoints.isLargeDesktop(screenWidth) + final baseCardWidth = ScreenBreakpoints.isLargeDesktop(screenWidth) ? 220.0 : ScreenBreakpoints.isDesktop(screenWidth) ? 200.0 @@ -296,15 +299,35 @@ class HubSectionState extends State { ? 190.0 : 160.0; - // Store item extent for scroll calculations - _itemExtent = cardWidth + 4; // 4px total horizontal padding + // Get episode poster mode setting + final episodePosterMode = context.watch().episodePosterMode; + + // Determine hub content type for layout decisions + final hasEpisodes = widget.hub.items.any((item) => + item.usesWideAspectRatio(episodePosterMode)); + final hasNonEpisodes = widget.hub.items.any((item) => + !item.usesWideAspectRatio(episodePosterMode)); + + // Mixed hub = has both episodes AND non-episodes (like Continue Watching) + final isMixedHub = hasEpisodes && hasNonEpisodes; + + // Episode-only = all items are episodes with thumbnails + final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; + + // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) + final useWideLayout = episodePosterMode == EpisodePosterMode.episodeThumbnail && + (isEpisodeOnlyHub || isMixedHub); + + // Card dimensions based on hub type + const wideCardMultiplier = 1.5; + final cardWidth = useWideLayout ? baseCardWidth * wideCardMultiplier : baseCardWidth; + final posterWidth = cardWidth - 16; // 8px padding on each side + final posterHeight = useWideLayout + ? posterWidth * (9 / 16) // 16:9 for wide layout + : posterWidth * 1.5; // 2:3 for poster layout - // MediaCard has 8px padding on all sides (16px total horizontally) - final posterWidth = cardWidth - 16; - // 2:3 poster aspect ratio - final posterHeight = posterWidth * 1.5; - // Container height calculation final containerHeight = posterHeight + 66; + _itemExtent = cardWidth + 4; return SizedBox( height: containerHeight, @@ -334,6 +357,7 @@ class HubSectionState extends State { onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, forceGridMode: true, isInContinueWatching: widget.isInContinueWatching, + mixedHubContext: isMixedHub, ), ), ); diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 0089426f..f83a6381 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -30,6 +30,7 @@ class MediaCard extends StatefulWidget { final bool isInContinueWatching; final String? collectionId; // The collection ID if displaying within a collection final bool isOffline; // True for downloaded content without server access + final bool mixedHubContext; // True when in a hub with mixed content (movies + episodes) const MediaCard({ super.key, @@ -43,6 +44,7 @@ class MediaCard extends StatefulWidget { this.isInContinueWatching = false, this.collectionId, this.isOffline = false, + this.mixedHubContext = false, }); @override @@ -169,6 +171,7 @@ class MediaCardState extends State { onLongPress: _showContextMenu, isOffline: widget.isOffline, localPosterPath: localPosterPath, + mixedHubContext: widget.mixedHubContext, ) : _MediaCardList( item: widget.item, @@ -205,6 +208,7 @@ class _MediaCardGrid extends StatelessWidget { final VoidCallback onLongPress; final bool isOffline; final String? localPosterPath; + final bool mixedHubContext; const _MediaCardGrid({ required this.item, @@ -215,6 +219,7 @@ class _MediaCardGrid extends StatelessWidget { required this.onLongPress, this.isOffline = false, this.localPosterPath, + this.mixedHubContext = false, }); @override @@ -269,7 +274,7 @@ class _MediaCardGrid extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(tokens(context).radiusSm), - child: _buildPosterImage(context, item, isOffline: isOffline, localPosterPath: localPosterPath), + child: _buildPosterImage(context, item, isOffline: isOffline, localPosterPath: localPosterPath, mixedHubContext: mixedHubContext), ), _PosterOverlay(item: item), ], @@ -297,7 +302,7 @@ class _MediaCardList extends StatelessWidget { this.localPosterPath, }); - double get _posterWidth { + double _basePosterWidth() { switch (density) { case LibraryDensity.compact: return 80; @@ -308,8 +313,29 @@ class _MediaCardList extends StatelessWidget { } } - double get _posterHeight { - return _posterWidth * 1.5; // Maintain 2:3 aspect ratio + double _posterWidth(BuildContext context) { + final base = _basePosterWidth(); + // For episodes with thumbnail mode, use wider width to maintain reasonable thumbnail size + if (item is PlexMetadata) { + final mode = context.watch().episodePosterMode; + if ((item as PlexMetadata).usesWideAspectRatio(mode)) { + return base * 1.6; // Wider for 16:9 thumbnails + } + } + return base; + } + + double _posterHeight(BuildContext context) { + final base = _basePosterWidth(); + // For episodes with thumbnail mode, use 16:9 aspect ratio + if (item is PlexMetadata) { + final mode = context.watch().episodePosterMode; + if ((item as PlexMetadata).usesWideAspectRatio(mode)) { + // 16:9: height = width * 9/16 = base * 1.6 * 9/16 = base * 0.9 + return base * 0.9; + } + } + return base * 1.5; // Default 2:3 aspect ratio } double get _titleFontSize { @@ -467,8 +493,8 @@ class _MediaCardList extends StatelessWidget { children: [ // Poster (responsive size based on density) SizedBox( - width: _posterWidth, - height: _posterHeight, + width: _posterWidth(context), + height: _posterHeight(context), child: Stack( children: [ ClipRRect( @@ -545,7 +571,7 @@ class _MediaCardList extends StatelessWidget { } } -Widget _buildPosterImage(BuildContext context, dynamic item, {bool isOffline = false, String? localPosterPath}) { +Widget _buildPosterImage(BuildContext context, dynamic item, {bool isOffline = false, String? localPosterPath, bool mixedHubContext = false}) { String? posterUrl; IconData fallbackIcon = Symbols.movie_rounded; @@ -562,8 +588,20 @@ Widget _buildPosterImage(BuildContext context, dynamic item, {bool isOffline = f localFilePath: localPosterPath, ); } else if (item is PlexMetadata) { - final useSeasonPoster = context.watch().useSeasonPoster; - posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); + final episodePosterMode = context.watch().episodePosterMode; + posterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext); + + // Use thumb image type for 16:9 content (episodes, or movies in mixed hubs) + if (item.usesWideAspectRatio(episodePosterMode, mixedHubContext: mixedHubContext)) { + return PlexOptimizedImage.thumb( + client: isOffline ? null : context.getClientWithFallback(item.serverId), + imagePath: posterUrl, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + localFilePath: localPosterPath, + ); + } return PlexOptimizedImage.poster( client: isOffline ? null : context.getClientWithFallback(item.serverId), diff --git a/lib/widgets/media_grid_delegate.dart b/lib/widgets/media_grid_delegate.dart index 39ea8787..3e400597 100644 --- a/lib/widgets/media_grid_delegate.dart +++ b/lib/widgets/media_grid_delegate.dart @@ -10,19 +10,31 @@ class MediaGridDelegate { /// /// Uses [GridSizeCalculator.getMaxCrossAxisExtent] by default. /// Set [usePaddingAware] to true to use [GridSizeCalculator.getMaxCrossAxisExtentWithPadding] instead. + /// Set [useWideAspectRatio] to true to use 16:9 aspect ratio for episode thumbnails. static SliverGridDelegateWithMaxCrossAxisExtent createDelegate({ required BuildContext context, required LibraryDensity density, bool usePaddingAware = false, double horizontalPadding = 16, + bool useWideAspectRatio = false, }) { - final maxCrossAxisExtent = usePaddingAware + var maxCrossAxisExtent = usePaddingAware ? GridSizeCalculator.getMaxCrossAxisExtentWithPadding(context, density, horizontalPadding) : GridSizeCalculator.getMaxCrossAxisExtent(context, density); + final aspectRatio = useWideAspectRatio + ? GridLayoutConstants.episodeGridCellAspectRatio + : GridLayoutConstants.posterAspectRatio; + + // For wide aspect ratio (16:9), increase max extent so items are larger + // and there are fewer per row (roughly 1.8x wider to maintain similar visual area) + if (useWideAspectRatio) { + maxCrossAxisExtent *= 1.8; + } + return SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: maxCrossAxisExtent, - childAspectRatio: GridLayoutConstants.posterAspectRatio, + childAspectRatio: aspectRatio, crossAxisSpacing: GridLayoutConstants.crossAxisSpacing, mainAxisSpacing: GridLayoutConstants.mainAxisSpacing, ); diff --git a/lib/widgets/plex_optimized_image.dart b/lib/widgets/plex_optimized_image.dart index 38e227e9..b7a4e7c0 100644 --- a/lib/widgets/plex_optimized_image.dart +++ b/lib/widgets/plex_optimized_image.dart @@ -241,11 +241,14 @@ class PlexOptimizedImage extends StatelessWidget { // Pick the explicit size when it's a finite positive number, otherwise // fall back to the constraint or a sensible default so we don't end up // with NaN/Infinity when rounding to ints for caching. - final candidate = explicit ?? (constraintMax.isFinite && constraintMax > 0 ? constraintMax : fallback); - if (candidate.isNaN || candidate.isInfinite || candidate <= 0) { + // When explicit is infinite (double.infinity), prefer the constraint over fallback. + if (explicit == null || explicit.isNaN || explicit.isInfinite || explicit <= 0) { + if (constraintMax.isFinite && constraintMax > 0) { + return constraintMax; + } return fallback; } - return candidate; + return explicit; } // Return empty container if no image path