refactor: codebase review cleanup
This commit is contained in:
@@ -11,6 +11,9 @@ import '../utils/global_key_utils.dart';
|
||||
|
||||
part 'app_database.g.dart';
|
||||
|
||||
/// String values stored in [OfflineWatchProgress.actionType] (use `.name`).
|
||||
enum OfflineActionType { progress, watched, unwatched }
|
||||
|
||||
// Simplified database with API cache for offline support
|
||||
@DriftDatabase(tables: [DownloadedMedia, DownloadQueue, ApiCache, OfflineWatchProgress, SyncRules])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
@@ -150,7 +153,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
// Check for existing progress entry
|
||||
final existing =
|
||||
await (select(offlineWatchProgress)
|
||||
..where((t) => t.globalKey.equals(globalKey) & t.actionType.equals('progress'))
|
||||
..where((t) => t.globalKey.equals(globalKey) & t.actionType.equals(OfflineActionType.progress.name))
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
@@ -171,7 +174,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
actionType: 'progress',
|
||||
actionType: OfflineActionType.progress.name,
|
||||
viewOffset: Value(viewOffset),
|
||||
duration: Value(duration),
|
||||
shouldMarkWatched: Value(shouldMarkWatched),
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "Download slettet",
|
||||
"deleteConfirm": "Er du sikker på, at du vil slette \"${title}\"? Den downloadede fil fjernes fra din enhed.",
|
||||
"deletingWithProgress": "Sletter ${title}... (${current} af ${total})",
|
||||
"deleting": "Sletter...",
|
||||
"queuedTooltip": "I kø",
|
||||
"queuedFilesTooltip": "I kø: ${files}",
|
||||
"downloadingTooltip": "Downloader...",
|
||||
"downloadingFilesTooltip": "Downloader ${files}",
|
||||
"noDownloadsTree": "Ingen downloads",
|
||||
"pauseAll": "Pause alle",
|
||||
"resumeAll": "Genoptag alle",
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "Download gelöscht",
|
||||
"deleteConfirm": "Möchtest du \"${title}\" wirklich löschen? Die heruntergeladene Datei wird von deinem Gerät entfernt.",
|
||||
"deletingWithProgress": "Lösche ${title}... (${current} von ${total})",
|
||||
"deleting": "Lösche...",
|
||||
"queuedTooltip": "In Warteschlange",
|
||||
"queuedFilesTooltip": "In Warteschlange: ${files}",
|
||||
"downloadingTooltip": "Lädt herunter...",
|
||||
"downloadingFilesTooltip": "Lädt ${files} herunter",
|
||||
"noDownloadsTree": "Keine Downloads",
|
||||
"pauseAll": "Alle pausieren",
|
||||
"resumeAll": "Alle fortsetzen",
|
||||
|
||||
@@ -778,7 +778,12 @@
|
||||
"episodesQueued": "${count} episodes queued for download",
|
||||
"downloadDeleted": "Download deleted",
|
||||
"deleteConfirm": "Are you sure you want to delete \"${title}\"? This will remove the downloaded file from your device.",
|
||||
"deleting": "Deleting...",
|
||||
"deletingWithProgress": "Deleting ${title}... (${current} of ${total})",
|
||||
"queuedTooltip": "Queued",
|
||||
"queuedFilesTooltip": "Queued ${files}",
|
||||
"downloadingTooltip": "Downloading...",
|
||||
"downloadingFilesTooltip": "Downloading ${files}",
|
||||
"noDownloadsTree": "No downloads",
|
||||
"pauseAll": "Pause all",
|
||||
"resumeAll": "Resume all",
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "Descarga eliminada",
|
||||
"deleteConfirm": "¿Estás seguro de que quieres eliminar \"${title}\"? Esto borrará el archivo descargado de tu dispositivo.",
|
||||
"deletingWithProgress": "Eliminando ${title}... (${current} de ${total})",
|
||||
"deleting": "Eliminando...",
|
||||
"queuedTooltip": "En cola",
|
||||
"queuedFilesTooltip": "En cola: ${files}",
|
||||
"downloadingTooltip": "Descargando...",
|
||||
"downloadingFilesTooltip": "Descargando ${files}",
|
||||
"noDownloadsTree": "Sin descargas",
|
||||
"pauseAll": "Pausar todo",
|
||||
"resumeAll": "Reanudar todo",
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "Télécharger supprimé",
|
||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer \"${title}\" ? Cela supprimera le fichier téléchargé de votre appareil.",
|
||||
"deletingWithProgress": "Suppression de ${title}... (${current} sur ${total})",
|
||||
"deleting": "Suppression...",
|
||||
"queuedTooltip": "En attente",
|
||||
"queuedFilesTooltip": "En attente : ${files}",
|
||||
"downloadingTooltip": "Téléchargement...",
|
||||
"downloadingFilesTooltip": "Téléchargement de ${files}",
|
||||
"noDownloadsTree": "Aucun téléchargement",
|
||||
"pauseAll": "Tout mettre en pause",
|
||||
"resumeAll": "Tout reprendre",
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "Download eliminato",
|
||||
"deleteConfirm": "Sei sicuro di voler eliminare \"${title}\"? Il file scaricato verrà rimosso dal tuo dispositivo.",
|
||||
"deletingWithProgress": "Eliminazione di ${title}... (${current} di ${total})",
|
||||
"deleting": "Eliminazione...",
|
||||
"queuedTooltip": "In coda",
|
||||
"queuedFilesTooltip": "In coda: ${files}",
|
||||
"downloadingTooltip": "Download in corso...",
|
||||
"downloadingFilesTooltip": "Download di ${files}",
|
||||
"noDownloadsTree": "Nessun download",
|
||||
"pauseAll": "Metti tutto in pausa",
|
||||
"resumeAll": "Riprendi tutto",
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "ダウンロードを削除しました",
|
||||
"deleteConfirm": "\"${title}\"を削除してもよろしいですか?ダウンロードしたファイルがデバイスから削除されます。",
|
||||
"deletingWithProgress": "${title}を削除中... (${current}/${total})",
|
||||
"deleting": "削除中...",
|
||||
"queuedTooltip": "キュー",
|
||||
"queuedFilesTooltip": "キュー: ${files}",
|
||||
"downloadingTooltip": "ダウンロード中...",
|
||||
"downloadingFilesTooltip": "${files} をダウンロード中",
|
||||
"noDownloadsTree": "ダウンロードなし",
|
||||
"pauseAll": "すべて一時停止",
|
||||
"resumeAll": "すべて再開",
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "다운로드 삭제됨",
|
||||
"deleteConfirm": "\"${title}\"를 삭제 하시겠습니까? 다운로드한 파일이 기기에서 삭제됩니다.",
|
||||
"deletingWithProgress": "${title} 삭제 중... (${current}/${total})",
|
||||
"deleting": "삭제 중...",
|
||||
"queuedTooltip": "대기 중",
|
||||
"queuedFilesTooltip": "대기 중: ${files}",
|
||||
"downloadingTooltip": "다운로드 중...",
|
||||
"downloadingFilesTooltip": "${files} 다운로드 중",
|
||||
"noDownloadsTree": "다운로드 없음",
|
||||
"pauseAll": "모두 일시정지",
|
||||
"resumeAll": "모두 재개",
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "Nedlasting slettet",
|
||||
"deleteConfirm": "Er du sikker på at du vil slette \"${title}\"? Dette vil fjerne den nedlastede filen fra enheten din.",
|
||||
"deletingWithProgress": "Sletter ${title}... (${current} av ${total})",
|
||||
"deleting": "Sletter...",
|
||||
"queuedTooltip": "I kø",
|
||||
"queuedFilesTooltip": "I kø: ${files}",
|
||||
"downloadingTooltip": "Laster ned...",
|
||||
"downloadingFilesTooltip": "Laster ned ${files}",
|
||||
"noDownloadsTree": "Ingen nedlastinger",
|
||||
"pauseAll": "Pause alle",
|
||||
"resumeAll": "Gjenoppta alle",
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "Download verwijderd",
|
||||
"deleteConfirm": "Weet je zeker dat je \"${title}\" wilt verwijderen? Het gedownloade bestand wordt van je apparaat verwijderd.",
|
||||
"deletingWithProgress": "Verwijderen van ${title}... (${current} van ${total})",
|
||||
"deleting": "Verwijderen...",
|
||||
"queuedTooltip": "In wachtrij",
|
||||
"queuedFilesTooltip": "In wachtrij: ${files}",
|
||||
"downloadingTooltip": "Downloaden...",
|
||||
"downloadingFilesTooltip": "Downloaden ${files}",
|
||||
"noDownloadsTree": "Geen downloads",
|
||||
"pauseAll": "Alles pauzeren",
|
||||
"resumeAll": "Alles hervatten",
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "Pobranie usunięte",
|
||||
"deleteConfirm": "Czy na pewno chcesz usunąć \"${title}\"? Spowoduje to usunięcie pobranego pliku z urządzenia.",
|
||||
"deletingWithProgress": "Usuwanie ${title}... (${current} z ${total})",
|
||||
"deleting": "Usuwanie...",
|
||||
"queuedTooltip": "W kolejce",
|
||||
"queuedFilesTooltip": "W kolejce: ${files}",
|
||||
"downloadingTooltip": "Pobieranie...",
|
||||
"downloadingFilesTooltip": "Pobieranie ${files}",
|
||||
"noDownloadsTree": "Brak pobrań",
|
||||
"pauseAll": "Wstrzymaj wszystko",
|
||||
"resumeAll": "Wznów wszystko",
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "Download excluído",
|
||||
"deleteConfirm": "Tem certeza que deseja excluir \"${title}\"? Isso removerá o arquivo baixado do seu dispositivo.",
|
||||
"deletingWithProgress": "Excluindo ${title}... (${current} de ${total})",
|
||||
"deleting": "Excluindo...",
|
||||
"queuedTooltip": "Na fila",
|
||||
"queuedFilesTooltip": "Na fila: ${files}",
|
||||
"downloadingTooltip": "Baixando...",
|
||||
"downloadingFilesTooltip": "Baixando ${files}",
|
||||
"noDownloadsTree": "Nenhum download",
|
||||
"pauseAll": "Pausar todos",
|
||||
"resumeAll": "Retomar todos",
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "Загрузка удалена",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить \"${title}\"? Загруженный файл будет удалён с устройства.",
|
||||
"deletingWithProgress": "Удаление ${title}... (${current} из ${total})",
|
||||
"deleting": "Удаление...",
|
||||
"queuedTooltip": "В очереди",
|
||||
"queuedFilesTooltip": "В очереди: ${files}",
|
||||
"downloadingTooltip": "Загрузка...",
|
||||
"downloadingFilesTooltip": "Загрузка ${files}",
|
||||
"noDownloadsTree": "Нет загрузок",
|
||||
"pauseAll": "Приостановить все",
|
||||
"resumeAll": "Возобновить все",
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 15
|
||||
/// Strings: 14430 (962 per locale)
|
||||
/// Strings: 14505 (967 per locale)
|
||||
///
|
||||
/// Built on 2026-04-24 at 16:44 UTC
|
||||
/// Built on 2026-04-25 at 01:00 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsDa implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => 'Download slettet';
|
||||
@override String deleteConfirm({required Object title}) => 'Er du sikker på, at du vil slette "${title}"? Den downloadede fil fjernes fra din enhed.';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Sletter ${title}... (${current} af ${total})';
|
||||
@override String get deleting => 'Sletter...';
|
||||
@override String get queuedTooltip => 'I kø';
|
||||
@override String queuedFilesTooltip({required Object files}) => 'I kø: ${files}';
|
||||
@override String get downloadingTooltip => 'Downloader...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => 'Downloader ${files}';
|
||||
@override String get noDownloadsTree => 'Ingen downloads';
|
||||
@override String get pauseAll => 'Pause alle';
|
||||
@override String get resumeAll => 'Genoptag alle';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsDa {
|
||||
'downloads.downloadDeleted' => 'Download slettet',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => 'Er du sikker på, at du vil slette "${title}"? Den downloadede fil fjernes fra din enhed.',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Sletter ${title}... (${current} af ${total})',
|
||||
'downloads.deleting' => 'Sletter...',
|
||||
'downloads.queuedTooltip' => 'I kø',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => 'I kø: ${files}',
|
||||
'downloads.downloadingTooltip' => 'Downloader...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Downloader ${files}',
|
||||
'downloads.noDownloadsTree' => 'Ingen downloads',
|
||||
'downloads.pauseAll' => 'Pause alle',
|
||||
'downloads.resumeAll' => 'Genoptag alle',
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsDe implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => 'Download gelöscht';
|
||||
@override String deleteConfirm({required Object title}) => 'Möchtest du "${title}" wirklich löschen? Die heruntergeladene Datei wird von deinem Gerät entfernt.';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Lösche ${title}... (${current} von ${total})';
|
||||
@override String get deleting => 'Lösche...';
|
||||
@override String get queuedTooltip => 'In Warteschlange';
|
||||
@override String queuedFilesTooltip({required Object files}) => 'In Warteschlange: ${files}';
|
||||
@override String get downloadingTooltip => 'Lädt herunter...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => 'Lädt ${files} herunter';
|
||||
@override String get noDownloadsTree => 'Keine Downloads';
|
||||
@override String get pauseAll => 'Alle pausieren';
|
||||
@override String get resumeAll => 'Alle fortsetzen';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsDe {
|
||||
'downloads.downloadDeleted' => 'Download gelöscht',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => 'Möchtest du "${title}" wirklich löschen? Die heruntergeladene Datei wird von deinem Gerät entfernt.',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Lösche ${title}... (${current} von ${total})',
|
||||
'downloads.deleting' => 'Lösche...',
|
||||
'downloads.queuedTooltip' => 'In Warteschlange',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => 'In Warteschlange: ${files}',
|
||||
'downloads.downloadingTooltip' => 'Lädt herunter...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Lädt ${files} herunter',
|
||||
'downloads.noDownloadsTree' => 'Keine Downloads',
|
||||
'downloads.pauseAll' => 'Alle pausieren',
|
||||
'downloads.resumeAll' => 'Alle fortsetzen',
|
||||
|
||||
@@ -2384,9 +2384,24 @@ class TranslationsDownloadsEn {
|
||||
/// en: 'Are you sure you want to delete "${title}"? This will remove the downloaded file from your device.'
|
||||
String deleteConfirm({required Object title}) => 'Are you sure you want to delete "${title}"? This will remove the downloaded file from your device.';
|
||||
|
||||
/// en: 'Deleting...'
|
||||
String get deleting => 'Deleting...';
|
||||
|
||||
/// en: 'Deleting ${title}... (${current} of ${total})'
|
||||
String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Deleting ${title}... (${current} of ${total})';
|
||||
|
||||
/// en: 'Queued'
|
||||
String get queuedTooltip => 'Queued';
|
||||
|
||||
/// en: 'Queued ${files}'
|
||||
String queuedFilesTooltip({required Object files}) => 'Queued ${files}';
|
||||
|
||||
/// en: 'Downloading...'
|
||||
String get downloadingTooltip => 'Downloading...';
|
||||
|
||||
/// en: 'Downloading ${files}'
|
||||
String downloadingFilesTooltip({required Object files}) => 'Downloading ${files}';
|
||||
|
||||
/// en: 'No downloads'
|
||||
String get noDownloadsTree => 'No downloads';
|
||||
|
||||
@@ -4171,7 +4186,12 @@ extension on Translations {
|
||||
'downloads.episodesQueued' => ({required Object count}) => '${count} episodes queued for download',
|
||||
'downloads.downloadDeleted' => 'Download deleted',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => 'Are you sure you want to delete "${title}"? This will remove the downloaded file from your device.',
|
||||
'downloads.deleting' => 'Deleting...',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Deleting ${title}... (${current} of ${total})',
|
||||
'downloads.queuedTooltip' => 'Queued',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => 'Queued ${files}',
|
||||
'downloads.downloadingTooltip' => 'Downloading...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Downloading ${files}',
|
||||
'downloads.noDownloadsTree' => 'No downloads',
|
||||
'downloads.pauseAll' => 'Pause all',
|
||||
'downloads.resumeAll' => 'Resume all',
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsEs implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => 'Descarga eliminada';
|
||||
@override String deleteConfirm({required Object title}) => '¿Estás seguro de que quieres eliminar "${title}"? Esto borrará el archivo descargado de tu dispositivo.';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Eliminando ${title}... (${current} de ${total})';
|
||||
@override String get deleting => 'Eliminando...';
|
||||
@override String get queuedTooltip => 'En cola';
|
||||
@override String queuedFilesTooltip({required Object files}) => 'En cola: ${files}';
|
||||
@override String get downloadingTooltip => 'Descargando...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => 'Descargando ${files}';
|
||||
@override String get noDownloadsTree => 'Sin descargas';
|
||||
@override String get pauseAll => 'Pausar todo';
|
||||
@override String get resumeAll => 'Reanudar todo';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsEs {
|
||||
'downloads.downloadDeleted' => 'Descarga eliminada',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => '¿Estás seguro de que quieres eliminar "${title}"? Esto borrará el archivo descargado de tu dispositivo.',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Eliminando ${title}... (${current} de ${total})',
|
||||
'downloads.deleting' => 'Eliminando...',
|
||||
'downloads.queuedTooltip' => 'En cola',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => 'En cola: ${files}',
|
||||
'downloads.downloadingTooltip' => 'Descargando...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Descargando ${files}',
|
||||
'downloads.noDownloadsTree' => 'Sin descargas',
|
||||
'downloads.pauseAll' => 'Pausar todo',
|
||||
'downloads.resumeAll' => 'Reanudar todo',
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsFr implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => 'Télécharger supprimé';
|
||||
@override String deleteConfirm({required Object title}) => 'Êtes-vous sûr de vouloir supprimer "${title}" ? Cela supprimera le fichier téléchargé de votre appareil.';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Suppression de ${title}... (${current} sur ${total})';
|
||||
@override String get deleting => 'Suppression...';
|
||||
@override String get queuedTooltip => 'En attente';
|
||||
@override String queuedFilesTooltip({required Object files}) => 'En attente : ${files}';
|
||||
@override String get downloadingTooltip => 'Téléchargement...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => 'Téléchargement de ${files}';
|
||||
@override String get noDownloadsTree => 'Aucun téléchargement';
|
||||
@override String get pauseAll => 'Tout mettre en pause';
|
||||
@override String get resumeAll => 'Tout reprendre';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsFr {
|
||||
'downloads.downloadDeleted' => 'Télécharger supprimé',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => 'Êtes-vous sûr de vouloir supprimer "${title}" ? Cela supprimera le fichier téléchargé de votre appareil.',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Suppression de ${title}... (${current} sur ${total})',
|
||||
'downloads.deleting' => 'Suppression...',
|
||||
'downloads.queuedTooltip' => 'En attente',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => 'En attente : ${files}',
|
||||
'downloads.downloadingTooltip' => 'Téléchargement...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Téléchargement de ${files}',
|
||||
'downloads.noDownloadsTree' => 'Aucun téléchargement',
|
||||
'downloads.pauseAll' => 'Tout mettre en pause',
|
||||
'downloads.resumeAll' => 'Tout reprendre',
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsIt implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => 'Download eliminato';
|
||||
@override String deleteConfirm({required Object title}) => 'Sei sicuro di voler eliminare "${title}"? Il file scaricato verrà rimosso dal tuo dispositivo.';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Eliminazione di ${title}... (${current} di ${total})';
|
||||
@override String get deleting => 'Eliminazione...';
|
||||
@override String get queuedTooltip => 'In coda';
|
||||
@override String queuedFilesTooltip({required Object files}) => 'In coda: ${files}';
|
||||
@override String get downloadingTooltip => 'Download in corso...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => 'Download di ${files}';
|
||||
@override String get noDownloadsTree => 'Nessun download';
|
||||
@override String get pauseAll => 'Metti tutto in pausa';
|
||||
@override String get resumeAll => 'Riprendi tutto';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsIt {
|
||||
'downloads.downloadDeleted' => 'Download eliminato',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => 'Sei sicuro di voler eliminare "${title}"? Il file scaricato verrà rimosso dal tuo dispositivo.',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Eliminazione di ${title}... (${current} di ${total})',
|
||||
'downloads.deleting' => 'Eliminazione...',
|
||||
'downloads.queuedTooltip' => 'In coda',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => 'In coda: ${files}',
|
||||
'downloads.downloadingTooltip' => 'Download in corso...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Download di ${files}',
|
||||
'downloads.noDownloadsTree' => 'Nessun download',
|
||||
'downloads.pauseAll' => 'Metti tutto in pausa',
|
||||
'downloads.resumeAll' => 'Riprendi tutto',
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsJa implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => 'ダウンロードを削除しました';
|
||||
@override String deleteConfirm({required Object title}) => '"${title}"を削除してもよろしいですか?ダウンロードしたファイルがデバイスから削除されます。';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => '${title}を削除中... (${current}/${total})';
|
||||
@override String get deleting => '削除中...';
|
||||
@override String get queuedTooltip => 'キュー';
|
||||
@override String queuedFilesTooltip({required Object files}) => 'キュー: ${files}';
|
||||
@override String get downloadingTooltip => 'ダウンロード中...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => '${files} をダウンロード中';
|
||||
@override String get noDownloadsTree => 'ダウンロードなし';
|
||||
@override String get pauseAll => 'すべて一時停止';
|
||||
@override String get resumeAll => 'すべて再開';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsJa {
|
||||
'downloads.downloadDeleted' => 'ダウンロードを削除しました',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => '"${title}"を削除してもよろしいですか?ダウンロードしたファイルがデバイスから削除されます。',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => '${title}を削除中... (${current}/${total})',
|
||||
'downloads.deleting' => '削除中...',
|
||||
'downloads.queuedTooltip' => 'キュー',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => 'キュー: ${files}',
|
||||
'downloads.downloadingTooltip' => 'ダウンロード中...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => '${files} をダウンロード中',
|
||||
'downloads.noDownloadsTree' => 'ダウンロードなし',
|
||||
'downloads.pauseAll' => 'すべて一時停止',
|
||||
'downloads.resumeAll' => 'すべて再開',
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsKo implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => '다운로드 삭제됨';
|
||||
@override String deleteConfirm({required Object title}) => '"${title}"를 삭제 하시겠습니까? 다운로드한 파일이 기기에서 삭제됩니다.';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => '${title} 삭제 중... (${current}/${total})';
|
||||
@override String get deleting => '삭제 중...';
|
||||
@override String get queuedTooltip => '대기 중';
|
||||
@override String queuedFilesTooltip({required Object files}) => '대기 중: ${files}';
|
||||
@override String get downloadingTooltip => '다운로드 중...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => '${files} 다운로드 중';
|
||||
@override String get noDownloadsTree => '다운로드 없음';
|
||||
@override String get pauseAll => '모두 일시정지';
|
||||
@override String get resumeAll => '모두 재개';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsKo {
|
||||
'downloads.downloadDeleted' => '다운로드 삭제됨',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => '"${title}"를 삭제 하시겠습니까? 다운로드한 파일이 기기에서 삭제됩니다.',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => '${title} 삭제 중... (${current}/${total})',
|
||||
'downloads.deleting' => '삭제 중...',
|
||||
'downloads.queuedTooltip' => '대기 중',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => '대기 중: ${files}',
|
||||
'downloads.downloadingTooltip' => '다운로드 중...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => '${files} 다운로드 중',
|
||||
'downloads.noDownloadsTree' => '다운로드 없음',
|
||||
'downloads.pauseAll' => '모두 일시정지',
|
||||
'downloads.resumeAll' => '모두 재개',
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsNb implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => 'Nedlasting slettet';
|
||||
@override String deleteConfirm({required Object title}) => 'Er du sikker på at du vil slette "${title}"? Dette vil fjerne den nedlastede filen fra enheten din.';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Sletter ${title}... (${current} av ${total})';
|
||||
@override String get deleting => 'Sletter...';
|
||||
@override String get queuedTooltip => 'I kø';
|
||||
@override String queuedFilesTooltip({required Object files}) => 'I kø: ${files}';
|
||||
@override String get downloadingTooltip => 'Laster ned...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => 'Laster ned ${files}';
|
||||
@override String get noDownloadsTree => 'Ingen nedlastinger';
|
||||
@override String get pauseAll => 'Pause alle';
|
||||
@override String get resumeAll => 'Gjenoppta alle';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsNb {
|
||||
'downloads.downloadDeleted' => 'Nedlasting slettet',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => 'Er du sikker på at du vil slette "${title}"? Dette vil fjerne den nedlastede filen fra enheten din.',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Sletter ${title}... (${current} av ${total})',
|
||||
'downloads.deleting' => 'Sletter...',
|
||||
'downloads.queuedTooltip' => 'I kø',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => 'I kø: ${files}',
|
||||
'downloads.downloadingTooltip' => 'Laster ned...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Laster ned ${files}',
|
||||
'downloads.noDownloadsTree' => 'Ingen nedlastinger',
|
||||
'downloads.pauseAll' => 'Pause alle',
|
||||
'downloads.resumeAll' => 'Gjenoppta alle',
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsNl implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => 'Download verwijderd';
|
||||
@override String deleteConfirm({required Object title}) => 'Weet je zeker dat je "${title}" wilt verwijderen? Het gedownloade bestand wordt van je apparaat verwijderd.';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Verwijderen van ${title}... (${current} van ${total})';
|
||||
@override String get deleting => 'Verwijderen...';
|
||||
@override String get queuedTooltip => 'In wachtrij';
|
||||
@override String queuedFilesTooltip({required Object files}) => 'In wachtrij: ${files}';
|
||||
@override String get downloadingTooltip => 'Downloaden...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => 'Downloaden ${files}';
|
||||
@override String get noDownloadsTree => 'Geen downloads';
|
||||
@override String get pauseAll => 'Alles pauzeren';
|
||||
@override String get resumeAll => 'Alles hervatten';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsNl {
|
||||
'downloads.downloadDeleted' => 'Download verwijderd',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => 'Weet je zeker dat je "${title}" wilt verwijderen? Het gedownloade bestand wordt van je apparaat verwijderd.',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Verwijderen van ${title}... (${current} van ${total})',
|
||||
'downloads.deleting' => 'Verwijderen...',
|
||||
'downloads.queuedTooltip' => 'In wachtrij',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => 'In wachtrij: ${files}',
|
||||
'downloads.downloadingTooltip' => 'Downloaden...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Downloaden ${files}',
|
||||
'downloads.noDownloadsTree' => 'Geen downloads',
|
||||
'downloads.pauseAll' => 'Alles pauzeren',
|
||||
'downloads.resumeAll' => 'Alles hervatten',
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsPl implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => 'Pobranie usunięte';
|
||||
@override String deleteConfirm({required Object title}) => 'Czy na pewno chcesz usunąć "${title}"? Spowoduje to usunięcie pobranego pliku z urządzenia.';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Usuwanie ${title}... (${current} z ${total})';
|
||||
@override String get deleting => 'Usuwanie...';
|
||||
@override String get queuedTooltip => 'W kolejce';
|
||||
@override String queuedFilesTooltip({required Object files}) => 'W kolejce: ${files}';
|
||||
@override String get downloadingTooltip => 'Pobieranie...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => 'Pobieranie ${files}';
|
||||
@override String get noDownloadsTree => 'Brak pobrań';
|
||||
@override String get pauseAll => 'Wstrzymaj wszystko';
|
||||
@override String get resumeAll => 'Wznów wszystko';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsPl {
|
||||
'downloads.downloadDeleted' => 'Pobranie usunięte',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => 'Czy na pewno chcesz usunąć "${title}"? Spowoduje to usunięcie pobranego pliku z urządzenia.',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Usuwanie ${title}... (${current} z ${total})',
|
||||
'downloads.deleting' => 'Usuwanie...',
|
||||
'downloads.queuedTooltip' => 'W kolejce',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => 'W kolejce: ${files}',
|
||||
'downloads.downloadingTooltip' => 'Pobieranie...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Pobieranie ${files}',
|
||||
'downloads.noDownloadsTree' => 'Brak pobrań',
|
||||
'downloads.pauseAll' => 'Wstrzymaj wszystko',
|
||||
'downloads.resumeAll' => 'Wznów wszystko',
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsPt implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => 'Download excluído';
|
||||
@override String deleteConfirm({required Object title}) => 'Tem certeza que deseja excluir "${title}"? Isso removerá o arquivo baixado do seu dispositivo.';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Excluindo ${title}... (${current} de ${total})';
|
||||
@override String get deleting => 'Excluindo...';
|
||||
@override String get queuedTooltip => 'Na fila';
|
||||
@override String queuedFilesTooltip({required Object files}) => 'Na fila: ${files}';
|
||||
@override String get downloadingTooltip => 'Baixando...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => 'Baixando ${files}';
|
||||
@override String get noDownloadsTree => 'Nenhum download';
|
||||
@override String get pauseAll => 'Pausar todos';
|
||||
@override String get resumeAll => 'Retomar todos';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsPt {
|
||||
'downloads.downloadDeleted' => 'Download excluído',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => 'Tem certeza que deseja excluir "${title}"? Isso removerá o arquivo baixado do seu dispositivo.',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Excluindo ${title}... (${current} de ${total})',
|
||||
'downloads.deleting' => 'Excluindo...',
|
||||
'downloads.queuedTooltip' => 'Na fila',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => 'Na fila: ${files}',
|
||||
'downloads.downloadingTooltip' => 'Baixando...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Baixando ${files}',
|
||||
'downloads.noDownloadsTree' => 'Nenhum download',
|
||||
'downloads.pauseAll' => 'Pausar todos',
|
||||
'downloads.resumeAll' => 'Retomar todos',
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsRu implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => 'Загрузка удалена';
|
||||
@override String deleteConfirm({required Object title}) => 'Вы уверены, что хотите удалить "${title}"? Загруженный файл будет удалён с устройства.';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Удаление ${title}... (${current} из ${total})';
|
||||
@override String get deleting => 'Удаление...';
|
||||
@override String get queuedTooltip => 'В очереди';
|
||||
@override String queuedFilesTooltip({required Object files}) => 'В очереди: ${files}';
|
||||
@override String get downloadingTooltip => 'Загрузка...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => 'Загрузка ${files}';
|
||||
@override String get noDownloadsTree => 'Нет загрузок';
|
||||
@override String get pauseAll => 'Приостановить все';
|
||||
@override String get resumeAll => 'Возобновить все';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsRu {
|
||||
'downloads.downloadDeleted' => 'Загрузка удалена',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => 'Вы уверены, что хотите удалить "${title}"? Загруженный файл будет удалён с устройства.',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Удаление ${title}... (${current} из ${total})',
|
||||
'downloads.deleting' => 'Удаление...',
|
||||
'downloads.queuedTooltip' => 'В очереди',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => 'В очереди: ${files}',
|
||||
'downloads.downloadingTooltip' => 'Загрузка...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Загрузка ${files}',
|
||||
'downloads.noDownloadsTree' => 'Нет загрузок',
|
||||
'downloads.pauseAll' => 'Приостановить все',
|
||||
'downloads.resumeAll' => 'Возобновить все',
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsSv implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => 'Nedladdning borttagen';
|
||||
@override String deleteConfirm({required Object title}) => 'Är du säker på att du vill ta bort "${title}"? Den nedladdade filen kommer att tas bort från din enhet.';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Tar bort ${title}... (${current} av ${total})';
|
||||
@override String get deleting => 'Tar bort...';
|
||||
@override String get queuedTooltip => 'I kö';
|
||||
@override String queuedFilesTooltip({required Object files}) => 'I kö: ${files}';
|
||||
@override String get downloadingTooltip => 'Laddar ned...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => 'Laddar ned ${files}';
|
||||
@override String get noDownloadsTree => 'Inga nedladdningar';
|
||||
@override String get pauseAll => 'Pausa alla';
|
||||
@override String get resumeAll => 'Återuppta alla';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsSv {
|
||||
'downloads.downloadDeleted' => 'Nedladdning borttagen',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => 'Är du säker på att du vill ta bort "${title}"? Den nedladdade filen kommer att tas bort från din enhet.',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Tar bort ${title}... (${current} av ${total})',
|
||||
'downloads.deleting' => 'Tar bort...',
|
||||
'downloads.queuedTooltip' => 'I kö',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => 'I kö: ${files}',
|
||||
'downloads.downloadingTooltip' => 'Laddar ned...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Laddar ned ${files}',
|
||||
'downloads.noDownloadsTree' => 'Inga nedladdningar',
|
||||
'downloads.pauseAll' => 'Pausa alla',
|
||||
'downloads.resumeAll' => 'Återuppta alla',
|
||||
|
||||
@@ -1040,6 +1040,11 @@ class _TranslationsDownloadsZh implements TranslationsDownloadsEn {
|
||||
@override String get downloadDeleted => '下载已删除';
|
||||
@override String deleteConfirm({required Object title}) => '确定要删除 "${title}" 吗?下载的文件将从您的设备中删除。';
|
||||
@override String deletingWithProgress({required Object title, required Object current, required Object total}) => '正在删除 ${title}... (${current}/${total})';
|
||||
@override String get deleting => '正在删除...';
|
||||
@override String get queuedTooltip => '已排队';
|
||||
@override String queuedFilesTooltip({required Object files}) => '已排队:${files}';
|
||||
@override String get downloadingTooltip => '正在下载...';
|
||||
@override String downloadingFilesTooltip({required Object files}) => '正在下载 ${files}';
|
||||
@override String get noDownloadsTree => '暂无下载';
|
||||
@override String get pauseAll => '全部暂停';
|
||||
@override String get resumeAll => '全部继续';
|
||||
@@ -2240,6 +2245,11 @@ extension on TranslationsZh {
|
||||
'downloads.downloadDeleted' => '下载已删除',
|
||||
'downloads.deleteConfirm' => ({required Object title}) => '确定要删除 "${title}" 吗?下载的文件将从您的设备中删除。',
|
||||
'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => '正在删除 ${title}... (${current}/${total})',
|
||||
'downloads.deleting' => '正在删除...',
|
||||
'downloads.queuedTooltip' => '已排队',
|
||||
'downloads.queuedFilesTooltip' => ({required Object files}) => '已排队:${files}',
|
||||
'downloads.downloadingTooltip' => '正在下载...',
|
||||
'downloads.downloadingFilesTooltip' => ({required Object files}) => '正在下载 ${files}',
|
||||
'downloads.noDownloadsTree' => '暂无下载',
|
||||
'downloads.pauseAll' => '全部暂停',
|
||||
'downloads.resumeAll' => '全部继续',
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "Nedladdning borttagen",
|
||||
"deleteConfirm": "Är du säker på att du vill ta bort \"${title}\"? Den nedladdade filen kommer att tas bort från din enhet.",
|
||||
"deletingWithProgress": "Tar bort ${title}... (${current} av ${total})",
|
||||
"deleting": "Tar bort...",
|
||||
"queuedTooltip": "I kö",
|
||||
"queuedFilesTooltip": "I kö: ${files}",
|
||||
"downloadingTooltip": "Laddar ned...",
|
||||
"downloadingFilesTooltip": "Laddar ned ${files}",
|
||||
"noDownloadsTree": "Inga nedladdningar",
|
||||
"pauseAll": "Pausa alla",
|
||||
"resumeAll": "Återuppta alla",
|
||||
|
||||
@@ -779,6 +779,11 @@
|
||||
"downloadDeleted": "下载已删除",
|
||||
"deleteConfirm": "确定要删除 \"${title}\" 吗?下载的文件将从您的设备中删除。",
|
||||
"deletingWithProgress": "正在删除 ${title}... (${current}/${total})",
|
||||
"deleting": "正在删除...",
|
||||
"queuedTooltip": "已排队",
|
||||
"queuedFilesTooltip": "已排队:${files}",
|
||||
"downloadingTooltip": "正在下载...",
|
||||
"downloadingFilesTooltip": "正在下载 ${files}",
|
||||
"noDownloadsTree": "暂无下载",
|
||||
"pauseAll": "全部暂停",
|
||||
"resumeAll": "全部继续",
|
||||
|
||||
+6
-7
@@ -1,11 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:io' show Platform, ProcessInfo;
|
||||
import 'dart:ui' show AppExitResponse;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences_foundation/shared_preferences_foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'dart:io' show Platform, ProcessInfo;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -145,7 +144,7 @@ Future<void> _bootstrapApp() async {
|
||||
await initializeDateFormatting(savedLocale.languageCode, null);
|
||||
|
||||
// Configure image cache — keep budget modest to leave headroom for Skia decode buffers
|
||||
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
PaintingBinding.instance.imageCache.maximumSize = 1000;
|
||||
PaintingBinding.instance.imageCache.maximumSizeBytes = 150 << 20; // 150MB
|
||||
} else {
|
||||
@@ -157,7 +156,7 @@ Future<void> _bootstrapApp() async {
|
||||
final futures = <Future<void>>[];
|
||||
|
||||
// Initialize window_manager for desktop platforms
|
||||
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
futures.add(windowManager.ensureInitialized());
|
||||
}
|
||||
|
||||
@@ -212,7 +211,7 @@ Future<void> _bootstrapApp() async {
|
||||
GamepadService.instance.start();
|
||||
|
||||
// Desktop-only services
|
||||
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
DiscordRPCService.instance.initialize();
|
||||
}
|
||||
|
||||
@@ -415,7 +414,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// On desktop, periodically check RSS and evict image cache if too high
|
||||
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
_memoryCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
final rss = ProcessInfo.currentRss;
|
||||
if (rss > 1536 * 1024 * 1024) {
|
||||
@@ -592,7 +591,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
// (sync, downloads, cache) still hold references to the executor.
|
||||
// SQLite WAL mode handles process death; desktop uses onExitRequested.
|
||||
InAppReviewService.instance.endSession();
|
||||
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
if (ProcessInfo.currentRss > 1024 * 1024 * 1024) {
|
||||
// 1GB
|
||||
_evictImageCaches();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../widgets/media_context_menu.dart';
|
||||
|
||||
/// Tracks tap position and exposes show-context-menu helpers for media cards
|
||||
/// that wrap their tappable area in a [MediaContextMenu].
|
||||
mixin ContextMenuTapMixin<T extends StatefulWidget> on State<T> {
|
||||
final GlobalKey<MediaContextMenuState> contextMenuKey = GlobalKey<MediaContextMenuState>();
|
||||
Offset? _tapPosition;
|
||||
|
||||
void storeTapPosition(TapDownDetails details) {
|
||||
_tapPosition = details.globalPosition;
|
||||
}
|
||||
|
||||
bool get isContextMenuOpen => contextMenuKey.currentState?.isContextMenuOpen ?? false;
|
||||
|
||||
/// Show at the last tap position (long-press, mouse).
|
||||
void showContextMenuFromTap() {
|
||||
contextMenuKey.currentState?.showContextMenu(context, position: _tapPosition);
|
||||
}
|
||||
|
||||
/// Show without a tap position (keyboard, gamepad).
|
||||
void showContextMenu() {
|
||||
contextMenuKey.currentState?.showContextMenu(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Adds [safeNotifyListeners] which no-ops after [dispose]. Use in providers
|
||||
/// that fire from async paths where a late callback could otherwise trip
|
||||
/// Flutter's debug-only "used after dispose" assert.
|
||||
mixin DisposableChangeNotifierMixin on ChangeNotifier {
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isDisposed => _disposed;
|
||||
|
||||
bool safeNotifyListeners() {
|
||||
if (_disposed) return false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,12 @@ mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
|
||||
return gridItemFocusNodes.putIfAbsent(index, () => FocusNode(debugLabel: '${prefix}_$index'));
|
||||
}
|
||||
|
||||
/// Get the focus node for [index], routing index 0 through [firstNode] when
|
||||
/// the grid pins a dedicated node for the first item (e.g. `firstItemFocusNode`).
|
||||
FocusNode focusNodeForIndex(int index, FocusNode firstNode, {required String prefix}) {
|
||||
return index == 0 ? firstNode : getGridItemFocusNode(index, prefix: prefix);
|
||||
}
|
||||
|
||||
/// Record that the item at [index] received focus.
|
||||
void trackGridItemFocus(int index, bool hasFocus) {
|
||||
if (hasFocus) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Utility class for loading font assets for libass subtitle rendering.
|
||||
///
|
||||
/// Extracts font files from Flutter assets to the app's cache directory to ensure
|
||||
@@ -44,11 +45,9 @@ class SubtitleFontLoader {
|
||||
}
|
||||
|
||||
return fontDir.path;
|
||||
} catch (e) {
|
||||
} catch (e, st) {
|
||||
// Return null if font loading fails - libass will fall back gracefully
|
||||
if (kDebugMode) {
|
||||
debugPrint('Failed to load subtitle font: $e');
|
||||
}
|
||||
appLogger.w('Failed to load subtitle font', error: e, stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,8 +220,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
try {
|
||||
final parsed = jsonDecode(value);
|
||||
if (parsed is List) trackList = parsed;
|
||||
} catch (_) {
|
||||
// Ignore parse errors
|
||||
} catch (e) {
|
||||
appLogger.d('Player: track-list parse failed', error: e);
|
||||
}
|
||||
}
|
||||
if (trackList != null) {
|
||||
@@ -260,7 +260,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
try {
|
||||
final parsed = jsonDecode(value);
|
||||
if (parsed is List) deviceList = parsed;
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('Player: device-list parse failed', error: e);
|
||||
}
|
||||
}
|
||||
if (deviceList != null) {
|
||||
final devices = deviceList
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../services/sync_rule_executor.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/episode_collection.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||
|
||||
/// Filter mode for batch downloads (shows/seasons).
|
||||
/// Use [all] to download everything, or [unwatched] with an optional maxCount.
|
||||
@@ -39,7 +40,7 @@ class DownloadedArtwork {
|
||||
}
|
||||
|
||||
/// Provider for managing download state and operations.
|
||||
class DownloadProvider extends ChangeNotifier {
|
||||
class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
||||
final DownloadManagerService _downloadManager;
|
||||
final AppDatabase _database;
|
||||
final SyncRuleExecutor _syncRuleExecutor;
|
||||
@@ -158,7 +159,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
'Loaded ${_downloads.length} downloads, ${_metadata.length} metadata entries, '
|
||||
'${_totalEpisodeCounts.length} episode counts, and ${_syncRules.length} sync rules',
|
||||
);
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load persisted downloads', error: e);
|
||||
}
|
||||
@@ -236,7 +237,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
appLogger.d('Notifying listeners for ${progress.globalKey}');
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -606,7 +607,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
try {
|
||||
// Mark as queueing to show loading state in UI
|
||||
_queueing.add(globalKey);
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
|
||||
final mt = metadata.mediaType;
|
||||
|
||||
@@ -624,7 +625,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
} finally {
|
||||
_queueing.remove(globalKey);
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,7 +753,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
|
||||
// Update local state immediately for UI feedback
|
||||
_downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued);
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
|
||||
// Actually trigger download via DownloadManagerService
|
||||
await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex);
|
||||
@@ -945,7 +946,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
await _downloadManager.cancelDownload(globalKey);
|
||||
_downloads.remove(globalKey);
|
||||
_metadata.remove(globalKey);
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -975,11 +976,11 @@ class DownloadProvider extends ChangeNotifier {
|
||||
_metadata.remove(globalKey);
|
||||
_artworkPaths.remove(globalKey);
|
||||
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
} catch (e) {
|
||||
// Remove from deletion tracking on error
|
||||
_deletionProgress.remove(globalKey);
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -993,7 +994,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
// Update progress
|
||||
_deletionProgress[progress.globalKey] = progress;
|
||||
}
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
|
||||
/// Get deletion progress for an item
|
||||
@@ -1038,7 +1039,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
|
||||
if (updatedCount > 0) {
|
||||
appLogger.i('Refreshed metadata from cache for $updatedCount items');
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,7 +1124,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
final rule = await _database.getSyncRule(globalKey);
|
||||
if (rule != null) {
|
||||
_syncRules[globalKey] = rule;
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
appLogger.i('Created sync rule: $globalKey ($targetType, filter=$downloadFilter, keep $episodeCount)');
|
||||
}
|
||||
@@ -1134,7 +1135,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
final existing = _syncRules[globalKey];
|
||||
if (existing != null) {
|
||||
_syncRules[globalKey] = existing.copyWith(episodeCount: episodeCount);
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
appLogger.i('Updated sync rule $globalKey: keep $episodeCount');
|
||||
}
|
||||
@@ -1145,7 +1146,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
final existing = _syncRules[globalKey];
|
||||
if (existing != null) {
|
||||
_syncRules[globalKey] = existing.copyWith(downloadFilter: downloadFilter);
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
appLogger.i('Updated sync rule $globalKey: filter=$downloadFilter');
|
||||
}
|
||||
@@ -1156,7 +1157,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
final existing = _syncRules[globalKey];
|
||||
if (existing != null) {
|
||||
_syncRules[globalKey] = existing.copyWith(enabled: enabled);
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
appLogger.i('${enabled ? 'Enabled' : 'Disabled'} sync rule: $globalKey');
|
||||
}
|
||||
@@ -1165,7 +1166,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
Future<void> deleteSyncRule(String globalKey) async {
|
||||
await _database.deleteSyncRule(globalKey);
|
||||
_syncRules.remove(globalKey);
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
appLogger.i('Deleted sync rule: $globalKey');
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ class LibrariesProvider extends ChangeNotifier {
|
||||
LibrariesLoadState _loadState = LibrariesLoadState.initial;
|
||||
String? _errorMessage;
|
||||
|
||||
/// Coalesces concurrent `loadLibraries()` calls so two simultaneous callers
|
||||
/// see the same in-flight result instead of racing two separate fetches.
|
||||
Future<void>? _inFlightLoad;
|
||||
|
||||
/// Unmodifiable list of all libraries (filtered for supported types, ordered)
|
||||
List<PlexLibrary> get libraries => List.unmodifiable(_libraries);
|
||||
|
||||
@@ -44,7 +48,11 @@ class LibrariesProvider extends ChangeNotifier {
|
||||
|
||||
/// Load libraries from all connected servers.
|
||||
/// Filters out music libraries and applies saved ordering.
|
||||
Future<void> loadLibraries() async {
|
||||
Future<void> loadLibraries() {
|
||||
return _inFlightLoad ??= _loadLibrariesInternal().whenComplete(() => _inFlightLoad = null);
|
||||
}
|
||||
|
||||
Future<void> _loadLibrariesInternal() async {
|
||||
if (_aggregationService == null) {
|
||||
appLogger.w('LibrariesProvider: Cannot load libraries - not initialized');
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/livetv_dvr.dart';
|
||||
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../services/data_aggregation_service.dart';
|
||||
import '../services/multi_server_manager.dart';
|
||||
@@ -23,7 +24,7 @@ class LiveTvServerInfo {
|
||||
|
||||
/// Provider for multi-server Plex connections
|
||||
/// Manages multiple PlexClient instances and provides data aggregation
|
||||
class MultiServerProvider extends ChangeNotifier {
|
||||
class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
||||
final MultiServerManager _serverManager;
|
||||
final DataAggregationService _aggregationService;
|
||||
StreamSubscription? _statusSubscription;
|
||||
@@ -46,7 +47,7 @@ class MultiServerProvider extends ChangeNotifier {
|
||||
final hasNewServer = currentOnline.any((id) => !_previousOnlineServerIds.contains(id));
|
||||
_previousOnlineServerIds = currentOnline;
|
||||
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
|
||||
// Only re-check live TV when a new server came online
|
||||
if (hasNewServer) {
|
||||
@@ -90,7 +91,7 @@ class MultiServerProvider extends ChangeNotifier {
|
||||
void clearAllConnections() {
|
||||
_serverManager.disconnectAll();
|
||||
appLogger.d('MultiServerProvider: All connections cleared');
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
|
||||
/// Reconnect all servers after a profile switch
|
||||
@@ -104,7 +105,7 @@ class MultiServerProvider extends ChangeNotifier {
|
||||
final connectedCount = await _serverManager.connectToAllServers(servers, clientIdentifier: clientIdentifier);
|
||||
|
||||
appLogger.i('MultiServerProvider: Reconnected to $connectedCount/${servers.length} servers after profile switch');
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
return connectedCount;
|
||||
}
|
||||
|
||||
@@ -142,7 +143,7 @@ class MultiServerProvider extends ChangeNotifier {
|
||||
|
||||
// Notify when availability changes OR when the server set changes
|
||||
if (hadLiveTv != _hasLiveTv || !oldServerIds.containsAll(newServerIds) || !newServerIds.containsAll(oldServerIds)) {
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||
import '../services/multi_server_manager.dart';
|
||||
import '../services/offline_mode_source.dart';
|
||||
|
||||
/// Tracks offline mode status based on network connectivity and server reachability.
|
||||
class OfflineModeProvider extends ChangeNotifier implements OfflineModeSource {
|
||||
class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMixin implements OfflineModeSource {
|
||||
final MultiServerManager _serverManager;
|
||||
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
||||
@@ -61,7 +62,7 @@ class OfflineModeProvider extends ChangeNotifier implements OfflineModeSource {
|
||||
_hasNetworkConnection = !results.contains(ConnectivityResult.none);
|
||||
|
||||
if (wasOffline != isOffline) {
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
},
|
||||
onError: (e) {
|
||||
@@ -81,17 +82,17 @@ class OfflineModeProvider extends ChangeNotifier implements OfflineModeSource {
|
||||
_hasServerConnection = statusMap.values.any((isOnline) => isOnline);
|
||||
|
||||
if (wasOffline != isOffline) {
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
|
||||
/// Force a refresh of connectivity status
|
||||
Future<void> refresh() async {
|
||||
await _updateConnectionFlags();
|
||||
notifyListeners();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1368,7 +1368,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (heroClient == null) {
|
||||
return Container(color: Theme.of(context).colorScheme.surfaceContainerHighest);
|
||||
return ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest);
|
||||
}
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final dpr = PlexImageHelper.effectiveDevicePixelRatio(context);
|
||||
@@ -1388,9 +1388,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
cacheManager: PlexImageCacheManager.instance,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) =>
|
||||
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
errorWidget: (context, url, error) =>
|
||||
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -1399,7 +1399,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
|
||||
// Gradient Overlay - blends into scaffold background
|
||||
Positioned(
|
||||
|
||||
@@ -75,13 +75,11 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
isAppBarFocused = false;
|
||||
});
|
||||
|
||||
if (targetIndex == 0) {
|
||||
firstItemFocusNode.requestFocus();
|
||||
} else {
|
||||
getGridItemFocusNode(targetIndex, prefix: 'detail_grid_item').requestFocus();
|
||||
}
|
||||
_focusNodeForIndex(targetIndex).requestFocus();
|
||||
}
|
||||
|
||||
FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(index, firstItemFocusNode, prefix: 'detail_grid_item');
|
||||
|
||||
/// Wrap [slivers] in the standard detail-screen scaffold — PopScope that
|
||||
/// defers to [handleBackNavigation], plus a Scaffold with a CustomScrollView
|
||||
/// bound to [scrollController]. Callers build the slivers themselves
|
||||
@@ -167,9 +165,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final focusNode = index == 0
|
||||
? firstItemFocusNode
|
||||
: getGridItemFocusNode(index, prefix: 'detail_grid_item');
|
||||
final focusNode = _focusNodeForIndex(index);
|
||||
|
||||
return FocusableMediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
@@ -203,9 +199,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final inFirstRow = GridSizeCalculator.isFirstRow(index, columnCount);
|
||||
final focusNode = index == 0
|
||||
? firstItemFocusNode
|
||||
: getGridItemFocusNode(index, prefix: 'detail_grid_item');
|
||||
final focusNode = _focusNodeForIndex(index);
|
||||
|
||||
return FocusableMediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
|
||||
@@ -71,13 +71,11 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
isAppBarFocused = false;
|
||||
});
|
||||
|
||||
if (targetIndex == 0) {
|
||||
firstItemFocusNode.requestFocus();
|
||||
} else {
|
||||
getGridItemFocusNode(targetIndex, prefix: 'hub_detail_item').requestFocus();
|
||||
}
|
||||
_focusNodeForIndex(targetIndex).requestFocus();
|
||||
}
|
||||
|
||||
FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(index, firstItemFocusNode, prefix: 'hub_detail_item');
|
||||
|
||||
/// Get the correct PlexClient for this hub's server
|
||||
PlexClient _getClientForHub() {
|
||||
return context.getClientForServer(widget.hub.serverId!);
|
||||
@@ -342,9 +340,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
itemCount: _filteredItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _filteredItems[index];
|
||||
final focusNode = index == 0
|
||||
? firstItemFocusNode
|
||||
: getGridItemFocusNode(index, prefix: 'hub_detail_item');
|
||||
final focusNode = _focusNodeForIndex(index);
|
||||
|
||||
return FocusableMediaCard(
|
||||
focusNode: focusNode,
|
||||
@@ -385,9 +381,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = _filteredItems[index];
|
||||
final focusNode = index == 0
|
||||
? firstItemFocusNode
|
||||
: getGridItemFocusNode(index, prefix: 'hub_detail_item');
|
||||
final focusNode = _focusNodeForIndex(index);
|
||||
final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount);
|
||||
final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
@@ -5,6 +6,7 @@ import '../../focus/focusable_button.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../models/livetv_channel.dart';
|
||||
import '../../models/livetv_program.dart';
|
||||
import '../../services/image_cache_service.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/overlay_sheet.dart';
|
||||
@@ -159,12 +161,13 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
child: blurArtwork(
|
||||
Image.network(
|
||||
widget.posterUrl!,
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.posterUrl!,
|
||||
cacheManager: PlexImageCacheManager.instance,
|
||||
width: 80,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -139,7 +139,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
windowManager.addListener(this);
|
||||
windowManager.setPreventClose(true);
|
||||
}
|
||||
@@ -465,7 +465,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
routeObserver.unsubscribe(this);
|
||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
windowManager.removeListener(this);
|
||||
windowManager.setPreventClose(false);
|
||||
}
|
||||
|
||||
@@ -348,8 +348,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Silently fail
|
||||
} catch (e) {
|
||||
appLogger.d('Episode cache sync skipped', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,8 +608,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (progress?.status == DownloadStatus.queued) {
|
||||
final currentFile = progress?.currentFile;
|
||||
final tooltip = currentFile != null && currentFile.contains('episodes')
|
||||
? 'Queued $currentFile'
|
||||
: 'Queued';
|
||||
? t.downloads.queuedFilesTooltip(files: currentFile)
|
||||
: t.downloads.queuedTooltip;
|
||||
|
||||
return IconButton.filledTonal(
|
||||
onPressed: null,
|
||||
@@ -625,8 +625,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
// Show episode count in tooltip for shows/seasons
|
||||
final currentFile = progress?.currentFile;
|
||||
final tooltip = currentFile != null && currentFile.contains('episodes')
|
||||
? 'Downloading $currentFile'
|
||||
: 'Downloading...';
|
||||
? t.downloads.downloadingFilesTooltip(files: currentFile)
|
||||
: t.downloads.downloadingTooltip;
|
||||
|
||||
return IconButton.filledTonal(
|
||||
onPressed: null,
|
||||
@@ -2182,7 +2182,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
_episodes = episodeLists.expand((e) => e).toList();
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
} catch (_) {
|
||||
} catch (e, st) {
|
||||
appLogger.w('Failed to load episodes for all seasons', error: e, stackTrace: st);
|
||||
setStateIfMounted(() => _isLoadingEpisodes = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../mixins/context_menu_tap_mixin.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
@@ -42,18 +43,7 @@ class PlaylistItemCard extends StatefulWidget {
|
||||
State<PlaylistItemCard> createState() => _PlaylistItemCardState();
|
||||
}
|
||||
|
||||
class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
|
||||
Offset? _tapPosition;
|
||||
|
||||
void _storeTapPosition(TapDownDetails details) {
|
||||
_tapPosition = details.globalPosition;
|
||||
}
|
||||
|
||||
void _showContextMenu() {
|
||||
_contextMenuKey.currentState?.showContextMenu(context, position: _tapPosition);
|
||||
}
|
||||
|
||||
class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTapMixin<PlaylistItemCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -80,7 +70,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
}
|
||||
|
||||
return MediaContextMenu(
|
||||
key: _contextMenuKey,
|
||||
key: contextMenuKey,
|
||||
item: widget.item,
|
||||
onRefresh: widget.onRefresh,
|
||||
onTap: widget.onTap,
|
||||
@@ -90,10 +80,10 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
shape: cardShape,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
onTapDown: _storeTapPosition,
|
||||
onLongPress: _showContextMenu,
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: _showContextMenu,
|
||||
onTapDown: storeTapPosition,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
onSecondaryTapDown: storeTapPosition,
|
||||
onSecondaryTap: showContextMenuFromTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
|
||||
@@ -741,7 +741,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
|
||||
// Audio passthrough (desktop only - sends bitstream to receiver)
|
||||
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
if (settingsService.getAudioPassthrough()) {
|
||||
await player!.setAudioPassthrough(true);
|
||||
}
|
||||
@@ -1030,7 +1030,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Set MPV video-sync mode for smoother playback when display is synced
|
||||
try {
|
||||
await player!.setProperty('video-sync', 'display-tempo');
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('video-sync property unsupported', error: e);
|
||||
}
|
||||
|
||||
if (mounted && player != null) {
|
||||
await player!.play();
|
||||
@@ -1619,14 +1621,22 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
final partId = _currentMediaInfo!.partId!;
|
||||
final client = _getClientForMetadata(context);
|
||||
final service = BifThumbnailService();
|
||||
service.load(client, partId).then((_) {
|
||||
// Guard against media having changed while the download was in flight
|
||||
if (mounted && _currentMediaInfo?.partId == partId) {
|
||||
setState(() => _bifService = service);
|
||||
} else {
|
||||
service.dispose();
|
||||
}
|
||||
});
|
||||
unawaited(
|
||||
service
|
||||
.load(client, partId)
|
||||
.then((_) {
|
||||
// Guard against media having changed while the download was in flight
|
||||
if (mounted && _currentMediaInfo?.partId == partId) {
|
||||
setState(() => _bifService = service);
|
||||
} else {
|
||||
service.dispose();
|
||||
}
|
||||
})
|
||||
.catchError((e, st) {
|
||||
appLogger.w('BIF thumbnail load failed for part $partId', error: e, stackTrace: st);
|
||||
service.dispose();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await _initVideoFilterAndPip();
|
||||
@@ -1716,7 +1726,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// MPV video-sync tuning (no-op on ExoPlayer).
|
||||
try {
|
||||
await player!.setProperty('video-sync', 'display-tempo');
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('video-sync property unsupported on this player', error: e);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to apply pre-playback frame rate matching', error: e);
|
||||
}
|
||||
@@ -2167,7 +2179,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
try {
|
||||
_companionRemoteProvider = context.read<CompanionRemoteProvider>();
|
||||
_companionRemoteProvider!.sendCommand(RemoteCommandType.syncState, data: {'playerActive': true});
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('CompanionRemote provider unavailable', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
void _cleanupCompanionRemoteCallbacks() {
|
||||
@@ -3313,7 +3327,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
try {
|
||||
playbackState.setCurrentItem(episodeMetadata);
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('playbackState.setCurrentItem failed', error: e);
|
||||
}
|
||||
|
||||
await _loadAdjacentEpisodes();
|
||||
|
||||
@@ -3931,7 +3947,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
valueListenable: _isExiting,
|
||||
builder: (context, isExiting, child) {
|
||||
if (!isExiting) return const SizedBox.shrink();
|
||||
return Positioned.fill(child: Container(color: Colors.black));
|
||||
return const Positioned.fill(child: ColoredBox(color: Colors.black));
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import '../mpv/player/player.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Generates and manages an ambient lighting GLSL shader that fills letterbox/pillarbox
|
||||
/// bars with a blurred, dimmed version of the video edges.
|
||||
@@ -40,9 +40,7 @@ class AmbientLightingService {
|
||||
// Write static shader (only needs to happen once)
|
||||
_shaderPath ??= await _writeShaderToTemp(_generateShader());
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('AmbientLightingService: Shader path: $_shaderPath');
|
||||
}
|
||||
appLogger.d('AmbientLightingService: Shader path: $_shaderPath');
|
||||
|
||||
// Set video-aspect-override to fill the entire output area
|
||||
await _player.setProperty('video-aspect-override', outputAspect.toString());
|
||||
@@ -52,13 +50,9 @@ class AmbientLightingService {
|
||||
|
||||
_enabled = true;
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('AmbientLightingService: Enabled (video=$videoAspect, output=$outputAspect)');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('AmbientLightingService: Failed to enable: $e');
|
||||
}
|
||||
appLogger.d('AmbientLightingService: Enabled (video=$videoAspect, output=$outputAspect)');
|
||||
} catch (e, st) {
|
||||
appLogger.w('AmbientLightingService: Failed to enable', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,13 +69,9 @@ class AmbientLightingService {
|
||||
|
||||
_enabled = false;
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('AmbientLightingService: Disabled');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('AmbientLightingService: Failed to disable: $e');
|
||||
}
|
||||
appLogger.d('AmbientLightingService: Disabled');
|
||||
} catch (e, st) {
|
||||
appLogger.w('AmbientLightingService: Failed to disable', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -546,7 +546,9 @@ class CompanionRemotePeerService with KeepaliveMixin {
|
||||
if (_channel != null) {
|
||||
try {
|
||||
await _channel!.sink.close();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('CompanionRemote: channel close on timeout failed', error: e);
|
||||
}
|
||||
_channel = null;
|
||||
}
|
||||
throw const RemotePeerError(type: RemotePeerErrorType.timeout, message: 'Timed out joining session');
|
||||
@@ -591,7 +593,9 @@ class CompanionRemotePeerService with KeepaliveMixin {
|
||||
for (final ch in channels) {
|
||||
try {
|
||||
ch.sink.close();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('CompanionRemote: race-loser close ignored', error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,7 +614,9 @@ class CompanionRemotePeerService with KeepaliveMixin {
|
||||
appLogger.d('CompanionRemote: Race winner: $address');
|
||||
completer.complete(address);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('CompanionRemote: race message parse skipped', error: e);
|
||||
}
|
||||
},
|
||||
onError: (_) {},
|
||||
onDone: () {},
|
||||
@@ -762,14 +768,18 @@ class CompanionRemotePeerService with KeepaliveMixin {
|
||||
if (_clientSocket != null) {
|
||||
try {
|
||||
await _clientSocket!.close();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('CompanionRemote: client socket close ignored', error: e);
|
||||
}
|
||||
_clientSocket = null;
|
||||
}
|
||||
|
||||
if (_channel != null) {
|
||||
try {
|
||||
await _channel!.sink.close();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('CompanionRemote: channel close ignored', error: e);
|
||||
}
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ class LanDiscoveryService {
|
||||
|
||||
// Listener state (client)
|
||||
RawDatagramSocket? _listenSocket;
|
||||
StreamSubscription<RawSocketEvent>? _listenSubscription;
|
||||
Timer? _staleCleanupTimer;
|
||||
final Map<String, DiscoveredHost> _discoveredHosts = {};
|
||||
final _hostsController = StreamController<List<DiscoveredHost>>.broadcast();
|
||||
@@ -175,7 +176,7 @@ class LanDiscoveryService {
|
||||
|
||||
appLogger.d('LanDiscovery: Listening on port $discoveryPort');
|
||||
|
||||
_listenSocket!.listen((RawSocketEvent event) {
|
||||
_listenSubscription = _listenSocket!.listen((RawSocketEvent event) {
|
||||
if (event == RawSocketEvent.read) {
|
||||
final datagram = _listenSocket?.receive();
|
||||
if (datagram != null) {
|
||||
@@ -269,6 +270,8 @@ class LanDiscoveryService {
|
||||
void _stopListeningInternal() {
|
||||
_staleCleanupTimer?.cancel();
|
||||
_staleCleanupTimer = null;
|
||||
_listenSubscription?.cancel();
|
||||
_listenSubscription = null;
|
||||
_listenSocket?.close();
|
||||
_listenSocket = null;
|
||||
appLogger.d('LanDiscovery: Listening stopped');
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dart_discord_presence/dart_discord_presence.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -7,6 +6,7 @@ import 'package:http/http.dart' as http;
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/future_extensions.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/plex_http_client.dart';
|
||||
import 'plex_client.dart';
|
||||
import 'settings_service.dart';
|
||||
@@ -59,7 +59,7 @@ class DiscordRPCService {
|
||||
|
||||
/// Check if Discord RPC is available on this platform
|
||||
static bool get isAvailable {
|
||||
if (!Platform.isMacOS && !Platform.isWindows && !Platform.isLinux) {
|
||||
if (!PlatformDetector.isDesktopOS()) {
|
||||
return false;
|
||||
}
|
||||
return DiscordRPC.isAvailable;
|
||||
@@ -238,7 +238,9 @@ class DiscordRPCService {
|
||||
_errorSubscription = null;
|
||||
try {
|
||||
_rpc?.dispose();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('DiscordRPC: dispose ignored', error: e);
|
||||
}
|
||||
_rpc = null;
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
@@ -305,12 +305,19 @@ class DownloadManagerService {
|
||||
// Attempt deferred supplementary downloads for recovered items
|
||||
_processPendingSupplementaryDownloads(client);
|
||||
|
||||
_database.getNextQueueItem().then((item) {
|
||||
if (item != null) {
|
||||
appLogger.i('Resuming queued downloads after app restart');
|
||||
_processQueue(client);
|
||||
}
|
||||
});
|
||||
unawaited(
|
||||
_database
|
||||
.getNextQueueItem()
|
||||
.then((item) {
|
||||
if (item != null) {
|
||||
appLogger.i('Resuming queued downloads after app restart');
|
||||
_processQueue(client);
|
||||
}
|
||||
})
|
||||
.catchError((e, st) {
|
||||
appLogger.e('Failed to resume queued downloads', error: e, stackTrace: st);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Attempt supplementary downloads (artwork, subtitles) for items that were
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/key_event_simulator.dart' as key_sim;
|
||||
import '../utils/platform_detector.dart';
|
||||
|
||||
/// Service that bridges gamepad input to Flutter's focus navigation system.
|
||||
///
|
||||
@@ -62,7 +63,7 @@ class GamepadService with WindowListener {
|
||||
|
||||
/// Start listening to gamepad events.
|
||||
/// Only active on desktop platforms (macOS, Windows, Linux).
|
||||
static bool get _isDesktop => Platform.isMacOS || Platform.isWindows || Platform.isLinux;
|
||||
static bool get _isDesktop => PlatformDetector.isDesktopOS();
|
||||
|
||||
void start() async {
|
||||
appLogger.i('GamepadService: Starting on ${Platform.operatingSystem}');
|
||||
|
||||
@@ -237,10 +237,17 @@ class MultiServerManager {
|
||||
appLogger.i('Successfully connected to ${server.name}');
|
||||
|
||||
// Fire-and-forget: fetch server prefs and cache watched threshold
|
||||
client.fetchServerPrefs().then((_) {
|
||||
final threshold = client.watchedThresholdPercent;
|
||||
SettingsService.instanceOrNull?.setWatchedThreshold(serverId, threshold);
|
||||
});
|
||||
unawaited(
|
||||
client
|
||||
.fetchServerPrefs()
|
||||
.then((_) {
|
||||
final threshold = client.watchedThresholdPercent;
|
||||
SettingsService.instanceOrNull?.setWatchedThreshold(serverId, threshold);
|
||||
})
|
||||
.catchError((Object e, StackTrace st) {
|
||||
appLogger.w('fetchServerPrefs failed for ${server.name}', error: e, stackTrace: st);
|
||||
}),
|
||||
);
|
||||
|
||||
return serverId;
|
||||
} on TimeoutException {
|
||||
|
||||
@@ -191,13 +191,13 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
///
|
||||
/// Removes any conflicting actions for the same item.
|
||||
Future<void> queueMarkWatched({required String serverId, required String ratingKey}) =>
|
||||
_queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: 'watched');
|
||||
_queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: OfflineActionType.watched.name);
|
||||
|
||||
/// Queue a manual "mark as unwatched" action.
|
||||
///
|
||||
/// Removes any conflicting actions for the same item.
|
||||
Future<void> queueMarkUnwatched({required String serverId, required String ratingKey}) =>
|
||||
_queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: 'unwatched');
|
||||
_queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: OfflineActionType.unwatched.name);
|
||||
|
||||
/// Internal helper to queue watch/unwatch actions.
|
||||
Future<void> _queueWatchStatusAction({
|
||||
@@ -281,7 +281,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
if (action == null) return null;
|
||||
|
||||
// Only return offset for progress actions
|
||||
if (action.actionType == 'progress') {
|
||||
if (action.actionType == OfflineActionType.progress.name) {
|
||||
return action.viewOffset;
|
||||
}
|
||||
|
||||
@@ -402,9 +402,9 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
// listeners (UI invalidation, Trakt sync). Best-effort: a missed metadata
|
||||
// fetch only suppresses the event, not the Plex API call.
|
||||
final emitsEvent =
|
||||
action.actionType == 'watched' ||
|
||||
action.actionType == 'unwatched' ||
|
||||
(action.actionType == 'progress' && action.shouldMarkWatched);
|
||||
action.actionType == OfflineActionType.watched.name ||
|
||||
action.actionType == OfflineActionType.unwatched.name ||
|
||||
(action.actionType == OfflineActionType.progress.name && action.shouldMarkWatched);
|
||||
PlexMetadata? metadata;
|
||||
if (emitsEvent) {
|
||||
try {
|
||||
@@ -488,7 +488,9 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
if (existingMeta['Media'] == null) {
|
||||
try {
|
||||
await client.getMetadataWithImages(episode.ratingKey);
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('Cache repair fetch skipped for ${episode.ratingKey}', error: e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No existing entry — write what we have
|
||||
|
||||
@@ -80,7 +80,11 @@ class PlexAuthService {
|
||||
}
|
||||
|
||||
Future<PlexResponse> _getUser(String authToken) {
|
||||
return _http.get('$_plexApiBase/user', headers: _getCommonHeaders(authToken: authToken));
|
||||
return _http.get(
|
||||
'$_plexApiBase/user',
|
||||
headers: _getCommonHeaders(authToken: authToken),
|
||||
timeout: ConnectionTimeouts.plexTvReceive,
|
||||
);
|
||||
}
|
||||
|
||||
void _checkStatus(PlexResponse response) => throwIfHttpError(response);
|
||||
@@ -97,7 +101,11 @@ class PlexAuthService {
|
||||
|
||||
/// Create a PIN for authentication
|
||||
Future<Map<String, dynamic>> createPin() async {
|
||||
final response = await _http.post('$_plexApiBase/pins?strong=true', headers: _getCommonHeaders());
|
||||
final response = await _http.post(
|
||||
'$_plexApiBase/pins?strong=true',
|
||||
headers: _getCommonHeaders(),
|
||||
timeout: ConnectionTimeouts.plexTvReceive,
|
||||
);
|
||||
_checkStatus(response);
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
@@ -116,7 +124,11 @@ class PlexAuthService {
|
||||
/// Poll the PIN to check if it has been claimed
|
||||
Future<String?> checkPin(int pinId) async {
|
||||
try {
|
||||
final response = await _http.get('$_plexApiBase/pins/$pinId', headers: _getCommonHeaders());
|
||||
final response = await _http.get(
|
||||
'$_plexApiBase/pins/$pinId',
|
||||
headers: _getCommonHeaders(),
|
||||
timeout: ConnectionTimeouts.plexTvReceive,
|
||||
);
|
||||
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return data['authToken'] as String?;
|
||||
@@ -125,16 +137,20 @@ class PlexAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the PIN until it's claimed or timeout
|
||||
/// Poll the PIN until it's claimed or timeout.
|
||||
///
|
||||
/// Uses an exponential backoff (1s → 2s → 4s, capped at 5s) so a stalled
|
||||
/// claim doesn't hammer plex.tv every second for two minutes.
|
||||
Future<String?> pollPinUntilClaimed(
|
||||
int pinId, {
|
||||
Duration timeout = const Duration(minutes: 2),
|
||||
bool Function()? shouldCancel,
|
||||
}) async {
|
||||
final endTime = DateTime.now().add(timeout);
|
||||
var backoff = const Duration(seconds: 1);
|
||||
const maxBackoff = Duration(seconds: 5);
|
||||
|
||||
while (DateTime.now().isBefore(endTime)) {
|
||||
// Check if polling should be cancelled
|
||||
if (shouldCancel != null && shouldCancel()) {
|
||||
return null;
|
||||
}
|
||||
@@ -144,8 +160,9 @@ class PlexAuthService {
|
||||
return token;
|
||||
}
|
||||
|
||||
// Wait 1 second before polling again
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
await Future.delayed(backoff);
|
||||
final next = backoff * 2;
|
||||
backoff = next > maxBackoff ? maxBackoff : next;
|
||||
}
|
||||
|
||||
return null; // Timeout
|
||||
|
||||
@@ -2541,7 +2541,9 @@ class PlexClient {
|
||||
} else {
|
||||
programs.add(LiveTvProgram.fromJson(map));
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e, st) {
|
||||
appLogger.w('LiveTvProgram parse failed', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
return programs;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:saf_util/saf_util.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import 'package:saf_util/saf_util_platform_interface.dart';
|
||||
|
||||
@@ -27,7 +27,7 @@ class SafStorageService {
|
||||
final doc = await _safUtil.pickDirectory(writePermission: true, persistablePermission: true);
|
||||
return doc?.uri;
|
||||
} catch (e) {
|
||||
debugPrint('SAF pickDirectory error: $e');
|
||||
appLogger.w('SAF pickDirectory error', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class SafStorageService {
|
||||
final result = await _safUtil.mkdirp(parentUri, [name]);
|
||||
return result.uri;
|
||||
} catch (e) {
|
||||
debugPrint('SAF createDirectory error: $e');
|
||||
appLogger.w('SAF createDirectory error', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ class SafStorageService {
|
||||
try {
|
||||
return await _safUtil.child(parentUri, names);
|
||||
} catch (e) {
|
||||
debugPrint('SAF getChild error: $e');
|
||||
appLogger.w('SAF getChild error', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class SafStorageService {
|
||||
final result = await _safUtil.mkdirp(parentUri, pathComponents);
|
||||
return result.uri;
|
||||
} catch (e) {
|
||||
debugPrint('SAF createNestedDirectories error: $e');
|
||||
appLogger.w('SAF createNestedDirectories error', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ class SafStorageService {
|
||||
await _safUtil.delete(uri, isDir);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('SAF delete error: $e');
|
||||
appLogger.w('SAF delete error', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ class SafStorageService {
|
||||
try {
|
||||
return await _safUtil.exists(uri, isDir);
|
||||
} catch (e) {
|
||||
debugPrint('SAF exists error: $e');
|
||||
appLogger.w('SAF exists error', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ class SafStorageService {
|
||||
try {
|
||||
return await _safUtil.list(uri);
|
||||
} catch (e) {
|
||||
debugPrint('SAF list error: $e');
|
||||
appLogger.w('SAF list error', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1063,7 +1063,9 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
final migrated = lines.join('\n');
|
||||
prefs.setString(_keyMpvConfigText, migrated);
|
||||
return migrated;
|
||||
} catch (_) {}
|
||||
} catch (e, st) {
|
||||
appLogger.w('SettingsService: failed to migrate mpv config', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
@@ -1242,8 +1244,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
|
||||
bool getEnableCompanionRemoteServer() {
|
||||
// Default enabled on desktop/TV, disabled on mobile
|
||||
return prefs.getBool(_keyEnableCompanionRemoteServer) ??
|
||||
(Platform.isMacOS || Platform.isWindows || Platform.isLinux);
|
||||
return prefs.getBool(_keyEnableCompanionRemoteServer) ?? (PlatformDetector.isDesktopOS());
|
||||
}
|
||||
|
||||
// Auto Picture-in-Picture (Android & iOS)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../models/shader_preset.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Utility class for loading GLSL shader assets for MPV video enhancement.
|
||||
///
|
||||
@@ -70,10 +70,8 @@ class ShaderAssetLoader {
|
||||
}
|
||||
|
||||
return targetFile.path;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('Failed to extract shader $assetPath: $e');
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('Failed to extract shader $assetPath', error: e, stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -245,10 +243,8 @@ class ShaderAssetLoader {
|
||||
for (final shaderPath in _anime4kShaders.values) {
|
||||
await _extractShader(shaderPath);
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('Failed to preload shaders: $e');
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('Failed to preload shaders', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/shader_preset.dart';
|
||||
import '../mpv/player/player.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'ambient_lighting_service.dart';
|
||||
import 'shader_asset_loader.dart';
|
||||
|
||||
@@ -31,9 +30,7 @@ class ShaderService {
|
||||
/// and skip shader application for HDR content.
|
||||
Future<void> applyPreset(ShaderPreset preset) async {
|
||||
if (!isSupported) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('ShaderService: Shaders not supported on ${_player.playerType}');
|
||||
}
|
||||
appLogger.d('ShaderService: Shaders not supported on ${_player.playerType}');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,9 +39,7 @@ class ShaderService {
|
||||
if (preset.type == ShaderPresetType.nvscaler && preset.nvscalerConfig?.autoHdrSkip == true) {
|
||||
final isHdr = await _isHdrContent();
|
||||
if (isHdr) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('ShaderService: Skipping NVScaler on HDR content');
|
||||
}
|
||||
appLogger.d('ShaderService: Skipping NVScaler on HDR content');
|
||||
await _clearShaders();
|
||||
_currentPreset = ShaderPreset.none;
|
||||
await _reappendAmbientLighting();
|
||||
@@ -76,13 +71,9 @@ class ShaderService {
|
||||
// Re-append ambient lighting shader at end of chain
|
||||
await _reappendAmbientLighting();
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('ShaderService: Applied ${preset.name} with ${shaderPaths.length} shaders');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('ShaderService: Failed to apply preset: $e');
|
||||
}
|
||||
appLogger.d('ShaderService: Applied ${preset.name} with ${shaderPaths.length} shaders');
|
||||
} catch (e, st) {
|
||||
appLogger.w('ShaderService: Failed to apply preset', error: e, stackTrace: st);
|
||||
// Don't rethrow - shader failure shouldn't stop playback
|
||||
}
|
||||
}
|
||||
@@ -91,10 +82,8 @@ class ShaderService {
|
||||
Future<void> _clearShaders() async {
|
||||
try {
|
||||
await _player.command(['change-list', 'glsl-shaders', 'clr', '']);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('ShaderService: Failed to clear shaders: $e');
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('ShaderService: Failed to clear shaders', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,10 +95,8 @@ class ShaderService {
|
||||
|
||||
try {
|
||||
await service.reappendShader();
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('ShaderService: Failed to re-append ambient lighting: $e');
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('ShaderService: Failed to re-append ambient lighting', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,9 +123,7 @@ class ShaderService {
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('ShaderService: HDR detection failed: $e');
|
||||
}
|
||||
appLogger.d('ShaderService: HDR detection failed', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../mpv/mpv.dart';
|
||||
import '../services/pip_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Manages video Picture-in-Picture mode
|
||||
class VideoPIPManager {
|
||||
@@ -36,7 +37,9 @@ class VideoPIPManager {
|
||||
width = int.tryParse(dwidth);
|
||||
height = int.tryParse(dheight);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('VideoPipManager: dwidth/dheight unavailable', error: e);
|
||||
}
|
||||
|
||||
if (width == null || height == null) {
|
||||
try {
|
||||
@@ -46,7 +49,9 @@ class VideoPIPManager {
|
||||
width = int.tryParse(videoWidth);
|
||||
height = int.tryParse(videoHeight);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('VideoPipManager: width/height unavailable', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
width ??= _playerSize?.width.toInt();
|
||||
|
||||
+20
-19
@@ -98,25 +98,7 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
|
||||
margin: EdgeInsets.zero,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(14))),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: c.text.withValues(alpha: 0.08),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
hintStyle: TextStyle(color: c.textMuted),
|
||||
),
|
||||
inputDecorationTheme: _inputDecorationTheme(c.text, c.textMuted),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle),
|
||||
filledButtonTheme: FilledButtonThemeData(style: buttonStyle),
|
||||
sliderTheme: SliderThemeData(
|
||||
@@ -179,3 +161,22 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Brighter fill on focus so input focus is visible inside TV overscan.
|
||||
InputDecorationTheme _inputDecorationTheme(Color text, Color textMuted) {
|
||||
final unfocusedFill = text.withValues(alpha: 0.08);
|
||||
final focusedFill = text.withValues(alpha: 0.18);
|
||||
const border = OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12)), borderSide: BorderSide.none);
|
||||
return InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: WidgetStateColor.resolveWith(
|
||||
(states) => states.contains(WidgetState.focused) ? focusedFill : unfocusedFill,
|
||||
),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
border: border,
|
||||
enabledBorder: border,
|
||||
focusedBorder: border,
|
||||
hintStyle: TextStyle(color: textMuted),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,15 @@ class ScreenBreakpoints {
|
||||
static bool isWideTabletOrLarger(double width) => width >= wideTablet;
|
||||
}
|
||||
|
||||
/// Animation and notification durations.
|
||||
class AppDurations {
|
||||
static const Duration animFast = Duration(milliseconds: 200);
|
||||
static const Duration animMedium = Duration(milliseconds: 300);
|
||||
static const Duration animSlow = Duration(milliseconds: 500);
|
||||
static const Duration snackBarDefault = Duration(seconds: 3);
|
||||
static const Duration snackBarLong = Duration(seconds: 4);
|
||||
}
|
||||
|
||||
/// Grid layout constants
|
||||
class GridLayoutConstants {
|
||||
/// Default aspect ratio for media card grid cells (poster + text)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'layout_constants.dart';
|
||||
|
||||
Route<T> fadeRoute<T>(Widget page) {
|
||||
return PageRouteBuilder<T>(
|
||||
opaque: false,
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 500),
|
||||
transitionDuration: AppDurations.animSlow,
|
||||
reverseTransitionDuration: AppDurations.animSlow,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -249,4 +249,40 @@ class PlexImageHelper {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Optimized URL for hero/background art ([ImageType.art]).
|
||||
static String heroArtUrl({
|
||||
required PlexClient? client,
|
||||
required String? thumbPath,
|
||||
required BuildContext context,
|
||||
required double containerWidth,
|
||||
required double containerHeight,
|
||||
}) => _typedUrl(client, thumbPath, context, containerWidth, containerHeight, ImageType.art);
|
||||
|
||||
/// Optimized URL for clear-logo overlays ([ImageType.logo]).
|
||||
static String logoUrl({
|
||||
required PlexClient? client,
|
||||
required String? thumbPath,
|
||||
required BuildContext context,
|
||||
required double containerWidth,
|
||||
required double containerHeight,
|
||||
}) => _typedUrl(client, thumbPath, context, containerWidth, containerHeight, ImageType.logo);
|
||||
|
||||
static String _typedUrl(
|
||||
PlexClient? client,
|
||||
String? thumbPath,
|
||||
BuildContext context,
|
||||
double containerWidth,
|
||||
double containerHeight,
|
||||
ImageType type,
|
||||
) {
|
||||
return getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: thumbPath,
|
||||
maxWidth: containerWidth,
|
||||
maxHeight: containerHeight,
|
||||
devicePixelRatio: effectiveDevicePixelRatio(context),
|
||||
imageType: type,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,86 +18,71 @@ extension ProviderExtensions on BuildContext {
|
||||
// Direct profile settings access (nullable)
|
||||
PlexUserProfile? get profileSettings => userProfile.profileSettings;
|
||||
|
||||
/// Get PlexClient for a specific server ID
|
||||
/// Throws an exception if no client is available for the given serverId
|
||||
PlexClient getClientForServer(String serverId) {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(this, listen: false);
|
||||
/// Internal: resolve a [PlexClient] from a serverId or fall back to the
|
||||
/// first online server. Returns null if neither yields a client.
|
||||
PlexClient? _resolveClient(String? serverId) {
|
||||
final provider = Provider.of<MultiServerProvider>(this, listen: false);
|
||||
if (serverId != null) {
|
||||
final client = provider.getClientForServer(serverId);
|
||||
if (client != null) return client;
|
||||
}
|
||||
final fallbackId = provider.onlineServerIds.firstOrNull;
|
||||
if (fallbackId == null) return null;
|
||||
return provider.getClientForServer(fallbackId);
|
||||
}
|
||||
|
||||
final serverClient = multiServerProvider.getClientForServer(serverId);
|
||||
|
||||
if (serverClient == null) {
|
||||
appLogger.e('No client found for server $serverId');
|
||||
/// Internal: like [_resolveClient] but throws a localized exception when
|
||||
/// no client is available. The thrown message is the canonical
|
||||
/// `t.errors.noClientAvailable` so callers can surface it directly.
|
||||
PlexClient _requireClient(String? serverId, {bool fallback = true}) {
|
||||
final provider = Provider.of<MultiServerProvider>(this, listen: false);
|
||||
if (serverId != null) {
|
||||
final client = provider.getClientForServer(serverId);
|
||||
if (client != null) return client;
|
||||
if (!fallback) {
|
||||
appLogger.e('No client found for server $serverId');
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
}
|
||||
final fallbackId = provider.onlineServerIds.firstOrNull;
|
||||
final client = fallbackId == null ? null : provider.getClientForServer(fallbackId);
|
||||
if (client == null) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
return serverClient;
|
||||
return client;
|
||||
}
|
||||
|
||||
/// Get PlexClient for a specific server ID. Throws if unavailable.
|
||||
PlexClient getClientForServer(String serverId) => _requireClient(serverId, fallback: false);
|
||||
|
||||
/// Get PlexClient for a specific server ID, or null if unavailable.
|
||||
PlexClient? tryGetClientForServer(String? serverId) {
|
||||
if (serverId == null) return null;
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(this, listen: false);
|
||||
return multiServerProvider.getClientForServer(serverId);
|
||||
final provider = Provider.of<MultiServerProvider>(this, listen: false);
|
||||
return provider.getClientForServer(serverId);
|
||||
}
|
||||
|
||||
/// Get PlexClient for a library
|
||||
/// Throws an exception if no client is available
|
||||
PlexClient getClientForLibrary(PlexLibrary library) {
|
||||
// If library doesn't have a serverId, fall back to first available server
|
||||
if (library.serverId == null) {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(this, listen: false);
|
||||
final serverId = multiServerProvider.onlineServerIds.firstOrNull;
|
||||
if (serverId == null) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
return getClientForServer(serverId);
|
||||
}
|
||||
return getClientForServer(library.serverId!);
|
||||
}
|
||||
/// Get PlexClient for a library, falling back to the first online server
|
||||
/// when the library has no serverId. Throws if no client is available.
|
||||
PlexClient getClientForLibrary(PlexLibrary library) => _requireClient(library.serverId);
|
||||
|
||||
/// Get PlexClient for metadata, with fallback to first available server
|
||||
/// Throws an exception if no servers are available
|
||||
PlexClient getClientForMetadata(PlexMetadata metadata) {
|
||||
if (metadata.serverId != null) {
|
||||
return getClientForServer(metadata.serverId!);
|
||||
}
|
||||
return getFirstAvailableClient();
|
||||
}
|
||||
/// Get PlexClient for metadata, falling back to the first online server.
|
||||
/// Throws if no client is available.
|
||||
PlexClient getClientForMetadata(PlexMetadata metadata) => _requireClient(metadata.serverId);
|
||||
|
||||
/// Get PlexClient for metadata, or null if offline mode or no serverId
|
||||
/// Use this for screens that support offline mode
|
||||
/// Get PlexClient for metadata, or null in offline mode / when no serverId.
|
||||
PlexClient? getClientForMetadataOrNull(PlexMetadata metadata, {bool isOffline = false}) {
|
||||
if (isOffline || metadata.serverId == null) {
|
||||
return null;
|
||||
}
|
||||
if (isOffline) return null;
|
||||
return tryGetClientForServer(metadata.serverId);
|
||||
}
|
||||
|
||||
/// Get the first available client from connected servers
|
||||
/// Throws an exception if no servers are available
|
||||
PlexClient getFirstAvailableClient() {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(this, listen: false);
|
||||
final serverId = multiServerProvider.onlineServerIds.firstOrNull;
|
||||
if (serverId == null) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
return getClientForServer(serverId);
|
||||
}
|
||||
/// Get the first online server's client. Throws if none available.
|
||||
PlexClient getFirstAvailableClient() => _requireClient(null);
|
||||
|
||||
/// Get the first available client, or null if no servers are connected
|
||||
PlexClient? tryGetFirstAvailableClient() {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(this, listen: false);
|
||||
final serverId = multiServerProvider.onlineServerIds.firstOrNull;
|
||||
if (serverId == null) return null;
|
||||
return multiServerProvider.getClientForServer(serverId);
|
||||
}
|
||||
/// Get the first online server's client, or null.
|
||||
PlexClient? tryGetFirstAvailableClient() => _resolveClient(null);
|
||||
|
||||
/// Get client for a serverId with fallback to first available server
|
||||
/// Useful for items that might not have a serverId
|
||||
PlexClient getClientWithFallback(String? serverId) {
|
||||
if (serverId != null) {
|
||||
return getClientForServer(serverId);
|
||||
}
|
||||
return getFirstAvailableClient();
|
||||
}
|
||||
/// Get client for a serverId, falling back to the first online server.
|
||||
/// Throws if no client is available.
|
||||
PlexClient getClientWithFallback(String? serverId) => _requireClient(serverId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../widgets/deletion_progress_dialog.dart';
|
||||
|
||||
@@ -45,10 +46,10 @@ class SmartDeletionHandler {
|
||||
|
||||
// If no progress, show simple fallback
|
||||
if (progress == null) {
|
||||
return const AlertDialog(
|
||||
return AlertDialog(
|
||||
content: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [CircularProgressIndicator(), SizedBox(width: 20), Text('Deleting...')],
|
||||
children: [const CircularProgressIndicator(), const SizedBox(width: 20), Text(t.downloads.deleting)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'layout_constants.dart';
|
||||
|
||||
/// Global key for the root ScaffoldMessenger, allowing snackbars to survive navigation.
|
||||
final rootScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
@@ -31,9 +33,9 @@ void showSnackBar(BuildContext context, String message, {SnackBarType type = Sna
|
||||
if (!context.mounted) return;
|
||||
|
||||
final (backgroundColor, defaultDuration) = switch (type) {
|
||||
SnackBarType.info => (null, const Duration(seconds: 3)),
|
||||
SnackBarType.success => (Colors.green, const Duration(seconds: 3)),
|
||||
SnackBarType.error => (Colors.red, const Duration(seconds: 4)),
|
||||
SnackBarType.info => (null, AppDurations.snackBarDefault),
|
||||
SnackBarType.success => (Colors.green, AppDurations.snackBarDefault),
|
||||
SnackBarType.error => (Colors.red, AppDurations.snackBarLong),
|
||||
};
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -61,14 +63,14 @@ void showErrorSnackBar(BuildContext context, String message) {
|
||||
/// Shows an error snackbar using the root ScaffoldMessenger (survives navigation).
|
||||
void showGlobalErrorSnackBar(String message) {
|
||||
rootScaffoldMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red, duration: const Duration(seconds: 4)),
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red, duration: AppDurations.snackBarLong),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows an info snackbar through the main-screen messenger when available
|
||||
/// (so it floats above the mobile NavigationBar), falling back to the root
|
||||
/// messenger when the main screen is not mounted.
|
||||
void showMainSnackBar(String message, {Duration duration = const Duration(seconds: 3)}) {
|
||||
void showMainSnackBar(String message, {Duration duration = AppDurations.snackBarDefault}) {
|
||||
final messenger = mainScaffoldMessengerKey.currentState ?? rootScaffoldMessengerKey.currentState;
|
||||
messenger
|
||||
?..removeCurrentSnackBar()
|
||||
|
||||
@@ -232,7 +232,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> {
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to create session', error: e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.watchTogether.failedToCreate}: $e')));
|
||||
showErrorSnackBar(context, '${t.watchTogether.failedToCreate}: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
@@ -276,7 +276,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> {
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to join session', error: e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.watchTogether.failedToJoin}: $e')));
|
||||
showErrorSnackBar(context, '${t.watchTogether.failedToJoin}: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
@@ -299,7 +299,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> {
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to enter room', error: e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.watchTogether.failedToJoin}: $e')));
|
||||
showErrorSnackBar(context, '${t.watchTogether.failedToJoin}: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
@@ -729,6 +729,6 @@ class _SessionCodeRow extends StatelessWidget {
|
||||
|
||||
void _copySessionCode(BuildContext context) {
|
||||
Clipboard.setData(ClipboardData(text: sessionId));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.watchTogether.sessionCodeCopied)));
|
||||
showSnackBar(context, t.watchTogether.sessionCodeCopied);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +242,9 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
appLogger.w('WatchTogether: Pong timeout — closing WebSocket');
|
||||
try {
|
||||
_channel?.sink.close();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('WatchTogether: pong-timeout close ignored', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a raw JSON map to the relay.
|
||||
@@ -435,7 +437,9 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
||||
|
||||
try {
|
||||
await _channel?.sink.close();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('WatchTogether: channel close ignored', error: e);
|
||||
}
|
||||
_channel = null;
|
||||
|
||||
_connectedPeers.clear();
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/dialogs.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
@@ -301,7 +302,9 @@ class _ParticipantNotificationOverlayState extends State<ParticipantNotification
|
||||
try {
|
||||
final provider = context.read<WatchTogetherProvider>();
|
||||
_subscription = provider.participantEvents.listen(_onEvent);
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
appLogger.d('WatchTogetherOverlay: provider unavailable', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
void _onEvent(ParticipantEvent event) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../focus/focus_theme.dart';
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
import '../mixins/context_menu_tap_mixin.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
@@ -52,18 +53,7 @@ class EpisodeCard extends StatefulWidget {
|
||||
State<EpisodeCard> createState() => _EpisodeCardState();
|
||||
}
|
||||
|
||||
class _EpisodeCardState extends State<EpisodeCard> {
|
||||
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
|
||||
Offset? _tapPosition;
|
||||
|
||||
void _storeTapPosition(TapDownDetails details) {
|
||||
_tapPosition = details.globalPosition;
|
||||
}
|
||||
|
||||
void _showContextMenu() {
|
||||
_contextMenuKey.currentState?.showContextMenu(context, position: _tapPosition);
|
||||
}
|
||||
|
||||
class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<EpisodeCard> {
|
||||
Widget _buildEpisodeMetaRow(BuildContext context) {
|
||||
final mutedStyle = Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12);
|
||||
final dot = Padding(
|
||||
@@ -119,10 +109,10 @@ class _EpisodeCardState extends State<EpisodeCard> {
|
||||
enableLongPress: true,
|
||||
onNavigateUp: widget.onNavigateUp,
|
||||
onSelect: widget.onTap,
|
||||
onLongPress: _showContextMenu,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
disableScale: true,
|
||||
child: MediaContextMenu(
|
||||
key: _contextMenuKey,
|
||||
key: contextMenuKey,
|
||||
item: widget.episode,
|
||||
onRefresh: widget.onRefresh,
|
||||
onListRefresh: widget.onListRefresh,
|
||||
@@ -131,10 +121,10 @@ class _EpisodeCardState extends State<EpisodeCard> {
|
||||
key: Key(widget.episode.ratingKey),
|
||||
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
|
||||
onTap: widget.onTap,
|
||||
onTapDown: _storeTapPosition,
|
||||
onLongPress: _showContextMenu,
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: _showContextMenu,
|
||||
onTapDown: storeTapPosition,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
onSecondaryTapDown: storeTapPosition,
|
||||
onSecondaryTap: showContextMenuFromTap,
|
||||
hoverColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.05),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
|
||||
+12
-27
@@ -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/input_mode_tracker.dart';
|
||||
import '../mixins/context_menu_tap_mixin.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
@@ -59,28 +60,12 @@ class MediaCard extends StatefulWidget {
|
||||
State<MediaCard> createState() => MediaCardState();
|
||||
}
|
||||
|
||||
class MediaCardState extends State<MediaCard> {
|
||||
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
|
||||
Offset? _tapPosition;
|
||||
|
||||
void _storeTapPosition(TapDownDetails details) {
|
||||
_tapPosition = details.globalPosition;
|
||||
}
|
||||
|
||||
void _showContextMenu() {
|
||||
_contextMenuKey.currentState?.showContextMenu(context, position: _tapPosition);
|
||||
}
|
||||
|
||||
class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard> {
|
||||
/// Public method to trigger tap action (for keyboard/gamepad SELECT)
|
||||
void handleTap() {
|
||||
_handleTap(context);
|
||||
}
|
||||
|
||||
/// Public method to show context menu (for keyboard/gamepad context menu key)
|
||||
void showContextMenu() {
|
||||
_contextMenuKey.currentState?.showContextMenu(context);
|
||||
}
|
||||
|
||||
String _buildSemanticLabel() {
|
||||
final item = widget.item;
|
||||
|
||||
@@ -128,7 +113,7 @@ class MediaCardState extends State<MediaCard> {
|
||||
|
||||
void _handleTap(BuildContext context) async {
|
||||
// Ignore taps while context menu is open to avoid double-activating
|
||||
if (_contextMenuKey.currentState?.isContextMenuOpen == true) {
|
||||
if (contextMenuKey.currentState?.isContextMenuOpen == true) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,10 +175,10 @@ class MediaCardState extends State<MediaCard> {
|
||||
item: widget.item,
|
||||
semanticLabel: semanticLabel,
|
||||
onTap: () => _handleTap(context),
|
||||
onTapDown: _storeTapPosition,
|
||||
onLongPress: _showContextMenu,
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: _showContextMenu,
|
||||
onTapDown: storeTapPosition,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
onSecondaryTapDown: storeTapPosition,
|
||||
onSecondaryTap: showContextMenuFromTap,
|
||||
density: context.select<SettingsProvider, int>((s) => s.libraryDensity),
|
||||
isOffline: widget.isOffline,
|
||||
localPosterPath: localPosterPath,
|
||||
@@ -203,7 +188,7 @@ class MediaCardState extends State<MediaCard> {
|
||||
// MediaContextMenu as a non-widget helper — only wrap with its key for
|
||||
// programmatic context menu access; gesture callbacks are on InkWell directly.
|
||||
return MediaContextMenu(
|
||||
key: _contextMenuKey,
|
||||
key: contextMenuKey,
|
||||
item: widget.item,
|
||||
onRefresh: widget.onRefresh,
|
||||
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
|
||||
@@ -228,10 +213,10 @@ class MediaCardState extends State<MediaCard> {
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: () => _handleTap(context),
|
||||
onTapDown: _storeTapPosition,
|
||||
onLongPress: _showContextMenu,
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: _showContextMenu,
|
||||
onTapDown: storeTapPosition,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
onSecondaryTapDown: storeTapPosition,
|
||||
onSecondaryTap: showContextMenuFromTap,
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(3, 3, 3, 1),
|
||||
|
||||
@@ -1756,7 +1756,7 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> {
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(color: Colors.transparent),
|
||||
child: const ColoredBox(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
// Menu
|
||||
|
||||
@@ -468,7 +468,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
builder: (context, child) {
|
||||
return GestureDetector(
|
||||
onTap: _barrierDismissible ? () => _close() : null,
|
||||
child: Container(color: Colors.black.withValues(alpha: _barrierAnimation.value)),
|
||||
child: ColoredBox(color: Colors.black.withValues(alpha: _barrierAnimation.value)),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -2,15 +2,20 @@ import 'package:flutter/material.dart';
|
||||
|
||||
const pillInputRadius = BorderRadius.all(Radius.circular(100));
|
||||
|
||||
/// Brighter fill on focus so input focus is visible inside TV overscan.
|
||||
InputDecoration pillInputDecoration(BuildContext context, {String? hintText, Widget? prefixIcon, Widget? suffixIcon}) {
|
||||
final fillColor = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08);
|
||||
final onSurface = Theme.of(context).colorScheme.onSurface;
|
||||
final unfocusedFill = onSurface.withValues(alpha: 0.08);
|
||||
final focusedFill = onSurface.withValues(alpha: 0.18);
|
||||
const border = OutlineInputBorder(borderRadius: pillInputRadius, borderSide: BorderSide.none);
|
||||
return InputDecoration(
|
||||
hintText: hintText,
|
||||
prefixIcon: prefixIcon,
|
||||
suffixIcon: suffixIcon,
|
||||
filled: true,
|
||||
fillColor: fillColor,
|
||||
fillColor: WidgetStateColor.resolveWith(
|
||||
(states) => states.contains(WidgetState.focused) ? focusedFill : unfocusedFill,
|
||||
),
|
||||
border: border,
|
||||
enabledBorder: border,
|
||||
focusedBorder: border,
|
||||
|
||||
@@ -346,46 +346,26 @@ class PlexOptimizedImage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(BuildContext context) {
|
||||
Widget _surfacePlaceholder(BuildContext context, {IconData? icon, Color? iconColor, bool fillParent = false}) {
|
||||
final theme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: fallbackIcon != null
|
||||
? Center(child: AppIcon(fallbackIcon!, fill: 1, size: 40, color: Colors.white54))
|
||||
: null,
|
||||
width: fillParent ? null : width,
|
||||
height: fillParent ? null : height,
|
||||
color: theme.surfaceContainerHighest,
|
||||
child: icon == null
|
||||
? null
|
||||
: Center(child: AppIcon(icon, fill: 1, size: 40, color: iconColor ?? theme.onSurfaceVariant)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorWidget(BuildContext context, dynamic _) {
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
fallbackIcon ?? Symbols.broken_image_rounded,
|
||||
fill: 1,
|
||||
size: 40,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget _buildPlaceholder(BuildContext context) =>
|
||||
_surfacePlaceholder(context, icon: fallbackIcon, iconColor: Colors.white54);
|
||||
|
||||
Widget _buildFallback(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
fallbackIcon ?? Symbols.image_not_supported_rounded,
|
||||
fill: 1,
|
||||
size: 40,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget _buildErrorWidget(BuildContext context, dynamic _) =>
|
||||
_surfacePlaceholder(context, icon: fallbackIcon ?? Symbols.broken_image_rounded, fillParent: true);
|
||||
|
||||
Widget _buildFallback(BuildContext context) =>
|
||||
_surfacePlaceholder(context, icon: fallbackIcon ?? Symbols.image_not_supported_rounded);
|
||||
|
||||
String _generateCacheKey(String imageUrl) {
|
||||
// URL already encodes bucketed transcode dimensions via roundDimensions,
|
||||
|
||||
@@ -391,7 +391,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
// Add lifecycle observer to reload settings when app resumes
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// Add window listener for tracking fullscreen state (for button icon)
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
windowManager.addListener(this);
|
||||
_initAlwaysOnTopState();
|
||||
}
|
||||
@@ -705,11 +705,18 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
|
||||
if (shaderService.currentPreset.isEnabled) {
|
||||
// Currently active - disable temporarily
|
||||
shaderService.applyPreset(ShaderPreset.none).then((_) {
|
||||
// ignore: no-empty-block - setState triggers rebuild to reflect disabled shader
|
||||
if (mounted) setState(() {});
|
||||
widget.onShaderChanged?.call();
|
||||
});
|
||||
unawaited(
|
||||
shaderService
|
||||
.applyPreset(ShaderPreset.none)
|
||||
.then((_) {
|
||||
// ignore: no-empty-block - setState triggers rebuild to reflect disabled shader
|
||||
if (mounted) setState(() {});
|
||||
widget.onShaderChanged?.call();
|
||||
})
|
||||
.catchError((Object e, StackTrace st) {
|
||||
appLogger.w('Failed to disable shader', error: e, stackTrace: st);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// Currently off - restore saved preset
|
||||
final shaderProvider = context.read<ShaderProvider>();
|
||||
@@ -718,12 +725,19 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
final targetPreset = saved.isEnabled
|
||||
? saved
|
||||
: allPresets.firstWhere((p) => p.isEnabled, orElse: () => allPresets[1]);
|
||||
shaderService.applyPreset(targetPreset).then((_) {
|
||||
shaderProvider.setCurrentPreset(targetPreset);
|
||||
// ignore: no-empty-block - setState triggers rebuild to reflect restored shader
|
||||
if (mounted) setState(() {});
|
||||
widget.onShaderChanged?.call();
|
||||
});
|
||||
unawaited(
|
||||
shaderService
|
||||
.applyPreset(targetPreset)
|
||||
.then((_) {
|
||||
shaderProvider.setCurrentPreset(targetPreset);
|
||||
// ignore: no-empty-block - setState triggers rebuild to reflect restored shader
|
||||
if (mounted) setState(() {});
|
||||
widget.onShaderChanged?.call();
|
||||
})
|
||||
.catchError((Object e, StackTrace st) {
|
||||
appLogger.w('Failed to apply shader preset', error: e, stackTrace: st);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -766,7 +780,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
// Remove lifecycle observer
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// Remove window listener and reset always-on-top if it was enabled
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
windowManager.removeListener(this);
|
||||
if (_isAlwaysOnTop) {
|
||||
windowManager.setAlwaysOnTop(false);
|
||||
@@ -926,7 +940,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
final maxVol = _keyboardService!.maxVolume.toDouble();
|
||||
final newVolume = (volume - delta / 20).clamp(0.0, maxVol);
|
||||
widget.player.setVolume(newVolume);
|
||||
SettingsService.getInstance().then((s) => s.setVolume(newVolume));
|
||||
unawaited(SettingsService.getInstance().then((s) => s.setVolume(newVolume)));
|
||||
_showControlsFromPointerActivity();
|
||||
}
|
||||
}
|
||||
@@ -2033,7 +2047,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
onLongPressEnd: (_) => _handleLongPressEnd(),
|
||||
onLongPressCancel: _handleLongPressCancel,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(color: Colors.transparent),
|
||||
child: const ColoredBox(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
// Mobile double-tap zones for skip forward/backward
|
||||
@@ -2061,7 +2075,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
onLongPressEnd: (_) => _handleLongPressEnd(),
|
||||
onLongPressCancel: _handleLongPressCancel,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(color: Colors.transparent),
|
||||
child: const ColoredBox(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
// Right zone - skip forward (custom double-tap detection)
|
||||
@@ -2076,7 +2090,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
onLongPressEnd: (_) => _handleLongPressEnd(),
|
||||
onLongPressCancel: _handleLongPressCancel,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(color: Colors.transparent),
|
||||
child: const ColoredBox(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -185,7 +183,7 @@ class TrackChapterControls extends StatelessWidget {
|
||||
builder: (context, snapshot) {
|
||||
final tracks = snapshot.data;
|
||||
final isMobile = PlatformDetector.isMobile(context);
|
||||
final isDesktop = Platform.isWindows || Platform.isLinux || Platform.isMacOS;
|
||||
final isDesktop = PlatformDetector.isDesktopOS();
|
||||
|
||||
// Build list of buttons dynamically to track indices
|
||||
final buttons = <Widget>[];
|
||||
|
||||
Reference in New Issue
Block a user