diff --git a/lib/focus/focus_theme.dart b/lib/focus/focus_theme.dart index 8b9537c6..88bcf41c 100644 --- a/lib/focus/focus_theme.dart +++ b/lib/focus/focus_theme.dart @@ -5,8 +5,12 @@ class FocusTheme { FocusTheme._(); static const double focusScale = 1.02; + static const double fullCardFocusScale = 1.03; static const double focusBorderWidth = 2.5; static const double defaultBorderRadius = 8.0; + static const double focusGlowInnerBlurRadius = 18; + static const double focusGlowOuterBlurRadius = 34; + static const double focusGlowSpreadRadius = 1.5; static Color getFocusBorderColor(BuildContext context) { return Theme.of(context).colorScheme.primary; @@ -20,13 +24,42 @@ class FocusTheme { BuildContext context, { required bool isFocused, double borderRadius = defaultBorderRadius, + double borderStrokeAlign = BorderSide.strokeAlignInside, Color? color, }) { final focusColor = color ?? getFocusBorderColor(context); return BoxDecoration( borderRadius: BorderRadius.circular(borderRadius), - border: Border.all(color: isFocused ? focusColor : Colors.transparent, width: focusBorderWidth), + border: Border.all( + color: isFocused ? focusColor : Colors.transparent, + width: focusBorderWidth, + strokeAlign: borderStrokeAlign, + ), + ); + } + + static BoxDecoration focusGlowDecoration( + BuildContext context, { + required bool isFocused, + double borderRadius = defaultBorderRadius, + Color? color, + }) { + final focusColor = color ?? getFocusBorderColor(context); + + return BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + boxShadow: [ + BoxShadow( + color: isFocused ? focusColor.withValues(alpha: 0.34) : Colors.transparent, + blurRadius: focusGlowInnerBlurRadius, + spreadRadius: focusGlowSpreadRadius, + ), + BoxShadow( + color: isFocused ? focusColor.withValues(alpha: 0.20) : Colors.transparent, + blurRadius: focusGlowOuterBlurRadius, + ), + ], ); } diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 47f16aef..bf9c85be 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -106,6 +106,18 @@ class FocusableWrapper extends StatefulWidget { /// Useful for elements like sliders where scaling looks odd. final bool disableScale; + /// Scale used for the focus animation. + final double focusScale; + + /// Stroke alignment for the focus border. + final double focusBorderStrokeAlign; + + /// Whether to draw a glow around the focused widget. + final bool useFocusGlow; + + /// Whether to draw the focus border as a foreground decoration. + final bool useForegroundFocusDecoration; + /// Whether descendants can receive focus. /// Set to false when the child widget has its own Focus (e.g. buttons) /// that would compete with this wrapper's focus handling. @@ -136,6 +148,10 @@ class FocusableWrapper extends StatefulWidget { this.useBackgroundFocus = false, this.focusColor, this.disableScale = false, + this.focusScale = FocusTheme.focusScale, + this.focusBorderStrokeAlign = BorderSide.strokeAlignInside, + this.useFocusGlow = false, + this.useForegroundFocusDecoration = false, this.descendantsAreFocusable = true, }); @@ -149,7 +165,7 @@ class _FocusableWrapperState extends State with SingleTickerPr bool _isFocused = false; late final AnimationController _animationController; - late final Animation _scaleAnimation; + late Animation _scaleAnimation; // Long-press detection for SELECT key Timer? _longPressTimer; @@ -178,9 +194,13 @@ class _FocusableWrapperState extends State with SingleTickerPr void _initAnimations() { _animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 150)); - _scaleAnimation = Tween( + _scaleAnimation = _createScaleAnimation(); + } + + Animation _createScaleAnimation() { + return Tween( begin: 1.0, - end: FocusTheme.focusScale, + end: widget.focusScale, ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic)); } @@ -200,6 +220,10 @@ class _FocusableWrapperState extends State with SingleTickerPr if (widget.canRequestFocus != oldWidget.canRequestFocus) { _focusNode.canRequestFocus = widget.canRequestFocus; } + + if (widget.focusScale != oldWidget.focusScale) { + _scaleAnimation = _createScaleAnimation(); + } } @override @@ -447,14 +471,23 @@ class _FocusableWrapperState extends State with SingleTickerPr } // Choose decoration based on useBackgroundFocus - final decoration = widget.useBackgroundFocus + final focusDecoration = widget.useBackgroundFocus ? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius) : FocusTheme.focusDecoration( context, isFocused: showFocus, borderRadius: widget.borderRadius, color: widget.focusColor, + borderStrokeAlign: widget.focusBorderStrokeAlign, ); + final glowDecoration = widget.useFocusGlow + ? FocusTheme.focusGlowDecoration( + context, + isFocused: showFocus, + borderRadius: widget.borderRadius, + color: widget.focusColor, + ) + : null; Widget result = Focus( focusNode: _focusNode, @@ -471,7 +504,8 @@ class _FocusableWrapperState extends State with SingleTickerPr child: AnimatedContainer( duration: duration, curve: Curves.easeOutCubic, - decoration: decoration, + decoration: widget.useForegroundFocusDecoration ? glowDecoration : focusDecoration, + foregroundDecoration: widget.useForegroundFocusDecoration ? focusDecoration : null, child: widget.child, ), ); diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index ff0a1570..e90d6263 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "Показвай сезон и номер на епизод върху картите на епизодите", "showSeasonPostersOnTabs": "Показвай постери на сезоните в табовете", "showSeasonPostersOnTabsDescription": "Показвай постера на всеки сезон над неговия таб", + "tvFullCardLayout": "Пълни TV карти", + "tvFullCardLayoutDescription": "Използвай TV карти само с изображения, с насложени имена на актьорите", "hideSpoilers": "Скривай спойлери за негледани епизоди", "hideSpoilersDescription": "Замазвай миниатюри и описания за негледани епизоди", "playerBackend": "Енджин на плеъра", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 04c7737f..b053a16c 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "Vis sæson- og episodenummer på episodekort", "showSeasonPostersOnTabs": "Vis sæsonplakater på faner", "showSeasonPostersOnTabsDescription": "Vis hver sæsons plakat over dens fane", + "tvFullCardLayout": "Fuldflade TV-kort", + "tvFullCardLayoutDescription": "Brug TV-kort kun med billeder og skuespillernavne ovenpå", "hideSpoilers": "Skjul spoilere for usete episoder", "hideSpoilersDescription": "Slør miniaturebilleder og beskrivelser for usete episoder", "playerBackend": "Afspillerbackend", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 4ddca97b..2b6f4d10 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "Staffel- und Episodennummer auf Episodenkarten anzeigen", "showSeasonPostersOnTabs": "Staffelposter auf Tabs anzeigen", "showSeasonPostersOnTabsDescription": "Poster jeder Staffel über ihrem Tab anzeigen", + "tvFullCardLayout": "Vollflächige TV-Karten", + "tvFullCardLayoutDescription": "TV-Karten nur mit Bild verwenden und Darstellernamen einblenden", "hideSpoilers": "Spoiler für nicht gesehene Episoden verbergen", "hideSpoilersDescription": "Vorschaubilder und Beschreibungen ungesehener Episoden verwischen", "playerBackend": "Player-Backend", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 42a2b809..c8d7e49a 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "Show season and episode number on episode cards", "showSeasonPostersOnTabs": "Show Season Posters on Tabs", "showSeasonPostersOnTabsDescription": "Show each season's poster above its tab", + "tvFullCardLayout": "Full TV Cards", + "tvFullCardLayoutDescription": "Use image-only TV cards with actor names overlaid", "hideSpoilers": "Hide Spoilers for Unwatched Episodes", "hideSpoilersDescription": "Blur thumbnails and descriptions for unwatched episodes", "playerBackend": "Player Backend", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 48a556b3..23ebcf01 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "Mostrar temporada y episodio en tarjetas de episodio", "showSeasonPostersOnTabs": "Mostrar pósters de temporada en las pestañas", "showSeasonPostersOnTabsDescription": "Mostrar el póster de cada temporada sobre su pestaña", + "tvFullCardLayout": "Tarjetas TV completas", + "tvFullCardLayoutDescription": "Usar tarjetas TV solo con imagen y nombres de actores superpuestos", "hideSpoilers": "Ocultar spoilers de episodios no vistos", "hideSpoilersDescription": "Desenfocar miniaturas y descripciones de episodios no vistos", "playerBackend": "Reproductor", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index b39f5cfb..4f815422 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "Afficher la saison et l'épisode sur les cartes d'épisode", "showSeasonPostersOnTabs": "Afficher les posters de saison sur les onglets", "showSeasonPostersOnTabsDescription": "Afficher l'affiche de chaque saison au-dessus de son onglet", + "tvFullCardLayout": "Cartes TV pleines", + "tvFullCardLayoutDescription": "Utiliser des cartes TV avec image seule et noms des acteurs superposés", "hideSpoilers": "Masquer les spoilers des épisodes non vus", "hideSpoilersDescription": "Flouter les miniatures et descriptions des épisodes non vus", "playerBackend": "Moteur de lecture", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index fdab1508..a07a4158 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "Mostra stagione ed episodio sulle schede episodio", "showSeasonPostersOnTabs": "Mostra poster delle stagioni sulle schede", "showSeasonPostersOnTabsDescription": "Mostra il poster di ogni stagione sopra la sua scheda", + "tvFullCardLayout": "Schede TV piene", + "tvFullCardLayoutDescription": "Usa schede TV solo immagine con i nomi degli attori sovrapposti", "hideSpoilers": "Nascondi spoiler per episodi non visti", "hideSpoilersDescription": "Sfoca miniature e descrizioni degli episodi non visti", "playerBackend": "Motore di riproduzione", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 91d3b6d7..9c9878f2 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "エピソードカードにシーズン番号とエピソード番号を表示します", "showSeasonPostersOnTabs": "タブにシーズンポスターを表示", "showSeasonPostersOnTabsDescription": "各シーズンのポスターをタブの上に表示します", + "tvFullCardLayout": "フルTVカード", + "tvFullCardLayoutDescription": "TVカードを画像のみで表示し、俳優名を重ねて表示します", "hideSpoilers": "未視聴エピソードのネタバレを非表示", "hideSpoilersDescription": "未視聴エピソードのサムネイルと説明をぼかします", "playerBackend": "プレーヤーバックエンド", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 0f674c6e..947cd1c9 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "에피소드 카드에 시즌 및 에피소드 번호 표시", "showSeasonPostersOnTabs": "탭에 시즌 포스터 표시", "showSeasonPostersOnTabsDescription": "각 시즌 포스터를 탭 위에 표시", + "tvFullCardLayout": "전체 TV 카드", + "tvFullCardLayoutDescription": "TV 카드에 이미지만 표시하고 배우 이름을 오버레이로 표시", "hideSpoilers": "미시청 에피소드 스포일러 숨기기", "hideSpoilersDescription": "시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리", "playerBackend": "플레이어 백엔드", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 6802e5d7..452f8a79 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "Vis sesong- og episodenummer på episodekort", "showSeasonPostersOnTabs": "Vis sesongplakater på faner", "showSeasonPostersOnTabsDescription": "Vis hver sesongs plakat over fanen", + "tvFullCardLayout": "Fulle TV-kort", + "tvFullCardLayoutDescription": "Bruk bildebaserte TV-kort med skuespillernavn lagt over", "hideSpoilers": "Skjul spoilere for usette episoder", "hideSpoilersDescription": "Slør miniatyrbilder og beskrivelser for usette episoder", "playerBackend": "Spillermotor", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index a074f63e..86c93759 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "Toon seizoen- en afleveringsnummer op afleveringskaarten", "showSeasonPostersOnTabs": "Toon seizoensposters op tabbladen", "showSeasonPostersOnTabsDescription": "Toon de poster van elk seizoen boven het tabblad", + "tvFullCardLayout": "Volledige tv-kaarten", + "tvFullCardLayoutDescription": "Gebruik tv-kaarten met alleen afbeeldingen en namen van acteurs als overlay", "hideSpoilers": "Spoilers voor ongekeken afleveringen verbergen", "hideSpoilersDescription": "Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen", "playerBackend": "Speler backend", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 09ac4c16..56196b34 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "Pokazuj numer sezonu i odcinka na kartach odcinków", "showSeasonPostersOnTabs": "Pokaż plakaty sezonów na zakładkach", "showSeasonPostersOnTabsDescription": "Pokazuj plakat każdego sezonu nad jego kartą", + "tvFullCardLayout": "Pełne karty TV", + "tvFullCardLayoutDescription": "Używaj kart TV tylko z obrazem i nałożonymi nazwiskami aktorów", "hideSpoilers": "Ukryj spoilery nieobejrzanych odcinków", "hideSpoilersDescription": "Rozmywaj miniatury i opisy nieobejrzanych odcinków", "playerBackend": "Backend odtwarzacza", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index b9799458..fc684349 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "Mostrar temporada e episódio nos cartões de episódio", "showSeasonPostersOnTabs": "Mostrar Pôsteres de Temporada nas Abas", "showSeasonPostersOnTabsDescription": "Mostrar o pôster de cada temporada acima da aba", + "tvFullCardLayout": "Cartões TV completos", + "tvFullCardLayoutDescription": "Usar cartões de TV só com imagem e nomes dos atores sobrepostos", "hideSpoilers": "Ocultar Spoilers de Episódios Não Assistidos", "hideSpoilersDescription": "Desfocar miniaturas e descrições de episódios não vistos", "playerBackend": "Backend do Player", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 8be44f81..71222002 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "Показывать номер сезона и серии на карточках серий", "showSeasonPostersOnTabs": "Показывать постеры сезонов на вкладках", "showSeasonPostersOnTabsDescription": "Показывать постер каждого сезона над его вкладкой", + "tvFullCardLayout": "Полные TV-карточки", + "tvFullCardLayoutDescription": "Использовать TV-карточки только с изображением и именами актёров поверх него", "hideSpoilers": "Скрыть спойлеры непросмотренных эпизодов", "hideSpoilersDescription": "Размывать миниатюры и описания непросмотренных серий", "playerBackend": "Бэкенд плеера", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index b75caa69..66ff8329 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 18832 (1177 per locale) +/// Strings: 18864 (1179 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index bafc657b..47412bca 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsBg extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => 'Показвай сезон и номер на епизод върху картите на епизодите'; @override String get showSeasonPostersOnTabs => 'Показвай постери на сезоните в табовете'; @override String get showSeasonPostersOnTabsDescription => 'Показвай постера на всеки сезон над неговия таб'; + @override String get tvFullCardLayout => 'Пълни TV карти'; + @override String get tvFullCardLayoutDescription => 'Използвай TV карти само с изображения, с насложени имена на актьорите'; @override String get hideSpoilers => 'Скривай спойлери за негледани епизоди'; @override String get hideSpoilersDescription => 'Замазвай миниатюри и описания за негледани епизоди'; @override String get playerBackend => 'Енджин на плеъра'; @@ -1922,6 +1924,8 @@ extension on TranslationsBg { 'settings.showEpisodeNumberOnCardsDescription' => 'Показвай сезон и номер на епизод върху картите на епизодите', 'settings.showSeasonPostersOnTabs' => 'Показвай постери на сезоните в табовете', 'settings.showSeasonPostersOnTabsDescription' => 'Показвай постера на всеки сезон над неговия таб', + 'settings.tvFullCardLayout' => 'Пълни TV карти', + 'settings.tvFullCardLayoutDescription' => 'Използвай TV карти само с изображения, с насложени имена на актьорите', 'settings.hideSpoilers' => 'Скривай спойлери за негледани епизоди', 'settings.hideSpoilersDescription' => 'Замазвай миниатюри и описания за негледани епизоди', 'settings.playerBackend' => 'Енджин на плеъра', @@ -2316,10 +2320,10 @@ extension on TranslationsBg { 'profiles.active' => 'Активен', 'profiles.manage' => 'Управление', 'profiles.delete' => 'Изтрий', - 'profiles.signOut' => 'Изход', - 'profiles.signOutPlexTitle' => 'Изход от Plex?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'Изход', + 'profiles.signOutPlexTitle' => 'Изход от Plex?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Премахване на ${displayName} и всички Plex Home потребители? Можете да влезете отново по всяко време.', 'profiles.signedOutPlex' => 'Излязохте от Plex.', 'profiles.signOutFailed' => 'Изходът е неуспешен.', @@ -2830,10 +2834,10 @@ extension on TranslationsBg { 'metadataEdit.originalTitle' => 'Оригинално заглавие', 'metadataEdit.releaseDate' => 'Дата на излизане', 'metadataEdit.contentRating' => 'Възрастов рейтинг', - 'metadataEdit.studio' => 'Студио', - 'metadataEdit.tagline' => 'Слоган', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'Студио', + 'metadataEdit.tagline' => 'Слоган', 'metadataEdit.summary' => 'Резюме', 'metadataEdit.poster' => 'Постер', 'metadataEdit.background' => 'Фон', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 73cd207c..5641d442 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsDa extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => 'Vis sæson- og episodenummer på episodekort'; @override String get showSeasonPostersOnTabs => 'Vis sæsonplakater på faner'; @override String get showSeasonPostersOnTabsDescription => 'Vis hver sæsons plakat over dens fane'; + @override String get tvFullCardLayout => 'Fuldflade TV-kort'; + @override String get tvFullCardLayoutDescription => 'Brug TV-kort kun med billeder og skuespillernavne ovenpå'; @override String get hideSpoilers => 'Skjul spoilere for usete episoder'; @override String get hideSpoilersDescription => 'Slør miniaturebilleder og beskrivelser for usete episoder'; @override String get playerBackend => 'Afspillerbackend'; @@ -1922,6 +1924,8 @@ extension on TranslationsDa { 'settings.showEpisodeNumberOnCardsDescription' => 'Vis sæson- og episodenummer på episodekort', 'settings.showSeasonPostersOnTabs' => 'Vis sæsonplakater på faner', 'settings.showSeasonPostersOnTabsDescription' => 'Vis hver sæsons plakat over dens fane', + 'settings.tvFullCardLayout' => 'Fuldflade TV-kort', + 'settings.tvFullCardLayoutDescription' => 'Brug TV-kort kun med billeder og skuespillernavne ovenpå', 'settings.hideSpoilers' => 'Skjul spoilere for usete episoder', 'settings.hideSpoilersDescription' => 'Slør miniaturebilleder og beskrivelser for usete episoder', 'settings.playerBackend' => 'Afspillerbackend', @@ -2316,10 +2320,10 @@ extension on TranslationsDa { 'profiles.active' => 'Aktiv', 'profiles.manage' => 'Administrer', 'profiles.delete' => 'Slet', - 'profiles.signOut' => 'Log ud', - 'profiles.signOutPlexTitle' => 'Log ud af Plex?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'Log ud', + 'profiles.signOutPlexTitle' => 'Log ud af Plex?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Fjern ${displayName} og alle Plex Home-brugere? Log ind igen når som helst.', 'profiles.signedOutPlex' => 'Logget ud af Plex.', 'profiles.signOutFailed' => 'Log ud mislykkedes.', @@ -2830,10 +2834,10 @@ extension on TranslationsDa { 'metadataEdit.originalTitle' => 'Originaltitel', 'metadataEdit.releaseDate' => 'Udgivelsesdato', 'metadataEdit.contentRating' => 'Aldersgrænse', - 'metadataEdit.studio' => 'Studie', - 'metadataEdit.tagline' => 'Tagline', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'Studie', + 'metadataEdit.tagline' => 'Tagline', 'metadataEdit.summary' => 'Resumé', 'metadataEdit.poster' => 'Plakat', 'metadataEdit.background' => 'Baggrund', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 85728712..9f76fa30 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsDe extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => 'Staffel- und Episodennummer auf Episodenkarten anzeigen'; @override String get showSeasonPostersOnTabs => 'Staffelposter auf Tabs anzeigen'; @override String get showSeasonPostersOnTabsDescription => 'Poster jeder Staffel über ihrem Tab anzeigen'; + @override String get tvFullCardLayout => 'Vollflächige TV-Karten'; + @override String get tvFullCardLayoutDescription => 'TV-Karten nur mit Bild verwenden und Darstellernamen einblenden'; @override String get hideSpoilers => 'Spoiler für nicht gesehene Episoden verbergen'; @override String get hideSpoilersDescription => 'Vorschaubilder und Beschreibungen ungesehener Episoden verwischen'; @override String get playerBackend => 'Player-Backend'; @@ -1922,6 +1924,8 @@ extension on TranslationsDe { 'settings.showEpisodeNumberOnCardsDescription' => 'Staffel- und Episodennummer auf Episodenkarten anzeigen', 'settings.showSeasonPostersOnTabs' => 'Staffelposter auf Tabs anzeigen', 'settings.showSeasonPostersOnTabsDescription' => 'Poster jeder Staffel über ihrem Tab anzeigen', + 'settings.tvFullCardLayout' => 'Vollflächige TV-Karten', + 'settings.tvFullCardLayoutDescription' => 'TV-Karten nur mit Bild verwenden und Darstellernamen einblenden', 'settings.hideSpoilers' => 'Spoiler für nicht gesehene Episoden verbergen', 'settings.hideSpoilersDescription' => 'Vorschaubilder und Beschreibungen ungesehener Episoden verwischen', 'settings.playerBackend' => 'Player-Backend', @@ -2316,10 +2320,10 @@ extension on TranslationsDe { 'profiles.active' => 'Aktiv', 'profiles.manage' => 'Verwalten', 'profiles.delete' => 'Löschen', - 'profiles.signOut' => 'Abmelden', - 'profiles.signOutPlexTitle' => 'Von Plex abmelden?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'Abmelden', + 'profiles.signOutPlexTitle' => 'Von Plex abmelden?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} und alle Plex Home-Benutzer entfernen? Du kannst dich jederzeit wieder anmelden.', 'profiles.signedOutPlex' => 'Von Plex abgemeldet.', 'profiles.signOutFailed' => 'Abmeldung fehlgeschlagen.', @@ -2830,10 +2834,10 @@ extension on TranslationsDe { 'metadataEdit.originalTitle' => 'Originaltitel', 'metadataEdit.releaseDate' => 'Erscheinungsdatum', 'metadataEdit.contentRating' => 'Altersfreigabe', - 'metadataEdit.studio' => 'Studio', - 'metadataEdit.tagline' => 'Tagline', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'Studio', + 'metadataEdit.tagline' => 'Tagline', 'metadataEdit.summary' => 'Zusammenfassung', 'metadataEdit.poster' => 'Poster', 'metadataEdit.background' => 'Hintergrund', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index c480ebfd..5c0edb90 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -488,6 +488,12 @@ class TranslationsSettingsEn { /// en: 'Show each season's poster above its tab' String get showSeasonPostersOnTabsDescription => 'Show each season\'s poster above its tab'; + /// en: 'Full TV Cards' + String get tvFullCardLayout => 'Full TV Cards'; + + /// en: 'Use image-only TV cards with actor names overlaid' + String get tvFullCardLayoutDescription => 'Use image-only TV cards with actor names overlaid'; + /// en: 'Hide Spoilers for Unwatched Episodes' String get hideSpoilers => 'Hide Spoilers for Unwatched Episodes'; @@ -4282,6 +4288,8 @@ extension on Translations { 'settings.showEpisodeNumberOnCardsDescription' => 'Show season and episode number on episode cards', 'settings.showSeasonPostersOnTabs' => 'Show Season Posters on Tabs', 'settings.showSeasonPostersOnTabsDescription' => 'Show each season\'s poster above its tab', + 'settings.tvFullCardLayout' => 'Full TV Cards', + 'settings.tvFullCardLayoutDescription' => 'Use image-only TV cards with actor names overlaid', 'settings.hideSpoilers' => 'Hide Spoilers for Unwatched Episodes', 'settings.hideSpoilersDescription' => 'Blur thumbnails and descriptions for unwatched episodes', 'settings.playerBackend' => 'Player Backend', @@ -4676,10 +4684,10 @@ extension on Translations { 'profiles.active' => 'Active', 'profiles.manage' => 'Manage', 'profiles.delete' => 'Delete', - 'profiles.signOut' => 'Sign out', - 'profiles.signOutPlexTitle' => 'Sign out of Plex?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'Sign out', + 'profiles.signOutPlexTitle' => 'Sign out of Plex?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Remove ${displayName} and all Plex Home users? Sign back in anytime.', 'profiles.signedOutPlex' => 'Signed out of Plex.', 'profiles.signOutFailed' => 'Sign out failed.', @@ -5190,10 +5198,10 @@ extension on Translations { 'metadataEdit.originalTitle' => 'Original Title', 'metadataEdit.releaseDate' => 'Release Date', 'metadataEdit.contentRating' => 'Content Rating', - 'metadataEdit.studio' => 'Studio', - 'metadataEdit.tagline' => 'Tagline', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'Studio', + 'metadataEdit.tagline' => 'Tagline', 'metadataEdit.summary' => 'Summary', 'metadataEdit.poster' => 'Poster', 'metadataEdit.background' => 'Background', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 13d2bf8f..73ea4964 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsEs extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => 'Mostrar temporada y episodio en tarjetas de episodio'; @override String get showSeasonPostersOnTabs => 'Mostrar pósters de temporada en las pestañas'; @override String get showSeasonPostersOnTabsDescription => 'Mostrar el póster de cada temporada sobre su pestaña'; + @override String get tvFullCardLayout => 'Tarjetas TV completas'; + @override String get tvFullCardLayoutDescription => 'Usar tarjetas TV solo con imagen y nombres de actores superpuestos'; @override String get hideSpoilers => 'Ocultar spoilers de episodios no vistos'; @override String get hideSpoilersDescription => 'Desenfocar miniaturas y descripciones de episodios no vistos'; @override String get playerBackend => 'Reproductor'; @@ -1922,6 +1924,8 @@ extension on TranslationsEs { 'settings.showEpisodeNumberOnCardsDescription' => 'Mostrar temporada y episodio en tarjetas de episodio', 'settings.showSeasonPostersOnTabs' => 'Mostrar pósters de temporada en las pestañas', 'settings.showSeasonPostersOnTabsDescription' => 'Mostrar el póster de cada temporada sobre su pestaña', + 'settings.tvFullCardLayout' => 'Tarjetas TV completas', + 'settings.tvFullCardLayoutDescription' => 'Usar tarjetas TV solo con imagen y nombres de actores superpuestos', 'settings.hideSpoilers' => 'Ocultar spoilers de episodios no vistos', 'settings.hideSpoilersDescription' => 'Desenfocar miniaturas y descripciones de episodios no vistos', 'settings.playerBackend' => 'Reproductor', @@ -2316,10 +2320,10 @@ extension on TranslationsEs { 'profiles.active' => 'Activo', 'profiles.manage' => 'Administrar', 'profiles.delete' => 'Eliminar', - 'profiles.signOut' => 'Cerrar sesión', - 'profiles.signOutPlexTitle' => '¿Cerrar sesión de Plex?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'Cerrar sesión', + 'profiles.signOutPlexTitle' => '¿Cerrar sesión de Plex?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => '¿Eliminar ${displayName} y todos los usuarios de Plex Home? Puedes iniciar sesión de nuevo cuando quieras.', 'profiles.signedOutPlex' => 'Sesión de Plex cerrada.', 'profiles.signOutFailed' => 'Error al cerrar sesión.', @@ -2830,10 +2834,10 @@ extension on TranslationsEs { 'metadataEdit.originalTitle' => 'Título original', 'metadataEdit.releaseDate' => 'Fecha de estreno', 'metadataEdit.contentRating' => 'Clasificación de contenido', - 'metadataEdit.studio' => 'Estudio', - 'metadataEdit.tagline' => 'Eslogan', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'Estudio', + 'metadataEdit.tagline' => 'Eslogan', 'metadataEdit.summary' => 'Resumen', 'metadataEdit.poster' => 'Póster', 'metadataEdit.background' => 'Fondo', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 812318d8..7a08db8d 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsFr extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => 'Afficher la saison et l\'épisode sur les cartes d\'épisode'; @override String get showSeasonPostersOnTabs => 'Afficher les posters de saison sur les onglets'; @override String get showSeasonPostersOnTabsDescription => 'Afficher l\'affiche de chaque saison au-dessus de son onglet'; + @override String get tvFullCardLayout => 'Cartes TV pleines'; + @override String get tvFullCardLayoutDescription => 'Utiliser des cartes TV avec image seule et noms des acteurs superposés'; @override String get hideSpoilers => 'Masquer les spoilers des épisodes non vus'; @override String get hideSpoilersDescription => 'Flouter les miniatures et descriptions des épisodes non vus'; @override String get playerBackend => 'Moteur de lecture'; @@ -1922,6 +1924,8 @@ extension on TranslationsFr { 'settings.showEpisodeNumberOnCardsDescription' => 'Afficher la saison et l\'épisode sur les cartes d\'épisode', 'settings.showSeasonPostersOnTabs' => 'Afficher les posters de saison sur les onglets', 'settings.showSeasonPostersOnTabsDescription' => 'Afficher l\'affiche de chaque saison au-dessus de son onglet', + 'settings.tvFullCardLayout' => 'Cartes TV pleines', + 'settings.tvFullCardLayoutDescription' => 'Utiliser des cartes TV avec image seule et noms des acteurs superposés', 'settings.hideSpoilers' => 'Masquer les spoilers des épisodes non vus', 'settings.hideSpoilersDescription' => 'Flouter les miniatures et descriptions des épisodes non vus', 'settings.playerBackend' => 'Moteur de lecture', @@ -2316,10 +2320,10 @@ extension on TranslationsFr { 'profiles.active' => 'Actif', 'profiles.manage' => 'Gérer', 'profiles.delete' => 'Supprimer', - 'profiles.signOut' => 'Se déconnecter', - 'profiles.signOutPlexTitle' => 'Se déconnecter de Plex ?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'Se déconnecter', + 'profiles.signOutPlexTitle' => 'Se déconnecter de Plex ?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Supprimer ${displayName} et tous les utilisateurs Plex Home ? Reconnexion possible à tout moment.', 'profiles.signedOutPlex' => 'Déconnecté de Plex.', 'profiles.signOutFailed' => 'Échec de la déconnexion.', @@ -2830,10 +2834,10 @@ extension on TranslationsFr { 'metadataEdit.originalTitle' => 'Titre original', 'metadataEdit.releaseDate' => 'Date de sortie', 'metadataEdit.contentRating' => 'Classification', - 'metadataEdit.studio' => 'Studio', - 'metadataEdit.tagline' => 'Slogan', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'Studio', + 'metadataEdit.tagline' => 'Slogan', 'metadataEdit.summary' => 'Résumé', 'metadataEdit.poster' => 'Affiche', 'metadataEdit.background' => 'Arrière-plan', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 3d74f985..a31fde13 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsIt extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => 'Mostra stagione ed episodio sulle schede episodio'; @override String get showSeasonPostersOnTabs => 'Mostra poster delle stagioni sulle schede'; @override String get showSeasonPostersOnTabsDescription => 'Mostra il poster di ogni stagione sopra la sua scheda'; + @override String get tvFullCardLayout => 'Schede TV piene'; + @override String get tvFullCardLayoutDescription => 'Usa schede TV solo immagine con i nomi degli attori sovrapposti'; @override String get hideSpoilers => 'Nascondi spoiler per episodi non visti'; @override String get hideSpoilersDescription => 'Sfoca miniature e descrizioni degli episodi non visti'; @override String get playerBackend => 'Motore di riproduzione'; @@ -1922,6 +1924,8 @@ extension on TranslationsIt { 'settings.showEpisodeNumberOnCardsDescription' => 'Mostra stagione ed episodio sulle schede episodio', 'settings.showSeasonPostersOnTabs' => 'Mostra poster delle stagioni sulle schede', 'settings.showSeasonPostersOnTabsDescription' => 'Mostra il poster di ogni stagione sopra la sua scheda', + 'settings.tvFullCardLayout' => 'Schede TV piene', + 'settings.tvFullCardLayoutDescription' => 'Usa schede TV solo immagine con i nomi degli attori sovrapposti', 'settings.hideSpoilers' => 'Nascondi spoiler per episodi non visti', 'settings.hideSpoilersDescription' => 'Sfoca miniature e descrizioni degli episodi non visti', 'settings.playerBackend' => 'Motore di riproduzione', @@ -2316,10 +2320,10 @@ extension on TranslationsIt { 'profiles.active' => 'Attivo', 'profiles.manage' => 'Gestisci', 'profiles.delete' => 'Elimina', - 'profiles.signOut' => 'Esci', - 'profiles.signOutPlexTitle' => 'Uscire da Plex?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'Esci', + 'profiles.signOutPlexTitle' => 'Uscire da Plex?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Rimuovere ${displayName} e tutti gli utenti Plex Home? Puoi accedere di nuovo quando vuoi.', 'profiles.signedOutPlex' => 'Uscito da Plex.', 'profiles.signOutFailed' => 'Uscita non riuscita.', @@ -2830,10 +2834,10 @@ extension on TranslationsIt { 'metadataEdit.originalTitle' => 'Titolo originale', 'metadataEdit.releaseDate' => 'Data di uscita', 'metadataEdit.contentRating' => 'Classificazione', - 'metadataEdit.studio' => 'Studio', - 'metadataEdit.tagline' => 'Tagline', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'Studio', + 'metadataEdit.tagline' => 'Tagline', 'metadataEdit.summary' => 'Trama', 'metadataEdit.poster' => 'Poster', 'metadataEdit.background' => 'Sfondo', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 9b46e8b6..872ea816 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsJa extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => 'エピソードカードにシーズン番号とエピソード番号を表示します'; @override String get showSeasonPostersOnTabs => 'タブにシーズンポスターを表示'; @override String get showSeasonPostersOnTabsDescription => '各シーズンのポスターをタブの上に表示します'; + @override String get tvFullCardLayout => 'フルTVカード'; + @override String get tvFullCardLayoutDescription => 'TVカードを画像のみで表示し、俳優名を重ねて表示します'; @override String get hideSpoilers => '未視聴エピソードのネタバレを非表示'; @override String get hideSpoilersDescription => '未視聴エピソードのサムネイルと説明をぼかします'; @override String get playerBackend => 'プレーヤーバックエンド'; @@ -1922,6 +1924,8 @@ extension on TranslationsJa { 'settings.showEpisodeNumberOnCardsDescription' => 'エピソードカードにシーズン番号とエピソード番号を表示します', 'settings.showSeasonPostersOnTabs' => 'タブにシーズンポスターを表示', 'settings.showSeasonPostersOnTabsDescription' => '各シーズンのポスターをタブの上に表示します', + 'settings.tvFullCardLayout' => 'フルTVカード', + 'settings.tvFullCardLayoutDescription' => 'TVカードを画像のみで表示し、俳優名を重ねて表示します', 'settings.hideSpoilers' => '未視聴エピソードのネタバレを非表示', 'settings.hideSpoilersDescription' => '未視聴エピソードのサムネイルと説明をぼかします', 'settings.playerBackend' => 'プレーヤーバックエンド', @@ -2316,10 +2320,10 @@ extension on TranslationsJa { 'profiles.active' => 'アクティブ', 'profiles.manage' => '管理', 'profiles.delete' => '削除', - 'profiles.signOut' => 'サインアウト', - 'profiles.signOutPlexTitle' => 'Plex からサインアウトしますか?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'サインアウト', + 'profiles.signOutPlexTitle' => 'Plex からサインアウトしますか?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName}とすべてのPlex Homeユーザーを削除しますか?いつでも再サインインできます。', 'profiles.signedOutPlex' => 'Plex からサインアウトしました。', 'profiles.signOutFailed' => 'サインアウトに失敗しました。', @@ -2830,10 +2834,10 @@ extension on TranslationsJa { 'metadataEdit.originalTitle' => '原題', 'metadataEdit.releaseDate' => '公開日', 'metadataEdit.contentRating' => 'コンテンツレーティング', - 'metadataEdit.studio' => 'スタジオ', - 'metadataEdit.tagline' => 'タグライン', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'スタジオ', + 'metadataEdit.tagline' => 'タグライン', 'metadataEdit.summary' => 'あらすじ', 'metadataEdit.poster' => 'ポスター', 'metadataEdit.background' => '背景', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index ad0cf0c0..7bb93104 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsKo extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => '에피소드 카드에 시즌 및 에피소드 번호 표시'; @override String get showSeasonPostersOnTabs => '탭에 시즌 포스터 표시'; @override String get showSeasonPostersOnTabsDescription => '각 시즌 포스터를 탭 위에 표시'; + @override String get tvFullCardLayout => '전체 TV 카드'; + @override String get tvFullCardLayoutDescription => 'TV 카드에 이미지만 표시하고 배우 이름을 오버레이로 표시'; @override String get hideSpoilers => '미시청 에피소드 스포일러 숨기기'; @override String get hideSpoilersDescription => '시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리'; @override String get playerBackend => '플레이어 백엔드'; @@ -1922,6 +1924,8 @@ extension on TranslationsKo { 'settings.showEpisodeNumberOnCardsDescription' => '에피소드 카드에 시즌 및 에피소드 번호 표시', 'settings.showSeasonPostersOnTabs' => '탭에 시즌 포스터 표시', 'settings.showSeasonPostersOnTabsDescription' => '각 시즌 포스터를 탭 위에 표시', + 'settings.tvFullCardLayout' => '전체 TV 카드', + 'settings.tvFullCardLayoutDescription' => 'TV 카드에 이미지만 표시하고 배우 이름을 오버레이로 표시', 'settings.hideSpoilers' => '미시청 에피소드 스포일러 숨기기', 'settings.hideSpoilersDescription' => '시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리', 'settings.playerBackend' => '플레이어 백엔드', @@ -2316,10 +2320,10 @@ extension on TranslationsKo { 'profiles.active' => '활성', 'profiles.manage' => '관리', 'profiles.delete' => '삭제', - 'profiles.signOut' => '로그아웃', - 'profiles.signOutPlexTitle' => 'Plex에서 로그아웃하시겠습니까?', _ => null, } ?? switch (path) { + 'profiles.signOut' => '로그아웃', + 'profiles.signOutPlexTitle' => 'Plex에서 로그아웃하시겠습니까?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} 및 모든 Plex Home 사용자를 제거할까요? 언제든 다시 로그인할 수 있습니다.', 'profiles.signedOutPlex' => 'Plex에서 로그아웃되었습니다.', 'profiles.signOutFailed' => '로그아웃에 실패했습니다.', @@ -2830,10 +2834,10 @@ extension on TranslationsKo { 'metadataEdit.originalTitle' => '원제', 'metadataEdit.releaseDate' => '출시일', 'metadataEdit.contentRating' => '콘텐츠 등급', - 'metadataEdit.studio' => '스튜디오', - 'metadataEdit.tagline' => '태그라인', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => '스튜디오', + 'metadataEdit.tagline' => '태그라인', 'metadataEdit.summary' => '줄거리', 'metadataEdit.poster' => '포스터', 'metadataEdit.background' => '배경', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index f9afbb60..1f0be9b5 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsNb extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => 'Vis sesong- og episodenummer på episodekort'; @override String get showSeasonPostersOnTabs => 'Vis sesongplakater på faner'; @override String get showSeasonPostersOnTabsDescription => 'Vis hver sesongs plakat over fanen'; + @override String get tvFullCardLayout => 'Fulle TV-kort'; + @override String get tvFullCardLayoutDescription => 'Bruk bildebaserte TV-kort med skuespillernavn lagt over'; @override String get hideSpoilers => 'Skjul spoilere for usette episoder'; @override String get hideSpoilersDescription => 'Slør miniatyrbilder og beskrivelser for usette episoder'; @override String get playerBackend => 'Spillermotor'; @@ -1922,6 +1924,8 @@ extension on TranslationsNb { 'settings.showEpisodeNumberOnCardsDescription' => 'Vis sesong- og episodenummer på episodekort', 'settings.showSeasonPostersOnTabs' => 'Vis sesongplakater på faner', 'settings.showSeasonPostersOnTabsDescription' => 'Vis hver sesongs plakat over fanen', + 'settings.tvFullCardLayout' => 'Fulle TV-kort', + 'settings.tvFullCardLayoutDescription' => 'Bruk bildebaserte TV-kort med skuespillernavn lagt over', 'settings.hideSpoilers' => 'Skjul spoilere for usette episoder', 'settings.hideSpoilersDescription' => 'Slør miniatyrbilder og beskrivelser for usette episoder', 'settings.playerBackend' => 'Spillermotor', @@ -2316,10 +2320,10 @@ extension on TranslationsNb { 'profiles.active' => 'Aktiv', 'profiles.manage' => 'Administrer', 'profiles.delete' => 'Slett', - 'profiles.signOut' => 'Logg ut', - 'profiles.signOutPlexTitle' => 'Logge ut av Plex?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'Logg ut', + 'profiles.signOutPlexTitle' => 'Logge ut av Plex?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Fjerne ${displayName} og alle Plex Home-brukere? Du kan logge inn igjen når som helst.', 'profiles.signedOutPlex' => 'Logget ut av Plex.', 'profiles.signOutFailed' => 'Utlogging mislyktes.', @@ -2830,10 +2834,10 @@ extension on TranslationsNb { 'metadataEdit.originalTitle' => 'Originaltittel', 'metadataEdit.releaseDate' => 'Utgivelsesdato', 'metadataEdit.contentRating' => 'Innholdsvurdering', - 'metadataEdit.studio' => 'Studio', - 'metadataEdit.tagline' => 'Slagord', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'Studio', + 'metadataEdit.tagline' => 'Slagord', 'metadataEdit.summary' => 'Sammendrag', 'metadataEdit.poster' => 'Plakat', 'metadataEdit.background' => 'Bakgrunn', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 0d4e7327..d9107c6c 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsNl extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => 'Toon seizoen- en afleveringsnummer op afleveringskaarten'; @override String get showSeasonPostersOnTabs => 'Toon seizoensposters op tabbladen'; @override String get showSeasonPostersOnTabsDescription => 'Toon de poster van elk seizoen boven het tabblad'; + @override String get tvFullCardLayout => 'Volledige tv-kaarten'; + @override String get tvFullCardLayoutDescription => 'Gebruik tv-kaarten met alleen afbeeldingen en namen van acteurs als overlay'; @override String get hideSpoilers => 'Spoilers voor ongekeken afleveringen verbergen'; @override String get hideSpoilersDescription => 'Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen'; @override String get playerBackend => 'Speler backend'; @@ -1922,6 +1924,8 @@ extension on TranslationsNl { 'settings.showEpisodeNumberOnCardsDescription' => 'Toon seizoen- en afleveringsnummer op afleveringskaarten', 'settings.showSeasonPostersOnTabs' => 'Toon seizoensposters op tabbladen', 'settings.showSeasonPostersOnTabsDescription' => 'Toon de poster van elk seizoen boven het tabblad', + 'settings.tvFullCardLayout' => 'Volledige tv-kaarten', + 'settings.tvFullCardLayoutDescription' => 'Gebruik tv-kaarten met alleen afbeeldingen en namen van acteurs als overlay', 'settings.hideSpoilers' => 'Spoilers voor ongekeken afleveringen verbergen', 'settings.hideSpoilersDescription' => 'Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen', 'settings.playerBackend' => 'Speler backend', @@ -2316,10 +2320,10 @@ extension on TranslationsNl { 'profiles.active' => 'Actief', 'profiles.manage' => 'Beheren', 'profiles.delete' => 'Verwijderen', - 'profiles.signOut' => 'Afmelden', - 'profiles.signOutPlexTitle' => 'Afmelden bij Plex?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'Afmelden', + 'profiles.signOutPlexTitle' => 'Afmelden bij Plex?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} en alle Plex Home-gebruikers verwijderen? Je kunt altijd opnieuw inloggen.', 'profiles.signedOutPlex' => 'Afgemeld bij Plex.', 'profiles.signOutFailed' => 'Afmelden mislukt.', @@ -2830,10 +2834,10 @@ extension on TranslationsNl { 'metadataEdit.originalTitle' => 'Oorspronkelijke titel', 'metadataEdit.releaseDate' => 'Releasedatum', 'metadataEdit.contentRating' => 'Leeftijdsclassificatie', - 'metadataEdit.studio' => 'Studio', - 'metadataEdit.tagline' => 'Tagline', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'Studio', + 'metadataEdit.tagline' => 'Tagline', 'metadataEdit.summary' => 'Samenvatting', 'metadataEdit.poster' => 'Poster', 'metadataEdit.background' => 'Achtergrond', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 46a29863..e4867339 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsPl extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => 'Pokazuj numer sezonu i odcinka na kartach odcinków'; @override String get showSeasonPostersOnTabs => 'Pokaż plakaty sezonów na zakładkach'; @override String get showSeasonPostersOnTabsDescription => 'Pokazuj plakat każdego sezonu nad jego kartą'; + @override String get tvFullCardLayout => 'Pełne karty TV'; + @override String get tvFullCardLayoutDescription => 'Używaj kart TV tylko z obrazem i nałożonymi nazwiskami aktorów'; @override String get hideSpoilers => 'Ukryj spoilery nieobejrzanych odcinków'; @override String get hideSpoilersDescription => 'Rozmywaj miniatury i opisy nieobejrzanych odcinków'; @override String get playerBackend => 'Backend odtwarzacza'; @@ -1922,6 +1924,8 @@ extension on TranslationsPl { 'settings.showEpisodeNumberOnCardsDescription' => 'Pokazuj numer sezonu i odcinka na kartach odcinków', 'settings.showSeasonPostersOnTabs' => 'Pokaż plakaty sezonów na zakładkach', 'settings.showSeasonPostersOnTabsDescription' => 'Pokazuj plakat każdego sezonu nad jego kartą', + 'settings.tvFullCardLayout' => 'Pełne karty TV', + 'settings.tvFullCardLayoutDescription' => 'Używaj kart TV tylko z obrazem i nałożonymi nazwiskami aktorów', 'settings.hideSpoilers' => 'Ukryj spoilery nieobejrzanych odcinków', 'settings.hideSpoilersDescription' => 'Rozmywaj miniatury i opisy nieobejrzanych odcinków', 'settings.playerBackend' => 'Backend odtwarzacza', @@ -2316,10 +2320,10 @@ extension on TranslationsPl { 'profiles.active' => 'Aktywny', 'profiles.manage' => 'Zarządzaj', 'profiles.delete' => 'Usuń', - 'profiles.signOut' => 'Wyloguj się', - 'profiles.signOutPlexTitle' => 'Wylogować się z Plex?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'Wyloguj się', + 'profiles.signOutPlexTitle' => 'Wylogować się z Plex?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Usunąć ${displayName} i wszystkich użytkowników Plex Home? Możesz zalogować się ponownie w każdej chwili.', 'profiles.signedOutPlex' => 'Wylogowano z Plex.', 'profiles.signOutFailed' => 'Wylogowanie nie powiodło się.', @@ -2830,10 +2834,10 @@ extension on TranslationsPl { 'metadataEdit.originalTitle' => 'Tytuł oryginalny', 'metadataEdit.releaseDate' => 'Data premiery', 'metadataEdit.contentRating' => 'Klasyfikacja wiekowa', - 'metadataEdit.studio' => 'Studio', - 'metadataEdit.tagline' => 'Tagline', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'Studio', + 'metadataEdit.tagline' => 'Tagline', 'metadataEdit.summary' => 'Opis', 'metadataEdit.poster' => 'Plakat', 'metadataEdit.background' => 'Tło', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index fbf40651..67b1ec8b 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsPt extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => 'Mostrar temporada e episódio nos cartões de episódio'; @override String get showSeasonPostersOnTabs => 'Mostrar Pôsteres de Temporada nas Abas'; @override String get showSeasonPostersOnTabsDescription => 'Mostrar o pôster de cada temporada acima da aba'; + @override String get tvFullCardLayout => 'Cartões TV completos'; + @override String get tvFullCardLayoutDescription => 'Usar cartões de TV só com imagem e nomes dos atores sobrepostos'; @override String get hideSpoilers => 'Ocultar Spoilers de Episódios Não Assistidos'; @override String get hideSpoilersDescription => 'Desfocar miniaturas e descrições de episódios não vistos'; @override String get playerBackend => 'Backend do Player'; @@ -1922,6 +1924,8 @@ extension on TranslationsPt { 'settings.showEpisodeNumberOnCardsDescription' => 'Mostrar temporada e episódio nos cartões de episódio', 'settings.showSeasonPostersOnTabs' => 'Mostrar Pôsteres de Temporada nas Abas', 'settings.showSeasonPostersOnTabsDescription' => 'Mostrar o pôster de cada temporada acima da aba', + 'settings.tvFullCardLayout' => 'Cartões TV completos', + 'settings.tvFullCardLayoutDescription' => 'Usar cartões de TV só com imagem e nomes dos atores sobrepostos', 'settings.hideSpoilers' => 'Ocultar Spoilers de Episódios Não Assistidos', 'settings.hideSpoilersDescription' => 'Desfocar miniaturas e descrições de episódios não vistos', 'settings.playerBackend' => 'Backend do Player', @@ -2316,10 +2320,10 @@ extension on TranslationsPt { 'profiles.active' => 'Ativo', 'profiles.manage' => 'Gerenciar', 'profiles.delete' => 'Excluir', - 'profiles.signOut' => 'Sair', - 'profiles.signOutPlexTitle' => 'Sair do Plex?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'Sair', + 'profiles.signOutPlexTitle' => 'Sair do Plex?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Remover ${displayName} e todos os usuários Plex Home? Você pode entrar novamente quando quiser.', 'profiles.signedOutPlex' => 'Saiu do Plex.', 'profiles.signOutFailed' => 'Falha ao sair.', @@ -2830,10 +2834,10 @@ extension on TranslationsPt { 'metadataEdit.originalTitle' => 'Título Original', 'metadataEdit.releaseDate' => 'Data de Lançamento', 'metadataEdit.contentRating' => 'Classificação Indicativa', - 'metadataEdit.studio' => 'Estúdio', - 'metadataEdit.tagline' => 'Tagline', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'Estúdio', + 'metadataEdit.tagline' => 'Tagline', 'metadataEdit.summary' => 'Sinopse', 'metadataEdit.poster' => 'Poster', 'metadataEdit.background' => 'Plano de Fundo', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 91188e79..247374ac 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsRu extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => 'Показывать номер сезона и серии на карточках серий'; @override String get showSeasonPostersOnTabs => 'Показывать постеры сезонов на вкладках'; @override String get showSeasonPostersOnTabsDescription => 'Показывать постер каждого сезона над его вкладкой'; + @override String get tvFullCardLayout => 'Полные TV-карточки'; + @override String get tvFullCardLayoutDescription => 'Использовать TV-карточки только с изображением и именами актёров поверх него'; @override String get hideSpoilers => 'Скрыть спойлеры непросмотренных эпизодов'; @override String get hideSpoilersDescription => 'Размывать миниатюры и описания непросмотренных серий'; @override String get playerBackend => 'Бэкенд плеера'; @@ -1922,6 +1924,8 @@ extension on TranslationsRu { 'settings.showEpisodeNumberOnCardsDescription' => 'Показывать номер сезона и серии на карточках серий', 'settings.showSeasonPostersOnTabs' => 'Показывать постеры сезонов на вкладках', 'settings.showSeasonPostersOnTabsDescription' => 'Показывать постер каждого сезона над его вкладкой', + 'settings.tvFullCardLayout' => 'Полные TV-карточки', + 'settings.tvFullCardLayoutDescription' => 'Использовать TV-карточки только с изображением и именами актёров поверх него', 'settings.hideSpoilers' => 'Скрыть спойлеры непросмотренных эпизодов', 'settings.hideSpoilersDescription' => 'Размывать миниатюры и описания непросмотренных серий', 'settings.playerBackend' => 'Бэкенд плеера', @@ -2316,10 +2320,10 @@ extension on TranslationsRu { 'profiles.active' => 'Активный', 'profiles.manage' => 'Управление', 'profiles.delete' => 'Удалить', - 'profiles.signOut' => 'Выйти', - 'profiles.signOutPlexTitle' => 'Выйти из Plex?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'Выйти', + 'profiles.signOutPlexTitle' => 'Выйти из Plex?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Удалить ${displayName} и всех пользователей Plex Home? Вы сможете войти снова в любое время.', 'profiles.signedOutPlex' => 'Вы вышли из Plex.', 'profiles.signOutFailed' => 'Не удалось выйти.', @@ -2830,10 +2834,10 @@ extension on TranslationsRu { 'metadataEdit.originalTitle' => 'Оригинальное название', 'metadataEdit.releaseDate' => 'Дата выпуска', 'metadataEdit.contentRating' => 'Возрастной рейтинг', - 'metadataEdit.studio' => 'Студия', - 'metadataEdit.tagline' => 'Слоган', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'Студия', + 'metadataEdit.tagline' => 'Слоган', 'metadataEdit.summary' => 'Описание', 'metadataEdit.poster' => 'Постер', 'metadataEdit.background' => 'Фон', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 424d3739..f4520f33 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsSv extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => 'Visa säsongs- och avsnittsnummer på avsnittskort'; @override String get showSeasonPostersOnTabs => 'Visa säsongsaffischer på flikar'; @override String get showSeasonPostersOnTabsDescription => 'Visa varje säsongs affisch ovanför fliken'; + @override String get tvFullCardLayout => 'Heltäckande TV-kort'; + @override String get tvFullCardLayoutDescription => 'Använd TV-kort med enbart bild och skådespelarnamn ovanpå'; @override String get hideSpoilers => 'Dölj spoilers för osedda avsnitt'; @override String get hideSpoilersDescription => 'Sudda miniatyrbilder och beskrivningar för osedda avsnitt'; @override String get playerBackend => 'Spelarmotor'; @@ -1922,6 +1924,8 @@ extension on TranslationsSv { 'settings.showEpisodeNumberOnCardsDescription' => 'Visa säsongs- och avsnittsnummer på avsnittskort', 'settings.showSeasonPostersOnTabs' => 'Visa säsongsaffischer på flikar', 'settings.showSeasonPostersOnTabsDescription' => 'Visa varje säsongs affisch ovanför fliken', + 'settings.tvFullCardLayout' => 'Heltäckande TV-kort', + 'settings.tvFullCardLayoutDescription' => 'Använd TV-kort med enbart bild och skådespelarnamn ovanpå', 'settings.hideSpoilers' => 'Dölj spoilers för osedda avsnitt', 'settings.hideSpoilersDescription' => 'Sudda miniatyrbilder och beskrivningar för osedda avsnitt', 'settings.playerBackend' => 'Spelarmotor', @@ -2316,10 +2320,10 @@ extension on TranslationsSv { 'profiles.active' => 'Aktiv', 'profiles.manage' => 'Hantera', 'profiles.delete' => 'Ta bort', - 'profiles.signOut' => 'Logga ut', - 'profiles.signOutPlexTitle' => 'Logga ut från Plex?', _ => null, } ?? switch (path) { + 'profiles.signOut' => 'Logga ut', + 'profiles.signOutPlexTitle' => 'Logga ut från Plex?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Ta bort ${displayName} och alla Plex Home-användare? Du kan logga in igen när som helst.', 'profiles.signedOutPlex' => 'Utloggad från Plex.', 'profiles.signOutFailed' => 'Utloggningen misslyckades.', @@ -2830,10 +2834,10 @@ extension on TranslationsSv { 'metadataEdit.originalTitle' => 'Originaltitel', 'metadataEdit.releaseDate' => 'Utgivningsdatum', 'metadataEdit.contentRating' => 'Åldersgräns', - 'metadataEdit.studio' => 'Studio', - 'metadataEdit.tagline' => 'Tagline', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => 'Studio', + 'metadataEdit.tagline' => 'Tagline', 'metadataEdit.summary' => 'Sammanfattning', 'metadataEdit.poster' => 'Poster', 'metadataEdit.background' => 'Bakgrund', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index db91f67e..7cb320a4 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -254,6 +254,8 @@ class _TranslationsSettingsZh extends TranslationsSettingsEn { @override String get showEpisodeNumberOnCardsDescription => '在剧集卡片上显示季和集编号'; @override String get showSeasonPostersOnTabs => '在选项卡上显示季海报'; @override String get showSeasonPostersOnTabsDescription => '在每季标签上方显示该季海报'; + @override String get tvFullCardLayout => '完整 TV 卡片'; + @override String get tvFullCardLayoutDescription => '使用仅显示图片的 TV 卡片,并叠加演员姓名'; @override String get hideSpoilers => '隐藏未看剧集的剧透内容'; @override String get hideSpoilersDescription => '模糊未观看剧集的缩略图和描述'; @override String get playerBackend => '播放器引擎'; @@ -1922,6 +1924,8 @@ extension on TranslationsZh { 'settings.showEpisodeNumberOnCardsDescription' => '在剧集卡片上显示季和集编号', 'settings.showSeasonPostersOnTabs' => '在选项卡上显示季海报', 'settings.showSeasonPostersOnTabsDescription' => '在每季标签上方显示该季海报', + 'settings.tvFullCardLayout' => '完整 TV 卡片', + 'settings.tvFullCardLayoutDescription' => '使用仅显示图片的 TV 卡片,并叠加演员姓名', 'settings.hideSpoilers' => '隐藏未看剧集的剧透内容', 'settings.hideSpoilersDescription' => '模糊未观看剧集的缩略图和描述', 'settings.playerBackend' => '播放器引擎', @@ -2316,10 +2320,10 @@ extension on TranslationsZh { 'profiles.active' => '活跃', 'profiles.manage' => '管理', 'profiles.delete' => '删除', - 'profiles.signOut' => '退出登录', - 'profiles.signOutPlexTitle' => '退出 Plex 登录?', _ => null, } ?? switch (path) { + 'profiles.signOut' => '退出登录', + 'profiles.signOutPlexTitle' => '退出 Plex 登录?', 'profiles.signOutPlexMessage' => ({required Object displayName}) => '要移除 ${displayName} 和所有 Plex Home 用户吗?可随时重新登录。', 'profiles.signedOutPlex' => '已退出 Plex 登录。', 'profiles.signOutFailed' => '退出登录失败。', @@ -2830,10 +2834,10 @@ extension on TranslationsZh { 'metadataEdit.originalTitle' => '原始标题', 'metadataEdit.releaseDate' => '上映日期', 'metadataEdit.contentRating' => '内容分级', - 'metadataEdit.studio' => '制片厂', - 'metadataEdit.tagline' => '标语', _ => null, } ?? switch (path) { + 'metadataEdit.studio' => '制片厂', + 'metadataEdit.tagline' => '标语', 'metadataEdit.summary' => '简介', 'metadataEdit.poster' => '海报', 'metadataEdit.background' => '背景', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 0894b09e..87be6b4d 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "Visa säsongs- och avsnittsnummer på avsnittskort", "showSeasonPostersOnTabs": "Visa säsongsaffischer på flikar", "showSeasonPostersOnTabsDescription": "Visa varje säsongs affisch ovanför fliken", + "tvFullCardLayout": "Heltäckande TV-kort", + "tvFullCardLayoutDescription": "Använd TV-kort med enbart bild och skådespelarnamn ovanpå", "hideSpoilers": "Dölj spoilers för osedda avsnitt", "hideSpoilersDescription": "Sudda miniatyrbilder och beskrivningar för osedda avsnitt", "playerBackend": "Spelarmotor", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 797a8c96..d6cbbf83 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -126,6 +126,8 @@ "showEpisodeNumberOnCardsDescription": "在剧集卡片上显示季和集编号", "showSeasonPostersOnTabs": "在选项卡上显示季海报", "showSeasonPostersOnTabsDescription": "在每季标签上方显示该季海报", + "tvFullCardLayout": "完整 TV 卡片", + "tvFullCardLayoutDescription": "使用仅显示图片的 TV 卡片,并叠加演员姓名", "hideSpoilers": "隐藏未看剧集的剧透内容", "hideSpoilersDescription": "模糊未观看剧集的缩略图和描述", "playerBackend": "播放器引擎", diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index f375b07c..d62e5e23 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1554,6 +1554,7 @@ class _DiscoverScreenState extends State hubs: browseHubs, density: svc.read(SettingsService.libraryDensity), episodePosterMode: svc.read(SettingsService.episodePosterMode), + fullCardLayout: svc.read(SettingsService.tvFullCardLayout), tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, ); final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble(); diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index b4517f15..4089f56f 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -317,21 +317,33 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> { // Extra top padding for focus decoration (scale + border extends beyond item bounds) const effectivePadding = EdgeInsets.only(left: 8, right: 8, top: 8); - return SettingValueBuilder( - pref: SettingsService.libraryDensity, - builder: (context, density, _) { + return SettingsBuilder( + prefs: const [SettingsService.libraryDensity, SettingsService.tvFullCardLayout], + builder: (context) { + final settings = SettingsService.instanceOrNull!; + final density = settings.read(SettingsService.libraryDensity); + final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout); final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density); // Use LayoutBuilder to get actual available width (accounting for sidebar) return LayoutBuilder( builder: (context, constraints) { final availableWidth = constraints.maxWidth - effectivePadding.left - effectivePadding.right; - final columnCount = GridSizeCalculator.getColumnCount(availableWidth, maxCrossAxisExtent); + final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout); + final columnCount = GridSizeCalculator.getColumnCount( + availableWidth, + maxCrossAxisExtent, + crossAxisSpacing: gridSpacing, + ); return GridView.builder( padding: effectivePadding, // Allow focus decoration to render outside scroll bounds clipBehavior: Clip.none, - gridDelegate: MediaGridDelegate.createDelegate(context: context, density: density), + gridDelegate: MediaGridDelegate.createDelegate( + context: context, + density: density, + fullBleedImage: fullCardLayout, + ), itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; @@ -342,6 +354,7 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> { focusNode: isFirst ? _firstItemFocusNode : null, onBack: widget.onBack, isOffline: true, // Downloaded content works without server + fullBleedImage: fullCardLayout, onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, ); }, diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index f21b1a87..79f2b928 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -169,11 +169,12 @@ mixin FocusableDetailScreenMixin on State, GridFocu VoidCallback? onListRefresh, }) { return SettingsBuilder( - prefs: const [SettingsService.viewMode, SettingsService.libraryDensity], + prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], builder: (context) { final svc = SettingsService.instanceOrNull!; final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; final libraryDensity = svc.read(SettingsService.libraryDensity); + final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout); if (isListMode) { return SliverPadding( @@ -206,9 +207,18 @@ mixin FocusableDetailScreenMixin on State, GridFocu padding: const EdgeInsets.all(8), sliver: SliverLayoutBuilder( builder: (context, constraints) { - final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxExtent); + final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout); + final columnCount = GridSizeCalculator.getColumnCount( + constraints.crossAxisExtent, + maxExtent, + crossAxisSpacing: gridSpacing, + ); return SliverGrid.builder( - gridDelegate: MediaGridDelegate.createDelegate(context: context, density: libraryDensity), + gridDelegate: MediaGridDelegate.createDelegate( + context: context, + density: libraryDensity, + fullBleedImage: fullCardLayout, + ), itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; @@ -222,6 +232,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu onRefresh: onRefresh, collectionId: collectionId, onListRefresh: onListRefresh, + fullBleedImage: fullCardLayout, onNavigateUp: inFirstRow ? navigateToAppBar : null, onBack: handleBackFromContent, onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), @@ -248,11 +259,12 @@ mixin FocusableDetailScreenMixin on State, GridFocu VoidCallback? onListRefresh, }) { return SettingsBuilder( - prefs: const [SettingsService.viewMode, SettingsService.libraryDensity], + prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], builder: (context) { final svc = SettingsService.instanceOrNull!; final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; final libraryDensity = svc.read(SettingsService.libraryDensity); + final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout); Widget buildTile(int index, {required bool inFirstRow, required bool disableScale}) { final item = itemAt(index); @@ -269,6 +281,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu onRefresh: onRefresh, collectionId: collectionId, onListRefresh: onListRefresh, + fullBleedImage: fullCardLayout && !disableScale, onNavigateUp: inFirstRow ? navigateToAppBar : null, onBack: handleBackFromContent, onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), @@ -290,9 +303,18 @@ mixin FocusableDetailScreenMixin on State, GridFocu padding: const EdgeInsets.all(8), sliver: SliverLayoutBuilder( builder: (context, constraints) { - final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxExtent); + final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout); + final columnCount = GridSizeCalculator.getColumnCount( + constraints.crossAxisExtent, + maxExtent, + crossAxisSpacing: gridSpacing, + ); return SliverGrid.builder( - gridDelegate: MediaGridDelegate.createDelegate(context: context, density: libraryDensity), + gridDelegate: MediaGridDelegate.createDelegate( + context: context, + density: libraryDensity, + fullBleedImage: fullCardLayout, + ), itemCount: totalItems, itemBuilder: (context, index) => buildTile( index, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index b586b9d2..6165a019 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -510,12 +510,14 @@ class _HubDetailScreenState extends State SettingsService.viewMode, SettingsService.episodePosterMode, SettingsService.libraryDensity, + SettingsService.tvFullCardLayout, ], builder: (context) { final svc = SettingsService.instanceOrNull!; final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; final episodePosterMode = svc.read(SettingsService.episodePosterMode); final libraryDensity = svc.read(SettingsService.libraryDensity); + final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout); // Determine hub content type for layout decisions final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode)); @@ -570,9 +572,14 @@ class _HubDetailScreenState extends State libraryDensity, 16, ); + final gridSpacing = MediaGridDelegate.spacingFor( + context: context, + fullBleedImage: fullCardLayout, + ); final columnCount = GridSizeCalculator.getColumnCount( constraints.crossAxisExtent, useWideLayout ? maxExtent * 1.8 : maxExtent, + crossAxisSpacing: gridSpacing, ); return SliverGrid( @@ -582,6 +589,7 @@ class _HubDetailScreenState extends State usePaddingAware: true, horizontalPadding: 16, useWideAspectRatio: useWideLayout, + fullBleedImage: fullCardLayout, ), delegate: SliverChildBuilderDelegate((context, index) { final item = _filteredItems[index]; @@ -602,6 +610,7 @@ class _HubDetailScreenState extends State onBack: handleBackFromContent, onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), mixedHubContext: isMixedHub, + fullBleedImage: fullCardLayout, ); }, childCount: _filteredItems.length), ); diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 3a2ca291..f99753db 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1511,7 +1511,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildItemsSliver(context), ), ]; @@ -1568,6 +1573,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildMediaCardItem( @@ -1640,6 +1658,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState trackGridItemFocus(index, hasFocus), onListRefresh: _loadItems, + fullBleedImage: fullBleedImage, ); } diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index de4bcf7d..86da0753 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -10,6 +10,7 @@ import '../../../utils/grid_size_calculator.dart'; import '../../../utils/layout_constants.dart'; import '../../../utils/library_refresh_notifier.dart'; import '../../../utils/media_server_http_client.dart'; +import '../../../utils/platform_detector.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/media_grid_delegate.dart'; import '../../../widgets/settings_builder.dart'; @@ -106,16 +107,20 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState items) { return SettingsBuilder( - prefs: const [SettingsService.viewMode, SettingsService.libraryDensity], + prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], builder: (context) { final settings = SettingsService.instanceOrNull!; final viewMode = settings.read(SettingsService.viewMode); final density = settings.read(SettingsService.libraryDensity); + final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout); return CustomScrollView( clipBehavior: Clip.none, slivers: [ SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), - if (viewMode == ViewMode.list) _buildListSliver(density) else _buildGridSliver(density), + if (viewMode == ViewMode.list) + _buildListSliver(density) + else + _buildGridSliver(density, fullCardLayout: fullCardLayout), ], ); }, @@ -139,25 +144,42 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState - _buildMediaCardItem(index, isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount)), + itemBuilder: (context, index) => _buildMediaCardItem( + index, + isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount), + fullBleedImage: fullCardLayout, + ), ); }, ), ); } - Widget _buildMediaCardItem(int index, {required bool isFirstColumn, bool disableScale = false}) { + Widget _buildMediaCardItem( + int index, { + required bool isFirstColumn, + bool disableScale = false, + bool fullBleedImage = false, + }) { final item = loadedItems[index]; if (item == null) { ensureIndexLoaded(index, pageSize: _pageSize); @@ -169,6 +191,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState items) { return SettingsBuilder( - prefs: const [SettingsService.viewMode, SettingsService.libraryDensity], + prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], builder: (context) { final settings = SettingsService.instanceOrNull!; final viewMode = settings.read(SettingsService.viewMode); final density = settings.read(SettingsService.libraryDensity); + final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout); return CustomScrollView( clipBehavior: Clip.none, slivers: [ SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), - if (viewMode == ViewMode.list) _buildListSliver(density) else _buildGridSliver(density), + if (viewMode == ViewMode.list) + _buildListSliver(density) + else + _buildGridSliver(density, fullCardLayout: fullCardLayout), ], ); }, @@ -141,25 +146,42 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState - _buildPlaylistCard(index, isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount)), + itemBuilder: (context, index) => _buildPlaylistCard( + index, + isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount), + fullBleedImage: fullCardLayout, + ), ); }, ), ); } - Widget _buildPlaylistCard(int index, {required bool isFirstColumn, bool disableScale = false}) { + Widget _buildPlaylistCard( + int index, { + required bool isFirstColumn, + bool disableScale = false, + bool fullBleedImage = false, + }) { final playlist = loadedItems[index]; if (playlist == null) { ensureIndexLoaded(index, pageSize: _pageSize); @@ -171,6 +193,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState episodePosterMode: svc.read(SettingsService.episodePosterMode), episodePosterModeForHub: _tvDetailEpisodePosterModeForHub, widePosterScaleForHub: _tvDetailWidePosterScaleForHub, + fullCardLayout: svc.read(SettingsService.tvFullCardLayout), tallPosterScale: _tvDetailTallPosterScale, ); } @@ -3471,6 +3472,7 @@ class _MediaDetailScreenState extends State availableWidth: availableWidth, density: svc.read(SettingsService.libraryDensity), episodePosterMode: svc.read(SettingsService.episodePosterMode), + fullCardLayout: svc.read(SettingsService.tvFullCardLayout), scale: scale, tallPosterScale: _tvDetailTallPosterScale, widePosterScale: 1.0, diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index c85a2d09..21b1b565 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -33,6 +33,13 @@ class AppearanceSettingsScreen extends StatelessWidget { _densitySelector(), _viewModeSelector(), _episodePosterModeSelector(), + if (PlatformDetector.isTV()) + SettingSwitchTile( + pref: SettingsService.tvFullCardLayout, + icon: Symbols.image_rounded, + title: t.settings.tvFullCardLayout, + subtitle: t.settings.tvFullCardLayoutDescription, + ), SettingSwitchTile( pref: SettingsService.showEpisodeNumberOnCards, icon: Symbols.tag_rounded, diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 78e1f0ad..59616fe4 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -278,6 +278,7 @@ class SettingsService extends BaseSharedPreferencesService { static const seekTimeLarge = IntPref('seek_time_large', defaultValue: 30); static const rewindOnResume = IntPref('rewind_on_resume'); static const showHeroSection = BoolPref('show_hero_section', defaultValue: true); + static const tvFullCardLayout = BoolPref('tv_full_card_layout', defaultValue: false); static const useGlobalHubs = BoolPref('use_global_hubs', defaultValue: true); static const showServerNameOnHubs = BoolPref('show_server_name_on_hubs'); static const groupLibrariesByServer = BoolPref('group_libraries_by_server', defaultValue: true); diff --git a/lib/utils/grid_size_calculator.dart b/lib/utils/grid_size_calculator.dart index 1f8a60bd..e7a37ee5 100644 --- a/lib/utils/grid_size_calculator.dart +++ b/lib/utils/grid_size_calculator.dart @@ -53,18 +53,29 @@ class GridSizeCalculator { /// [crossAxisExtent] should come from layout constraints (e.g. `SliverLayoutBuilder` /// or `LayoutBuilder`), not from `MediaQuery`, to account for sidebars or other /// elements that reduce the grid's actual width. - static int getColumnCount(double crossAxisExtent, double maxCrossAxisExtent) { - final crossAxisSpacing = GridLayoutConstants.crossAxisSpacing; + static int getColumnCount( + double crossAxisExtent, + double maxCrossAxisExtent, { + double crossAxisSpacing = GridLayoutConstants.crossAxisSpacing, + }) { return ((crossAxisExtent + crossAxisSpacing) / (maxCrossAxisExtent + crossAxisSpacing)).ceil().clamp(1, 100); } + static double getCellWidthForColumnCount( + double crossAxisExtent, + int columnCount, { + double crossAxisSpacing = GridLayoutConstants.crossAxisSpacing, + }) { + return (crossAxisExtent - (crossAxisSpacing * (columnCount - 1))) / columnCount; + } + /// Computes the actual cell width that a grid with [getMaxCrossAxisExtent] would produce /// for the given [availableWidth]. This matches SliverGridDelegateWithMaxCrossAxisExtent's /// internal calculation, so horizontal scroll lists can use the same width as grids. static double getCellWidth(double availableWidth, BuildContext context, int density) { final maxExtent = getMaxCrossAxisExtent(context, density); final columns = getColumnCount(availableWidth, maxExtent); - return availableWidth / columns; + return getCellWidthForColumnCount(availableWidth, columns); } static bool isFirstRow(int index, int columnCount) { diff --git a/lib/utils/layout_constants.dart b/lib/utils/layout_constants.dart index 43db3771..69d8765e 100644 --- a/lib/utils/layout_constants.dart +++ b/lib/utils/layout_constants.dart @@ -41,6 +41,8 @@ class AppDurations { class GridLayoutConstants { static const double posterAspectRatio = 2 / 3.3; + static const double fullCardPosterAspectRatio = 2 / 3; + static const double episodeThumbnailAspectRatio = 16 / 9; static const double episodeGridCellAspectRatio = 1.4; @@ -48,6 +50,8 @@ class GridLayoutConstants { static const double crossAxisSpacing = 0; static const double mainAxisSpacing = 0; + static double fullCardGridSpacingForScale(double scale) => (12 * scale).clamp(8, 18).toDouble(); + /// Standard grid padding static EdgeInsets get gridPadding => const EdgeInsets.only(left: 2, right: 2, bottom: 2); } diff --git a/lib/widgets/focus_builders.dart b/lib/widgets/focus_builders.dart index 0aef1fd9..848cdf59 100644 --- a/lib/widgets/focus_builders.dart +++ b/lib/widgets/focus_builders.dart @@ -73,6 +73,10 @@ class FocusBuilders { VoidCallback? onTap, VoidCallback? onLongPress, double borderRadius = FocusTheme.defaultBorderRadius, + double focusScale = FocusTheme.focusScale, + double focusBorderStrokeAlign = BorderSide.strokeAlignInside, + bool useFocusGlow = false, + bool useForegroundFocusDecoration = false, required Widget child, }) { final isKeyboardMode = InputModeTracker.isKeyboardMode(context); @@ -93,15 +97,25 @@ class FocusBuilders { final duration = FocusTheme.getAnimationDuration(context); final showFocus = isFocused && isKeyboardMode; + final focusDecoration = FocusTheme.focusDecoration( + context, + isFocused: showFocus, + borderRadius: borderRadius, + borderStrokeAlign: focusBorderStrokeAlign, + ); + final glowDecoration = useFocusGlow + ? FocusTheme.focusGlowDecoration(context, isFocused: showFocus, borderRadius: borderRadius) + : null; final focusedWidget = AnimatedScale( - scale: showFocus ? FocusTheme.focusScale : 1.0, + scale: showFocus ? focusScale : 1.0, duration: duration, curve: Curves.easeOutCubic, child: AnimatedContainer( duration: duration, curve: Curves.easeOutCubic, - decoration: FocusTheme.focusDecoration(context, isFocused: showFocus, borderRadius: borderRadius), + decoration: useForegroundFocusDecoration ? glowDecoration : focusDecoration, + foregroundDecoration: useForegroundFocusDecoration ? focusDecoration : null, child: child, ), ); @@ -138,6 +152,10 @@ class FocusBuilders { VoidCallback? onTap, VoidCallback? onLongPress, double borderRadius = FocusTheme.defaultBorderRadius, + double focusScale = FocusTheme.focusScale, + double focusBorderStrokeAlign = BorderSide.strokeAlignInside, + bool useFocusGlow = false, + bool useForegroundFocusDecoration = false, required Widget child, }) { return buildFocusableCard( @@ -148,6 +166,10 @@ class FocusBuilders { onTap: onTap, onLongPress: onLongPress, borderRadius: borderRadius, + focusScale: focusScale, + focusBorderStrokeAlign: focusBorderStrokeAlign, + useFocusGlow: useFocusGlow, + useForegroundFocusDecoration: useForegroundFocusDecoration, child: child, ); } diff --git a/lib/widgets/focusable_media_card.dart b/lib/widgets/focusable_media_card.dart index 6f36f889..38d40a7c 100644 --- a/lib/widgets/focusable_media_card.dart +++ b/lib/widgets/focusable_media_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../focus/focus_theme.dart'; import '../focus/focusable_wrapper.dart'; import '../utils/platform_detector.dart'; import 'media_card.dart'; @@ -30,6 +31,9 @@ class FocusableMediaCard extends StatefulWidget { /// True when in a hub with mixed content (movies + episodes) final bool mixedHubContext; + /// Render grid cards as image-only full-bleed cards. + final bool fullBleedImage; + /// Show server name in list view (multi-server) final bool showServerName; @@ -79,6 +83,7 @@ class FocusableMediaCard extends StatefulWidget { this.collectionId, this.isOffline = false, this.mixedHubContext = false, + this.fullBleedImage = false, this.showServerName = false, this.disableScale = false, this.focusNode, @@ -111,6 +116,10 @@ class _FocusableMediaCardState extends State { onFocusChange: widget.onFocusChange, enableLongPress: true, disableScale: widget.disableScale, + focusScale: widget.fullBleedImage ? FocusTheme.fullCardFocusScale : FocusTheme.focusScale, + focusBorderStrokeAlign: widget.fullBleedImage ? BorderSide.strokeAlignOutside : BorderSide.strokeAlignInside, + useFocusGlow: widget.fullBleedImage, + useForegroundFocusDecoration: widget.fullBleedImage, useComfortableZone: !PlatformDetector.isTV(), // Always center on TV scrollAlignment: 0.5, child: MediaCard( @@ -127,6 +136,7 @@ class _FocusableMediaCardState extends State { collectionId: widget.collectionId, isOffline: widget.isOffline, mixedHubContext: widget.mixedHubContext, + fullBleedImage: widget.fullBleedImage, showServerName: widget.showServerName, ), ); diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index f133cdf4..a66e8cfd 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -60,6 +60,7 @@ class MediaCard extends StatefulWidget { final bool mixedHubContext; // True when in a hub with mixed content (movies + episodes) final bool showServerName; // Show server name in list view (multi-server) final EpisodePosterMode? episodePosterModeOverride; + final bool fullBleedImage; const MediaCard({ super.key, @@ -77,6 +78,7 @@ class MediaCard extends StatefulWidget { this.mixedHubContext = false, this.showServerName = false, this.episodePosterModeOverride, + this.fullBleedImage = false, }); @override @@ -264,8 +266,65 @@ class MediaCardState extends State with ContextMenuTapMixin _handleTap(context, item), + onTapDown: storeTapPosition, + onLongPress: showContextMenuFromTap, + onSecondaryTapDown: storeTapPosition, + onSecondaryTap: showContextMenuFromTap, + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: ClipRRect( + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: Stack( + fit: StackFit.expand, + children: [ + _buildPosterImage( + context, + item, + isOffline: widget.isOffline, + localPosterPath: localPosterPath, + mixedHubContext: widget.mixedHubContext, + episodePosterModeOverride: widget.episodePosterModeOverride, + knownWidth: width, + knownHeight: height, + ), + if (item is MediaItem) _MediaCardHelpers.buildWatchProgress(context, item), + ], + ), + ), + ), + ); + } + + Widget _buildStandardGridCard(BuildContext context, Object item, String? localPosterPath) { // Compute actual poster dimensions from card dimensions - final posterWidth = widget.width != null ? widget.width! - 6 : null; // 3px padding each side + final posterWidth = widget.width != null ? widget.width! - 6 : null; final posterHeight = widget.height; return SizedBox( diff --git a/lib/widgets/media_grid_delegate.dart b/lib/widgets/media_grid_delegate.dart index 2fecfdd1..577fdb0b 100644 --- a/lib/widgets/media_grid_delegate.dart +++ b/lib/widgets/media_grid_delegate.dart @@ -3,13 +3,14 @@ import '../utils/grid_size_calculator.dart'; import '../utils/layout_constants.dart'; /// Shared grid delegate configuration for media item grids -/// Maintains consistent spacing (2/3.3 aspect ratio, 0 spacing) across all media grids +/// Maintains consistent aspect ratio and spacing across all media grids. class MediaGridDelegate { /// Creates a standard grid delegate for media items /// /// 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. + /// Set [fullBleedImage] to true when the card is image-only and should not reserve text height. /// Pass [maxCrossAxisExtentOverride] to bypass the calculator and the wide-aspect multiplier — /// the caller is then responsible for providing a fully-resolved per-cell width. static SliverGridDelegateWithMaxCrossAxisExtent createDelegate({ @@ -18,11 +19,11 @@ class MediaGridDelegate { bool usePaddingAware = false, double horizontalPadding = 16, bool useWideAspectRatio = false, + bool fullBleedImage = false, double? maxCrossAxisExtentOverride, }) { - final aspectRatio = useWideAspectRatio - ? GridLayoutConstants.episodeGridCellAspectRatio - : GridLayoutConstants.posterAspectRatio; + final aspectRatio = aspectRatioFor(useWideAspectRatio: useWideAspectRatio, fullBleedImage: fullBleedImage); + final spacing = spacingFor(context: context, fullBleedImage: fullBleedImage); double maxCrossAxisExtent; if (maxCrossAxisExtentOverride != null) { @@ -42,8 +43,23 @@ class MediaGridDelegate { return SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: maxCrossAxisExtent, childAspectRatio: aspectRatio, - crossAxisSpacing: GridLayoutConstants.crossAxisSpacing, - mainAxisSpacing: GridLayoutConstants.mainAxisSpacing, + crossAxisSpacing: spacing, + mainAxisSpacing: spacing, ); } + + static double spacingFor({required BuildContext context, bool fullBleedImage = false}) { + if (!fullBleedImage) return GridLayoutConstants.crossAxisSpacing; + return GridLayoutConstants.fullCardGridSpacingForScale(TvLayoutConstants.scaleOf(context)); + } + + static double aspectRatioFor({bool useWideAspectRatio = false, bool fullBleedImage = false}) { + if (fullBleedImage) { + return useWideAspectRatio + ? GridLayoutConstants.episodeThumbnailAspectRatio + : GridLayoutConstants.fullCardPosterAspectRatio; + } + + return useWideAspectRatio ? GridLayoutConstants.episodeGridCellAspectRatio : GridLayoutConstants.posterAspectRatio; + } } diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index d94185fb..c9e65129 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -20,6 +20,7 @@ import '../utils/media_navigation_helper.dart'; import '../utils/provider_extensions.dart'; import '../utils/layout_constants.dart'; import 'app_icon.dart'; +import 'clickable_cursor.dart'; import 'focus_builders.dart'; import 'horizontal_scroll_with_arrows.dart'; import 'media_card.dart'; @@ -57,6 +58,7 @@ class TvBrowseRailLayoutMetrics { class TvBrowseRailLayout { static const double compactTallPosterScale = 0.80; static const double compactEpisodeThumbnailScale = compactTallPosterScale; + static const double fullCardFocusScale = FocusTheme.fullCardFocusScale; static double scaleForSize(Size size) => TvLayoutConstants.scaleForSize(size); @@ -70,6 +72,21 @@ class TvBrowseRailLayout { static double itemGapForScale(double _) => 0; + static double fullCardItemGapForScale(double scale) => (12 * scale).clamp(8, 18).toDouble(); + + static double viewAllItemWidthForScale(double scale) => (104 * scale).clamp(88, 132).toDouble(); + + static double viewAllPillHeightForScale(double scale) => (44 * scale).clamp(36, 54).toDouble(); + + static double fullCardFocusPaintOverflowForScale(double scale) { + return (FocusTheme.focusGlowOuterBlurRadius + + FocusTheme.focusGlowSpreadRadius + + FocusTheme.focusBorderWidth + + (10 * scale)) + .clamp(42, 64) + .toDouble(); + } + static double hubStripHeightForScale(double scale) => 36 * scale; static double hubStripGapForScale(double _) => 0; @@ -111,12 +128,13 @@ class TvBrowseRailLayout { required int density, required EpisodePosterMode episodePosterMode, required double scale, + bool fullCardLayout = false, double tallPosterScale = 1.0, double widePosterScale = 1.0, }) { final focusExtra = FocusTheme.focusBorderWidth * 2 * scale; final railEdgePadding = focusExtra + (12 * scale); - final itemGap = itemGapForScale(scale); + final itemGap = fullCardLayout ? fullCardItemGapForScale(scale) : itemGapForScale(scale); final isPersonHub = TvBrowseRailLayout.isPersonHub(hub); final hasWide = !isPersonHub && hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode)); final hasTall = !isPersonHub && hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode)); @@ -131,9 +149,10 @@ class TvBrowseRailLayout { itemGap: itemGap, ); final cardWidth = baseCardWidth * (useWideLayout ? widePosterScale : tallPosterScale); - final posterWidth = cardWidth - (6 * scale); + final posterWidth = fullCardLayout ? cardWidth : cardWidth - (6 * scale); final posterHeight = isPersonHub ? posterWidth : (useWideLayout ? posterWidth * 9 / 16 : posterWidth * 1.5); - final containerHeight = (posterHeight + ((isPersonHub ? 58 : 42) * scale)).ceilToDouble(); + final labelHeight = fullCardLayout ? 0.0 : ((isPersonHub ? 58 : 42) * scale); + final containerHeight = (posterHeight + labelHeight).ceilToDouble(); final height = containerHeight + focusExtra + (14 * scale); return TvBrowseRailLayoutMetrics( @@ -159,6 +178,7 @@ class TvBrowseRailLayout { EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub, double Function(MediaHub hub)? widePosterScaleForHub, required double scale, + bool fullCardLayout = false, double tallPosterScale = 1.0, double widePosterScale = 1.0, }) { @@ -170,6 +190,7 @@ class TvBrowseRailLayout { density: density, episodePosterMode: episodePosterModeForHub?.call(hub) ?? episodePosterMode, scale: scale, + fullCardLayout: fullCardLayout, tallPosterScale: tallPosterScale, widePosterScale: widePosterScaleForHub?.call(hub) ?? widePosterScale, ); @@ -185,7 +206,7 @@ class TvBrowseRailLayout { required double scale, }) { final itemContentWidth = hub.items.length * (metrics.cardWidth + metrics.itemGap); - final moreContentWidth = hub.more ? (132 * scale) + metrics.itemGap : 0.0; + final moreContentWidth = hub.more ? viewAllItemWidthForScale(scale) + metrics.itemGap : 0.0; final contentWidth = (metrics.railEdgePadding * 2) + itemContentWidth + moreContentWidth; return (contentWidth - viewportWidth).clamp(0.0, double.infinity).toDouble(); } @@ -196,7 +217,7 @@ class TvBrowseRailLayout { required TvBrowseRailLayoutMetrics metrics, required double scale, }) { - if (index == hub.items.length && hub.more) return (132 * scale) + metrics.itemGap; + if (index == hub.items.length && hub.more) return viewAllItemWidthForScale(scale) + metrics.itemGap; return metrics.cardWidth + metrics.itemGap; } @@ -227,6 +248,7 @@ class TvBrowseRailLayout { required EpisodePosterMode episodePosterMode, EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub, double Function(MediaHub hub)? widePosterScaleForHub, + bool fullCardLayout = false, double tallPosterScale = 1.0, double widePosterScale = 1.0, }) { @@ -244,6 +266,7 @@ class TvBrowseRailLayout { episodePosterModeForHub: episodePosterModeForHub, widePosterScaleForHub: widePosterScaleForHub, scale: scale, + fullCardLayout: fullCardLayout, tallPosterScale: tallPosterScale, widePosterScale: widePosterScale, ); @@ -784,7 +807,11 @@ class TvBrowseRailState extends State { Widget build(BuildContext context) { if (_activeHub == null) return const SizedBox.shrink(); return SettingsBuilder( - prefs: const [SettingsService.libraryDensity, SettingsService.episodePosterMode], + prefs: const [ + SettingsService.libraryDensity, + SettingsService.episodePosterMode, + SettingsService.tvFullCardLayout, + ], builder: (context) => LayoutBuilder( builder: (context, constraints) { final svc = SettingsService.instanceOrNull!; @@ -800,6 +827,7 @@ class TvBrowseRailState extends State { final railViewportWidth = (availableWidth + interactionExpansion).clamp(1.0, double.infinity).toDouble(); final density = svc.read(SettingsService.libraryDensity); final episodePosterMode = svc.read(SettingsService.episodePosterMode); + final fullCardLayout = svc.read(SettingsService.tvFullCardLayout); final modes = [for (final hub in widget.hubs) widget.episodePosterModeForHub?.call(hub) ?? episodePosterMode]; final wideScales = [ for (final hub in widget.hubs) widget.widePosterScaleForHub?.call(hub) ?? widget.widePosterScale, @@ -812,6 +840,7 @@ class TvBrowseRailState extends State { density: density, episodePosterMode: modes[i], scale: scale, + fullCardLayout: fullCardLayout, tallPosterScale: widget.tallPosterScale, widePosterScale: wideScales[i], ), @@ -845,7 +874,9 @@ class TvBrowseRailState extends State { TvBrowseRailLayout.railTopPaddingForScale(scale) + viewportHeight + TvBrowseRailLayout.railBottomPaddingForScale(scale); - + final paintOverflow = fullCardLayout && hasFocus + ? TvBrowseRailLayout.fullCardFocusPaintOverflowForScale(scale) + : 0.0; return Focus( focusNode: _focusNode, onKeyEvent: _handleKeyEvent, @@ -874,7 +905,12 @@ class TvBrowseRailState extends State { duration: FocusTheme.getAnimationDuration(context), curve: Curves.easeOutCubic, child: ClipRect( - clipper: _RailClipper(leftOverflow: horizontalInset, rightOverflow: 0, verticalOverflow: 0), + clipper: _RailClipper( + leftOverflow: horizontalInset, + rightOverflow: paintOverflow, + topOverflow: 0, + bottomOverflow: paintOverflow, + ), child: SizedBox( height: viewportHeight, child: _buildHubSectionList( @@ -883,6 +919,7 @@ class TvBrowseRailState extends State { metricsByHub: metricsByHub, sectionHeights: sectionHeights, scale: scale, + fullCardLayout: fullCardLayout, leftOverflow: horizontalInset, interactionExpansion: interactionExpansion, railViewportWidth: railViewportWidth, @@ -908,6 +945,7 @@ class TvBrowseRailState extends State { required List metricsByHub, required List sectionHeights, required double scale, + required bool fullCardLayout, required double leftOverflow, required double interactionExpansion, required double railViewportWidth, @@ -946,6 +984,7 @@ class TvBrowseRailState extends State { episodePosterMode: modes[hubIndex], metrics: metrics, scale: scale, + fullCardLayout: fullCardLayout, leftOverflow: leftOverflow, interactionExpansion: interactionExpansion, railViewportWidth: railViewportWidth, @@ -1010,6 +1049,7 @@ class TvBrowseRailState extends State { required EpisodePosterMode episodePosterMode, required TvBrowseRailLayoutMetrics metrics, required double scale, + required bool fullCardLayout, required double leftOverflow, required double interactionExpansion, required double railViewportWidth, @@ -1019,6 +1059,9 @@ class TvBrowseRailState extends State { final inactiveIndex = HubFocusMemory.getForHubOnly(hub.id, totalCount); final focusedIndex = isActiveHub ? _itemIndex : inactiveIndex; final scrollController = _scrollControllerForHub(hub, metrics, railViewportWidth, scale, focusedIndex); + final paintOverflow = fullCardLayout && hasFocus && isActiveHub + ? TvBrowseRailLayout.fullCardFocusPaintOverflowForScale(scale) + : 0.0; _metricsByHub[hub.id] = metrics; _scaleByHub[hub.id] = scale; @@ -1030,8 +1073,8 @@ class TvBrowseRailState extends State { child: ClipRect( clipper: _RailClipper( leftOverflow: leftOverflow, - rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap, - verticalOverflow: metrics.focusExtra, + rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap + paintOverflow, + verticalOverflow: fullCardLayout ? math.max(metrics.focusExtra, paintOverflow) : metrics.focusExtra, ), child: HorizontalScrollWithArrows( controller: scrollController, @@ -1048,79 +1091,69 @@ class TvBrowseRailState extends State { if (itemIndex == hub.items.length) { return Padding( padding: EdgeInsets.only(right: metrics.itemGap), - child: FocusBuilders.buildLockedFocusWrapper( - context: context, - isFocused: isFocused, - onTap: () { - _selectHubItem(hub, hubIndex, itemIndex); - _navigateToHubDetail(hub); - }, - child: SizedBox( - width: 132 * scale, - height: metrics.containerHeight - metrics.itemGap, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppIcon( - Symbols.arrow_forward_rounded, - fill: 1, - size: 42 * scale, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.72), - ), - SizedBox(height: 6 * scale), - Text( - t.common.viewAll, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.72), - fontWeight: FontWeight.w700, - ), - ), - ], - ), + child: Align( + alignment: Alignment.centerLeft, + child: _buildViewAllButton( + context, + isFocused: isFocused, + scale: scale, + onTap: () { + _selectHubItem(hub, hubIndex, itemIndex); + _navigateToHubDetail(hub); + }, ), ), ); } final item = hub.items[itemIndex]; + final focusableCard = FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + borderRadius: tokens(context).radiusSm, + focusScale: fullCardLayout ? TvBrowseRailLayout.fullCardFocusScale : FocusTheme.focusScale, + focusBorderStrokeAlign: fullCardLayout ? BorderSide.strokeAlignOutside : BorderSide.strokeAlignInside, + useFocusGlow: fullCardLayout, + useForegroundFocusDecoration: fullCardLayout, + onTap: () { + _selectHubItem(hub, hubIndex, itemIndex); + unawaited(_activateCurrentItem()); + }, + onLongPress: metrics.isPersonHub + ? null + : () { + _selectHubItem(hub, hubIndex, itemIndex); + _cardKeyFor(hub, itemIndex).currentState?.showContextMenu(); + }, + child: metrics.isPersonHub + ? _buildPersonCard( + context, + item, + cardWidth: metrics.cardWidth, + imageSize: metrics.posterHeight, + scale: scale, + fullCardLayout: fullCardLayout, + ) + : MediaCard( + key: _cardKeyFor(hub, itemIndex), + item: item, + width: metrics.cardWidth, + height: metrics.posterHeight, + onRefresh: widget.onRefresh, + onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + forceGridMode: true, + fullBleedImage: fullCardLayout, + isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false, + mixedHubContext: metrics.isMixedHub, + episodePosterModeOverride: episodePosterMode, + ), + ); + return Padding( padding: EdgeInsets.only(right: metrics.itemGap), child: MouseRegion( onEnter: (_) => _setHoveredItem(hub, itemIndex), - child: FocusBuilders.buildLockedFocusWrapper( - context: context, - isFocused: isFocused, - onTap: () { - _selectHubItem(hub, hubIndex, itemIndex); - unawaited(_activateCurrentItem()); - }, - onLongPress: metrics.isPersonHub - ? null - : () { - _selectHubItem(hub, hubIndex, itemIndex); - _cardKeyFor(hub, itemIndex).currentState?.showContextMenu(); - }, - child: metrics.isPersonHub - ? _buildPersonCard( - context, - item, - cardWidth: metrics.cardWidth, - imageSize: metrics.posterHeight, - scale: scale, - ) - : MediaCard( - key: _cardKeyFor(hub, itemIndex), - item: item, - width: metrics.cardWidth, - height: metrics.posterHeight, - onRefresh: widget.onRefresh, - onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, - forceGridMode: true, - isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false, - mixedHubContext: metrics.isMixedHub, - episodePosterModeOverride: episodePosterMode, - ), - ), + child: Align(alignment: Alignment.topLeft, child: focusableCard), ), ); }, @@ -1137,10 +1170,81 @@ class TvBrowseRailState extends State { required double cardWidth, required double imageSize, required double scale, + required bool fullCardLayout, }) { final theme = Theme.of(context); final characterName = item.parentTitle; + if (fullCardLayout) { + return SizedBox( + width: cardWidth, + height: imageSize, + child: ClipRRect( + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: Stack( + fit: StackFit.expand, + children: [ + OptimizedMediaImage( + client: context.tryGetMediaClientWithFallback(item.serverId), + imagePath: item.thumbPath, + width: cardWidth, + height: imageSize, + fit: BoxFit.cover, + imageType: ImageType.avatar, + fallbackIcon: Symbols.person_rounded, + ), + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.78)], + stops: const [0.45, 1.0], + ), + ), + ), + Positioned( + left: 10 * scale, + right: 10 * scale, + bottom: 9 * scale, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 13 * scale, + height: 1.1, + fontWeight: FontWeight.w800, + ), + ), + if (characterName != null && characterName.isNotEmpty) ...[ + SizedBox(height: 2 * scale), + Text( + characterName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.82), + fontSize: 11 * scale, + height: 1.1, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + return SizedBox( width: cardWidth, child: Padding( @@ -1191,6 +1295,78 @@ class TvBrowseRailState extends State { ), ); } + + Widget _buildViewAllButton( + BuildContext context, { + required bool isFocused, + required double scale, + required VoidCallback onTap, + }) { + final theme = Theme.of(context); + final duration = FocusTheme.getAnimationDuration(context); + final width = TvBrowseRailLayout.viewAllItemWidthForScale(scale); + final height = TvBrowseRailLayout.viewAllPillHeightForScale(scale); + final foreground = isFocused ? theme.colorScheme.primary : theme.colorScheme.onSurface.withValues(alpha: 0.78); + final background = isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.20) + : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.42); + + return ClickableCursor( + child: GestureDetector( + onTap: onTap, + child: AnimatedScale( + scale: isFocused ? 1.04 : 1.0, + duration: duration, + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: duration, + curve: Curves.easeOutCubic, + width: width, + height: height, + padding: EdgeInsets.symmetric(horizontal: (12 * scale).clamp(10, 16).toDouble()), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(height / 2), + boxShadow: isFocused + ? [ + BoxShadow( + color: theme.colorScheme.primary.withValues(alpha: 0.20), + blurRadius: 18, + spreadRadius: 1, + ), + ] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + t.common.viewAll, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foreground, + fontSize: (13 * scale).clamp(12, 16).toDouble(), + fontWeight: FontWeight.w800, + letterSpacing: 0.1, + ), + ), + ), + SizedBox(width: (5 * scale).clamp(4, 7).toDouble()), + AppIcon( + Symbols.arrow_forward_rounded, + fill: 1, + size: (18 * scale).clamp(16, 22).toDouble(), + color: foreground, + ), + ], + ), + ), + ), + ), + ); + } } class _RailBackgroundBleed extends StatelessWidget { @@ -1226,18 +1402,27 @@ class _RailBackgroundBleed extends StatelessWidget { class _RailClipper extends CustomClipper { final double leftOverflow; final double rightOverflow; - final double verticalOverflow; + final double topOverflow; + final double bottomOverflow; - const _RailClipper({this.leftOverflow = 0, required this.rightOverflow, required this.verticalOverflow}); + const _RailClipper({ + this.leftOverflow = 0, + required this.rightOverflow, + double verticalOverflow = 0, + double? topOverflow, + double? bottomOverflow, + }) : topOverflow = topOverflow ?? verticalOverflow, + bottomOverflow = bottomOverflow ?? verticalOverflow; @override Rect getClip(Size size) => - Rect.fromLTRB(-leftOverflow, -verticalOverflow, size.width + rightOverflow, size.height + verticalOverflow); + Rect.fromLTRB(-leftOverflow, -topOverflow, size.width + rightOverflow, size.height + bottomOverflow); @override bool shouldReclip(covariant _RailClipper oldClipper) { return oldClipper.leftOverflow != leftOverflow || oldClipper.rightOverflow != rightOverflow || - oldClipper.verticalOverflow != verticalOverflow; + oldClipper.topOverflow != topOverflow || + oldClipper.bottomOverflow != bottomOverflow; } } diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index 9432a9dc..0ab888e2 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -165,6 +165,7 @@ void main() { hubs: [hub], density: LibraryDensity.max, episodePosterMode: settings.read(SettingsService.episodePosterMode), + fullCardLayout: settings.read(SettingsService.tvFullCardLayout), tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, ); final minimumSpotlightBottom = railHeight + (8 * scale); diff --git a/test/services/settings_service_test.dart b/test/services/settings_service_test.dart index 45c393f2..df00f4af 100644 --- a/test/services/settings_service_test.dart +++ b/test/services/settings_service_test.dart @@ -74,6 +74,14 @@ void main() { }); }); + group('SettingsService TV card defaults', () { + test('full card layout starts disabled', () async { + final settings = await SettingsService.getInstance(); + + expect(settings.read(SettingsService.tvFullCardLayout), isFalse); + }); + }); + group('SettingsService companion remote prefs', () { test('last manual host address trims whitespace and drops blanks', () async { final settings = await SettingsService.getInstance(); diff --git a/test/widgets/media_card_full_card_test.dart b/test/widgets/media_card_full_card_test.dart new file mode 100644 index 00000000..75e9d3b6 --- /dev/null +++ b/test/widgets/media_card_full_card_test.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/layout_constants.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/focusable_media_card.dart'; +import 'package:plezy/widgets/media_card.dart'; +import 'package:plezy/widgets/media_grid_delegate.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + }); + + test('full bleed grid delegates use image aspect ratios', () { + expect(MediaGridDelegate.aspectRatioFor(fullBleedImage: true), GridLayoutConstants.fullCardPosterAspectRatio); + expect( + MediaGridDelegate.aspectRatioFor(useWideAspectRatio: true, fullBleedImage: true), + GridLayoutConstants.episodeThumbnailAspectRatio, + ); + expect(MediaGridDelegate.aspectRatioFor(useWideAspectRatio: true), GridLayoutConstants.episodeGridCellAspectRatio); + }); + + testWidgets('full bleed grid delegates use scaled gutters', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + late SliverGridDelegateWithMaxCrossAxisExtent delegate; + await tester.pumpWidget( + _TestApp( + child: Builder( + builder: (context) { + delegate = MediaGridDelegate.createDelegate( + context: context, + density: LibraryDensity.defaultValue, + fullBleedImage: true, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(delegate.crossAxisSpacing, greaterThan(0)); + expect(delegate.mainAxisSpacing, delegate.crossAxisSpacing); + expect(delegate.crossAxisSpacing, GridLayoutConstants.fullCardGridSpacingForScale(0.85)); + }); + + testWidgets('full bleed grid media cards hide text when constrained by a grid cell', (tester) async { + final item = MediaItem( + id: 'movie_1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Hidden Movie', + year: 2024, + ); + + await tester.pumpWidget( + _TestApp( + child: SizedBox( + width: 200, + height: 300, + child: MediaCard(item: item, forceGridMode: true, fullBleedImage: true, isOffline: true), + ), + ), + ); + + expect(find.text('Hidden Movie'), findsNothing); + expect(find.text('2024'), findsNothing); + expect(tester.getSize(find.byType(InkWell)), const Size(200, 300)); + }); + + testWidgets('standard grid media cards still show text', (tester) async { + final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Visible Movie'); + + await tester.pumpWidget( + _TestApp( + child: SizedBox(width: 200, height: 330, child: MediaCard(item: item, forceGridMode: true, isOffline: true)), + ), + ); + + expect(find.text('Visible Movie'), findsOneWidget); + }); + + testWidgets('full bleed flag does not hide list media card text', (tester) async { + final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'List Movie'); + + await tester.pumpWidget( + _TestApp( + child: SizedBox( + width: 420, + height: 160, + child: MediaCard(item: item, forceListMode: true, fullBleedImage: true, isOffline: true), + ), + ), + ); + + expect(find.text('List Movie'), findsOneWidget); + }); + + testWidgets('full bleed focusable media card uses outside ring and local glow', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + final focusNode = FocusNode(debugLabel: 'full_bleed_card'); + addTearDown(focusNode.dispose); + final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Focused Movie'); + + await tester.pumpWidget( + InputModeTracker( + child: _TestApp( + child: SizedBox( + width: 200, + height: 300, + child: FocusableMediaCard( + item: item, + forceGridMode: true, + fullBleedImage: true, + focusNode: focusNode, + isOffline: true, + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + + final focusDecoration = find.byWidgetPredicate( + (widget) => + widget is AnimatedContainer && + widget.decoration is BoxDecoration && + widget.foregroundDecoration is BoxDecoration, + ); + + expect(focusDecoration, findsOneWidget); + final focusedContainer = tester.widget(focusDecoration); + final glowDecoration = focusedContainer.decoration as BoxDecoration; + final foregroundDecoration = focusedContainer.foregroundDecoration as BoxDecoration; + final border = foregroundDecoration.border as Border; + + expect(glowDecoration.boxShadow, hasLength(2)); + expect(glowDecoration.boxShadow!.first.color, isNot(Colors.transparent)); + expect(border.top.strokeAlign, BorderSide.strokeAlignOutside); + }); +} + +class _TestApp extends StatelessWidget { + final Widget child; + + const _TestApp({required this.child}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold(body: Center(child: child)), + ); + } +} diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index c557453b..7edb6b03 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/focus/dpad_navigator.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/focus/locked_hub_controller.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_hub.dart'; @@ -12,6 +13,7 @@ import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/side_navigation_rail.dart'; import 'package:plezy/widgets/tv_browse_rail.dart'; import 'package:provider/provider.dart'; @@ -176,6 +178,40 @@ void main() { expect(compactHeight, lessThan(defaultHeight)); }); + test('full card layout removes label reserve and preserves episode poster mode', () { + final episode = MediaItem( + id: 'episode_1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode 1', + thumbPath: '/episode-thumb', + grandparentThumbPath: '/show-poster', + ); + final hub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: [episode], size: 1); + + final detailed = TvBrowseRailLayout.metricsForHub( + hub: hub, + availableWidth: 1040, + density: LibraryDensity.defaultValue, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + scale: 0.85, + ); + final full = TvBrowseRailLayout.metricsForHub( + hub: hub, + availableWidth: 1040, + density: LibraryDensity.defaultValue, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + scale: 0.85, + fullCardLayout: true, + ); + + expect(full.height, lessThan(detailed.height)); + expect(full.useWideLayout, isTrue); + expect(detailed.itemGap, 0); + expect(full.itemGap, closeTo(12 * 0.85, 0.001)); + expect(full.posterHeight, closeTo(full.posterWidth * 9 / 16, 0.001)); + }); + test('compact wide poster scale makes clips match compact episode thumbnails', () { final episode = MediaItem( id: 'episode_1', @@ -275,6 +311,326 @@ void main() { expect(headerText.style?.color, theme.colorScheme.onSurface); }); + testWidgets('full card layout hides media text and overlays actor text when enabled', (tester) async { + await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, true); + + final serverManager = MultiServerManager(); + final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Hidden Movie'); + final actor = MediaItem( + id: 'actor_1', + backend: MediaBackend.plex, + kind: MediaKind.unknown, + title: 'Actor Name', + parentTitle: 'Character Name', + ); + final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1); + final actorHub = MediaHub(id: 'actors', title: 'Cast', type: 'person', items: [actor], size: 1); + + Widget rail(MediaHub hub) { + return ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail(hubs: [hub], iconForHub: (_, _) => Icons.movie_rounded), + ), + ), + ), + ); + } + + await tester.pumpWidget(rail(movieHub)); + await tester.pump(); + expect(find.text('Hidden Movie'), findsNothing); + + await tester.pumpWidget(rail(actorHub)); + await tester.pump(); + expect(find.text('Actor Name'), findsOneWidget); + expect(find.text('Character Name'), findsOneWidget); + }); + + testWidgets('full card focus adds outside ring, local glow, and card image scale', (tester) async { + await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, true); + + TvDetectionService.debugSetAppleTVOverride(true); + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + final serverManager = MultiServerManager(); + final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail(hubs: [hub], autofocus: true, iconForHub: (_, _) => Icons.movie_rounded), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final focusDecoration = find.byWidgetPredicate( + (widget) => + widget is AnimatedContainer && + widget.decoration is BoxDecoration && + widget.foregroundDecoration is BoxDecoration, + ); + final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio); + final metrics = TvBrowseRailLayout.metricsForHub( + hub: hub, + availableWidth: 1280 - TvBrowseRailLayout.horizontalInsetForScale(scale), + density: LibraryDensity.defaultValue, + episodePosterMode: EpisodePosterMode.seriesPoster, + scale: scale, + fullCardLayout: true, + ); + + expect(focusDecoration, findsOneWidget); + final focusDecorationWidget = tester.widget(focusDecoration); + final glowDecoration = focusDecorationWidget.decoration as BoxDecoration; + final foregroundDecoration = focusDecorationWidget.foregroundDecoration as BoxDecoration; + final border = foregroundDecoration.border as Border; + final focusDecorationSize = tester.getSize(focusDecoration); + final focusScale = tester.widget( + find.ancestor(of: focusDecoration, matching: find.byType(AnimatedScale)).first, + ); + + expect(border.top.strokeAlign, BorderSide.strokeAlignOutside); + expect(find.byType(ShaderMask), findsNothing); + expect(find.byType(CompositedTransformFollower), findsNothing); + expect(find.byType(CompositedTransformTarget), findsNothing); + expect(glowDecoration.boxShadow, hasLength(2)); + expect(glowDecoration.boxShadow!.first.color, isNot(Colors.transparent)); + expect(focusScale.scale, closeTo(1.03, 0.0001)); + expect(focusDecorationSize.width, closeTo(metrics.cardWidth, 0.001)); + expect(focusDecorationSize.height, closeTo(metrics.posterHeight, 0.001)); + }); + + testWidgets('vertical hub viewport keeps top clipping while switching hubs', (tester) async { + await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, true); + + TvDetectionService.debugSetAppleTVOverride(true); + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + final serverManager = MultiServerManager(); + final firstMovie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1'); + final secondMovie = MediaItem(id: 'movie_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 2'); + final firstHub = MediaHub(id: 'movies_1', title: 'Movies 1', type: 'movie', items: [firstMovie], size: 1); + final secondHub = MediaHub(id: 'movies_2', title: 'Movies 2', type: 'movie', items: [secondMovie], size: 1); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail( + hubs: [firstHub, secondHub], + autofocus: true, + iconForHub: (_, _) => Icons.movie_rounded, + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio); + final expectedPaintOverflow = TvBrowseRailLayout.fullCardFocusPaintOverflowForScale(scale); + final expectedLeftOverflow = TvBrowseRailLayout.horizontalInsetForScale(scale); + final verticalViewportClip = tester + .widgetList( + find.ancestor(of: find.byKey(const ValueKey('tv_browse_rail_vertical')), matching: find.byType(ClipRect)), + ) + .singleWhere((widget) => widget.clipper != null); + final clipRectSize = tester.getSize(find.byWidget(verticalViewportClip)); + final clip = verticalViewportClip.clipper!.getClip(clipRectSize); + + expect(clip.left, closeTo(-expectedLeftOverflow, 0.001)); + expect(clip.left, greaterThan(-expectedPaintOverflow)); + expect(clip.top, 0); + expect(clip.bottom, greaterThanOrEqualTo(clipRectSize.height + expectedPaintOverflow)); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + + expect(find.byType(CompositedTransformFollower), findsNothing); + }); + + testWidgets('detailed card layout can still show media text', (tester) async { + await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false); + + final serverManager = MultiServerManager(); + final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Visible Movie'); + final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail(hubs: [hub], iconForHub: (_, _) => Icons.movie_rounded), + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Visible Movie'), findsOneWidget); + }); + + testWidgets('detailed card focus ring wraps card content height', (tester) async { + await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false); + TvDetectionService.debugSetAppleTVOverride(true); + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + final serverManager = MultiServerManager(); + final movie = MediaItem( + id: 'movie_1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Visible Movie', + year: 2024, + ); + final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail(hubs: [hub], autofocus: true, iconForHub: (_, _) => Icons.movie_rounded), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final focusDecoration = find.ancestor( + of: find.text('Visible Movie'), + matching: find.byWidgetPredicate((widget) { + if (widget is! AnimatedContainer || widget.decoration is! BoxDecoration) return false; + return (widget.decoration as BoxDecoration).border is Border && widget.foregroundDecoration == null; + }), + ); + + expect(focusDecoration, findsOneWidget); + expect(find.text('2024'), findsOneWidget); + + final focusRect = tester.getRect(focusDecoration); + final subtitleRect = tester.getRect(find.text('2024')); + expect(focusRect.bottom - subtitleRect.bottom, lessThan(8)); + }); + + testWidgets('view all item uses compact pill focus style', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + final serverManager = MultiServerManager(); + final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 2, more: true); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail(hubs: [hub], autofocus: true, iconForHub: (_, _) => Icons.movie_rounded), + ), + ), + ), + ), + ), + ); + await tester.pump(); + tester.state(find.byType(TvBrowseRail)).requestFocus(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + + final viewAllText = find.text('View All'); + final pill = find.ancestor(of: viewAllText, matching: find.byType(AnimatedContainer)); + final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio); + + expect(viewAllText, findsOneWidget); + expect(pill, findsOneWidget); + final pillWidget = tester.widget(pill); + final decoration = pillWidget.decoration as BoxDecoration; + final pillSize = tester.getSize(pill); + + expect(decoration.border, isNull); + expect(decoration.boxShadow, isNotNull); + expect(pillSize.width, closeTo(TvBrowseRailLayout.viewAllItemWidthForScale(scale), 0.001)); + expect(pillSize.width, lessThan(132 * scale)); + expect(pillSize.height, closeTo(TvBrowseRailLayout.viewAllPillHeightForScale(scale), 0.001)); + }); + testWidgets('inactive hub contents render at reduced opacity', (tester) async { final serverManager = MultiServerManager(); final firstItem = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1'); @@ -500,6 +856,7 @@ void main() { await tester.pump(); final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio); + final fullCardLayout = SettingsService.instanceOrNull!.read(SettingsService.tvFullCardLayout); final availableWidth = 700 - TvBrowseRailLayout.horizontalInsetForScale(scale); final movieMetrics = TvBrowseRailLayout.metricsForHub( hub: movieHub, @@ -507,6 +864,7 @@ void main() { density: LibraryDensity.defaultValue, episodePosterMode: EpisodePosterMode.episodeThumbnail, scale: scale, + fullCardLayout: fullCardLayout, ); final expectedVerticalOffset = TvBrowseRailLayout.hubSectionHeightFor( scale: scale, @@ -533,6 +891,7 @@ void main() { density: LibraryDensity.defaultValue, episodePosterMode: EpisodePosterMode.episodeThumbnail, scale: scale, + fullCardLayout: fullCardLayout, ); final expectedOffset = TvBrowseRailLayout.scrollOffsetForIndex( hub: episodeHub, @@ -620,6 +979,7 @@ void main() { }); testWidgets('keeps late episode thumbnails visible in long TV rows', (tester) async { + await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false); tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(1280, 720); addTearDown(() { @@ -707,6 +1067,7 @@ void main() { }); testWidgets('keeps late episode thumbnails visible during rapid key repeat', (tester) async { + await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false); tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(1280, 720); addTearDown(() {