feat: add View All card and dpad focus to hub detail

close #474
This commit is contained in:
edde746
2026-02-20 22:10:52 +01:00
parent b25df3b411
commit ec054fce46
22 changed files with 325 additions and 108 deletions
+2 -1
View File
@@ -51,7 +51,8 @@
"exitConfirmTitle": "App beenden?",
"exitConfirmMessage": "Möchtest du die App wirklich beenden?",
"dontAskAgain": "Nicht erneut fragen",
"exit": "Beenden"
"exit": "Beenden",
"viewAll": "Alle anzeigen"
},
"screens": {
"licenses": "Lizenzen",
+2 -1
View File
@@ -51,7 +51,8 @@
"exitConfirmTitle": "Exit app?",
"exitConfirmMessage": "Are you sure you want to exit?",
"dontAskAgain": "Don't ask again",
"exit": "Exit"
"exit": "Exit",
"viewAll": "View All"
},
"screens": {
"licenses": "Licenses",
+2 -1
View File
@@ -51,7 +51,8 @@
"exitConfirmTitle": "¿Salir de la app?",
"exitConfirmMessage": "¿Estás seguro de que quieres salir?",
"dontAskAgain": "No volver a preguntar",
"exit": "Salir"
"exit": "Salir",
"viewAll": "Ver todo"
},
"screens": {
"licenses": "Licencias",
+2 -1
View File
@@ -51,7 +51,8 @@
"exitConfirmTitle": "Quitter l'application ?",
"exitConfirmMessage": "Êtes-vous sûr de vouloir quitter ?",
"dontAskAgain": "Ne plus demander",
"exit": "Quitter"
"exit": "Quitter",
"viewAll": "Tout afficher"
},
"screens": {
"licenses": "Licenses",
+2 -1
View File
@@ -51,7 +51,8 @@
"exitConfirmTitle": "Uscire dall'app?",
"exitConfirmMessage": "Sei sicuro di voler uscire?",
"dontAskAgain": "Non chiedere più",
"exit": "Esci"
"exit": "Esci",
"viewAll": "Mostra tutto"
},
"screens": {
"licenses": "Licenze",
+2 -1
View File
@@ -51,7 +51,8 @@
"exitConfirmTitle": "앱을 종료하시겠습니까?",
"exitConfirmMessage": "정말 종료하시겠습니까?",
"dontAskAgain": "다시 묻지 않기",
"exit": "종료"
"exit": "종료",
"viewAll": "모두 보기"
},
"screens": {
"licenses": "라이선스",
+2 -1
View File
@@ -51,7 +51,8 @@
"exitConfirmTitle": "App afsluiten?",
"exitConfirmMessage": "Weet je zeker dat je wilt afsluiten?",
"dontAskAgain": "Niet meer vragen",
"exit": "Afsluiten"
"exit": "Afsluiten",
"viewAll": "Alles weergeven"
},
"screens": {
"licenses": "Licenties",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 9
/// Strings: 6669 (741 per locale)
/// Strings: 6687 (743 per locale)
///
/// Built on 2026-02-18 at 03:36 UTC
/// Built on 2026-02-20 at 20:23 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+4 -4
View File
@@ -149,6 +149,7 @@ class _TranslationsCommonDe implements TranslationsCommonEn {
@override String get exitConfirmMessage => 'Möchtest du die App wirklich beenden?';
@override String get dontAskAgain => 'Nicht erneut fragen';
@override String get exit => 'Beenden';
@override String get viewAll => 'Alle anzeigen';
}
// Path: screens
@@ -401,7 +402,6 @@ class _TranslationsMediaMenuDe implements TranslationsMediaMenuEn {
@override String get mediaDeletedSuccessfully => 'Medienelement erfolgreich gelöscht';
@override String get mediaFailedToDelete => 'Löschen des Medienelements fehlgeschlagen';
@override String get rate => 'Bewerten';
}
// Path: accessibility
@@ -1278,6 +1278,7 @@ extension on TranslationsDe {
'common.exitConfirmMessage' => 'Möchtest du die App wirklich beenden?',
'common.dontAskAgain' => 'Nicht erneut fragen',
'common.exit' => 'Beenden',
'common.viewAll' => 'Alle anzeigen',
'screens.licenses' => 'Lizenzen',
'screens.switchProfile' => 'Profil wechseln',
'screens.subtitleStyling' => 'Untertitel-Stil',
@@ -1476,7 +1477,6 @@ extension on TranslationsDe {
'mediaMenu.mediaDeletedSuccessfully' => 'Medienelement erfolgreich gelöscht',
'mediaMenu.mediaFailedToDelete' => 'Löschen des Medienelements fehlgeschlagen',
'mediaMenu.rate' => 'Bewerten',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, Film',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, Serie',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
@@ -1742,10 +1742,10 @@ extension on TranslationsDe {
'liveTv.today' => 'Heute',
'liveTv.midnight' => 'Mitternacht',
'liveTv.overnight' => 'Nacht',
'liveTv.morning' => 'Morgen',
'liveTv.daytime' => 'Tagsüber',
_ => null,
} ?? switch (path) {
'liveTv.morning' => 'Morgen',
'liveTv.daytime' => 'Tagsüber',
'liveTv.evening' => 'Abend',
'liveTv.lateNight' => 'Spätnacht',
'liveTv.whatsOn' => 'Jetzt im TV',
+6 -4
View File
@@ -248,6 +248,9 @@ class TranslationsCommonEn {
/// en: 'Exit'
String get exit => 'Exit';
/// en: 'View All'
String get viewAll => 'View All';
}
// Path: screens
@@ -859,7 +862,6 @@ class TranslationsMediaMenuEn {
/// en: 'Rate'
String get rate => 'Rate';
}
// Path: accessibility
@@ -2769,6 +2771,7 @@ extension on Translations {
'common.exitConfirmMessage' => 'Are you sure you want to exit?',
'common.dontAskAgain' => 'Don\'t ask again',
'common.exit' => 'Exit',
'common.viewAll' => 'View All',
'screens.licenses' => 'Licenses',
'screens.switchProfile' => 'Switch Profile',
'screens.subtitleStyling' => 'Subtitle Styling',
@@ -2967,7 +2970,6 @@ extension on Translations {
'mediaMenu.mediaDeletedSuccessfully' => 'Media item deleted successfully',
'mediaMenu.mediaFailedToDelete' => 'Failed to delete media item',
'mediaMenu.rate' => 'Rate',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, movie',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, TV show',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
@@ -3233,10 +3235,10 @@ extension on Translations {
'liveTv.today' => 'Today',
'liveTv.midnight' => 'Midnight',
'liveTv.overnight' => 'Overnight',
'liveTv.morning' => 'Morning',
'liveTv.daytime' => 'Daytime',
_ => null,
} ?? switch (path) {
'liveTv.morning' => 'Morning',
'liveTv.daytime' => 'Daytime',
'liveTv.evening' => 'Evening',
'liveTv.lateNight' => 'Late Night',
'liveTv.whatsOn' => 'What\'s On',
+4 -4
View File
@@ -149,6 +149,7 @@ class _TranslationsCommonEs implements TranslationsCommonEn {
@override String get exitConfirmMessage => '¿Estás seguro de que quieres salir?';
@override String get dontAskAgain => 'No volver a preguntar';
@override String get exit => 'Salir';
@override String get viewAll => 'Ver todo';
}
// Path: screens
@@ -401,7 +402,6 @@ class _TranslationsMediaMenuEs implements TranslationsMediaMenuEn {
@override String get mediaDeletedSuccessfully => 'Elemento multimedia eliminado con éxito';
@override String get mediaFailedToDelete => 'Error al eliminar el elemento multimedia';
@override String get rate => 'Calificar';
}
// Path: accessibility
@@ -1278,6 +1278,7 @@ extension on TranslationsEs {
'common.exitConfirmMessage' => '¿Estás seguro de que quieres salir?',
'common.dontAskAgain' => 'No volver a preguntar',
'common.exit' => 'Salir',
'common.viewAll' => 'Ver todo',
'screens.licenses' => 'Licencias',
'screens.switchProfile' => 'Cambiar Perfil',
'screens.subtitleStyling' => 'Estilo de Subtítulos',
@@ -1476,7 +1477,6 @@ extension on TranslationsEs {
'mediaMenu.mediaDeletedSuccessfully' => 'Elemento multimedia eliminado con éxito',
'mediaMenu.mediaFailedToDelete' => 'Error al eliminar el elemento multimedia',
'mediaMenu.rate' => 'Calificar',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, película',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, serie de TV',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
@@ -1742,10 +1742,10 @@ extension on TranslationsEs {
'liveTv.today' => 'Hoy',
'liveTv.midnight' => 'Medianoche',
'liveTv.overnight' => 'Madrugada',
'liveTv.morning' => 'Mañana',
'liveTv.daytime' => 'Día',
_ => null,
} ?? switch (path) {
'liveTv.morning' => 'Mañana',
'liveTv.daytime' => 'Día',
'liveTv.evening' => 'Noche',
'liveTv.lateNight' => 'Trasnoche',
'liveTv.whatsOn' => 'En emisión',
+4 -4
View File
@@ -149,6 +149,7 @@ class _TranslationsCommonFr implements TranslationsCommonEn {
@override String get exitConfirmMessage => 'Êtes-vous sûr de vouloir quitter ?';
@override String get dontAskAgain => 'Ne plus demander';
@override String get exit => 'Quitter';
@override String get viewAll => 'Tout afficher';
}
// Path: screens
@@ -401,7 +402,6 @@ class _TranslationsMediaMenuFr implements TranslationsMediaMenuEn {
@override String get mediaDeletedSuccessfully => 'Élément média supprimé avec succès';
@override String get mediaFailedToDelete => 'Échec de la suppression de l\'élément média';
@override String get rate => 'Noter';
}
// Path: accessibility
@@ -1278,6 +1278,7 @@ extension on TranslationsFr {
'common.exitConfirmMessage' => 'Êtes-vous sûr de vouloir quitter ?',
'common.dontAskAgain' => 'Ne plus demander',
'common.exit' => 'Quitter',
'common.viewAll' => 'Tout afficher',
'screens.licenses' => 'Licenses',
'screens.switchProfile' => 'Changer de profil',
'screens.subtitleStyling' => 'Configuration des sous-titres',
@@ -1476,7 +1477,6 @@ extension on TranslationsFr {
'mediaMenu.mediaDeletedSuccessfully' => 'Élément média supprimé avec succès',
'mediaMenu.mediaFailedToDelete' => 'Échec de la suppression de l\'élément média',
'mediaMenu.rate' => 'Noter',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, film',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, show TV',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
@@ -1742,10 +1742,10 @@ extension on TranslationsFr {
'liveTv.today' => 'Aujourd\'hui',
'liveTv.midnight' => 'Minuit',
'liveTv.overnight' => 'Nuit',
'liveTv.morning' => 'Matin',
'liveTv.daytime' => 'Journée',
_ => null,
} ?? switch (path) {
'liveTv.morning' => 'Matin',
'liveTv.daytime' => 'Journée',
'liveTv.evening' => 'Soirée',
'liveTv.lateNight' => 'Nuit tardive',
'liveTv.whatsOn' => 'En ce moment',
+4 -4
View File
@@ -149,6 +149,7 @@ class _TranslationsCommonIt implements TranslationsCommonEn {
@override String get exitConfirmMessage => 'Sei sicuro di voler uscire?';
@override String get dontAskAgain => 'Non chiedere più';
@override String get exit => 'Esci';
@override String get viewAll => 'Mostra tutto';
}
// Path: screens
@@ -401,7 +402,6 @@ class _TranslationsMediaMenuIt implements TranslationsMediaMenuEn {
@override String get mediaDeletedSuccessfully => 'Elemento multimediale eliminato con successo';
@override String get mediaFailedToDelete => 'Impossibile eliminare l\'elemento multimediale';
@override String get rate => 'Valuta';
}
// Path: accessibility
@@ -1278,6 +1278,7 @@ extension on TranslationsIt {
'common.exitConfirmMessage' => 'Sei sicuro di voler uscire?',
'common.dontAskAgain' => 'Non chiedere più',
'common.exit' => 'Esci',
'common.viewAll' => 'Mostra tutto',
'screens.licenses' => 'Licenze',
'screens.switchProfile' => 'Cambia profilo',
'screens.subtitleStyling' => 'Stile sottotitoli',
@@ -1476,7 +1477,6 @@ extension on TranslationsIt {
'mediaMenu.mediaDeletedSuccessfully' => 'Elemento multimediale eliminato con successo',
'mediaMenu.mediaFailedToDelete' => 'Impossibile eliminare l\'elemento multimediale',
'mediaMenu.rate' => 'Valuta',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, film',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, serie TV',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
@@ -1742,10 +1742,10 @@ extension on TranslationsIt {
'liveTv.today' => 'Oggi',
'liveTv.midnight' => 'Mezzanotte',
'liveTv.overnight' => 'Notte',
'liveTv.morning' => 'Mattina',
'liveTv.daytime' => 'Giorno',
_ => null,
} ?? switch (path) {
'liveTv.morning' => 'Mattina',
'liveTv.daytime' => 'Giorno',
'liveTv.evening' => 'Sera',
'liveTv.lateNight' => 'Notte tarda',
'liveTv.whatsOn' => 'In onda ora',
+4 -4
View File
@@ -149,6 +149,7 @@ class _TranslationsCommonKo implements TranslationsCommonEn {
@override String get exitConfirmMessage => '정말 종료하시겠습니까?';
@override String get dontAskAgain => '다시 묻지 않기';
@override String get exit => '종료';
@override String get viewAll => '모두 보기';
}
// Path: screens
@@ -401,7 +402,6 @@ class _TranslationsMediaMenuKo implements TranslationsMediaMenuEn {
@override String get mediaDeletedSuccessfully => '미디어 항목이 성공적으로 삭제되었습니다';
@override String get mediaFailedToDelete => '미디어 항목 삭제 실패';
@override String get rate => '평가';
}
// Path: accessibility
@@ -1278,6 +1278,7 @@ extension on TranslationsKo {
'common.exitConfirmMessage' => '정말 종료하시겠습니까?',
'common.dontAskAgain' => '다시 묻지 않기',
'common.exit' => '종료',
'common.viewAll' => '모두 보기',
'screens.licenses' => '라이선스',
'screens.switchProfile' => '프로필 전환',
'screens.subtitleStyling' => '자막 스타일 설정',
@@ -1476,7 +1477,6 @@ extension on TranslationsKo {
'mediaMenu.mediaDeletedSuccessfully' => '미디어 항목이 성공적으로 삭제되었습니다',
'mediaMenu.mediaFailedToDelete' => '미디어 항목 삭제 실패',
'mediaMenu.rate' => '평가',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, 영화',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, TV 프로그램',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
@@ -1742,10 +1742,10 @@ extension on TranslationsKo {
'liveTv.today' => '오늘',
'liveTv.midnight' => '자정',
'liveTv.overnight' => '심야',
'liveTv.morning' => '아침',
'liveTv.daytime' => '',
_ => null,
} ?? switch (path) {
'liveTv.morning' => '아침',
'liveTv.daytime' => '',
'liveTv.evening' => '저녁',
'liveTv.lateNight' => '심야 방송',
'liveTv.whatsOn' => '지금 방송 중',
+4 -4
View File
@@ -149,6 +149,7 @@ class _TranslationsCommonNl implements TranslationsCommonEn {
@override String get exitConfirmMessage => 'Weet je zeker dat je wilt afsluiten?';
@override String get dontAskAgain => 'Niet meer vragen';
@override String get exit => 'Afsluiten';
@override String get viewAll => 'Alles weergeven';
}
// Path: screens
@@ -401,7 +402,6 @@ class _TranslationsMediaMenuNl implements TranslationsMediaMenuEn {
@override String get mediaDeletedSuccessfully => 'Media-item succesvol verwijderd';
@override String get mediaFailedToDelete => 'Verwijderen van media-item mislukt';
@override String get rate => 'Beoordelen';
}
// Path: accessibility
@@ -1278,6 +1278,7 @@ extension on TranslationsNl {
'common.exitConfirmMessage' => 'Weet je zeker dat je wilt afsluiten?',
'common.dontAskAgain' => 'Niet meer vragen',
'common.exit' => 'Afsluiten',
'common.viewAll' => 'Alles weergeven',
'screens.licenses' => 'Licenties',
'screens.switchProfile' => 'Wissel van profiel',
'screens.subtitleStyling' => 'Ondertitel opmaak',
@@ -1476,7 +1477,6 @@ extension on TranslationsNl {
'mediaMenu.mediaDeletedSuccessfully' => 'Media-item succesvol verwijderd',
'mediaMenu.mediaFailedToDelete' => 'Verwijderen van media-item mislukt',
'mediaMenu.rate' => 'Beoordelen',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, film',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, TV-serie',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
@@ -1742,10 +1742,10 @@ extension on TranslationsNl {
'liveTv.today' => 'Vandaag',
'liveTv.midnight' => 'Middernacht',
'liveTv.overnight' => 'Nacht',
'liveTv.morning' => 'Ochtend',
'liveTv.daytime' => 'Overdag',
_ => null,
} ?? switch (path) {
'liveTv.morning' => 'Ochtend',
'liveTv.daytime' => 'Overdag',
'liveTv.evening' => 'Avond',
'liveTv.lateNight' => 'Late avond',
'liveTv.whatsOn' => 'Nu op TV',
+4 -4
View File
@@ -149,6 +149,7 @@ class _TranslationsCommonSv implements TranslationsCommonEn {
@override String get exitConfirmMessage => 'Är du säker på att du vill avsluta?';
@override String get dontAskAgain => 'Fråga inte igen';
@override String get exit => 'Avsluta';
@override String get viewAll => 'Visa alla';
}
// Path: screens
@@ -401,7 +402,6 @@ class _TranslationsMediaMenuSv implements TranslationsMediaMenuEn {
@override String get mediaDeletedSuccessfully => 'Mediaobjekt borttaget';
@override String get mediaFailedToDelete => 'Kunde inte ta bort mediaobjekt';
@override String get rate => 'Betygsätt';
}
// Path: accessibility
@@ -1278,6 +1278,7 @@ extension on TranslationsSv {
'common.exitConfirmMessage' => 'Är du säker på att du vill avsluta?',
'common.dontAskAgain' => 'Fråga inte igen',
'common.exit' => 'Avsluta',
'common.viewAll' => 'Visa alla',
'screens.licenses' => 'Licenser',
'screens.switchProfile' => 'Byt profil',
'screens.subtitleStyling' => 'Undertext-styling',
@@ -1476,7 +1477,6 @@ extension on TranslationsSv {
'mediaMenu.mediaDeletedSuccessfully' => 'Mediaobjekt borttaget',
'mediaMenu.mediaFailedToDelete' => 'Kunde inte ta bort mediaobjekt',
'mediaMenu.rate' => 'Betygsätt',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, film',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, TV-serie',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
@@ -1742,10 +1742,10 @@ extension on TranslationsSv {
'liveTv.today' => 'Idag',
'liveTv.midnight' => 'Midnatt',
'liveTv.overnight' => 'Natt',
'liveTv.morning' => 'Morgon',
'liveTv.daytime' => 'Dagtid',
_ => null,
} ?? switch (path) {
'liveTv.morning' => 'Morgon',
'liveTv.daytime' => 'Dagtid',
'liveTv.evening' => 'Kväll',
'liveTv.lateNight' => 'Sen kväll',
'liveTv.whatsOn' => 'På TV nu',
+4 -4
View File
@@ -149,6 +149,7 @@ class _TranslationsCommonZh implements TranslationsCommonEn {
@override String get exitConfirmMessage => '确定要退出吗?';
@override String get dontAskAgain => '不再询问';
@override String get exit => '退出';
@override String get viewAll => '查看全部';
}
// Path: screens
@@ -401,7 +402,6 @@ class _TranslationsMediaMenuZh implements TranslationsMediaMenuEn {
@override String get mediaDeletedSuccessfully => '媒体项已成功删除';
@override String get mediaFailedToDelete => '删除媒体项失败';
@override String get rate => '评分';
}
// Path: accessibility
@@ -1278,6 +1278,7 @@ extension on TranslationsZh {
'common.exitConfirmMessage' => '确定要退出吗?',
'common.dontAskAgain' => '不再询问',
'common.exit' => '退出',
'common.viewAll' => '查看全部',
'screens.licenses' => '许可证',
'screens.switchProfile' => '切换用户',
'screens.subtitleStyling' => '字幕样式',
@@ -1476,7 +1477,6 @@ extension on TranslationsZh {
'mediaMenu.mediaDeletedSuccessfully' => '媒体项已成功删除',
'mediaMenu.mediaFailedToDelete' => '删除媒体项失败',
'mediaMenu.rate' => '评分',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, 电影',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, 电视剧',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
@@ -1742,10 +1742,10 @@ extension on TranslationsZh {
'liveTv.today' => '今天',
'liveTv.midnight' => '午夜',
'liveTv.overnight' => '凌晨',
'liveTv.morning' => '上午',
'liveTv.daytime' => '白天',
_ => null,
} ?? switch (path) {
'liveTv.morning' => '上午',
'liveTv.daytime' => '白天',
'liveTv.evening' => '晚上',
'liveTv.lateNight' => '深夜',
'liveTv.whatsOn' => '正在播出',
+2 -1
View File
@@ -51,7 +51,8 @@
"exitConfirmTitle": "Avsluta appen?",
"exitConfirmMessage": "Är du säker på att du vill avsluta?",
"dontAskAgain": "Fråga inte igen",
"exit": "Avsluta"
"exit": "Avsluta",
"viewAll": "Visa alla"
},
"screens": {
"licenses": "Licenser",
+2 -1
View File
@@ -51,7 +51,8 @@
"exitConfirmTitle": "退出应用?",
"exitConfirmMessage": "确定要退出吗?",
"dontAskAgain": "不再询问",
"exit": "退出"
"exit": "退出",
"viewAll": "查看全部"
},
"screens": {
"licenses": "许可证",
+202 -50
View File
@@ -10,9 +10,17 @@ import '../providers/settings_provider.dart';
import '../services/settings_service.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../widgets/media_grid_sliver.dart';
import '../widgets/focused_scroll_scaffold.dart';
import '../utils/grid_size_calculator.dart';
import '../widgets/focusable_media_card.dart';
import '../widgets/media_grid_delegate.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/overlay_sheet.dart';
import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../mixins/grid_focus_node_mixin.dart';
import 'libraries/sort_bottom_sheet.dart';
import 'libraries/state_messages.dart';
import '../mixins/refreshable.dart';
@@ -28,7 +36,7 @@ class HubDetailScreen extends StatefulWidget {
State<HubDetailScreen> createState() => _HubDetailScreenState();
}
class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, GridFocusNodeMixin {
PlexClient get client => _getClientForHub();
List<PlexMetadata> _items = [];
@@ -39,6 +47,14 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
bool _isLoading = false;
String? _errorMessage;
late final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'hub_detail_first_item');
late final FocusNode _sortButtonFocusNode = FocusNode(debugLabel: 'hub_detail_sort');
bool _isAppBarFocused = false;
bool _backHandledByKeyEvent = false;
/// Key for getting a context below OverlaySheetHost
final GlobalKey _overlayChildKey = GlobalKey();
/// Get the correct PlexClient for this hub's server
PlexClient _getClientForHub() {
return context.getClientForServer(widget.hub.serverId!);
@@ -47,6 +63,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
@override
void initState() {
super.initState();
_sortButtonFocusNode.addListener(_onSortButtonFocusChange);
// Start with items already loaded in the hub
_items = widget.hub.items;
_filteredItems = widget.hub.items;
@@ -56,6 +73,72 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
}
// Load sorts based on the library type
_loadSorts();
// Auto-focus first grid item in keyboard mode after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (InputModeTracker.isKeyboardMode(context) && _filteredItems.isNotEmpty) {
_firstItemFocusNode.requestFocus();
}
});
}
@override
void dispose() {
_sortButtonFocusNode.removeListener(_onSortButtonFocusChange);
_firstItemFocusNode.dispose();
_sortButtonFocusNode.dispose();
disposeGridFocusNodes();
super.dispose();
}
void _onSortButtonFocusChange() {
if (!mounted) return;
final hasFocus = _sortButtonFocusNode.hasFocus;
if (hasFocus && !_isAppBarFocused) {
setState(() => _isAppBarFocused = true);
} else if (!hasFocus && _isAppBarFocused) {
setState(() => _isAppBarFocused = false);
}
}
void _focusGrid() {
if (_filteredItems.isEmpty) return;
final targetIndex =
shouldRestoreGridFocus && lastFocusedGridIndex! < _filteredItems.length ? lastFocusedGridIndex! : 0;
if (targetIndex == 0) {
_firstItemFocusNode.requestFocus();
} else {
getGridItemFocusNode(targetIndex, prefix: 'hub_detail_item').requestFocus();
}
}
void _navigateToAppBar() {
setState(() => _isAppBarFocused = true);
_sortButtonFocusNode.requestFocus();
}
void _handleBackFromContent() {
_backHandledByKeyEvent = true;
_navigateToAppBar();
}
KeyEventResult _handleSortButtonKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
final backResult = handleBackKeyAction(event, () => Navigator.pop(context));
if (backResult != KeyEventResult.ignored) return backResult;
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (key.isDownKey) {
_focusGrid();
return KeyEventResult.handled;
}
if (key.isSelectKey) {
_showSortBottomSheet();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
Future<void> _loadSorts() async {
@@ -153,7 +236,8 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
}
void _showSortBottomSheet() {
OverlaySheetController.of(context).show(
final overlayContext = _overlayChildKey.currentContext ?? context;
OverlaySheetController.of(overlayContext).show(
builder: (context) => SortBottomSheet(
sortOptions: _sortOptions,
selectedSort: _selectedSort,
@@ -230,58 +314,126 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
@override
Widget build(BuildContext context) {
return OverlaySheetHost(
child: FocusedScrollScaffold(
title: Text(widget.hub.title),
actions: [
IconButton(
icon: AppIcon(Symbols.swap_vert_rounded, fill: 1, semanticLabel: t.libraries.sort),
onPressed: _showSortBottomSheet,
),
],
slivers: [
if (_errorMessage != null)
SliverFillRemaining(
child: ErrorStateWidget(
message: _errorMessage!,
icon: Symbols.error_outline_rounded,
onRetry: _loadMoreItems,
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
final sortButtonFocused = isKeyboardMode && _isAppBarFocused;
return PopScope(
canPop: !isKeyboardMode || _isAppBarFocused,
onPopInvokedWithResult: (didPop, _) {
if (didPop || _backHandledByKeyEvent) {
_backHandledByKeyEvent = false;
return;
}
_navigateToAppBar();
},
child: OverlaySheetHost(
child: Scaffold(
key: _overlayChildKey,
body: CustomScrollView(
clipBehavior: Clip.none,
slivers: [
CustomAppBar(
title: Text(widget.hub.title),
pinned: true,
actions: [
Focus(
focusNode: _sortButtonFocusNode,
onKeyEvent: _handleSortButtonKeyEvent,
child: Container(
decoration: FocusTheme.focusBackgroundDecoration(isFocused: sortButtonFocused, borderRadius: 20),
child: IconButton(
icon: AppIcon(Symbols.swap_vert_rounded, fill: 1, semanticLabel: t.libraries.sort),
onPressed: _showSortBottomSheet,
),
),
),
],
),
)
else if (_filteredItems.isEmpty && _isLoading)
const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))
else if (_filteredItems.isEmpty)
SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound)))
else
Builder(
builder: (context) {
final episodePosterMode = context.watch<SettingsProvider>().episodePosterMode;
if (_errorMessage != null)
SliverFillRemaining(
child: ErrorStateWidget(
message: _errorMessage!,
icon: Symbols.error_outline_rounded,
onRetry: _loadMoreItems,
),
)
else if (_filteredItems.isEmpty && _isLoading)
const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))
else if (_filteredItems.isEmpty)
SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound)))
else
Builder(
builder: (context) {
final settings = context.watch<SettingsProvider>();
final episodePosterMode = settings.episodePosterMode;
// Determine hub content type for layout decisions
final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode));
final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode));
// Determine hub content type for layout decisions
final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode));
final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode));
// Mixed hub = has both episodes AND non-episodes
final isMixedHub = hasEpisodes && hasNonEpisodes;
// Mixed hub = has both episodes AND non-episodes
final isMixedHub = hasEpisodes && hasNonEpisodes;
// Episode-only = all items are episodes with thumbnails
final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes;
// Episode-only = all items are episodes with thumbnails
final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes;
// Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode)
final useWideLayout =
episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub);
// Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode)
final useWideLayout =
episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub);
return MediaGridSliver(
items: _filteredItems,
onRefresh: _handleItemRefresh,
usePaddingAwareExtent: true,
horizontalPadding: 16,
useWideAspectRatio: useWideLayout,
mixedHubContext: isMixedHub,
);
},
),
],
return SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
sliver: SliverLayoutBuilder(
builder: (context, constraints) {
final maxExtent = GridSizeCalculator.getMaxCrossAxisExtentWithPadding(
context,
settings.libraryDensity,
16,
);
final columnCount = GridSizeCalculator.getColumnCount(
constraints.crossAxisExtent,
useWideLayout ? maxExtent * 1.8 : maxExtent,
);
return SliverGrid(
gridDelegate: MediaGridDelegate.createDelegate(
context: context,
density: settings.libraryDensity,
usePaddingAware: true,
horizontalPadding: 16,
useWideAspectRatio: useWideLayout,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = _filteredItems[index];
final focusNode = index == 0
? _firstItemFocusNode
: getGridItemFocusNode(index, prefix: 'hub_detail_item');
final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount);
final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount);
return FocusableMediaCard(
focusNode: focusNode,
item: item,
onRefresh: _handleItemRefresh,
onNavigateUp: isFirstRow ? _navigateToAppBar : null,
onNavigateLeft: isFirstColumn ? () {} : null,
onBack: _handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
mixedHubContext: isMixedHub,
);
},
childCount: _filteredItems.length,
),
);
},
),
);
},
),
],
),
),
),
);
}
+5
View File
@@ -24,6 +24,9 @@ class FocusableMediaCard extends StatefulWidget {
/// True for downloaded content without server access
final bool isOffline;
/// True when in a hub with mixed content (movies + episodes)
final bool mixedHubContext;
/// Optional external focus node for programmatic focus control.
/// If not provided, an internal focus node is created.
final FocusNode? focusNode;
@@ -60,6 +63,7 @@ class FocusableMediaCard extends StatefulWidget {
this.isInContinueWatching = false,
this.collectionId,
this.isOffline = false,
this.mixedHubContext = false,
this.focusNode,
this.onNavigateUp,
this.onNavigateLeft,
@@ -102,6 +106,7 @@ class _FocusableMediaCardState extends State<FocusableMediaCard> {
isInContinueWatching: widget.isInContinueWatching,
collectionId: widget.collectionId,
isOffline: widget.isOffline,
mixedHubContext: widget.mixedHubContext,
),
);
}
+60 -11
View File
@@ -6,6 +6,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../providers/settings_provider.dart';
import '../services/settings_service.dart' show EpisodePosterMode, LibraryDensity;
@@ -94,12 +95,15 @@ class HubSectionState extends State<HubSection> {
_hubFocusNode.addListener(_onFocusChange);
}
/// Total item count including the optional "View All" card
int get _totalItemCount => widget.hub.items.length + (widget.hub.more ? 1 : 0);
@override
void didUpdateWidget(HubSection oldWidget) {
super.didUpdateWidget(oldWidget);
// Clamp focus index if item count changed
if (widget.hub.items.length != oldWidget.hub.items.length) {
final maxIndex = widget.hub.items.isEmpty ? 0 : widget.hub.items.length - 1;
final maxIndex = _totalItemCount == 0 ? 0 : _totalItemCount - 1;
if (_focusedIndex > maxIndex) {
_focusedIndex = maxIndex;
}
@@ -128,9 +132,9 @@ class HubSectionState extends State<HubSection> {
/// Request focus on this hub at a specific item index
void requestFocusAt(int index) {
if (widget.hub.items.isEmpty) return;
if (_totalItemCount == 0) return;
final clamped = index.clamp(0, widget.hub.items.length - 1);
final clamped = index.clamp(0, _totalItemCount - 1);
_focusedIndex = clamped;
// Remember this position for this specific hub
HubFocusMemory.setForHub(widget.hub.hubKey, clamped);
@@ -145,7 +149,7 @@ class HubSectionState extends State<HubSection> {
/// Request focus using the stored memory for this hub
void requestFocusFromMemory() {
final index = HubFocusMemory.getForHub(widget.hub.hubKey, widget.hub.items.length);
final index = HubFocusMemory.getForHub(widget.hub.hubKey, _totalItemCount);
requestFocusAt(index);
}
@@ -166,7 +170,7 @@ class HubSectionState extends State<HubSection> {
bool get hasFocusedItem => _hubFocusNode.hasFocus;
/// Get the number of items in this hub
int get itemCount => widget.hub.items.length;
int get itemCount => _totalItemCount;
/// Scroll to center the item at the given index
void _scrollToIndex(int index, {bool animate = true}) {
@@ -225,8 +229,8 @@ class HubSectionState extends State<HubSection> {
return KeyEventResult.ignored;
}
final itemCount = widget.hub.items.length;
if (itemCount == 0) return KeyEventResult.ignored;
final totalCount = _totalItemCount;
if (totalCount == 0) return KeyEventResult.ignored;
// Left: move to previous item, or navigate to sidebar at left edge
if (key.isLeftKey) {
@@ -246,7 +250,7 @@ class HubSectionState extends State<HubSection> {
// Right: move to next item, ALWAYS consume to prevent escape
if (key.isRightKey) {
if (_focusedIndex < itemCount - 1) {
if (_focusedIndex < totalCount - 1) {
setState(() {
_focusedIndex++;
});
@@ -287,12 +291,18 @@ class HubSectionState extends State<HubSection> {
}
void _activateCurrentItem() {
if (_focusedIndex == widget.hub.items.length && widget.hub.more) {
_navigateToHubDetail(context);
return;
}
if (_focusedIndex >= widget.hub.items.length) return;
final item = widget.hub.items[_focusedIndex];
_navigateToItem(item);
}
void _showContextMenuForCurrentItem() {
// No context menu for the "View All" card
if (_focusedIndex >= widget.hub.items.length) return;
_mediaCardKeys[_focusedIndex]?.currentState?.showContextMenu();
}
@@ -307,6 +317,7 @@ class HubSectionState extends State<HubSection> {
@override
Widget build(BuildContext context) {
final hasFocus = _hubFocusNode.hasFocus;
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -350,7 +361,7 @@ class HubSectionState extends State<HubSection> {
),
),
],
if (widget.hub.more) ...[
if (widget.hub.more && !isKeyboardMode) ...[
const SizedBox(width: 4),
const AppIcon(Symbols.chevron_right_rounded, fill: 1, size: 20),
],
@@ -425,11 +436,49 @@ class HubSectionState extends State<HubSection> {
controller: scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
itemCount: widget.hub.items.length,
itemCount: isKeyboardMode ? _totalItemCount : widget.hub.items.length,
itemBuilder: (context, index) {
final item = widget.hub.items[index];
final isItemFocused = hasFocus && index == _focusedIndex;
// "View All" card at end
if (index == widget.hub.items.length) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: _LockedHubItemWrapper(
isFocused: isItemFocused,
onTap: () {
_onItemTapped(index);
_navigateToHubDetail(context);
},
child: SizedBox(
width: 80,
height: containerHeight - 10,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.arrow_forward_rounded,
size: 32,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
const SizedBox(height: 4),
Text(
t.common.viewAll,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
),
),
),
),
);
}
final item = widget.hub.items[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: _LockedHubItemWrapper(