feat(livetv): add DVR and recording support

This commit is contained in:
edde746
2026-05-04 09:31:53 +02:00
parent dee12ac336
commit 720d0d2604
64 changed files with 5542 additions and 210 deletions
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "Genindlæs guide",
"now": "Nu",
"today": "I dag",
"tomorrow": "I morgen",
"midnight": "Midnat",
"overnight": "Nat",
"morning": "Morgen",
@@ -741,7 +742,41 @@
"joinSession": "Deltag i igangværende session",
"watchFromStart": "Se fra start (${minutes} min siden)",
"watchLive": "Se live",
"goToLive": "Gå til live"
"goToLive": "Gå til live",
"record": "Optag",
"recordEpisode": "Optag episode",
"recordSeries": "Optag serie",
"recordOptions": "Optageindstillinger",
"recordings": "Optagelser",
"scheduledRecordings": "Planlagt",
"recordingRules": "Optagelsesregler",
"noScheduledRecordings": "Ingen optagelser planlagt",
"noRecordingRules": "Ingen optagelsesregler endnu",
"manageRecording": "Administrer optagelse",
"cancelRecording": "Annullér optagelse",
"cancelRecordingTitle": "Annullér denne optagelse?",
"cancelRecordingMessage": "${title} bliver ikke længere optaget.",
"deleteRule": "Slet regel",
"deleteRuleTitle": "Slet optagelsesregel?",
"deleteRuleMessage": "Fremtidige episoder af ${title} bliver ikke optaget.",
"recordingScheduled": "Optagelse planlagt",
"alreadyScheduled": "Dette program er allerede planlagt",
"dvrAdminRequired": "DVR-indstillinger kræver en administratorkonto",
"recordingFailed": "Kunne ikke planlægge optagelse",
"recordingTargetMissing": "Kunne ikke bestemme optagelsesbibliotek",
"recordNotAvailable": "Optagelse er ikke tilgængelig for dette program",
"recordingCancelled": "Optagelse annulleret",
"recordingRuleDeleted": "Optagelsesregel slettet",
"processRecordingRules": "Genberegn regler",
"loadingRecordings": "Indlæser optagelser ...",
"recordingInProgress": "Optager nu",
"recordingsCount": "${count} planlagt",
"editRule": "Rediger regel",
"editRuleAction": "Rediger",
"recordingRuleUpdated": "Optagelsesregel opdateret",
"guideReloadRequested": "Guide-opdatering anmodet",
"rulesProcessRequested": "Regelevaluering anmodet",
"recordShow": "Optag program"
},
"collections": {
"title": "Samlinger",
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "Programmführer neu laden",
"now": "Jetzt",
"today": "Heute",
"tomorrow": "Morgen",
"midnight": "Mitternacht",
"overnight": "Nacht",
"morning": "Morgen",
@@ -741,7 +742,41 @@
"joinSession": "Laufender Sitzung beitreten",
"watchFromStart": "Von Anfang an ansehen (vor ${minutes} Min.)",
"watchLive": "Live ansehen",
"goToLive": "Zum Live-Bild"
"goToLive": "Zum Live-Bild",
"record": "Aufnehmen",
"recordEpisode": "Episode aufnehmen",
"recordSeries": "Serie aufnehmen",
"recordOptions": "Aufnahmeoptionen",
"recordings": "Aufnahmen",
"scheduledRecordings": "Geplant",
"recordingRules": "Aufnahmeregeln",
"noScheduledRecordings": "Keine geplanten Aufnahmen",
"noRecordingRules": "Noch keine Aufnahmeregeln",
"manageRecording": "Aufnahme verwalten",
"cancelRecording": "Aufnahme abbrechen",
"cancelRecordingTitle": "Diese Aufnahme abbrechen?",
"cancelRecordingMessage": "${title} wird nicht mehr aufgenommen.",
"deleteRule": "Regel löschen",
"deleteRuleTitle": "Aufnahmeregel löschen?",
"deleteRuleMessage": "Zukünftige Episoden von ${title} werden nicht aufgenommen.",
"recordingScheduled": "Aufnahme geplant",
"alreadyScheduled": "Dieses Programm ist bereits geplant",
"dvrAdminRequired": "DVR-Einstellungen erfordern ein Administratorkonto",
"recordingFailed": "Aufnahme konnte nicht geplant werden",
"recordingTargetMissing": "Aufnahmebibliothek konnte nicht ermittelt werden",
"recordNotAvailable": "Aufnahme für dieses Programm nicht verfügbar",
"recordingCancelled": "Aufnahme abgebrochen",
"recordingRuleDeleted": "Aufnahmeregel gelöscht",
"processRecordingRules": "Regeln neu bewerten",
"loadingRecordings": "Aufnahmen werden geladen ...",
"recordingInProgress": "Wird jetzt aufgenommen",
"recordingsCount": "${count} geplant",
"editRule": "Regel bearbeiten",
"editRuleAction": "Bearbeiten",
"recordingRuleUpdated": "Aufnahmeregel aktualisiert",
"guideReloadRequested": "Guide-Aktualisierung angefordert",
"rulesProcessRequested": "Regelauswertung angefordert",
"recordShow": "Sendung aufnehmen"
},
"collections": {
"title": "Sammlungen",
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "Reload Guide",
"now": "Now",
"today": "Today",
"tomorrow": "Tomorrow",
"midnight": "Midnight",
"overnight": "Overnight",
"morning": "Morning",
@@ -741,7 +742,41 @@
"joinSession": "Join Session in Progress",
"watchFromStart": "Watch from start (${minutes} min ago)",
"watchLive": "Watch Live",
"goToLive": "Go to Live"
"goToLive": "Go to Live",
"record": "Record",
"recordEpisode": "Record Episode",
"recordSeries": "Record Series",
"recordOptions": "Record Options",
"recordings": "Recordings",
"scheduledRecordings": "Scheduled",
"recordingRules": "Recording Rules",
"noScheduledRecordings": "Nothing scheduled to record",
"noRecordingRules": "No recording rules yet",
"manageRecording": "Manage recording",
"cancelRecording": "Cancel recording",
"cancelRecordingTitle": "Cancel this recording?",
"cancelRecordingMessage": "${title} will no longer be recorded.",
"deleteRule": "Delete rule",
"deleteRuleTitle": "Delete recording rule?",
"deleteRuleMessage": "Future episodes of ${title} will not be recorded.",
"recordingScheduled": "Recording scheduled",
"alreadyScheduled": "This program is already scheduled",
"dvrAdminRequired": "DVR settings require an admin account",
"recordingFailed": "Couldn't schedule recording",
"recordingTargetMissing": "Couldn't determine recording library",
"recordNotAvailable": "Recording not available for this program",
"recordingCancelled": "Recording cancelled",
"recordingRuleDeleted": "Recording rule deleted",
"processRecordingRules": "Re-evaluate rules",
"loadingRecordings": "Loading recordings...",
"recordingInProgress": "Recording now",
"recordingsCount": "${count} scheduled",
"editRule": "Edit rule",
"editRuleAction": "Edit",
"recordingRuleUpdated": "Recording rule updated",
"guideReloadRequested": "Guide refresh requested",
"rulesProcessRequested": "Rule re-evaluation requested",
"recordShow": "Record show"
},
"collections": {
"title": "Collections",
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "Recargar guía",
"now": "Ahora",
"today": "Hoy",
"tomorrow": "Mañana",
"midnight": "Medianoche",
"overnight": "Madrugada",
"morning": "Mañana",
@@ -741,7 +742,41 @@
"joinSession": "Unirse a sesión en curso",
"watchFromStart": "Ver desde el inicio (hace ${minutes} min)",
"watchLive": "Ver en vivo",
"goToLive": "Ir a en vivo"
"goToLive": "Ir a en vivo",
"record": "Grabar",
"recordEpisode": "Grabar episodio",
"recordSeries": "Grabar serie",
"recordOptions": "Opciones de grabación",
"recordings": "Grabaciones",
"scheduledRecordings": "Programadas",
"recordingRules": "Reglas de grabación",
"noScheduledRecordings": "No hay grabaciones programadas",
"noRecordingRules": "Aún no hay reglas de grabación",
"manageRecording": "Gestionar grabación",
"cancelRecording": "Cancelar grabación",
"cancelRecordingTitle": "¿Cancelar esta grabación?",
"cancelRecordingMessage": "${title} ya no se grabará.",
"deleteRule": "Eliminar regla",
"deleteRuleTitle": "¿Eliminar regla de grabación?",
"deleteRuleMessage": "Los próximos episodios de ${title} no se grabarán.",
"recordingScheduled": "Grabación programada",
"alreadyScheduled": "Este programa ya está programado",
"dvrAdminRequired": "La configuración DVR requiere una cuenta de administrador",
"recordingFailed": "No se pudo programar la grabación",
"recordingTargetMissing": "No se pudo determinar la biblioteca de grabación",
"recordNotAvailable": "Grabación no disponible para este programa",
"recordingCancelled": "Grabación cancelada",
"recordingRuleDeleted": "Regla de grabación eliminada",
"processRecordingRules": "Reevaluar reglas",
"loadingRecordings": "Cargando grabaciones...",
"recordingInProgress": "Grabando ahora",
"recordingsCount": "${count} programadas",
"editRule": "Editar regla",
"editRuleAction": "Editar",
"recordingRuleUpdated": "Regla de grabación actualizada",
"guideReloadRequested": "Solicitada actualización de la guía",
"rulesProcessRequested": "Solicitada reevaluación de reglas",
"recordShow": "Grabar programa"
},
"collections": {
"title": "Colecciones",
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "Recharger le guide",
"now": "Maintenant",
"today": "Aujourd'hui",
"tomorrow": "Demain",
"midnight": "Minuit",
"overnight": "Nuit",
"morning": "Matin",
@@ -741,7 +742,41 @@
"joinSession": "Rejoindre la session en cours",
"watchFromStart": "Regarder depuis le début (il y a ${minutes} min)",
"watchLive": "Regarder en direct",
"goToLive": "Aller au direct"
"goToLive": "Aller au direct",
"record": "Enregistrer",
"recordEpisode": "Enregistrer l'épisode",
"recordSeries": "Enregistrer la série",
"recordOptions": "Options d'enregistrement",
"recordings": "Enregistrements",
"scheduledRecordings": "Programmés",
"recordingRules": "Règles d'enregistrement",
"noScheduledRecordings": "Aucun enregistrement programmé",
"noRecordingRules": "Aucune règle d'enregistrement",
"manageRecording": "Gérer l'enregistrement",
"cancelRecording": "Annuler l'enregistrement",
"cancelRecordingTitle": "Annuler cet enregistrement ?",
"cancelRecordingMessage": "${title} ne sera plus enregistré.",
"deleteRule": "Supprimer la règle",
"deleteRuleTitle": "Supprimer la règle d'enregistrement ?",
"deleteRuleMessage": "Les prochains épisodes de ${title} ne seront pas enregistrés.",
"recordingScheduled": "Enregistrement programmé",
"alreadyScheduled": "Ce programme est déjà programmé",
"dvrAdminRequired": "Les paramètres DVR nécessitent un compte administrateur",
"recordingFailed": "Impossible de programmer l'enregistrement",
"recordingTargetMissing": "Impossible de déterminer la bibliothèque d'enregistrement",
"recordNotAvailable": "Enregistrement non disponible pour ce programme",
"recordingCancelled": "Enregistrement annulé",
"recordingRuleDeleted": "Règle d'enregistrement supprimée",
"processRecordingRules": "Réévaluer les règles",
"loadingRecordings": "Chargement des enregistrements...",
"recordingInProgress": "Enregistrement en cours",
"recordingsCount": "${count} programmés",
"editRule": "Modifier la règle",
"editRuleAction": "Modifier",
"recordingRuleUpdated": "Règle d'enregistrement mise à jour",
"guideReloadRequested": "Mise à jour du guide demandée",
"rulesProcessRequested": "Réévaluation des règles demandée",
"recordShow": "Enregistrer l'émission"
},
"collections": {
"title": "Collections",
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "Ricarica guida",
"now": "Ora",
"today": "Oggi",
"tomorrow": "Domani",
"midnight": "Mezzanotte",
"overnight": "Notte",
"morning": "Mattina",
@@ -741,7 +742,41 @@
"joinSession": "Partecipa alla sessione in corso",
"watchFromStart": "Guarda dall'inizio (${minutes} min fa)",
"watchLive": "Guarda in diretta",
"goToLive": "Vai al live"
"goToLive": "Vai al live",
"record": "Registra",
"recordEpisode": "Registra episodio",
"recordSeries": "Registra serie",
"recordOptions": "Opzioni di registrazione",
"recordings": "Registrazioni",
"scheduledRecordings": "Programmate",
"recordingRules": "Regole di registrazione",
"noScheduledRecordings": "Nessuna registrazione programmata",
"noRecordingRules": "Nessuna regola di registrazione",
"manageRecording": "Gestisci registrazione",
"cancelRecording": "Annulla registrazione",
"cancelRecordingTitle": "Annullare questa registrazione?",
"cancelRecordingMessage": "${title} non sarà più registrato.",
"deleteRule": "Elimina regola",
"deleteRuleTitle": "Eliminare la regola di registrazione?",
"deleteRuleMessage": "I prossimi episodi di ${title} non saranno registrati.",
"recordingScheduled": "Registrazione programmata",
"alreadyScheduled": "Questo programma è già programmato",
"dvrAdminRequired": "Le impostazioni DVR richiedono un account amministratore",
"recordingFailed": "Impossibile programmare la registrazione",
"recordingTargetMissing": "Impossibile determinare la libreria di registrazione",
"recordNotAvailable": "Registrazione non disponibile per questo programma",
"recordingCancelled": "Registrazione annullata",
"recordingRuleDeleted": "Regola di registrazione eliminata",
"processRecordingRules": "Rivaluta regole",
"loadingRecordings": "Caricamento registrazioni...",
"recordingInProgress": "Registrazione in corso",
"recordingsCount": "${count} programmate",
"editRule": "Modifica regola",
"editRuleAction": "Modifica",
"recordingRuleUpdated": "Regola di registrazione aggiornata",
"guideReloadRequested": "Aggiornamento guida richiesto",
"rulesProcessRequested": "Rivalutazione regole richiesta",
"recordShow": "Registra programma"
},
"collections": {
"title": "Raccolte",
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "番組表を再読込",
"now": "現在",
"today": "今日",
"tomorrow": "明日",
"midnight": "深夜",
"overnight": "深夜",
"morning": "朝",
@@ -741,7 +742,41 @@
"joinSession": "進行中のセッションに参加",
"watchFromStart": "最初から視聴(${minutes}分前に開始)",
"watchLive": "ライブで視聴",
"goToLive": "ライブに移動"
"goToLive": "ライブに移動",
"record": "録画",
"recordEpisode": "このエピソードを録画",
"recordSeries": "シリーズを録画",
"recordOptions": "録画オプション",
"recordings": "録画",
"scheduledRecordings": "予約",
"recordingRules": "録画ルール",
"noScheduledRecordings": "予約された録画はありません",
"noRecordingRules": "録画ルールはまだありません",
"manageRecording": "録画を管理",
"cancelRecording": "録画をキャンセル",
"cancelRecordingTitle": "この録画をキャンセルしますか?",
"cancelRecordingMessage": "${title} は録画されなくなります。",
"deleteRule": "ルールを削除",
"deleteRuleTitle": "録画ルールを削除しますか?",
"deleteRuleMessage": "${title} の今後のエピソードは録画されません。",
"recordingScheduled": "録画を予約しました",
"alreadyScheduled": "この番組はすでに予約されています",
"dvrAdminRequired": "DVR 設定には管理者アカウントが必要です",
"recordingFailed": "録画を予約できませんでした",
"recordingTargetMissing": "録画ライブラリを特定できませんでした",
"recordNotAvailable": "この番組は録画できません",
"recordingCancelled": "録画をキャンセルしました",
"recordingRuleDeleted": "録画ルールを削除しました",
"processRecordingRules": "ルールを再評価",
"loadingRecordings": "録画を読み込み中...",
"recordingInProgress": "録画中",
"recordingsCount": "${count} 件予約済み",
"editRule": "ルールを編集",
"editRuleAction": "編集",
"recordingRuleUpdated": "録画ルールを更新しました",
"guideReloadRequested": "ガイドの更新を要求しました",
"rulesProcessRequested": "ルールの再評価を要求しました",
"recordShow": "番組を録画"
},
"collections": {
"title": "コレクション",
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "편성표 새로고침",
"now": "지금",
"today": "오늘",
"tomorrow": "내일",
"midnight": "자정",
"overnight": "심야",
"morning": "아침",
@@ -741,7 +742,41 @@
"joinSession": "진행 중인 세션 참여",
"watchFromStart": "처음부터 시청 (${minutes}분 전 시작)",
"watchLive": "실시간 시청",
"goToLive": "실시간으로 이동"
"goToLive": "실시간으로 이동",
"record": "녹화",
"recordEpisode": "에피소드 녹화",
"recordSeries": "시리즈 녹화",
"recordOptions": "녹화 옵션",
"recordings": "녹화",
"scheduledRecordings": "예약됨",
"recordingRules": "녹화 규칙",
"noScheduledRecordings": "예약된 녹화 없음",
"noRecordingRules": "녹화 규칙 없음",
"manageRecording": "녹화 관리",
"cancelRecording": "녹화 취소",
"cancelRecordingTitle": "이 녹화를 취소할까요?",
"cancelRecordingMessage": "${title}이(가) 더 이상 녹화되지 않습니다.",
"deleteRule": "규칙 삭제",
"deleteRuleTitle": "녹화 규칙을 삭제할까요?",
"deleteRuleMessage": "${title}의 향후 에피소드는 녹화되지 않습니다.",
"recordingScheduled": "녹화 예약됨",
"alreadyScheduled": "이 프로그램은 이미 예약되어 있습니다",
"dvrAdminRequired": "DVR 설정은 관리자 계정이 필요합니다",
"recordingFailed": "녹화를 예약할 수 없습니다",
"recordingTargetMissing": "녹화 라이브러리를 확인할 수 없습니다",
"recordNotAvailable": "이 프로그램은 녹화할 수 없습니다",
"recordingCancelled": "녹화 취소됨",
"recordingRuleDeleted": "녹화 규칙 삭제됨",
"processRecordingRules": "규칙 재평가",
"loadingRecordings": "녹화 불러오는 중...",
"recordingInProgress": "녹화 중",
"recordingsCount": "${count}개 예약됨",
"editRule": "규칙 편집",
"editRuleAction": "편집",
"recordingRuleUpdated": "녹화 규칙 업데이트됨",
"guideReloadRequested": "가이드 새로고침 요청됨",
"rulesProcessRequested": "규칙 재평가 요청됨",
"recordShow": "프로그램 녹화"
},
"collections": {
"title": "컬렉션",
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "Last inn programguide på nytt",
"now": "Nå",
"today": "I dag",
"tomorrow": "I morgen",
"midnight": "Midnatt",
"overnight": "Natt",
"morning": "Morgen",
@@ -741,7 +742,41 @@
"joinSession": "Bli med i pågående økt",
"watchFromStart": "Se fra starten (${minutes} min siden)",
"watchLive": "Se direkte",
"goToLive": "Gå til direkte"
"goToLive": "Gå til direkte",
"record": "Ta opp",
"recordEpisode": "Ta opp episode",
"recordSeries": "Ta opp serie",
"recordOptions": "Opptaksvalg",
"recordings": "Opptak",
"scheduledRecordings": "Planlagt",
"recordingRules": "Opptaksregler",
"noScheduledRecordings": "Ingen planlagte opptak",
"noRecordingRules": "Ingen opptaksregler ennå",
"manageRecording": "Administrer opptak",
"cancelRecording": "Avbryt opptak",
"cancelRecordingTitle": "Avbryte dette opptaket?",
"cancelRecordingMessage": "${title} blir ikke lenger tatt opp.",
"deleteRule": "Slett regel",
"deleteRuleTitle": "Slette opptaksregel?",
"deleteRuleMessage": "Fremtidige episoder av ${title} blir ikke tatt opp.",
"recordingScheduled": "Opptak planlagt",
"alreadyScheduled": "Dette programmet er allerede planlagt",
"dvrAdminRequired": "DVR-innstillinger krever en administratorkonto",
"recordingFailed": "Kunne ikke planlegge opptak",
"recordingTargetMissing": "Kunne ikke finne opptaksbibliotek",
"recordNotAvailable": "Opptak er ikke tilgjengelig for dette programmet",
"recordingCancelled": "Opptak avbrutt",
"recordingRuleDeleted": "Opptaksregel slettet",
"processRecordingRules": "Vurder regler på nytt",
"loadingRecordings": "Laster opptak ...",
"recordingInProgress": "Tar opp nå",
"recordingsCount": "${count} planlagt",
"editRule": "Rediger regel",
"editRuleAction": "Rediger",
"recordingRuleUpdated": "Opptaksregel oppdatert",
"guideReloadRequested": "Forespurte guide-oppdatering",
"rulesProcessRequested": "Forespurte regelevaluering",
"recordShow": "Ta opp program"
},
"collections": {
"title": "Samlinger",
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "Gids herladen",
"now": "Nu",
"today": "Vandaag",
"tomorrow": "Morgen",
"midnight": "Middernacht",
"overnight": "Nacht",
"morning": "Ochtend",
@@ -741,7 +742,41 @@
"joinSession": "Deelnemen aan lopende sessie",
"watchFromStart": "Kijk vanaf het begin (${minutes} min geleden)",
"watchLive": "Live kijken",
"goToLive": "Ga naar live"
"goToLive": "Ga naar live",
"record": "Opnemen",
"recordEpisode": "Aflevering opnemen",
"recordSeries": "Serie opnemen",
"recordOptions": "Opnameopties",
"recordings": "Opnames",
"scheduledRecordings": "Gepland",
"recordingRules": "Opnameregels",
"noScheduledRecordings": "Geen geplande opnames",
"noRecordingRules": "Nog geen opnameregels",
"manageRecording": "Opname beheren",
"cancelRecording": "Opname annuleren",
"cancelRecordingTitle": "Deze opname annuleren?",
"cancelRecordingMessage": "${title} wordt niet meer opgenomen.",
"deleteRule": "Regel verwijderen",
"deleteRuleTitle": "Opnameregel verwijderen?",
"deleteRuleMessage": "Toekomstige afleveringen van ${title} worden niet opgenomen.",
"recordingScheduled": "Opname gepland",
"alreadyScheduled": "Dit programma is al gepland",
"dvrAdminRequired": "DVR-instellingen vereisen een beheerdersaccount",
"recordingFailed": "Kon opname niet plannen",
"recordingTargetMissing": "Kon opnamebibliotheek niet bepalen",
"recordNotAvailable": "Opname niet beschikbaar voor dit programma",
"recordingCancelled": "Opname geannuleerd",
"recordingRuleDeleted": "Opnameregel verwijderd",
"processRecordingRules": "Regels opnieuw evalueren",
"loadingRecordings": "Opnames laden...",
"recordingInProgress": "Nu aan het opnemen",
"recordingsCount": "${count} gepland",
"editRule": "Regel bewerken",
"editRuleAction": "Bewerken",
"recordingRuleUpdated": "Opnameregel bijgewerkt",
"guideReloadRequested": "Gids-vernieuwing aangevraagd",
"rulesProcessRequested": "Regel-herevaluatie aangevraagd",
"recordShow": "Programma opnemen"
},
"collections": {
"title": "Collecties",
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "Odśwież przewodnik",
"now": "Teraz",
"today": "Dzisiaj",
"tomorrow": "Jutro",
"midnight": "Północ",
"overnight": "Nocą",
"morning": "Rano",
@@ -741,7 +742,41 @@
"joinSession": "Dołącz do trwającej sesji",
"watchFromStart": "Oglądaj od początku (${minutes} min temu)",
"watchLive": "Oglądaj na żywo",
"goToLive": "Przejdź do na żywo"
"goToLive": "Przejdź do na żywo",
"record": "Nagraj",
"recordEpisode": "Nagraj odcinek",
"recordSeries": "Nagraj serial",
"recordOptions": "Opcje nagrywania",
"recordings": "Nagrania",
"scheduledRecordings": "Zaplanowane",
"recordingRules": "Reguły nagrywania",
"noScheduledRecordings": "Brak zaplanowanych nagrań",
"noRecordingRules": "Brak reguł nagrywania",
"manageRecording": "Zarządzaj nagraniem",
"cancelRecording": "Anuluj nagrywanie",
"cancelRecordingTitle": "Anulować to nagrywanie?",
"cancelRecordingMessage": "${title} nie będzie już nagrywane.",
"deleteRule": "Usuń regułę",
"deleteRuleTitle": "Usunąć regułę nagrywania?",
"deleteRuleMessage": "Przyszłe odcinki ${title} nie będą nagrywane.",
"recordingScheduled": "Nagrywanie zaplanowane",
"alreadyScheduled": "Ten program jest już zaplanowany",
"dvrAdminRequired": "Ustawienia DVR wymagają konta administratora",
"recordingFailed": "Nie można zaplanować nagrywania",
"recordingTargetMissing": "Nie można określić biblioteki nagrań",
"recordNotAvailable": "Nagrywanie niedostępne dla tego programu",
"recordingCancelled": "Nagrywanie anulowane",
"recordingRuleDeleted": "Reguła nagrywania usunięta",
"processRecordingRules": "Ponów ocenę reguł",
"loadingRecordings": "Wczytywanie nagrań...",
"recordingInProgress": "Trwa nagrywanie",
"recordingsCount": "${count} zaplanowanych",
"editRule": "Edytuj regułę",
"editRuleAction": "Edytuj",
"recordingRuleUpdated": "Reguła nagrywania zaktualizowana",
"guideReloadRequested": "Zażądano odświeżenia przewodnika",
"rulesProcessRequested": "Zażądano ponownej oceny reguł",
"recordShow": "Nagraj program"
},
"collections": {
"title": "Kolekcje",
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "Recarregar Guia",
"now": "Agora",
"today": "Hoje",
"tomorrow": "Amanhã",
"midnight": "Meia-noite",
"overnight": "Madrugada",
"morning": "Manhã",
@@ -741,7 +742,41 @@
"joinSession": "Entrar na sessão em andamento",
"watchFromStart": "Assistir do início (${minutes} min atrás)",
"watchLive": "Assistir ao vivo",
"goToLive": "Ir para o ao vivo"
"goToLive": "Ir para o ao vivo",
"record": "Gravar",
"recordEpisode": "Gravar episódio",
"recordSeries": "Gravar série",
"recordOptions": "Opções de gravação",
"recordings": "Gravações",
"scheduledRecordings": "Agendadas",
"recordingRules": "Regras de gravação",
"noScheduledRecordings": "Sem gravações agendadas",
"noRecordingRules": "Sem regras de gravação",
"manageRecording": "Gerenciar gravação",
"cancelRecording": "Cancelar gravação",
"cancelRecordingTitle": "Cancelar esta gravação?",
"cancelRecordingMessage": "${title} não será mais gravado.",
"deleteRule": "Excluir regra",
"deleteRuleTitle": "Excluir regra de gravação?",
"deleteRuleMessage": "Episódios futuros de ${title} não serão gravados.",
"recordingScheduled": "Gravação agendada",
"alreadyScheduled": "Este programa já está agendado",
"dvrAdminRequired": "As configurações de DVR exigem uma conta de administrador",
"recordingFailed": "Não foi possível agendar a gravação",
"recordingTargetMissing": "Não foi possível determinar a biblioteca de gravação",
"recordNotAvailable": "Gravação indisponível para este programa",
"recordingCancelled": "Gravação cancelada",
"recordingRuleDeleted": "Regra de gravação excluída",
"processRecordingRules": "Reavaliar regras",
"loadingRecordings": "Carregando gravações...",
"recordingInProgress": "Gravando agora",
"recordingsCount": "${count} agendadas",
"editRule": "Editar regra",
"editRuleAction": "Editar",
"recordingRuleUpdated": "Regra de gravação atualizada",
"guideReloadRequested": "Atualização da guia solicitada",
"rulesProcessRequested": "Reavaliação de regras solicitada",
"recordShow": "Gravar programa"
},
"collections": {
"title": "Coleções",
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "Перезагрузить программу",
"now": "Сейчас",
"today": "Сегодня",
"tomorrow": "Завтра",
"midnight": "Полночь",
"overnight": "Ночь",
"morning": "Утро",
@@ -741,7 +742,41 @@
"joinSession": "Присоединиться к текущему сеансу",
"watchFromStart": "Смотреть сначала (${minutes} мин. назад)",
"watchLive": "Смотреть в прямом эфире",
"goToLive": "К прямому эфиру"
"goToLive": "К прямому эфиру",
"record": "Запись",
"recordEpisode": "Записать эпизод",
"recordSeries": "Записать сериал",
"recordOptions": "Параметры записи",
"recordings": "Записи",
"scheduledRecordings": "Запланировано",
"recordingRules": "Правила записи",
"noScheduledRecordings": "Нет запланированных записей",
"noRecordingRules": "Правил записи пока нет",
"manageRecording": "Управление записью",
"cancelRecording": "Отменить запись",
"cancelRecordingTitle": "Отменить эту запись?",
"cancelRecordingMessage": "${title} больше не будет записываться.",
"deleteRule": "Удалить правило",
"deleteRuleTitle": "Удалить правило записи?",
"deleteRuleMessage": "Будущие эпизоды ${title} не будут записаны.",
"recordingScheduled": "Запись запланирована",
"alreadyScheduled": "Эта передача уже запланирована",
"dvrAdminRequired": "Настройки DVR требуют учётной записи администратора",
"recordingFailed": "Не удалось запланировать запись",
"recordingTargetMissing": "Не удалось определить библиотеку записи",
"recordNotAvailable": "Запись недоступна для этой передачи",
"recordingCancelled": "Запись отменена",
"recordingRuleDeleted": "Правило записи удалено",
"processRecordingRules": "Пересчитать правила",
"loadingRecordings": "Загрузка записей...",
"recordingInProgress": "Идёт запись",
"recordingsCount": "Запланировано: ${count}",
"editRule": "Изменить правило",
"editRuleAction": "Изменить",
"recordingRuleUpdated": "Правило записи обновлено",
"guideReloadRequested": "Запрошено обновление гайда",
"rulesProcessRequested": "Запрошен пересчёт правил",
"recordShow": "Записать передачу"
},
"collections": {
"title": "Коллекции",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 15
/// Strings: 16395 (1093 per locale)
/// Strings: 16920 (1128 per locale)
///
/// Built on 2026-05-01 at 02:25 UTC
/// Built on 2026-05-04 at 07:15 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvDa extends TranslationsLiveTvEn {
@override String get reloadGuide => 'Genindlæs guide';
@override String get now => 'Nu';
@override String get today => 'I dag';
@override String get tomorrow => 'I morgen';
@override String get midnight => 'Midnat';
@override String get overnight => 'Nat';
@override String get morning => 'Morgen';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvDa extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => 'Se fra start (${minutes} min siden)';
@override String get watchLive => 'Se live';
@override String get goToLive => 'Gå til live';
@override String get record => 'Optag';
@override String get recordEpisode => 'Optag episode';
@override String get recordSeries => 'Optag serie';
@override String get recordOptions => 'Optageindstillinger';
@override String get recordings => 'Optagelser';
@override String get scheduledRecordings => 'Planlagt';
@override String get recordingRules => 'Optagelsesregler';
@override String get noScheduledRecordings => 'Ingen optagelser planlagt';
@override String get noRecordingRules => 'Ingen optagelsesregler endnu';
@override String get manageRecording => 'Administrer optagelse';
@override String get cancelRecording => 'Annullér optagelse';
@override String get cancelRecordingTitle => 'Annullér denne optagelse?';
@override String cancelRecordingMessage({required Object title}) => '${title} bliver ikke længere optaget.';
@override String get deleteRule => 'Slet regel';
@override String get deleteRuleTitle => 'Slet optagelsesregel?';
@override String deleteRuleMessage({required Object title}) => 'Fremtidige episoder af ${title} bliver ikke optaget.';
@override String get recordingScheduled => 'Optagelse planlagt';
@override String get alreadyScheduled => 'Dette program er allerede planlagt';
@override String get dvrAdminRequired => 'DVR-indstillinger kræver en administratorkonto';
@override String get recordingFailed => 'Kunne ikke planlægge optagelse';
@override String get recordingTargetMissing => 'Kunne ikke bestemme optagelsesbibliotek';
@override String get recordNotAvailable => 'Optagelse er ikke tilgængelig for dette program';
@override String get recordingCancelled => 'Optagelse annulleret';
@override String get recordingRuleDeleted => 'Optagelsesregel slettet';
@override String get processRecordingRules => 'Genberegn regler';
@override String get loadingRecordings => 'Indlæser optagelser ...';
@override String get recordingInProgress => 'Optager nu';
@override String recordingsCount({required Object count}) => '${count} planlagt';
@override String get editRule => 'Rediger regel';
@override String get editRuleAction => 'Rediger';
@override String get recordingRuleUpdated => 'Optagelsesregel opdateret';
@override String get guideReloadRequested => 'Guide-opdatering anmodet';
@override String get rulesProcessRequested => 'Regelevaluering anmodet';
@override String get recordShow => 'Optag program';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsDa {
'liveTv.reloadGuide' => 'Genindlæs guide',
'liveTv.now' => 'Nu',
'liveTv.today' => 'I dag',
'liveTv.tomorrow' => 'I morgen',
'liveTv.midnight' => 'Midnat',
'liveTv.overnight' => 'Nat',
'liveTv.morning' => 'Morgen',
@@ -2386,6 +2422,40 @@ extension on TranslationsDa {
'liveTv.watchFromStart' => ({required Object minutes}) => 'Se fra start (${minutes} min siden)',
'liveTv.watchLive' => 'Se live',
'liveTv.goToLive' => 'Gå til live',
'liveTv.record' => 'Optag',
'liveTv.recordEpisode' => 'Optag episode',
'liveTv.recordSeries' => 'Optag serie',
'liveTv.recordOptions' => 'Optageindstillinger',
'liveTv.recordings' => 'Optagelser',
'liveTv.scheduledRecordings' => 'Planlagt',
'liveTv.recordingRules' => 'Optagelsesregler',
'liveTv.noScheduledRecordings' => 'Ingen optagelser planlagt',
'liveTv.noRecordingRules' => 'Ingen optagelsesregler endnu',
'liveTv.manageRecording' => 'Administrer optagelse',
'liveTv.cancelRecording' => 'Annullér optagelse',
'liveTv.cancelRecordingTitle' => 'Annullér denne optagelse?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title} bliver ikke længere optaget.',
'liveTv.deleteRule' => 'Slet regel',
'liveTv.deleteRuleTitle' => 'Slet optagelsesregel?',
'liveTv.deleteRuleMessage' => ({required Object title}) => 'Fremtidige episoder af ${title} bliver ikke optaget.',
'liveTv.recordingScheduled' => 'Optagelse planlagt',
'liveTv.alreadyScheduled' => 'Dette program er allerede planlagt',
'liveTv.dvrAdminRequired' => 'DVR-indstillinger kræver en administratorkonto',
'liveTv.recordingFailed' => 'Kunne ikke planlægge optagelse',
'liveTv.recordingTargetMissing' => 'Kunne ikke bestemme optagelsesbibliotek',
'liveTv.recordNotAvailable' => 'Optagelse er ikke tilgængelig for dette program',
'liveTv.recordingCancelled' => 'Optagelse annulleret',
'liveTv.recordingRuleDeleted' => 'Optagelsesregel slettet',
'liveTv.processRecordingRules' => 'Genberegn regler',
'liveTv.loadingRecordings' => 'Indlæser optagelser ...',
'liveTv.recordingInProgress' => 'Optager nu',
'liveTv.recordingsCount' => ({required Object count}) => '${count} planlagt',
'liveTv.editRule' => 'Rediger regel',
'liveTv.editRuleAction' => 'Rediger',
'liveTv.recordingRuleUpdated' => 'Optagelsesregel opdateret',
'liveTv.guideReloadRequested' => 'Guide-opdatering anmodet',
'liveTv.rulesProcessRequested' => 'Regelevaluering anmodet',
'liveTv.recordShow' => 'Optag program',
'collections.title' => 'Samlinger',
'collections.collection' => 'Samling',
'collections.empty' => 'Samlingen er tom',
@@ -2703,6 +2773,8 @@ extension on TranslationsDa {
'metadataEdit.useOriginalTitle' => 'Brug originaltitel',
'metadataEdit.preferredAudioLanguage' => 'Foretrukket lydsprog',
'metadataEdit.preferredSubtitleLanguage' => 'Foretrukket undertekstsprog',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => 'Auto-vælg underteksttilstand',
'metadataEdit.manuallySelected' => 'Manuelt valgt',
'metadataEdit.shownWithForeignAudio' => 'Vist med fremmedsproget lyd',
@@ -2738,8 +2810,6 @@ extension on TranslationsDa {
'trakt.connectedAs' => ({required Object username}) => 'Forbundet som @${username}',
'trakt.disconnectConfirm' => 'Frakobl Trakt-konto?',
'trakt.disconnectConfirmBody' => 'Plezy stopper med at sende afspilningsbegivenheder til Trakt. Du kan genoprette forbindelse når som helst.',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => 'Realtids-scrobbling',
'trakt.scrobbleDescription' => 'Send afspil-, pause- og stop-begivenheder til Trakt under afspilning.',
'trakt.watchedSync' => 'Synkroniser sét-status',
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvDe extends TranslationsLiveTvEn {
@override String get reloadGuide => 'Programmführer neu laden';
@override String get now => 'Jetzt';
@override String get today => 'Heute';
@override String get tomorrow => 'Morgen';
@override String get midnight => 'Mitternacht';
@override String get overnight => 'Nacht';
@override String get morning => 'Morgen';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvDe extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => 'Von Anfang an ansehen (vor ${minutes} Min.)';
@override String get watchLive => 'Live ansehen';
@override String get goToLive => 'Zum Live-Bild';
@override String get record => 'Aufnehmen';
@override String get recordEpisode => 'Episode aufnehmen';
@override String get recordSeries => 'Serie aufnehmen';
@override String get recordOptions => 'Aufnahmeoptionen';
@override String get recordings => 'Aufnahmen';
@override String get scheduledRecordings => 'Geplant';
@override String get recordingRules => 'Aufnahmeregeln';
@override String get noScheduledRecordings => 'Keine geplanten Aufnahmen';
@override String get noRecordingRules => 'Noch keine Aufnahmeregeln';
@override String get manageRecording => 'Aufnahme verwalten';
@override String get cancelRecording => 'Aufnahme abbrechen';
@override String get cancelRecordingTitle => 'Diese Aufnahme abbrechen?';
@override String cancelRecordingMessage({required Object title}) => '${title} wird nicht mehr aufgenommen.';
@override String get deleteRule => 'Regel löschen';
@override String get deleteRuleTitle => 'Aufnahmeregel löschen?';
@override String deleteRuleMessage({required Object title}) => 'Zukünftige Episoden von ${title} werden nicht aufgenommen.';
@override String get recordingScheduled => 'Aufnahme geplant';
@override String get alreadyScheduled => 'Dieses Programm ist bereits geplant';
@override String get dvrAdminRequired => 'DVR-Einstellungen erfordern ein Administratorkonto';
@override String get recordingFailed => 'Aufnahme konnte nicht geplant werden';
@override String get recordingTargetMissing => 'Aufnahmebibliothek konnte nicht ermittelt werden';
@override String get recordNotAvailable => 'Aufnahme für dieses Programm nicht verfügbar';
@override String get recordingCancelled => 'Aufnahme abgebrochen';
@override String get recordingRuleDeleted => 'Aufnahmeregel gelöscht';
@override String get processRecordingRules => 'Regeln neu bewerten';
@override String get loadingRecordings => 'Aufnahmen werden geladen ...';
@override String get recordingInProgress => 'Wird jetzt aufgenommen';
@override String recordingsCount({required Object count}) => '${count} geplant';
@override String get editRule => 'Regel bearbeiten';
@override String get editRuleAction => 'Bearbeiten';
@override String get recordingRuleUpdated => 'Aufnahmeregel aktualisiert';
@override String get guideReloadRequested => 'Guide-Aktualisierung angefordert';
@override String get rulesProcessRequested => 'Regelauswertung angefordert';
@override String get recordShow => 'Sendung aufnehmen';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsDe {
'liveTv.reloadGuide' => 'Programmführer neu laden',
'liveTv.now' => 'Jetzt',
'liveTv.today' => 'Heute',
'liveTv.tomorrow' => 'Morgen',
'liveTv.midnight' => 'Mitternacht',
'liveTv.overnight' => 'Nacht',
'liveTv.morning' => 'Morgen',
@@ -2386,6 +2422,40 @@ extension on TranslationsDe {
'liveTv.watchFromStart' => ({required Object minutes}) => 'Von Anfang an ansehen (vor ${minutes} Min.)',
'liveTv.watchLive' => 'Live ansehen',
'liveTv.goToLive' => 'Zum Live-Bild',
'liveTv.record' => 'Aufnehmen',
'liveTv.recordEpisode' => 'Episode aufnehmen',
'liveTv.recordSeries' => 'Serie aufnehmen',
'liveTv.recordOptions' => 'Aufnahmeoptionen',
'liveTv.recordings' => 'Aufnahmen',
'liveTv.scheduledRecordings' => 'Geplant',
'liveTv.recordingRules' => 'Aufnahmeregeln',
'liveTv.noScheduledRecordings' => 'Keine geplanten Aufnahmen',
'liveTv.noRecordingRules' => 'Noch keine Aufnahmeregeln',
'liveTv.manageRecording' => 'Aufnahme verwalten',
'liveTv.cancelRecording' => 'Aufnahme abbrechen',
'liveTv.cancelRecordingTitle' => 'Diese Aufnahme abbrechen?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title} wird nicht mehr aufgenommen.',
'liveTv.deleteRule' => 'Regel löschen',
'liveTv.deleteRuleTitle' => 'Aufnahmeregel löschen?',
'liveTv.deleteRuleMessage' => ({required Object title}) => 'Zukünftige Episoden von ${title} werden nicht aufgenommen.',
'liveTv.recordingScheduled' => 'Aufnahme geplant',
'liveTv.alreadyScheduled' => 'Dieses Programm ist bereits geplant',
'liveTv.dvrAdminRequired' => 'DVR-Einstellungen erfordern ein Administratorkonto',
'liveTv.recordingFailed' => 'Aufnahme konnte nicht geplant werden',
'liveTv.recordingTargetMissing' => 'Aufnahmebibliothek konnte nicht ermittelt werden',
'liveTv.recordNotAvailable' => 'Aufnahme für dieses Programm nicht verfügbar',
'liveTv.recordingCancelled' => 'Aufnahme abgebrochen',
'liveTv.recordingRuleDeleted' => 'Aufnahmeregel gelöscht',
'liveTv.processRecordingRules' => 'Regeln neu bewerten',
'liveTv.loadingRecordings' => 'Aufnahmen werden geladen ...',
'liveTv.recordingInProgress' => 'Wird jetzt aufgenommen',
'liveTv.recordingsCount' => ({required Object count}) => '${count} geplant',
'liveTv.editRule' => 'Regel bearbeiten',
'liveTv.editRuleAction' => 'Bearbeiten',
'liveTv.recordingRuleUpdated' => 'Aufnahmeregel aktualisiert',
'liveTv.guideReloadRequested' => 'Guide-Aktualisierung angefordert',
'liveTv.rulesProcessRequested' => 'Regelauswertung angefordert',
'liveTv.recordShow' => 'Sendung aufnehmen',
'collections.title' => 'Sammlungen',
'collections.collection' => 'Sammlung',
'collections.empty' => 'Sammlung ist leer',
@@ -2703,6 +2773,8 @@ extension on TranslationsDe {
'metadataEdit.useOriginalTitle' => 'Originaltitel verwenden',
'metadataEdit.preferredAudioLanguage' => 'Bevorzugte Audiosprache',
'metadataEdit.preferredSubtitleLanguage' => 'Bevorzugte Untertitelsprache',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => 'Automatische Untertitelauswahl',
'metadataEdit.manuallySelected' => 'Manuell ausgewählt',
'metadataEdit.shownWithForeignAudio' => 'Bei fremdsprachigem Audio anzeigen',
@@ -2738,8 +2810,6 @@ extension on TranslationsDe {
'trakt.connectedAs' => ({required Object username}) => 'Verbunden als @${username}',
'trakt.disconnectConfirm' => 'Trakt-Konto trennen?',
'trakt.disconnectConfirmBody' => 'Plezy sendet keine Wiedergabe-Ereignisse mehr an Trakt. Du kannst dich jederzeit erneut verbinden.',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => 'Echtzeit-Scrobbling',
'trakt.scrobbleDescription' => 'Sende Play-, Pause- und Stopp-Ereignisse während der Wiedergabe an Trakt.',
'trakt.watchedSync' => 'Gesehen-Status synchronisieren',
+142 -2
View File
@@ -2182,6 +2182,9 @@ class TranslationsLiveTvEn {
/// en: 'Today'
String get today => 'Today';
/// en: 'Tomorrow'
String get tomorrow => 'Tomorrow';
/// en: 'Midnight'
String get midnight => 'Midnight';
@@ -2223,6 +2226,108 @@ class TranslationsLiveTvEn {
/// en: 'Go to Live'
String get goToLive => 'Go to Live';
/// en: 'Record'
String get record => 'Record';
/// en: 'Record Episode'
String get recordEpisode => 'Record Episode';
/// en: 'Record Series'
String get recordSeries => 'Record Series';
/// en: 'Record Options'
String get recordOptions => 'Record Options';
/// en: 'Recordings'
String get recordings => 'Recordings';
/// en: 'Scheduled'
String get scheduledRecordings => 'Scheduled';
/// en: 'Recording Rules'
String get recordingRules => 'Recording Rules';
/// en: 'Nothing scheduled to record'
String get noScheduledRecordings => 'Nothing scheduled to record';
/// en: 'No recording rules yet'
String get noRecordingRules => 'No recording rules yet';
/// en: 'Manage recording'
String get manageRecording => 'Manage recording';
/// en: 'Cancel recording'
String get cancelRecording => 'Cancel recording';
/// en: 'Cancel this recording?'
String get cancelRecordingTitle => 'Cancel this recording?';
/// en: '${title} will no longer be recorded.'
String cancelRecordingMessage({required Object title}) => '${title} will no longer be recorded.';
/// en: 'Delete rule'
String get deleteRule => 'Delete rule';
/// en: 'Delete recording rule?'
String get deleteRuleTitle => 'Delete recording rule?';
/// en: 'Future episodes of ${title} will not be recorded.'
String deleteRuleMessage({required Object title}) => 'Future episodes of ${title} will not be recorded.';
/// en: 'Recording scheduled'
String get recordingScheduled => 'Recording scheduled';
/// en: 'This program is already scheduled'
String get alreadyScheduled => 'This program is already scheduled';
/// en: 'DVR settings require an admin account'
String get dvrAdminRequired => 'DVR settings require an admin account';
/// en: 'Couldn't schedule recording'
String get recordingFailed => 'Couldn\'t schedule recording';
/// en: 'Couldn't determine recording library'
String get recordingTargetMissing => 'Couldn\'t determine recording library';
/// en: 'Recording not available for this program'
String get recordNotAvailable => 'Recording not available for this program';
/// en: 'Recording cancelled'
String get recordingCancelled => 'Recording cancelled';
/// en: 'Recording rule deleted'
String get recordingRuleDeleted => 'Recording rule deleted';
/// en: 'Re-evaluate rules'
String get processRecordingRules => 'Re-evaluate rules';
/// en: 'Loading recordings...'
String get loadingRecordings => 'Loading recordings...';
/// en: 'Recording now'
String get recordingInProgress => 'Recording now';
/// en: '${count} scheduled'
String recordingsCount({required Object count}) => '${count} scheduled';
/// en: 'Edit rule'
String get editRule => 'Edit rule';
/// en: 'Edit'
String get editRuleAction => 'Edit';
/// en: 'Recording rule updated'
String get recordingRuleUpdated => 'Recording rule updated';
/// en: 'Guide refresh requested'
String get guideReloadRequested => 'Guide refresh requested';
/// en: 'Rule re-evaluation requested'
String get rulesProcessRequested => 'Rule re-evaluation requested';
/// en: 'Record show'
String get recordShow => 'Record show';
}
// Path: collections
@@ -4564,6 +4669,7 @@ extension on Translations {
'liveTv.reloadGuide' => 'Reload Guide',
'liveTv.now' => 'Now',
'liveTv.today' => 'Today',
'liveTv.tomorrow' => 'Tomorrow',
'liveTv.midnight' => 'Midnight',
'liveTv.overnight' => 'Overnight',
'liveTv.morning' => 'Morning',
@@ -4578,6 +4684,40 @@ extension on Translations {
'liveTv.watchFromStart' => ({required Object minutes}) => 'Watch from start (${minutes} min ago)',
'liveTv.watchLive' => 'Watch Live',
'liveTv.goToLive' => 'Go to Live',
'liveTv.record' => 'Record',
'liveTv.recordEpisode' => 'Record Episode',
'liveTv.recordSeries' => 'Record Series',
'liveTv.recordOptions' => 'Record Options',
'liveTv.recordings' => 'Recordings',
'liveTv.scheduledRecordings' => 'Scheduled',
'liveTv.recordingRules' => 'Recording Rules',
'liveTv.noScheduledRecordings' => 'Nothing scheduled to record',
'liveTv.noRecordingRules' => 'No recording rules yet',
'liveTv.manageRecording' => 'Manage recording',
'liveTv.cancelRecording' => 'Cancel recording',
'liveTv.cancelRecordingTitle' => 'Cancel this recording?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title} will no longer be recorded.',
'liveTv.deleteRule' => 'Delete rule',
'liveTv.deleteRuleTitle' => 'Delete recording rule?',
'liveTv.deleteRuleMessage' => ({required Object title}) => 'Future episodes of ${title} will not be recorded.',
'liveTv.recordingScheduled' => 'Recording scheduled',
'liveTv.alreadyScheduled' => 'This program is already scheduled',
'liveTv.dvrAdminRequired' => 'DVR settings require an admin account',
'liveTv.recordingFailed' => 'Couldn\'t schedule recording',
'liveTv.recordingTargetMissing' => 'Couldn\'t determine recording library',
'liveTv.recordNotAvailable' => 'Recording not available for this program',
'liveTv.recordingCancelled' => 'Recording cancelled',
'liveTv.recordingRuleDeleted' => 'Recording rule deleted',
'liveTv.processRecordingRules' => 'Re-evaluate rules',
'liveTv.loadingRecordings' => 'Loading recordings...',
'liveTv.recordingInProgress' => 'Recording now',
'liveTv.recordingsCount' => ({required Object count}) => '${count} scheduled',
'liveTv.editRule' => 'Edit rule',
'liveTv.editRuleAction' => 'Edit',
'liveTv.recordingRuleUpdated' => 'Recording rule updated',
'liveTv.guideReloadRequested' => 'Guide refresh requested',
'liveTv.rulesProcessRequested' => 'Rule re-evaluation requested',
'liveTv.recordShow' => 'Record show',
'collections.title' => 'Collections',
'collections.collection' => 'Collection',
'collections.empty' => 'Collection is empty',
@@ -4895,6 +5035,8 @@ extension on Translations {
'metadataEdit.useOriginalTitle' => 'Use Original Title',
'metadataEdit.preferredAudioLanguage' => 'Preferred Audio Language',
'metadataEdit.preferredSubtitleLanguage' => 'Preferred Subtitle Language',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => 'Auto-Select Subtitle Mode',
'metadataEdit.manuallySelected' => 'Manually selected',
'metadataEdit.shownWithForeignAudio' => 'Shown with foreign audio',
@@ -4930,8 +5072,6 @@ extension on Translations {
'trakt.connectedAs' => ({required Object username}) => 'Connected as @${username}',
'trakt.disconnectConfirm' => 'Disconnect Trakt account?',
'trakt.disconnectConfirmBody' => 'Plezy will stop sending playback events to Trakt. You can reconnect at any time.',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => 'Real-time scrobbling',
'trakt.scrobbleDescription' => 'Send play, pause, and stop events to Trakt during playback.',
'trakt.watchedSync' => 'Sync watched status',
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvEs extends TranslationsLiveTvEn {
@override String get reloadGuide => 'Recargar guía';
@override String get now => 'Ahora';
@override String get today => 'Hoy';
@override String get tomorrow => 'Mañana';
@override String get midnight => 'Medianoche';
@override String get overnight => 'Madrugada';
@override String get morning => 'Mañana';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvEs extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => 'Ver desde el inicio (hace ${minutes} min)';
@override String get watchLive => 'Ver en vivo';
@override String get goToLive => 'Ir a en vivo';
@override String get record => 'Grabar';
@override String get recordEpisode => 'Grabar episodio';
@override String get recordSeries => 'Grabar serie';
@override String get recordOptions => 'Opciones de grabación';
@override String get recordings => 'Grabaciones';
@override String get scheduledRecordings => 'Programadas';
@override String get recordingRules => 'Reglas de grabación';
@override String get noScheduledRecordings => 'No hay grabaciones programadas';
@override String get noRecordingRules => 'Aún no hay reglas de grabación';
@override String get manageRecording => 'Gestionar grabación';
@override String get cancelRecording => 'Cancelar grabación';
@override String get cancelRecordingTitle => '¿Cancelar esta grabación?';
@override String cancelRecordingMessage({required Object title}) => '${title} ya no se grabará.';
@override String get deleteRule => 'Eliminar regla';
@override String get deleteRuleTitle => '¿Eliminar regla de grabación?';
@override String deleteRuleMessage({required Object title}) => 'Los próximos episodios de ${title} no se grabarán.';
@override String get recordingScheduled => 'Grabación programada';
@override String get alreadyScheduled => 'Este programa ya está programado';
@override String get dvrAdminRequired => 'La configuración DVR requiere una cuenta de administrador';
@override String get recordingFailed => 'No se pudo programar la grabación';
@override String get recordingTargetMissing => 'No se pudo determinar la biblioteca de grabación';
@override String get recordNotAvailable => 'Grabación no disponible para este programa';
@override String get recordingCancelled => 'Grabación cancelada';
@override String get recordingRuleDeleted => 'Regla de grabación eliminada';
@override String get processRecordingRules => 'Reevaluar reglas';
@override String get loadingRecordings => 'Cargando grabaciones...';
@override String get recordingInProgress => 'Grabando ahora';
@override String recordingsCount({required Object count}) => '${count} programadas';
@override String get editRule => 'Editar regla';
@override String get editRuleAction => 'Editar';
@override String get recordingRuleUpdated => 'Regla de grabación actualizada';
@override String get guideReloadRequested => 'Solicitada actualización de la guía';
@override String get rulesProcessRequested => 'Solicitada reevaluación de reglas';
@override String get recordShow => 'Grabar programa';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsEs {
'liveTv.reloadGuide' => 'Recargar guía',
'liveTv.now' => 'Ahora',
'liveTv.today' => 'Hoy',
'liveTv.tomorrow' => 'Mañana',
'liveTv.midnight' => 'Medianoche',
'liveTv.overnight' => 'Madrugada',
'liveTv.morning' => 'Mañana',
@@ -2386,6 +2422,40 @@ extension on TranslationsEs {
'liveTv.watchFromStart' => ({required Object minutes}) => 'Ver desde el inicio (hace ${minutes} min)',
'liveTv.watchLive' => 'Ver en vivo',
'liveTv.goToLive' => 'Ir a en vivo',
'liveTv.record' => 'Grabar',
'liveTv.recordEpisode' => 'Grabar episodio',
'liveTv.recordSeries' => 'Grabar serie',
'liveTv.recordOptions' => 'Opciones de grabación',
'liveTv.recordings' => 'Grabaciones',
'liveTv.scheduledRecordings' => 'Programadas',
'liveTv.recordingRules' => 'Reglas de grabación',
'liveTv.noScheduledRecordings' => 'No hay grabaciones programadas',
'liveTv.noRecordingRules' => 'Aún no hay reglas de grabación',
'liveTv.manageRecording' => 'Gestionar grabación',
'liveTv.cancelRecording' => 'Cancelar grabación',
'liveTv.cancelRecordingTitle' => '¿Cancelar esta grabación?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title} ya no se grabará.',
'liveTv.deleteRule' => 'Eliminar regla',
'liveTv.deleteRuleTitle' => '¿Eliminar regla de grabación?',
'liveTv.deleteRuleMessage' => ({required Object title}) => 'Los próximos episodios de ${title} no se grabarán.',
'liveTv.recordingScheduled' => 'Grabación programada',
'liveTv.alreadyScheduled' => 'Este programa ya está programado',
'liveTv.dvrAdminRequired' => 'La configuración DVR requiere una cuenta de administrador',
'liveTv.recordingFailed' => 'No se pudo programar la grabación',
'liveTv.recordingTargetMissing' => 'No se pudo determinar la biblioteca de grabación',
'liveTv.recordNotAvailable' => 'Grabación no disponible para este programa',
'liveTv.recordingCancelled' => 'Grabación cancelada',
'liveTv.recordingRuleDeleted' => 'Regla de grabación eliminada',
'liveTv.processRecordingRules' => 'Reevaluar reglas',
'liveTv.loadingRecordings' => 'Cargando grabaciones...',
'liveTv.recordingInProgress' => 'Grabando ahora',
'liveTv.recordingsCount' => ({required Object count}) => '${count} programadas',
'liveTv.editRule' => 'Editar regla',
'liveTv.editRuleAction' => 'Editar',
'liveTv.recordingRuleUpdated' => 'Regla de grabación actualizada',
'liveTv.guideReloadRequested' => 'Solicitada actualización de la guía',
'liveTv.rulesProcessRequested' => 'Solicitada reevaluación de reglas',
'liveTv.recordShow' => 'Grabar programa',
'collections.title' => 'Colecciones',
'collections.collection' => 'Colección',
'collections.empty' => 'La colección está vacía',
@@ -2703,6 +2773,8 @@ extension on TranslationsEs {
'metadataEdit.useOriginalTitle' => 'Usar título original',
'metadataEdit.preferredAudioLanguage' => 'Idioma de audio preferido',
'metadataEdit.preferredSubtitleLanguage' => 'Idioma de subtítulos preferido',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => 'Selección automática de subtítulos',
'metadataEdit.manuallySelected' => 'Seleccionado manualmente',
'metadataEdit.shownWithForeignAudio' => 'Mostrar con audio extranjero',
@@ -2738,8 +2810,6 @@ extension on TranslationsEs {
'trakt.connectedAs' => ({required Object username}) => 'Conectado como @${username}',
'trakt.disconnectConfirm' => '¿Desconectar cuenta de Trakt?',
'trakt.disconnectConfirmBody' => 'Plezy dejará de enviar eventos de reproducción a Trakt. Puedes volver a conectar cuando quieras.',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => 'Scrobbling en tiempo real',
'trakt.scrobbleDescription' => 'Enviar eventos de reproducción, pausa y parada a Trakt durante la reproducción.',
'trakt.watchedSync' => 'Sincronizar estado de visto',
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvFr extends TranslationsLiveTvEn {
@override String get reloadGuide => 'Recharger le guide';
@override String get now => 'Maintenant';
@override String get today => 'Aujourd\'hui';
@override String get tomorrow => 'Demain';
@override String get midnight => 'Minuit';
@override String get overnight => 'Nuit';
@override String get morning => 'Matin';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvFr extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => 'Regarder depuis le début (il y a ${minutes} min)';
@override String get watchLive => 'Regarder en direct';
@override String get goToLive => 'Aller au direct';
@override String get record => 'Enregistrer';
@override String get recordEpisode => 'Enregistrer l\'épisode';
@override String get recordSeries => 'Enregistrer la série';
@override String get recordOptions => 'Options d\'enregistrement';
@override String get recordings => 'Enregistrements';
@override String get scheduledRecordings => 'Programmés';
@override String get recordingRules => 'Règles d\'enregistrement';
@override String get noScheduledRecordings => 'Aucun enregistrement programmé';
@override String get noRecordingRules => 'Aucune règle d\'enregistrement';
@override String get manageRecording => 'Gérer l\'enregistrement';
@override String get cancelRecording => 'Annuler l\'enregistrement';
@override String get cancelRecordingTitle => 'Annuler cet enregistrement ?';
@override String cancelRecordingMessage({required Object title}) => '${title} ne sera plus enregistré.';
@override String get deleteRule => 'Supprimer la règle';
@override String get deleteRuleTitle => 'Supprimer la règle d\'enregistrement ?';
@override String deleteRuleMessage({required Object title}) => 'Les prochains épisodes de ${title} ne seront pas enregistrés.';
@override String get recordingScheduled => 'Enregistrement programmé';
@override String get alreadyScheduled => 'Ce programme est déjà programmé';
@override String get dvrAdminRequired => 'Les paramètres DVR nécessitent un compte administrateur';
@override String get recordingFailed => 'Impossible de programmer l\'enregistrement';
@override String get recordingTargetMissing => 'Impossible de déterminer la bibliothèque d\'enregistrement';
@override String get recordNotAvailable => 'Enregistrement non disponible pour ce programme';
@override String get recordingCancelled => 'Enregistrement annulé';
@override String get recordingRuleDeleted => 'Règle d\'enregistrement supprimée';
@override String get processRecordingRules => 'Réévaluer les règles';
@override String get loadingRecordings => 'Chargement des enregistrements...';
@override String get recordingInProgress => 'Enregistrement en cours';
@override String recordingsCount({required Object count}) => '${count} programmés';
@override String get editRule => 'Modifier la règle';
@override String get editRuleAction => 'Modifier';
@override String get recordingRuleUpdated => 'Règle d\'enregistrement mise à jour';
@override String get guideReloadRequested => 'Mise à jour du guide demandée';
@override String get rulesProcessRequested => 'Réévaluation des règles demandée';
@override String get recordShow => 'Enregistrer l\'émission';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsFr {
'liveTv.reloadGuide' => 'Recharger le guide',
'liveTv.now' => 'Maintenant',
'liveTv.today' => 'Aujourd\'hui',
'liveTv.tomorrow' => 'Demain',
'liveTv.midnight' => 'Minuit',
'liveTv.overnight' => 'Nuit',
'liveTv.morning' => 'Matin',
@@ -2386,6 +2422,40 @@ extension on TranslationsFr {
'liveTv.watchFromStart' => ({required Object minutes}) => 'Regarder depuis le début (il y a ${minutes} min)',
'liveTv.watchLive' => 'Regarder en direct',
'liveTv.goToLive' => 'Aller au direct',
'liveTv.record' => 'Enregistrer',
'liveTv.recordEpisode' => 'Enregistrer l\'épisode',
'liveTv.recordSeries' => 'Enregistrer la série',
'liveTv.recordOptions' => 'Options d\'enregistrement',
'liveTv.recordings' => 'Enregistrements',
'liveTv.scheduledRecordings' => 'Programmés',
'liveTv.recordingRules' => 'Règles d\'enregistrement',
'liveTv.noScheduledRecordings' => 'Aucun enregistrement programmé',
'liveTv.noRecordingRules' => 'Aucune règle d\'enregistrement',
'liveTv.manageRecording' => 'Gérer l\'enregistrement',
'liveTv.cancelRecording' => 'Annuler l\'enregistrement',
'liveTv.cancelRecordingTitle' => 'Annuler cet enregistrement ?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title} ne sera plus enregistré.',
'liveTv.deleteRule' => 'Supprimer la règle',
'liveTv.deleteRuleTitle' => 'Supprimer la règle d\'enregistrement ?',
'liveTv.deleteRuleMessage' => ({required Object title}) => 'Les prochains épisodes de ${title} ne seront pas enregistrés.',
'liveTv.recordingScheduled' => 'Enregistrement programmé',
'liveTv.alreadyScheduled' => 'Ce programme est déjà programmé',
'liveTv.dvrAdminRequired' => 'Les paramètres DVR nécessitent un compte administrateur',
'liveTv.recordingFailed' => 'Impossible de programmer l\'enregistrement',
'liveTv.recordingTargetMissing' => 'Impossible de déterminer la bibliothèque d\'enregistrement',
'liveTv.recordNotAvailable' => 'Enregistrement non disponible pour ce programme',
'liveTv.recordingCancelled' => 'Enregistrement annulé',
'liveTv.recordingRuleDeleted' => 'Règle d\'enregistrement supprimée',
'liveTv.processRecordingRules' => 'Réévaluer les règles',
'liveTv.loadingRecordings' => 'Chargement des enregistrements...',
'liveTv.recordingInProgress' => 'Enregistrement en cours',
'liveTv.recordingsCount' => ({required Object count}) => '${count} programmés',
'liveTv.editRule' => 'Modifier la règle',
'liveTv.editRuleAction' => 'Modifier',
'liveTv.recordingRuleUpdated' => 'Règle d\'enregistrement mise à jour',
'liveTv.guideReloadRequested' => 'Mise à jour du guide demandée',
'liveTv.rulesProcessRequested' => 'Réévaluation des règles demandée',
'liveTv.recordShow' => 'Enregistrer l\'émission',
'collections.title' => 'Collections',
'collections.collection' => 'Collection',
'collections.empty' => 'La collection est vide',
@@ -2703,6 +2773,8 @@ extension on TranslationsFr {
'metadataEdit.useOriginalTitle' => 'Utiliser le titre original',
'metadataEdit.preferredAudioLanguage' => 'Langue audio préférée',
'metadataEdit.preferredSubtitleLanguage' => 'Langue de sous-titres préférée',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => 'Sélection automatique des sous-titres',
'metadataEdit.manuallySelected' => 'Sélectionné manuellement',
'metadataEdit.shownWithForeignAudio' => 'Affichés avec audio étranger',
@@ -2738,8 +2810,6 @@ extension on TranslationsFr {
'trakt.connectedAs' => ({required Object username}) => 'Connecté en tant que @${username}',
'trakt.disconnectConfirm' => 'Déconnecter le compte Trakt ?',
'trakt.disconnectConfirmBody' => 'Plezy n\'enverra plus d\'événements de lecture à Trakt. Vous pouvez vous reconnecter à tout moment.',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => 'Scrobbling en temps réel',
'trakt.scrobbleDescription' => 'Envoyer les événements de lecture, pause et arrêt à Trakt pendant la lecture.',
'trakt.watchedSync' => 'Synchroniser le statut « vu »',
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvIt extends TranslationsLiveTvEn {
@override String get reloadGuide => 'Ricarica guida';
@override String get now => 'Ora';
@override String get today => 'Oggi';
@override String get tomorrow => 'Domani';
@override String get midnight => 'Mezzanotte';
@override String get overnight => 'Notte';
@override String get morning => 'Mattina';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvIt extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => 'Guarda dall\'inizio (${minutes} min fa)';
@override String get watchLive => 'Guarda in diretta';
@override String get goToLive => 'Vai al live';
@override String get record => 'Registra';
@override String get recordEpisode => 'Registra episodio';
@override String get recordSeries => 'Registra serie';
@override String get recordOptions => 'Opzioni di registrazione';
@override String get recordings => 'Registrazioni';
@override String get scheduledRecordings => 'Programmate';
@override String get recordingRules => 'Regole di registrazione';
@override String get noScheduledRecordings => 'Nessuna registrazione programmata';
@override String get noRecordingRules => 'Nessuna regola di registrazione';
@override String get manageRecording => 'Gestisci registrazione';
@override String get cancelRecording => 'Annulla registrazione';
@override String get cancelRecordingTitle => 'Annullare questa registrazione?';
@override String cancelRecordingMessage({required Object title}) => '${title} non sarà più registrato.';
@override String get deleteRule => 'Elimina regola';
@override String get deleteRuleTitle => 'Eliminare la regola di registrazione?';
@override String deleteRuleMessage({required Object title}) => 'I prossimi episodi di ${title} non saranno registrati.';
@override String get recordingScheduled => 'Registrazione programmata';
@override String get alreadyScheduled => 'Questo programma è già programmato';
@override String get dvrAdminRequired => 'Le impostazioni DVR richiedono un account amministratore';
@override String get recordingFailed => 'Impossibile programmare la registrazione';
@override String get recordingTargetMissing => 'Impossibile determinare la libreria di registrazione';
@override String get recordNotAvailable => 'Registrazione non disponibile per questo programma';
@override String get recordingCancelled => 'Registrazione annullata';
@override String get recordingRuleDeleted => 'Regola di registrazione eliminata';
@override String get processRecordingRules => 'Rivaluta regole';
@override String get loadingRecordings => 'Caricamento registrazioni...';
@override String get recordingInProgress => 'Registrazione in corso';
@override String recordingsCount({required Object count}) => '${count} programmate';
@override String get editRule => 'Modifica regola';
@override String get editRuleAction => 'Modifica';
@override String get recordingRuleUpdated => 'Regola di registrazione aggiornata';
@override String get guideReloadRequested => 'Aggiornamento guida richiesto';
@override String get rulesProcessRequested => 'Rivalutazione regole richiesta';
@override String get recordShow => 'Registra programma';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsIt {
'liveTv.reloadGuide' => 'Ricarica guida',
'liveTv.now' => 'Ora',
'liveTv.today' => 'Oggi',
'liveTv.tomorrow' => 'Domani',
'liveTv.midnight' => 'Mezzanotte',
'liveTv.overnight' => 'Notte',
'liveTv.morning' => 'Mattina',
@@ -2386,6 +2422,40 @@ extension on TranslationsIt {
'liveTv.watchFromStart' => ({required Object minutes}) => 'Guarda dall\'inizio (${minutes} min fa)',
'liveTv.watchLive' => 'Guarda in diretta',
'liveTv.goToLive' => 'Vai al live',
'liveTv.record' => 'Registra',
'liveTv.recordEpisode' => 'Registra episodio',
'liveTv.recordSeries' => 'Registra serie',
'liveTv.recordOptions' => 'Opzioni di registrazione',
'liveTv.recordings' => 'Registrazioni',
'liveTv.scheduledRecordings' => 'Programmate',
'liveTv.recordingRules' => 'Regole di registrazione',
'liveTv.noScheduledRecordings' => 'Nessuna registrazione programmata',
'liveTv.noRecordingRules' => 'Nessuna regola di registrazione',
'liveTv.manageRecording' => 'Gestisci registrazione',
'liveTv.cancelRecording' => 'Annulla registrazione',
'liveTv.cancelRecordingTitle' => 'Annullare questa registrazione?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title} non sarà più registrato.',
'liveTv.deleteRule' => 'Elimina regola',
'liveTv.deleteRuleTitle' => 'Eliminare la regola di registrazione?',
'liveTv.deleteRuleMessage' => ({required Object title}) => 'I prossimi episodi di ${title} non saranno registrati.',
'liveTv.recordingScheduled' => 'Registrazione programmata',
'liveTv.alreadyScheduled' => 'Questo programma è già programmato',
'liveTv.dvrAdminRequired' => 'Le impostazioni DVR richiedono un account amministratore',
'liveTv.recordingFailed' => 'Impossibile programmare la registrazione',
'liveTv.recordingTargetMissing' => 'Impossibile determinare la libreria di registrazione',
'liveTv.recordNotAvailable' => 'Registrazione non disponibile per questo programma',
'liveTv.recordingCancelled' => 'Registrazione annullata',
'liveTv.recordingRuleDeleted' => 'Regola di registrazione eliminata',
'liveTv.processRecordingRules' => 'Rivaluta regole',
'liveTv.loadingRecordings' => 'Caricamento registrazioni...',
'liveTv.recordingInProgress' => 'Registrazione in corso',
'liveTv.recordingsCount' => ({required Object count}) => '${count} programmate',
'liveTv.editRule' => 'Modifica regola',
'liveTv.editRuleAction' => 'Modifica',
'liveTv.recordingRuleUpdated' => 'Regola di registrazione aggiornata',
'liveTv.guideReloadRequested' => 'Aggiornamento guida richiesto',
'liveTv.rulesProcessRequested' => 'Rivalutazione regole richiesta',
'liveTv.recordShow' => 'Registra programma',
'collections.title' => 'Raccolte',
'collections.collection' => 'Raccolta',
'collections.empty' => 'La raccolta è vuota',
@@ -2703,6 +2773,8 @@ extension on TranslationsIt {
'metadataEdit.useOriginalTitle' => 'Usa titolo originale',
'metadataEdit.preferredAudioLanguage' => 'Lingua audio preferita',
'metadataEdit.preferredSubtitleLanguage' => 'Lingua sottotitoli preferita',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => 'Selezione automatica sottotitoli',
'metadataEdit.manuallySelected' => 'Selezionato manualmente',
'metadataEdit.shownWithForeignAudio' => 'Mostrati con audio straniero',
@@ -2738,8 +2810,6 @@ extension on TranslationsIt {
'trakt.connectedAs' => ({required Object username}) => 'Connesso come @${username}',
'trakt.disconnectConfirm' => 'Disconnettere l\'account Trakt?',
'trakt.disconnectConfirmBody' => 'Plezy smetterà di inviare eventi di riproduzione a Trakt. Puoi riconnetterti in qualsiasi momento.',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => 'Scrobbling in tempo reale',
'trakt.scrobbleDescription' => 'Invia eventi di riproduzione, pausa e arresto a Trakt durante la riproduzione.',
'trakt.watchedSync' => 'Sincronizza stato visualizzato',
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvJa extends TranslationsLiveTvEn {
@override String get reloadGuide => '番組表を再読込';
@override String get now => '現在';
@override String get today => '今日';
@override String get tomorrow => '明日';
@override String get midnight => '深夜';
@override String get overnight => '深夜';
@override String get morning => '';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvJa extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => '最初から視聴(${minutes}分前に開始)';
@override String get watchLive => 'ライブで視聴';
@override String get goToLive => 'ライブに移動';
@override String get record => '録画';
@override String get recordEpisode => 'このエピソードを録画';
@override String get recordSeries => 'シリーズを録画';
@override String get recordOptions => '録画オプション';
@override String get recordings => '録画';
@override String get scheduledRecordings => '予約';
@override String get recordingRules => '録画ルール';
@override String get noScheduledRecordings => '予約された録画はありません';
@override String get noRecordingRules => '録画ルールはまだありません';
@override String get manageRecording => '録画を管理';
@override String get cancelRecording => '録画をキャンセル';
@override String get cancelRecordingTitle => 'この録画をキャンセルしますか?';
@override String cancelRecordingMessage({required Object title}) => '${title} は録画されなくなります。';
@override String get deleteRule => 'ルールを削除';
@override String get deleteRuleTitle => '録画ルールを削除しますか?';
@override String deleteRuleMessage({required Object title}) => '${title} の今後のエピソードは録画されません。';
@override String get recordingScheduled => '録画を予約しました';
@override String get alreadyScheduled => 'この番組はすでに予約されています';
@override String get dvrAdminRequired => 'DVR 設定には管理者アカウントが必要です';
@override String get recordingFailed => '録画を予約できませんでした';
@override String get recordingTargetMissing => '録画ライブラリを特定できませんでした';
@override String get recordNotAvailable => 'この番組は録画できません';
@override String get recordingCancelled => '録画をキャンセルしました';
@override String get recordingRuleDeleted => '録画ルールを削除しました';
@override String get processRecordingRules => 'ルールを再評価';
@override String get loadingRecordings => '録画を読み込み中...';
@override String get recordingInProgress => '録画中';
@override String recordingsCount({required Object count}) => '${count} 件予約済み';
@override String get editRule => 'ルールを編集';
@override String get editRuleAction => '編集';
@override String get recordingRuleUpdated => '録画ルールを更新しました';
@override String get guideReloadRequested => 'ガイドの更新を要求しました';
@override String get rulesProcessRequested => 'ルールの再評価を要求しました';
@override String get recordShow => '番組を録画';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsJa {
'liveTv.reloadGuide' => '番組表を再読込',
'liveTv.now' => '現在',
'liveTv.today' => '今日',
'liveTv.tomorrow' => '明日',
'liveTv.midnight' => '深夜',
'liveTv.overnight' => '深夜',
'liveTv.morning' => '',
@@ -2386,6 +2422,40 @@ extension on TranslationsJa {
'liveTv.watchFromStart' => ({required Object minutes}) => '最初から視聴(${minutes}分前に開始)',
'liveTv.watchLive' => 'ライブで視聴',
'liveTv.goToLive' => 'ライブに移動',
'liveTv.record' => '録画',
'liveTv.recordEpisode' => 'このエピソードを録画',
'liveTv.recordSeries' => 'シリーズを録画',
'liveTv.recordOptions' => '録画オプション',
'liveTv.recordings' => '録画',
'liveTv.scheduledRecordings' => '予約',
'liveTv.recordingRules' => '録画ルール',
'liveTv.noScheduledRecordings' => '予約された録画はありません',
'liveTv.noRecordingRules' => '録画ルールはまだありません',
'liveTv.manageRecording' => '録画を管理',
'liveTv.cancelRecording' => '録画をキャンセル',
'liveTv.cancelRecordingTitle' => 'この録画をキャンセルしますか?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title} は録画されなくなります。',
'liveTv.deleteRule' => 'ルールを削除',
'liveTv.deleteRuleTitle' => '録画ルールを削除しますか?',
'liveTv.deleteRuleMessage' => ({required Object title}) => '${title} の今後のエピソードは録画されません。',
'liveTv.recordingScheduled' => '録画を予約しました',
'liveTv.alreadyScheduled' => 'この番組はすでに予約されています',
'liveTv.dvrAdminRequired' => 'DVR 設定には管理者アカウントが必要です',
'liveTv.recordingFailed' => '録画を予約できませんでした',
'liveTv.recordingTargetMissing' => '録画ライブラリを特定できませんでした',
'liveTv.recordNotAvailable' => 'この番組は録画できません',
'liveTv.recordingCancelled' => '録画をキャンセルしました',
'liveTv.recordingRuleDeleted' => '録画ルールを削除しました',
'liveTv.processRecordingRules' => 'ルールを再評価',
'liveTv.loadingRecordings' => '録画を読み込み中...',
'liveTv.recordingInProgress' => '録画中',
'liveTv.recordingsCount' => ({required Object count}) => '${count} 件予約済み',
'liveTv.editRule' => 'ルールを編集',
'liveTv.editRuleAction' => '編集',
'liveTv.recordingRuleUpdated' => '録画ルールを更新しました',
'liveTv.guideReloadRequested' => 'ガイドの更新を要求しました',
'liveTv.rulesProcessRequested' => 'ルールの再評価を要求しました',
'liveTv.recordShow' => '番組を録画',
'collections.title' => 'コレクション',
'collections.collection' => 'コレクション',
'collections.empty' => 'コレクションは空です',
@@ -2703,6 +2773,8 @@ extension on TranslationsJa {
'metadataEdit.useOriginalTitle' => '原題を使用',
'metadataEdit.preferredAudioLanguage' => '優先音声言語',
'metadataEdit.preferredSubtitleLanguage' => '優先字幕言語',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => '字幕自動選択モード',
'metadataEdit.manuallySelected' => '手動選択',
'metadataEdit.shownWithForeignAudio' => '外国語音声時に表示',
@@ -2738,8 +2810,6 @@ extension on TranslationsJa {
'trakt.connectedAs' => ({required Object username}) => '@${username} として接続済み',
'trakt.disconnectConfirm' => 'Trakt アカウントを切断しますか?',
'trakt.disconnectConfirmBody' => 'Plezy から Trakt への再生イベント送信が停止します。いつでも再接続できます。',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => 'リアルタイムのスクロブル',
'trakt.scrobbleDescription' => '再生中に再生・一時停止・停止イベントを Trakt に送信します。',
'trakt.watchedSync' => '視聴済みステータスを同期',
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvKo extends TranslationsLiveTvEn {
@override String get reloadGuide => '편성표 새로고침';
@override String get now => '지금';
@override String get today => '오늘';
@override String get tomorrow => '내일';
@override String get midnight => '자정';
@override String get overnight => '심야';
@override String get morning => '아침';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvKo extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => '처음부터 시청 (${minutes}분 전 시작)';
@override String get watchLive => '실시간 시청';
@override String get goToLive => '실시간으로 이동';
@override String get record => '녹화';
@override String get recordEpisode => '에피소드 녹화';
@override String get recordSeries => '시리즈 녹화';
@override String get recordOptions => '녹화 옵션';
@override String get recordings => '녹화';
@override String get scheduledRecordings => '예약됨';
@override String get recordingRules => '녹화 규칙';
@override String get noScheduledRecordings => '예약된 녹화 없음';
@override String get noRecordingRules => '녹화 규칙 없음';
@override String get manageRecording => '녹화 관리';
@override String get cancelRecording => '녹화 취소';
@override String get cancelRecordingTitle => '이 녹화를 취소할까요?';
@override String cancelRecordingMessage({required Object title}) => '${title}이(가) 더 이상 녹화되지 않습니다.';
@override String get deleteRule => '규칙 삭제';
@override String get deleteRuleTitle => '녹화 규칙을 삭제할까요?';
@override String deleteRuleMessage({required Object title}) => '${title}의 향후 에피소드는 녹화되지 않습니다.';
@override String get recordingScheduled => '녹화 예약됨';
@override String get alreadyScheduled => '이 프로그램은 이미 예약되어 있습니다';
@override String get dvrAdminRequired => 'DVR 설정은 관리자 계정이 필요합니다';
@override String get recordingFailed => '녹화를 예약할 수 없습니다';
@override String get recordingTargetMissing => '녹화 라이브러리를 확인할 수 없습니다';
@override String get recordNotAvailable => '이 프로그램은 녹화할 수 없습니다';
@override String get recordingCancelled => '녹화 취소됨';
@override String get recordingRuleDeleted => '녹화 규칙 삭제됨';
@override String get processRecordingRules => '규칙 재평가';
@override String get loadingRecordings => '녹화 불러오는 중...';
@override String get recordingInProgress => '녹화 중';
@override String recordingsCount({required Object count}) => '${count}개 예약됨';
@override String get editRule => '규칙 편집';
@override String get editRuleAction => '편집';
@override String get recordingRuleUpdated => '녹화 규칙 업데이트됨';
@override String get guideReloadRequested => '가이드 새로고침 요청됨';
@override String get rulesProcessRequested => '규칙 재평가 요청됨';
@override String get recordShow => '프로그램 녹화';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsKo {
'liveTv.reloadGuide' => '편성표 새로고침',
'liveTv.now' => '지금',
'liveTv.today' => '오늘',
'liveTv.tomorrow' => '내일',
'liveTv.midnight' => '자정',
'liveTv.overnight' => '심야',
'liveTv.morning' => '아침',
@@ -2386,6 +2422,40 @@ extension on TranslationsKo {
'liveTv.watchFromStart' => ({required Object minutes}) => '처음부터 시청 (${minutes}분 전 시작)',
'liveTv.watchLive' => '실시간 시청',
'liveTv.goToLive' => '실시간으로 이동',
'liveTv.record' => '녹화',
'liveTv.recordEpisode' => '에피소드 녹화',
'liveTv.recordSeries' => '시리즈 녹화',
'liveTv.recordOptions' => '녹화 옵션',
'liveTv.recordings' => '녹화',
'liveTv.scheduledRecordings' => '예약됨',
'liveTv.recordingRules' => '녹화 규칙',
'liveTv.noScheduledRecordings' => '예약된 녹화 없음',
'liveTv.noRecordingRules' => '녹화 규칙 없음',
'liveTv.manageRecording' => '녹화 관리',
'liveTv.cancelRecording' => '녹화 취소',
'liveTv.cancelRecordingTitle' => '이 녹화를 취소할까요?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title}이(가) 더 이상 녹화되지 않습니다.',
'liveTv.deleteRule' => '규칙 삭제',
'liveTv.deleteRuleTitle' => '녹화 규칙을 삭제할까요?',
'liveTv.deleteRuleMessage' => ({required Object title}) => '${title}의 향후 에피소드는 녹화되지 않습니다.',
'liveTv.recordingScheduled' => '녹화 예약됨',
'liveTv.alreadyScheduled' => '이 프로그램은 이미 예약되어 있습니다',
'liveTv.dvrAdminRequired' => 'DVR 설정은 관리자 계정이 필요합니다',
'liveTv.recordingFailed' => '녹화를 예약할 수 없습니다',
'liveTv.recordingTargetMissing' => '녹화 라이브러리를 확인할 수 없습니다',
'liveTv.recordNotAvailable' => '이 프로그램은 녹화할 수 없습니다',
'liveTv.recordingCancelled' => '녹화 취소됨',
'liveTv.recordingRuleDeleted' => '녹화 규칙 삭제됨',
'liveTv.processRecordingRules' => '규칙 재평가',
'liveTv.loadingRecordings' => '녹화 불러오는 중...',
'liveTv.recordingInProgress' => '녹화 중',
'liveTv.recordingsCount' => ({required Object count}) => '${count}개 예약됨',
'liveTv.editRule' => '규칙 편집',
'liveTv.editRuleAction' => '편집',
'liveTv.recordingRuleUpdated' => '녹화 규칙 업데이트됨',
'liveTv.guideReloadRequested' => '가이드 새로고침 요청됨',
'liveTv.rulesProcessRequested' => '규칙 재평가 요청됨',
'liveTv.recordShow' => '프로그램 녹화',
'collections.title' => '컬렉션',
'collections.collection' => '컬렉션',
'collections.empty' => '컬렉션이 비어 있습니다',
@@ -2703,6 +2773,8 @@ extension on TranslationsKo {
'metadataEdit.useOriginalTitle' => '원제 사용',
'metadataEdit.preferredAudioLanguage' => '선호 오디오 언어',
'metadataEdit.preferredSubtitleLanguage' => '선호 자막 언어',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => '자막 자동 선택 모드',
'metadataEdit.manuallySelected' => '수동 선택',
'metadataEdit.shownWithForeignAudio' => '외국어 오디오 시 표시',
@@ -2738,8 +2810,6 @@ extension on TranslationsKo {
'trakt.connectedAs' => ({required Object username}) => '@${username}(으)로 연결됨',
'trakt.disconnectConfirm' => 'Trakt 계정 연결을 끊으시겠습니까?',
'trakt.disconnectConfirmBody' => 'Plezy가 Trakt로 재생 이벤트를 보내지 않습니다. 언제든지 다시 연결할 수 있습니다.',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => '실시간 스크로블',
'trakt.scrobbleDescription' => '재생 중 재생, 일시정지, 정지 이벤트를 Trakt로 전송합니다.',
'trakt.watchedSync' => '시청 상태 동기화',
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvNb extends TranslationsLiveTvEn {
@override String get reloadGuide => 'Last inn programguide på nytt';
@override String get now => '';
@override String get today => 'I dag';
@override String get tomorrow => 'I morgen';
@override String get midnight => 'Midnatt';
@override String get overnight => 'Natt';
@override String get morning => 'Morgen';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvNb extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => 'Se fra starten (${minutes} min siden)';
@override String get watchLive => 'Se direkte';
@override String get goToLive => 'Gå til direkte';
@override String get record => 'Ta opp';
@override String get recordEpisode => 'Ta opp episode';
@override String get recordSeries => 'Ta opp serie';
@override String get recordOptions => 'Opptaksvalg';
@override String get recordings => 'Opptak';
@override String get scheduledRecordings => 'Planlagt';
@override String get recordingRules => 'Opptaksregler';
@override String get noScheduledRecordings => 'Ingen planlagte opptak';
@override String get noRecordingRules => 'Ingen opptaksregler ennå';
@override String get manageRecording => 'Administrer opptak';
@override String get cancelRecording => 'Avbryt opptak';
@override String get cancelRecordingTitle => 'Avbryte dette opptaket?';
@override String cancelRecordingMessage({required Object title}) => '${title} blir ikke lenger tatt opp.';
@override String get deleteRule => 'Slett regel';
@override String get deleteRuleTitle => 'Slette opptaksregel?';
@override String deleteRuleMessage({required Object title}) => 'Fremtidige episoder av ${title} blir ikke tatt opp.';
@override String get recordingScheduled => 'Opptak planlagt';
@override String get alreadyScheduled => 'Dette programmet er allerede planlagt';
@override String get dvrAdminRequired => 'DVR-innstillinger krever en administratorkonto';
@override String get recordingFailed => 'Kunne ikke planlegge opptak';
@override String get recordingTargetMissing => 'Kunne ikke finne opptaksbibliotek';
@override String get recordNotAvailable => 'Opptak er ikke tilgjengelig for dette programmet';
@override String get recordingCancelled => 'Opptak avbrutt';
@override String get recordingRuleDeleted => 'Opptaksregel slettet';
@override String get processRecordingRules => 'Vurder regler på nytt';
@override String get loadingRecordings => 'Laster opptak ...';
@override String get recordingInProgress => 'Tar opp nå';
@override String recordingsCount({required Object count}) => '${count} planlagt';
@override String get editRule => 'Rediger regel';
@override String get editRuleAction => 'Rediger';
@override String get recordingRuleUpdated => 'Opptaksregel oppdatert';
@override String get guideReloadRequested => 'Forespurte guide-oppdatering';
@override String get rulesProcessRequested => 'Forespurte regelevaluering';
@override String get recordShow => 'Ta opp program';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsNb {
'liveTv.reloadGuide' => 'Last inn programguide på nytt',
'liveTv.now' => '',
'liveTv.today' => 'I dag',
'liveTv.tomorrow' => 'I morgen',
'liveTv.midnight' => 'Midnatt',
'liveTv.overnight' => 'Natt',
'liveTv.morning' => 'Morgen',
@@ -2386,6 +2422,40 @@ extension on TranslationsNb {
'liveTv.watchFromStart' => ({required Object minutes}) => 'Se fra starten (${minutes} min siden)',
'liveTv.watchLive' => 'Se direkte',
'liveTv.goToLive' => 'Gå til direkte',
'liveTv.record' => 'Ta opp',
'liveTv.recordEpisode' => 'Ta opp episode',
'liveTv.recordSeries' => 'Ta opp serie',
'liveTv.recordOptions' => 'Opptaksvalg',
'liveTv.recordings' => 'Opptak',
'liveTv.scheduledRecordings' => 'Planlagt',
'liveTv.recordingRules' => 'Opptaksregler',
'liveTv.noScheduledRecordings' => 'Ingen planlagte opptak',
'liveTv.noRecordingRules' => 'Ingen opptaksregler ennå',
'liveTv.manageRecording' => 'Administrer opptak',
'liveTv.cancelRecording' => 'Avbryt opptak',
'liveTv.cancelRecordingTitle' => 'Avbryte dette opptaket?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title} blir ikke lenger tatt opp.',
'liveTv.deleteRule' => 'Slett regel',
'liveTv.deleteRuleTitle' => 'Slette opptaksregel?',
'liveTv.deleteRuleMessage' => ({required Object title}) => 'Fremtidige episoder av ${title} blir ikke tatt opp.',
'liveTv.recordingScheduled' => 'Opptak planlagt',
'liveTv.alreadyScheduled' => 'Dette programmet er allerede planlagt',
'liveTv.dvrAdminRequired' => 'DVR-innstillinger krever en administratorkonto',
'liveTv.recordingFailed' => 'Kunne ikke planlegge opptak',
'liveTv.recordingTargetMissing' => 'Kunne ikke finne opptaksbibliotek',
'liveTv.recordNotAvailable' => 'Opptak er ikke tilgjengelig for dette programmet',
'liveTv.recordingCancelled' => 'Opptak avbrutt',
'liveTv.recordingRuleDeleted' => 'Opptaksregel slettet',
'liveTv.processRecordingRules' => 'Vurder regler på nytt',
'liveTv.loadingRecordings' => 'Laster opptak ...',
'liveTv.recordingInProgress' => 'Tar opp nå',
'liveTv.recordingsCount' => ({required Object count}) => '${count} planlagt',
'liveTv.editRule' => 'Rediger regel',
'liveTv.editRuleAction' => 'Rediger',
'liveTv.recordingRuleUpdated' => 'Opptaksregel oppdatert',
'liveTv.guideReloadRequested' => 'Forespurte guide-oppdatering',
'liveTv.rulesProcessRequested' => 'Forespurte regelevaluering',
'liveTv.recordShow' => 'Ta opp program',
'collections.title' => 'Samlinger',
'collections.collection' => 'Samling',
'collections.empty' => 'Samlingen er tom',
@@ -2703,6 +2773,8 @@ extension on TranslationsNb {
'metadataEdit.useOriginalTitle' => 'Bruk originaltittel',
'metadataEdit.preferredAudioLanguage' => 'Foretrukket lydspråk',
'metadataEdit.preferredSubtitleLanguage' => 'Foretrukket undertekstspråk',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => 'Automatisk valg av undertekstmodus',
'metadataEdit.manuallySelected' => 'Manuelt valgt',
'metadataEdit.shownWithForeignAudio' => 'Vist med fremmedspråklig lyd',
@@ -2738,8 +2810,6 @@ extension on TranslationsNb {
'trakt.connectedAs' => ({required Object username}) => 'Tilkoblet som @${username}',
'trakt.disconnectConfirm' => 'Koble fra Trakt-konto?',
'trakt.disconnectConfirmBody' => 'Plezy slutter å sende avspillingshendelser til Trakt. Du kan koble til igjen når som helst.',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => 'Sanntids-scrobbling',
'trakt.scrobbleDescription' => 'Send avspillings-, pause- og stopphendelser til Trakt under avspilling.',
'trakt.watchedSync' => 'Synkroniser sett-status',
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvNl extends TranslationsLiveTvEn {
@override String get reloadGuide => 'Gids herladen';
@override String get now => 'Nu';
@override String get today => 'Vandaag';
@override String get tomorrow => 'Morgen';
@override String get midnight => 'Middernacht';
@override String get overnight => 'Nacht';
@override String get morning => 'Ochtend';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvNl extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => 'Kijk vanaf het begin (${minutes} min geleden)';
@override String get watchLive => 'Live kijken';
@override String get goToLive => 'Ga naar live';
@override String get record => 'Opnemen';
@override String get recordEpisode => 'Aflevering opnemen';
@override String get recordSeries => 'Serie opnemen';
@override String get recordOptions => 'Opnameopties';
@override String get recordings => 'Opnames';
@override String get scheduledRecordings => 'Gepland';
@override String get recordingRules => 'Opnameregels';
@override String get noScheduledRecordings => 'Geen geplande opnames';
@override String get noRecordingRules => 'Nog geen opnameregels';
@override String get manageRecording => 'Opname beheren';
@override String get cancelRecording => 'Opname annuleren';
@override String get cancelRecordingTitle => 'Deze opname annuleren?';
@override String cancelRecordingMessage({required Object title}) => '${title} wordt niet meer opgenomen.';
@override String get deleteRule => 'Regel verwijderen';
@override String get deleteRuleTitle => 'Opnameregel verwijderen?';
@override String deleteRuleMessage({required Object title}) => 'Toekomstige afleveringen van ${title} worden niet opgenomen.';
@override String get recordingScheduled => 'Opname gepland';
@override String get alreadyScheduled => 'Dit programma is al gepland';
@override String get dvrAdminRequired => 'DVR-instellingen vereisen een beheerdersaccount';
@override String get recordingFailed => 'Kon opname niet plannen';
@override String get recordingTargetMissing => 'Kon opnamebibliotheek niet bepalen';
@override String get recordNotAvailable => 'Opname niet beschikbaar voor dit programma';
@override String get recordingCancelled => 'Opname geannuleerd';
@override String get recordingRuleDeleted => 'Opnameregel verwijderd';
@override String get processRecordingRules => 'Regels opnieuw evalueren';
@override String get loadingRecordings => 'Opnames laden...';
@override String get recordingInProgress => 'Nu aan het opnemen';
@override String recordingsCount({required Object count}) => '${count} gepland';
@override String get editRule => 'Regel bewerken';
@override String get editRuleAction => 'Bewerken';
@override String get recordingRuleUpdated => 'Opnameregel bijgewerkt';
@override String get guideReloadRequested => 'Gids-vernieuwing aangevraagd';
@override String get rulesProcessRequested => 'Regel-herevaluatie aangevraagd';
@override String get recordShow => 'Programma opnemen';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsNl {
'liveTv.reloadGuide' => 'Gids herladen',
'liveTv.now' => 'Nu',
'liveTv.today' => 'Vandaag',
'liveTv.tomorrow' => 'Morgen',
'liveTv.midnight' => 'Middernacht',
'liveTv.overnight' => 'Nacht',
'liveTv.morning' => 'Ochtend',
@@ -2386,6 +2422,40 @@ extension on TranslationsNl {
'liveTv.watchFromStart' => ({required Object minutes}) => 'Kijk vanaf het begin (${minutes} min geleden)',
'liveTv.watchLive' => 'Live kijken',
'liveTv.goToLive' => 'Ga naar live',
'liveTv.record' => 'Opnemen',
'liveTv.recordEpisode' => 'Aflevering opnemen',
'liveTv.recordSeries' => 'Serie opnemen',
'liveTv.recordOptions' => 'Opnameopties',
'liveTv.recordings' => 'Opnames',
'liveTv.scheduledRecordings' => 'Gepland',
'liveTv.recordingRules' => 'Opnameregels',
'liveTv.noScheduledRecordings' => 'Geen geplande opnames',
'liveTv.noRecordingRules' => 'Nog geen opnameregels',
'liveTv.manageRecording' => 'Opname beheren',
'liveTv.cancelRecording' => 'Opname annuleren',
'liveTv.cancelRecordingTitle' => 'Deze opname annuleren?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title} wordt niet meer opgenomen.',
'liveTv.deleteRule' => 'Regel verwijderen',
'liveTv.deleteRuleTitle' => 'Opnameregel verwijderen?',
'liveTv.deleteRuleMessage' => ({required Object title}) => 'Toekomstige afleveringen van ${title} worden niet opgenomen.',
'liveTv.recordingScheduled' => 'Opname gepland',
'liveTv.alreadyScheduled' => 'Dit programma is al gepland',
'liveTv.dvrAdminRequired' => 'DVR-instellingen vereisen een beheerdersaccount',
'liveTv.recordingFailed' => 'Kon opname niet plannen',
'liveTv.recordingTargetMissing' => 'Kon opnamebibliotheek niet bepalen',
'liveTv.recordNotAvailable' => 'Opname niet beschikbaar voor dit programma',
'liveTv.recordingCancelled' => 'Opname geannuleerd',
'liveTv.recordingRuleDeleted' => 'Opnameregel verwijderd',
'liveTv.processRecordingRules' => 'Regels opnieuw evalueren',
'liveTv.loadingRecordings' => 'Opnames laden...',
'liveTv.recordingInProgress' => 'Nu aan het opnemen',
'liveTv.recordingsCount' => ({required Object count}) => '${count} gepland',
'liveTv.editRule' => 'Regel bewerken',
'liveTv.editRuleAction' => 'Bewerken',
'liveTv.recordingRuleUpdated' => 'Opnameregel bijgewerkt',
'liveTv.guideReloadRequested' => 'Gids-vernieuwing aangevraagd',
'liveTv.rulesProcessRequested' => 'Regel-herevaluatie aangevraagd',
'liveTv.recordShow' => 'Programma opnemen',
'collections.title' => 'Collecties',
'collections.collection' => 'Collectie',
'collections.empty' => 'Collectie is leeg',
@@ -2703,6 +2773,8 @@ extension on TranslationsNl {
'metadataEdit.useOriginalTitle' => 'Oorspronkelijke titel gebruiken',
'metadataEdit.preferredAudioLanguage' => 'Voorkeurstaal audio',
'metadataEdit.preferredSubtitleLanguage' => 'Voorkeurstaal ondertiteling',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => 'Automatische ondertitelselectie',
'metadataEdit.manuallySelected' => 'Handmatig geselecteerd',
'metadataEdit.shownWithForeignAudio' => 'Weergeven bij anderstalig geluid',
@@ -2738,8 +2810,6 @@ extension on TranslationsNl {
'trakt.connectedAs' => ({required Object username}) => 'Verbonden als @${username}',
'trakt.disconnectConfirm' => 'Trakt-account loskoppelen?',
'trakt.disconnectConfirmBody' => 'Plezy stopt met het versturen van afspeelgebeurtenissen naar Trakt. Je kunt op elk moment opnieuw verbinden.',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => 'Realtime scrobbling',
'trakt.scrobbleDescription' => 'Verstuur play-, pauze- en stopgebeurtenissen tijdens afspelen naar Trakt.',
'trakt.watchedSync' => 'Bekeken-status synchroniseren',
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvPl extends TranslationsLiveTvEn {
@override String get reloadGuide => 'Odśwież przewodnik';
@override String get now => 'Teraz';
@override String get today => 'Dzisiaj';
@override String get tomorrow => 'Jutro';
@override String get midnight => 'Północ';
@override String get overnight => 'Nocą';
@override String get morning => 'Rano';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvPl extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => 'Oglądaj od początku (${minutes} min temu)';
@override String get watchLive => 'Oglądaj na żywo';
@override String get goToLive => 'Przejdź do na żywo';
@override String get record => 'Nagraj';
@override String get recordEpisode => 'Nagraj odcinek';
@override String get recordSeries => 'Nagraj serial';
@override String get recordOptions => 'Opcje nagrywania';
@override String get recordings => 'Nagrania';
@override String get scheduledRecordings => 'Zaplanowane';
@override String get recordingRules => 'Reguły nagrywania';
@override String get noScheduledRecordings => 'Brak zaplanowanych nagrań';
@override String get noRecordingRules => 'Brak reguł nagrywania';
@override String get manageRecording => 'Zarządzaj nagraniem';
@override String get cancelRecording => 'Anuluj nagrywanie';
@override String get cancelRecordingTitle => 'Anulować to nagrywanie?';
@override String cancelRecordingMessage({required Object title}) => '${title} nie będzie już nagrywane.';
@override String get deleteRule => 'Usuń regułę';
@override String get deleteRuleTitle => 'Usunąć regułę nagrywania?';
@override String deleteRuleMessage({required Object title}) => 'Przyszłe odcinki ${title} nie będą nagrywane.';
@override String get recordingScheduled => 'Nagrywanie zaplanowane';
@override String get alreadyScheduled => 'Ten program jest już zaplanowany';
@override String get dvrAdminRequired => 'Ustawienia DVR wymagają konta administratora';
@override String get recordingFailed => 'Nie można zaplanować nagrywania';
@override String get recordingTargetMissing => 'Nie można określić biblioteki nagrań';
@override String get recordNotAvailable => 'Nagrywanie niedostępne dla tego programu';
@override String get recordingCancelled => 'Nagrywanie anulowane';
@override String get recordingRuleDeleted => 'Reguła nagrywania usunięta';
@override String get processRecordingRules => 'Ponów ocenę reguł';
@override String get loadingRecordings => 'Wczytywanie nagrań...';
@override String get recordingInProgress => 'Trwa nagrywanie';
@override String recordingsCount({required Object count}) => '${count} zaplanowanych';
@override String get editRule => 'Edytuj regułę';
@override String get editRuleAction => 'Edytuj';
@override String get recordingRuleUpdated => 'Reguła nagrywania zaktualizowana';
@override String get guideReloadRequested => 'Zażądano odświeżenia przewodnika';
@override String get rulesProcessRequested => 'Zażądano ponownej oceny reguł';
@override String get recordShow => 'Nagraj program';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsPl {
'liveTv.reloadGuide' => 'Odśwież przewodnik',
'liveTv.now' => 'Teraz',
'liveTv.today' => 'Dzisiaj',
'liveTv.tomorrow' => 'Jutro',
'liveTv.midnight' => 'Północ',
'liveTv.overnight' => 'Nocą',
'liveTv.morning' => 'Rano',
@@ -2386,6 +2422,40 @@ extension on TranslationsPl {
'liveTv.watchFromStart' => ({required Object minutes}) => 'Oglądaj od początku (${minutes} min temu)',
'liveTv.watchLive' => 'Oglądaj na żywo',
'liveTv.goToLive' => 'Przejdź do na żywo',
'liveTv.record' => 'Nagraj',
'liveTv.recordEpisode' => 'Nagraj odcinek',
'liveTv.recordSeries' => 'Nagraj serial',
'liveTv.recordOptions' => 'Opcje nagrywania',
'liveTv.recordings' => 'Nagrania',
'liveTv.scheduledRecordings' => 'Zaplanowane',
'liveTv.recordingRules' => 'Reguły nagrywania',
'liveTv.noScheduledRecordings' => 'Brak zaplanowanych nagrań',
'liveTv.noRecordingRules' => 'Brak reguł nagrywania',
'liveTv.manageRecording' => 'Zarządzaj nagraniem',
'liveTv.cancelRecording' => 'Anuluj nagrywanie',
'liveTv.cancelRecordingTitle' => 'Anulować to nagrywanie?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title} nie będzie już nagrywane.',
'liveTv.deleteRule' => 'Usuń regułę',
'liveTv.deleteRuleTitle' => 'Usunąć regułę nagrywania?',
'liveTv.deleteRuleMessage' => ({required Object title}) => 'Przyszłe odcinki ${title} nie będą nagrywane.',
'liveTv.recordingScheduled' => 'Nagrywanie zaplanowane',
'liveTv.alreadyScheduled' => 'Ten program jest już zaplanowany',
'liveTv.dvrAdminRequired' => 'Ustawienia DVR wymagają konta administratora',
'liveTv.recordingFailed' => 'Nie można zaplanować nagrywania',
'liveTv.recordingTargetMissing' => 'Nie można określić biblioteki nagrań',
'liveTv.recordNotAvailable' => 'Nagrywanie niedostępne dla tego programu',
'liveTv.recordingCancelled' => 'Nagrywanie anulowane',
'liveTv.recordingRuleDeleted' => 'Reguła nagrywania usunięta',
'liveTv.processRecordingRules' => 'Ponów ocenę reguł',
'liveTv.loadingRecordings' => 'Wczytywanie nagrań...',
'liveTv.recordingInProgress' => 'Trwa nagrywanie',
'liveTv.recordingsCount' => ({required Object count}) => '${count} zaplanowanych',
'liveTv.editRule' => 'Edytuj regułę',
'liveTv.editRuleAction' => 'Edytuj',
'liveTv.recordingRuleUpdated' => 'Reguła nagrywania zaktualizowana',
'liveTv.guideReloadRequested' => 'Zażądano odświeżenia przewodnika',
'liveTv.rulesProcessRequested' => 'Zażądano ponownej oceny reguł',
'liveTv.recordShow' => 'Nagraj program',
'collections.title' => 'Kolekcje',
'collections.collection' => 'Kolekcja',
'collections.empty' => 'Kolekcja jest pusta',
@@ -2703,6 +2773,8 @@ extension on TranslationsPl {
'metadataEdit.useOriginalTitle' => 'Użyj oryginalnego tytułu',
'metadataEdit.preferredAudioLanguage' => 'Preferowany język audio',
'metadataEdit.preferredSubtitleLanguage' => 'Preferowany język napisów',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => 'Tryb automatycznego wyboru napisów',
'metadataEdit.manuallySelected' => 'Wybrany ręcznie',
'metadataEdit.shownWithForeignAudio' => 'Wyświetlane przy obcojęzycznym audio',
@@ -2738,8 +2810,6 @@ extension on TranslationsPl {
'trakt.connectedAs' => ({required Object username}) => 'Połączono jako @${username}',
'trakt.disconnectConfirm' => 'Rozłączyć konto Trakt?',
'trakt.disconnectConfirmBody' => 'Plezy przestanie wysyłać zdarzenia odtwarzania do Trakt. Możesz połączyć się ponownie w dowolnej chwili.',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => 'Scrobbling w czasie rzeczywistym',
'trakt.scrobbleDescription' => 'Wysyłaj zdarzenia odtwarzania, pauzy i zatrzymania do Trakt podczas odtwarzania.',
'trakt.watchedSync' => 'Synchronizuj status obejrzane',
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvPt extends TranslationsLiveTvEn {
@override String get reloadGuide => 'Recarregar Guia';
@override String get now => 'Agora';
@override String get today => 'Hoje';
@override String get tomorrow => 'Amanhã';
@override String get midnight => 'Meia-noite';
@override String get overnight => 'Madrugada';
@override String get morning => 'Manhã';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvPt extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => 'Assistir do início (${minutes} min atrás)';
@override String get watchLive => 'Assistir ao vivo';
@override String get goToLive => 'Ir para o ao vivo';
@override String get record => 'Gravar';
@override String get recordEpisode => 'Gravar episódio';
@override String get recordSeries => 'Gravar série';
@override String get recordOptions => 'Opções de gravação';
@override String get recordings => 'Gravações';
@override String get scheduledRecordings => 'Agendadas';
@override String get recordingRules => 'Regras de gravação';
@override String get noScheduledRecordings => 'Sem gravações agendadas';
@override String get noRecordingRules => 'Sem regras de gravação';
@override String get manageRecording => 'Gerenciar gravação';
@override String get cancelRecording => 'Cancelar gravação';
@override String get cancelRecordingTitle => 'Cancelar esta gravação?';
@override String cancelRecordingMessage({required Object title}) => '${title} não será mais gravado.';
@override String get deleteRule => 'Excluir regra';
@override String get deleteRuleTitle => 'Excluir regra de gravação?';
@override String deleteRuleMessage({required Object title}) => 'Episódios futuros de ${title} não serão gravados.';
@override String get recordingScheduled => 'Gravação agendada';
@override String get alreadyScheduled => 'Este programa já está agendado';
@override String get dvrAdminRequired => 'As configurações de DVR exigem uma conta de administrador';
@override String get recordingFailed => 'Não foi possível agendar a gravação';
@override String get recordingTargetMissing => 'Não foi possível determinar a biblioteca de gravação';
@override String get recordNotAvailable => 'Gravação indisponível para este programa';
@override String get recordingCancelled => 'Gravação cancelada';
@override String get recordingRuleDeleted => 'Regra de gravação excluída';
@override String get processRecordingRules => 'Reavaliar regras';
@override String get loadingRecordings => 'Carregando gravações...';
@override String get recordingInProgress => 'Gravando agora';
@override String recordingsCount({required Object count}) => '${count} agendadas';
@override String get editRule => 'Editar regra';
@override String get editRuleAction => 'Editar';
@override String get recordingRuleUpdated => 'Regra de gravação atualizada';
@override String get guideReloadRequested => 'Atualização da guia solicitada';
@override String get rulesProcessRequested => 'Reavaliação de regras solicitada';
@override String get recordShow => 'Gravar programa';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsPt {
'liveTv.reloadGuide' => 'Recarregar Guia',
'liveTv.now' => 'Agora',
'liveTv.today' => 'Hoje',
'liveTv.tomorrow' => 'Amanhã',
'liveTv.midnight' => 'Meia-noite',
'liveTv.overnight' => 'Madrugada',
'liveTv.morning' => 'Manhã',
@@ -2386,6 +2422,40 @@ extension on TranslationsPt {
'liveTv.watchFromStart' => ({required Object minutes}) => 'Assistir do início (${minutes} min atrás)',
'liveTv.watchLive' => 'Assistir ao vivo',
'liveTv.goToLive' => 'Ir para o ao vivo',
'liveTv.record' => 'Gravar',
'liveTv.recordEpisode' => 'Gravar episódio',
'liveTv.recordSeries' => 'Gravar série',
'liveTv.recordOptions' => 'Opções de gravação',
'liveTv.recordings' => 'Gravações',
'liveTv.scheduledRecordings' => 'Agendadas',
'liveTv.recordingRules' => 'Regras de gravação',
'liveTv.noScheduledRecordings' => 'Sem gravações agendadas',
'liveTv.noRecordingRules' => 'Sem regras de gravação',
'liveTv.manageRecording' => 'Gerenciar gravação',
'liveTv.cancelRecording' => 'Cancelar gravação',
'liveTv.cancelRecordingTitle' => 'Cancelar esta gravação?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title} não será mais gravado.',
'liveTv.deleteRule' => 'Excluir regra',
'liveTv.deleteRuleTitle' => 'Excluir regra de gravação?',
'liveTv.deleteRuleMessage' => ({required Object title}) => 'Episódios futuros de ${title} não serão gravados.',
'liveTv.recordingScheduled' => 'Gravação agendada',
'liveTv.alreadyScheduled' => 'Este programa já está agendado',
'liveTv.dvrAdminRequired' => 'As configurações de DVR exigem uma conta de administrador',
'liveTv.recordingFailed' => 'Não foi possível agendar a gravação',
'liveTv.recordingTargetMissing' => 'Não foi possível determinar a biblioteca de gravação',
'liveTv.recordNotAvailable' => 'Gravação indisponível para este programa',
'liveTv.recordingCancelled' => 'Gravação cancelada',
'liveTv.recordingRuleDeleted' => 'Regra de gravação excluída',
'liveTv.processRecordingRules' => 'Reavaliar regras',
'liveTv.loadingRecordings' => 'Carregando gravações...',
'liveTv.recordingInProgress' => 'Gravando agora',
'liveTv.recordingsCount' => ({required Object count}) => '${count} agendadas',
'liveTv.editRule' => 'Editar regra',
'liveTv.editRuleAction' => 'Editar',
'liveTv.recordingRuleUpdated' => 'Regra de gravação atualizada',
'liveTv.guideReloadRequested' => 'Atualização da guia solicitada',
'liveTv.rulesProcessRequested' => 'Reavaliação de regras solicitada',
'liveTv.recordShow' => 'Gravar programa',
'collections.title' => 'Coleções',
'collections.collection' => 'Coleção',
'collections.empty' => 'A coleção está vazia',
@@ -2703,6 +2773,8 @@ extension on TranslationsPt {
'metadataEdit.useOriginalTitle' => 'Usar Título Original',
'metadataEdit.preferredAudioLanguage' => 'Idioma de Áudio Preferido',
'metadataEdit.preferredSubtitleLanguage' => 'Idioma de Legenda Preferido',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => 'Modo de Seleção Automática de Legendas',
'metadataEdit.manuallySelected' => 'Seleção manual',
'metadataEdit.shownWithForeignAudio' => 'Exibir com áudio estrangeiro',
@@ -2738,8 +2810,6 @@ extension on TranslationsPt {
'trakt.connectedAs' => ({required Object username}) => 'Conectado como @${username}',
'trakt.disconnectConfirm' => 'Desconectar conta do Trakt?',
'trakt.disconnectConfirmBody' => 'O Plezy deixará de enviar eventos de reprodução ao Trakt. Você pode reconectar a qualquer momento.',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => 'Scrobbling em tempo real',
'trakt.scrobbleDescription' => 'Envia eventos de reprodução, pausa e parada ao Trakt durante a exibição.',
'trakt.watchedSync' => 'Sincronizar status de assistido',
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvRu extends TranslationsLiveTvEn {
@override String get reloadGuide => 'Перезагрузить программу';
@override String get now => 'Сейчас';
@override String get today => 'Сегодня';
@override String get tomorrow => 'Завтра';
@override String get midnight => 'Полночь';
@override String get overnight => 'Ночь';
@override String get morning => 'Утро';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvRu extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => 'Смотреть сначала (${minutes} мин. назад)';
@override String get watchLive => 'Смотреть в прямом эфире';
@override String get goToLive => 'К прямому эфиру';
@override String get record => 'Запись';
@override String get recordEpisode => 'Записать эпизод';
@override String get recordSeries => 'Записать сериал';
@override String get recordOptions => 'Параметры записи';
@override String get recordings => 'Записи';
@override String get scheduledRecordings => 'Запланировано';
@override String get recordingRules => 'Правила записи';
@override String get noScheduledRecordings => 'Нет запланированных записей';
@override String get noRecordingRules => 'Правил записи пока нет';
@override String get manageRecording => 'Управление записью';
@override String get cancelRecording => 'Отменить запись';
@override String get cancelRecordingTitle => 'Отменить эту запись?';
@override String cancelRecordingMessage({required Object title}) => '${title} больше не будет записываться.';
@override String get deleteRule => 'Удалить правило';
@override String get deleteRuleTitle => 'Удалить правило записи?';
@override String deleteRuleMessage({required Object title}) => 'Будущие эпизоды ${title} не будут записаны.';
@override String get recordingScheduled => 'Запись запланирована';
@override String get alreadyScheduled => 'Эта передача уже запланирована';
@override String get dvrAdminRequired => 'Настройки DVR требуют учётной записи администратора';
@override String get recordingFailed => 'Не удалось запланировать запись';
@override String get recordingTargetMissing => 'Не удалось определить библиотеку записи';
@override String get recordNotAvailable => 'Запись недоступна для этой передачи';
@override String get recordingCancelled => 'Запись отменена';
@override String get recordingRuleDeleted => 'Правило записи удалено';
@override String get processRecordingRules => 'Пересчитать правила';
@override String get loadingRecordings => 'Загрузка записей...';
@override String get recordingInProgress => 'Идёт запись';
@override String recordingsCount({required Object count}) => 'Запланировано: ${count}';
@override String get editRule => 'Изменить правило';
@override String get editRuleAction => 'Изменить';
@override String get recordingRuleUpdated => 'Правило записи обновлено';
@override String get guideReloadRequested => 'Запрошено обновление гайда';
@override String get rulesProcessRequested => 'Запрошен пересчёт правил';
@override String get recordShow => 'Записать передачу';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsRu {
'liveTv.reloadGuide' => 'Перезагрузить программу',
'liveTv.now' => 'Сейчас',
'liveTv.today' => 'Сегодня',
'liveTv.tomorrow' => 'Завтра',
'liveTv.midnight' => 'Полночь',
'liveTv.overnight' => 'Ночь',
'liveTv.morning' => 'Утро',
@@ -2386,6 +2422,40 @@ extension on TranslationsRu {
'liveTv.watchFromStart' => ({required Object minutes}) => 'Смотреть сначала (${minutes} мин. назад)',
'liveTv.watchLive' => 'Смотреть в прямом эфире',
'liveTv.goToLive' => 'К прямому эфиру',
'liveTv.record' => 'Запись',
'liveTv.recordEpisode' => 'Записать эпизод',
'liveTv.recordSeries' => 'Записать сериал',
'liveTv.recordOptions' => 'Параметры записи',
'liveTv.recordings' => 'Записи',
'liveTv.scheduledRecordings' => 'Запланировано',
'liveTv.recordingRules' => 'Правила записи',
'liveTv.noScheduledRecordings' => 'Нет запланированных записей',
'liveTv.noRecordingRules' => 'Правил записи пока нет',
'liveTv.manageRecording' => 'Управление записью',
'liveTv.cancelRecording' => 'Отменить запись',
'liveTv.cancelRecordingTitle' => 'Отменить эту запись?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title} больше не будет записываться.',
'liveTv.deleteRule' => 'Удалить правило',
'liveTv.deleteRuleTitle' => 'Удалить правило записи?',
'liveTv.deleteRuleMessage' => ({required Object title}) => 'Будущие эпизоды ${title} не будут записаны.',
'liveTv.recordingScheduled' => 'Запись запланирована',
'liveTv.alreadyScheduled' => 'Эта передача уже запланирована',
'liveTv.dvrAdminRequired' => 'Настройки DVR требуют учётной записи администратора',
'liveTv.recordingFailed' => 'Не удалось запланировать запись',
'liveTv.recordingTargetMissing' => 'Не удалось определить библиотеку записи',
'liveTv.recordNotAvailable' => 'Запись недоступна для этой передачи',
'liveTv.recordingCancelled' => 'Запись отменена',
'liveTv.recordingRuleDeleted' => 'Правило записи удалено',
'liveTv.processRecordingRules' => 'Пересчитать правила',
'liveTv.loadingRecordings' => 'Загрузка записей...',
'liveTv.recordingInProgress' => 'Идёт запись',
'liveTv.recordingsCount' => ({required Object count}) => 'Запланировано: ${count}',
'liveTv.editRule' => 'Изменить правило',
'liveTv.editRuleAction' => 'Изменить',
'liveTv.recordingRuleUpdated' => 'Правило записи обновлено',
'liveTv.guideReloadRequested' => 'Запрошено обновление гайда',
'liveTv.rulesProcessRequested' => 'Запрошен пересчёт правил',
'liveTv.recordShow' => 'Записать передачу',
'collections.title' => 'Коллекции',
'collections.collection' => 'Коллекция',
'collections.empty' => 'Коллекция пуста',
@@ -2703,6 +2773,8 @@ extension on TranslationsRu {
'metadataEdit.useOriginalTitle' => 'Использовать оригинальное название',
'metadataEdit.preferredAudioLanguage' => 'Предпочитаемый язык аудио',
'metadataEdit.preferredSubtitleLanguage' => 'Предпочитаемый язык субтитров',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => 'Автовыбор субтитров',
'metadataEdit.manuallySelected' => 'Выбор вручную',
'metadataEdit.shownWithForeignAudio' => 'Показывать при иноязычном аудио',
@@ -2738,8 +2810,6 @@ extension on TranslationsRu {
'trakt.connectedAs' => ({required Object username}) => 'Подключено как @${username}',
'trakt.disconnectConfirm' => 'Отключить аккаунт Trakt?',
'trakt.disconnectConfirmBody' => 'Plezy перестанет отправлять события воспроизведения в Trakt. Вы можете подключиться снова в любое время.',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => 'Скробблинг в реальном времени',
'trakt.scrobbleDescription' => 'Отправлять события воспроизведения, паузы и остановки в Trakt во время просмотра.',
'trakt.watchedSync' => 'Синхронизация статуса просмотра',
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvSv extends TranslationsLiveTvEn {
@override String get reloadGuide => 'Ladda om programguide';
@override String get now => 'Nu';
@override String get today => 'Idag';
@override String get tomorrow => 'I morgon';
@override String get midnight => 'Midnatt';
@override String get overnight => 'Natt';
@override String get morning => 'Morgon';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvSv extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => 'Titta från början (${minutes} min sedan)';
@override String get watchLive => 'Titta live';
@override String get goToLive => 'Gå till live';
@override String get record => 'Spela in';
@override String get recordEpisode => 'Spela in avsnitt';
@override String get recordSeries => 'Spela in serie';
@override String get recordOptions => 'Inspelningsalternativ';
@override String get recordings => 'Inspelningar';
@override String get scheduledRecordings => 'Schemalagda';
@override String get recordingRules => 'Inspelningsregler';
@override String get noScheduledRecordings => 'Inga schemalagda inspelningar';
@override String get noRecordingRules => 'Inga inspelningsregler än';
@override String get manageRecording => 'Hantera inspelning';
@override String get cancelRecording => 'Avbryt inspelning';
@override String get cancelRecordingTitle => 'Avbryt denna inspelning?';
@override String cancelRecordingMessage({required Object title}) => '${title} kommer inte längre att spelas in.';
@override String get deleteRule => 'Ta bort regel';
@override String get deleteRuleTitle => 'Ta bort inspelningsregel?';
@override String deleteRuleMessage({required Object title}) => 'Kommande avsnitt av ${title} kommer inte att spelas in.';
@override String get recordingScheduled => 'Inspelning schemalagd';
@override String get alreadyScheduled => 'Detta program är redan schemalagt';
@override String get dvrAdminRequired => 'DVR-inställningar kräver ett administratörskonto';
@override String get recordingFailed => 'Det gick inte att schemalägga inspelning';
@override String get recordingTargetMissing => 'Det gick inte att hitta inspelningsbibliotek';
@override String get recordNotAvailable => 'Inspelning är inte tillgänglig för detta program';
@override String get recordingCancelled => 'Inspelning avbruten';
@override String get recordingRuleDeleted => 'Inspelningsregel borttagen';
@override String get processRecordingRules => 'Utvärdera regler igen';
@override String get loadingRecordings => 'Laddar inspelningar ...';
@override String get recordingInProgress => 'Spelar in nu';
@override String recordingsCount({required Object count}) => '${count} schemalagda';
@override String get editRule => 'Redigera regel';
@override String get editRuleAction => 'Redigera';
@override String get recordingRuleUpdated => 'Inspelningsregel uppdaterad';
@override String get guideReloadRequested => 'Begärde guidens uppdatering';
@override String get rulesProcessRequested => 'Begärde regelutvärdering';
@override String get recordShow => 'Spela in program';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsSv {
'liveTv.reloadGuide' => 'Ladda om programguide',
'liveTv.now' => 'Nu',
'liveTv.today' => 'Idag',
'liveTv.tomorrow' => 'I morgon',
'liveTv.midnight' => 'Midnatt',
'liveTv.overnight' => 'Natt',
'liveTv.morning' => 'Morgon',
@@ -2386,6 +2422,40 @@ extension on TranslationsSv {
'liveTv.watchFromStart' => ({required Object minutes}) => 'Titta från början (${minutes} min sedan)',
'liveTv.watchLive' => 'Titta live',
'liveTv.goToLive' => 'Gå till live',
'liveTv.record' => 'Spela in',
'liveTv.recordEpisode' => 'Spela in avsnitt',
'liveTv.recordSeries' => 'Spela in serie',
'liveTv.recordOptions' => 'Inspelningsalternativ',
'liveTv.recordings' => 'Inspelningar',
'liveTv.scheduledRecordings' => 'Schemalagda',
'liveTv.recordingRules' => 'Inspelningsregler',
'liveTv.noScheduledRecordings' => 'Inga schemalagda inspelningar',
'liveTv.noRecordingRules' => 'Inga inspelningsregler än',
'liveTv.manageRecording' => 'Hantera inspelning',
'liveTv.cancelRecording' => 'Avbryt inspelning',
'liveTv.cancelRecordingTitle' => 'Avbryt denna inspelning?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '${title} kommer inte längre att spelas in.',
'liveTv.deleteRule' => 'Ta bort regel',
'liveTv.deleteRuleTitle' => 'Ta bort inspelningsregel?',
'liveTv.deleteRuleMessage' => ({required Object title}) => 'Kommande avsnitt av ${title} kommer inte att spelas in.',
'liveTv.recordingScheduled' => 'Inspelning schemalagd',
'liveTv.alreadyScheduled' => 'Detta program är redan schemalagt',
'liveTv.dvrAdminRequired' => 'DVR-inställningar kräver ett administratörskonto',
'liveTv.recordingFailed' => 'Det gick inte att schemalägga inspelning',
'liveTv.recordingTargetMissing' => 'Det gick inte att hitta inspelningsbibliotek',
'liveTv.recordNotAvailable' => 'Inspelning är inte tillgänglig för detta program',
'liveTv.recordingCancelled' => 'Inspelning avbruten',
'liveTv.recordingRuleDeleted' => 'Inspelningsregel borttagen',
'liveTv.processRecordingRules' => 'Utvärdera regler igen',
'liveTv.loadingRecordings' => 'Laddar inspelningar ...',
'liveTv.recordingInProgress' => 'Spelar in nu',
'liveTv.recordingsCount' => ({required Object count}) => '${count} schemalagda',
'liveTv.editRule' => 'Redigera regel',
'liveTv.editRuleAction' => 'Redigera',
'liveTv.recordingRuleUpdated' => 'Inspelningsregel uppdaterad',
'liveTv.guideReloadRequested' => 'Begärde guidens uppdatering',
'liveTv.rulesProcessRequested' => 'Begärde regelutvärdering',
'liveTv.recordShow' => 'Spela in program',
'collections.title' => 'Samlingar',
'collections.collection' => 'Samling',
'collections.empty' => 'Samlingen är tom',
@@ -2703,6 +2773,8 @@ extension on TranslationsSv {
'metadataEdit.useOriginalTitle' => 'Använd originaltitel',
'metadataEdit.preferredAudioLanguage' => 'Föredraget ljudspråk',
'metadataEdit.preferredSubtitleLanguage' => 'Föredraget undertextspråk',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => 'Automatiskt val av undertexter',
'metadataEdit.manuallySelected' => 'Manuellt vald',
'metadataEdit.shownWithForeignAudio' => 'Visas vid främmande ljud',
@@ -2738,8 +2810,6 @@ extension on TranslationsSv {
'trakt.connectedAs' => ({required Object username}) => 'Ansluten som @${username}',
'trakt.disconnectConfirm' => 'Koppla från Trakt-konto?',
'trakt.disconnectConfirmBody' => 'Plezy slutar skicka uppspelningshändelser till Trakt. Du kan ansluta igen när som helst.',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => 'Realtids-scrobbling',
'trakt.scrobbleDescription' => 'Skicka uppspelnings-, paus- och stopphändelser till Trakt under uppspelning.',
'trakt.watchedSync' => 'Synkronisera tittad-status',
+72 -2
View File
@@ -965,6 +965,7 @@ class _TranslationsLiveTvZh extends TranslationsLiveTvEn {
@override String get reloadGuide => '重新加载节目指南';
@override String get now => '现在';
@override String get today => '今天';
@override String get tomorrow => '明天';
@override String get midnight => '午夜';
@override String get overnight => '凌晨';
@override String get morning => '上午';
@@ -979,6 +980,40 @@ class _TranslationsLiveTvZh extends TranslationsLiveTvEn {
@override String watchFromStart({required Object minutes}) => '从头观看(${minutes}分钟前开始)';
@override String get watchLive => '观看直播';
@override String get goToLive => '跳至直播';
@override String get record => '录制';
@override String get recordEpisode => '录制单集';
@override String get recordSeries => '录制整部剧集';
@override String get recordOptions => '录制选项';
@override String get recordings => '录制内容';
@override String get scheduledRecordings => '已安排';
@override String get recordingRules => '录制规则';
@override String get noScheduledRecordings => '没有安排的录制';
@override String get noRecordingRules => '还没有录制规则';
@override String get manageRecording => '管理录制';
@override String get cancelRecording => '取消录制';
@override String get cancelRecordingTitle => '取消此次录制?';
@override String cancelRecordingMessage({required Object title}) => '将不再录制 ${title}';
@override String get deleteRule => '删除规则';
@override String get deleteRuleTitle => '删除录制规则?';
@override String deleteRuleMessage({required Object title}) => '未来的 ${title} 剧集将不会被录制。';
@override String get recordingScheduled => '录制已安排';
@override String get alreadyScheduled => '此节目已安排录制';
@override String get dvrAdminRequired => 'DVR 设置需要管理员账户';
@override String get recordingFailed => '无法安排录制';
@override String get recordingTargetMissing => '无法确定录制库';
@override String get recordNotAvailable => '此节目无法录制';
@override String get recordingCancelled => '录制已取消';
@override String get recordingRuleDeleted => '录制规则已删除';
@override String get processRecordingRules => '重新评估规则';
@override String get loadingRecordings => '正在加载录制内容...';
@override String get recordingInProgress => '正在录制';
@override String recordingsCount({required Object count}) => '已安排 ${count}';
@override String get editRule => '编辑规则';
@override String get editRuleAction => '编辑';
@override String get recordingRuleUpdated => '录制规则已更新';
@override String get guideReloadRequested => '已请求更新节目指南';
@override String get rulesProcessRequested => '已请求重新评估规则';
@override String get recordShow => '录制节目';
}
// Path: collections
@@ -2372,6 +2407,7 @@ extension on TranslationsZh {
'liveTv.reloadGuide' => '重新加载节目指南',
'liveTv.now' => '现在',
'liveTv.today' => '今天',
'liveTv.tomorrow' => '明天',
'liveTv.midnight' => '午夜',
'liveTv.overnight' => '凌晨',
'liveTv.morning' => '上午',
@@ -2386,6 +2422,40 @@ extension on TranslationsZh {
'liveTv.watchFromStart' => ({required Object minutes}) => '从头观看(${minutes}分钟前开始)',
'liveTv.watchLive' => '观看直播',
'liveTv.goToLive' => '跳至直播',
'liveTv.record' => '录制',
'liveTv.recordEpisode' => '录制单集',
'liveTv.recordSeries' => '录制整部剧集',
'liveTv.recordOptions' => '录制选项',
'liveTv.recordings' => '录制内容',
'liveTv.scheduledRecordings' => '已安排',
'liveTv.recordingRules' => '录制规则',
'liveTv.noScheduledRecordings' => '没有安排的录制',
'liveTv.noRecordingRules' => '还没有录制规则',
'liveTv.manageRecording' => '管理录制',
'liveTv.cancelRecording' => '取消录制',
'liveTv.cancelRecordingTitle' => '取消此次录制?',
'liveTv.cancelRecordingMessage' => ({required Object title}) => '将不再录制 ${title}',
'liveTv.deleteRule' => '删除规则',
'liveTv.deleteRuleTitle' => '删除录制规则?',
'liveTv.deleteRuleMessage' => ({required Object title}) => '未来的 ${title} 剧集将不会被录制。',
'liveTv.recordingScheduled' => '录制已安排',
'liveTv.alreadyScheduled' => '此节目已安排录制',
'liveTv.dvrAdminRequired' => 'DVR 设置需要管理员账户',
'liveTv.recordingFailed' => '无法安排录制',
'liveTv.recordingTargetMissing' => '无法确定录制库',
'liveTv.recordNotAvailable' => '此节目无法录制',
'liveTv.recordingCancelled' => '录制已取消',
'liveTv.recordingRuleDeleted' => '录制规则已删除',
'liveTv.processRecordingRules' => '重新评估规则',
'liveTv.loadingRecordings' => '正在加载录制内容...',
'liveTv.recordingInProgress' => '正在录制',
'liveTv.recordingsCount' => ({required Object count}) => '已安排 ${count}',
'liveTv.editRule' => '编辑规则',
'liveTv.editRuleAction' => '编辑',
'liveTv.recordingRuleUpdated' => '录制规则已更新',
'liveTv.guideReloadRequested' => '已请求更新节目指南',
'liveTv.rulesProcessRequested' => '已请求重新评估规则',
'liveTv.recordShow' => '录制节目',
'collections.title' => '合集',
'collections.collection' => '合集',
'collections.empty' => '合集为空',
@@ -2703,6 +2773,8 @@ extension on TranslationsZh {
'metadataEdit.useOriginalTitle' => '使用原始标题',
'metadataEdit.preferredAudioLanguage' => '首选音频语言',
'metadataEdit.preferredSubtitleLanguage' => '首选字幕语言',
_ => null,
} ?? switch (path) {
'metadataEdit.subtitleMode' => '自动选择字幕模式',
'metadataEdit.manuallySelected' => '手动选择',
'metadataEdit.shownWithForeignAudio' => '外语音频时显示',
@@ -2738,8 +2810,6 @@ extension on TranslationsZh {
'trakt.connectedAs' => ({required Object username}) => '已以 @${username} 身份连接',
'trakt.disconnectConfirm' => '断开 Trakt 账户?',
'trakt.disconnectConfirmBody' => 'Plezy 将停止向 Trakt 发送播放事件。您随时可以重新连接。',
_ => null,
} ?? switch (path) {
'trakt.scrobble' => '实时 Scrobble',
'trakt.scrobbleDescription' => '在播放时向 Trakt 发送播放、暂停和停止事件。',
'trakt.watchedSync' => '同步已观看状态',
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "Ladda om programguide",
"now": "Nu",
"today": "Idag",
"tomorrow": "I morgon",
"midnight": "Midnatt",
"overnight": "Natt",
"morning": "Morgon",
@@ -741,7 +742,41 @@
"joinSession": "Gå med i pågående session",
"watchFromStart": "Titta från början (${minutes} min sedan)",
"watchLive": "Titta live",
"goToLive": "Gå till live"
"goToLive": "Gå till live",
"record": "Spela in",
"recordEpisode": "Spela in avsnitt",
"recordSeries": "Spela in serie",
"recordOptions": "Inspelningsalternativ",
"recordings": "Inspelningar",
"scheduledRecordings": "Schemalagda",
"recordingRules": "Inspelningsregler",
"noScheduledRecordings": "Inga schemalagda inspelningar",
"noRecordingRules": "Inga inspelningsregler än",
"manageRecording": "Hantera inspelning",
"cancelRecording": "Avbryt inspelning",
"cancelRecordingTitle": "Avbryt denna inspelning?",
"cancelRecordingMessage": "${title} kommer inte längre att spelas in.",
"deleteRule": "Ta bort regel",
"deleteRuleTitle": "Ta bort inspelningsregel?",
"deleteRuleMessage": "Kommande avsnitt av ${title} kommer inte att spelas in.",
"recordingScheduled": "Inspelning schemalagd",
"alreadyScheduled": "Detta program är redan schemalagt",
"dvrAdminRequired": "DVR-inställningar kräver ett administratörskonto",
"recordingFailed": "Det gick inte att schemalägga inspelning",
"recordingTargetMissing": "Det gick inte att hitta inspelningsbibliotek",
"recordNotAvailable": "Inspelning är inte tillgänglig för detta program",
"recordingCancelled": "Inspelning avbruten",
"recordingRuleDeleted": "Inspelningsregel borttagen",
"processRecordingRules": "Utvärdera regler igen",
"loadingRecordings": "Laddar inspelningar ...",
"recordingInProgress": "Spelar in nu",
"recordingsCount": "${count} schemalagda",
"editRule": "Redigera regel",
"editRuleAction": "Redigera",
"recordingRuleUpdated": "Inspelningsregel uppdaterad",
"guideReloadRequested": "Begärde guidens uppdatering",
"rulesProcessRequested": "Begärde regelutvärdering",
"recordShow": "Spela in program"
},
"collections": {
"title": "Samlingar",
+36 -1
View File
@@ -728,6 +728,7 @@
"reloadGuide": "重新加载节目指南",
"now": "现在",
"today": "今天",
"tomorrow": "明天",
"midnight": "午夜",
"overnight": "凌晨",
"morning": "上午",
@@ -741,7 +742,41 @@
"joinSession": "加入正在进行的会话",
"watchFromStart": "从头观看(${minutes}分钟前开始)",
"watchLive": "观看直播",
"goToLive": "跳至直播"
"goToLive": "跳至直播",
"record": "录制",
"recordEpisode": "录制单集",
"recordSeries": "录制整部剧集",
"recordOptions": "录制选项",
"recordings": "录制内容",
"scheduledRecordings": "已安排",
"recordingRules": "录制规则",
"noScheduledRecordings": "没有安排的录制",
"noRecordingRules": "还没有录制规则",
"manageRecording": "管理录制",
"cancelRecording": "取消录制",
"cancelRecordingTitle": "取消此次录制?",
"cancelRecordingMessage": "将不再录制 ${title}。",
"deleteRule": "删除规则",
"deleteRuleTitle": "删除录制规则?",
"deleteRuleMessage": "未来的 ${title} 剧集将不会被录制。",
"recordingScheduled": "录制已安排",
"alreadyScheduled": "此节目已安排录制",
"dvrAdminRequired": "DVR 设置需要管理员账户",
"recordingFailed": "无法安排录制",
"recordingTargetMissing": "无法确定录制库",
"recordNotAvailable": "此节目无法录制",
"recordingCancelled": "录制已取消",
"recordingRuleDeleted": "录制规则已删除",
"processRecordingRules": "重新评估规则",
"loadingRecordings": "正在加载录制内容...",
"recordingInProgress": "正在录制",
"recordingsCount": "已安排 ${count} 个",
"editRule": "编辑规则",
"editRuleAction": "编辑",
"recordingRuleUpdated": "录制规则已更新",
"guideReloadRequested": "已请求更新节目指南",
"rulesProcessRequested": "已请求重新评估规则",
"recordShow": "录制节目"
},
"collections": {
"title": "合集",
+98
View File
@@ -1,6 +1,20 @@
import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart';
import '../models/livetv_lineup.dart';
import '../models/livetv_program.dart';
import '../models/livetv_server_status.dart';
import '../models/livetv_session.dart';
import '../models/media_grab_operation.dart';
import '../models/media_grabber_device.dart';
import '../models/media_provider_info.dart';
import '../models/media_subscription.dart';
class LiveTvActivityResult<T> {
final T value;
final String? activityUuid;
const LiveTvActivityResult({required this.value, this.activityUuid});
}
enum FavoriteChannelPersistenceMode {
/// A single write replaces the full backend account's favorite list.
@@ -81,4 +95,88 @@ abstract class LiveTvSupport {
/// `/Users/{userId}/FavoriteItems/{channelId}` flag and saves the order
/// locally.
Future<void> setFavoriteChannels(List<FavoriteChannel> channels);
// ── DVR setup/lifecycle ─────────────────────────────────────────
Future<LiveTvServerStatus> fetchLiveTvServerStatus();
Future<LiveTvDvr?> fetchDvr(String dvrId);
Future<LiveTvActivityResult<LiveTvDvr?>> createDvr({
required List<String> devices,
required List<String> lineups,
String? language,
String? country,
String? postalCode,
});
Future<void> deleteDvr(String dvrId);
Future<void> updateDvrPrefs(String dvrId, Map<String, Object?> prefs);
Future<void> attachDeviceToDvr(String dvrId, String deviceId);
Future<void> detachDeviceFromDvr(String dvrId, String deviceId);
Future<void> addLineupToDvr(String dvrId, String lineupUri);
Future<void> removeLineupFromDvr(String dvrId, String lineupUri);
Future<LiveTvActivityResult<void>> reloadGuide(String dvrId);
Future<void> cancelGuideReload(String dvrId);
// ── Grabber devices/tuners ──────────────────────────────────────
Future<List<MediaGrabber>> fetchGrabbers({String? protocol});
Future<List<MediaGrabberDevice>> fetchGrabberDevices();
Future<LiveTvActivityResult<List<MediaGrabberDevice>>> discoverGrabberDevices();
Future<MediaGrabberDevice?> fetchGrabberDevice(String deviceId);
Future<MediaGrabberDevice?> addGrabberDevice(String uri, {String? grabberId});
Future<void> updateGrabberDevice(String deviceId, {bool? enabled, String? title});
Future<void> deleteGrabberDevice(String deviceId);
Future<List<MediaGrabberDeviceChannel>> fetchGrabberDeviceChannels(String deviceId);
Future<LiveTvActivityResult<MediaGrabberDevice?>> scanGrabberDevice(
String deviceId, {
String? source,
Map<String, Object?> prefs = const {},
String? network,
String? country,
});
Future<MediaGrabberDevice?> cancelGrabberDeviceScan(String deviceId);
Future<MediaGrabberDevice?> saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request);
Future<void> updateGrabberDevicePrefs(String deviceId, Map<String, Object?> prefs);
String buildGrabberDeviceThumbUrl(String deviceId, int version);
// ── EPG setup/lineups ───────────────────────────────────────────
Future<List<LiveTvCountry>> fetchEpgCountries();
Future<List<LiveTvLanguage>> fetchEpgLanguages();
Future<List<LiveTvRegion>> fetchEpgRegions(String country, String epgId);
Future<LiveTvLineupResult> fetchEpgLineups(String country, String epgId, {String? postalCode, String? region});
Future<List<LiveTvChannel>> fetchEpgChannelsForLineup(String lineupUri);
Future<List<LiveTvLineup>> fetchEpgChannelsForLineups(List<String> lineupUris);
Future<List<ChannelMapping>> computeEpgChannelMap({required String deviceUri, required String lineupUri});
Future<LiveTvActivityResult<Map<String, dynamic>?>> findBestLineup({
required String deviceUri,
required String lineupGroupUri,
});
// ── Recording rules / scheduled grabs ───────────────────────────
Future<List<SubscriptionTemplate>> getSubscriptionTemplate(String guid);
Future<List<MediaSubscription>> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true});
Future<MediaSubscription?> fetchRecordingRule(
String subscriptionId, {
bool includeGrabs = true,
bool includeStorage = true,
});
Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request);
Future<MediaSubscription?> updateRecordingRule(String subscriptionId, Map<String, Object?> prefs);
Future<void> deleteRecordingRule(String subscriptionId);
Future<MediaSubscription?> moveRecordingRule(String subscriptionId, {String? afterSubscriptionId});
Future<void> processRecordingRules();
Future<List<MediaGrabOperation>> fetchScheduledRecordings();
Future<void> cancelGrab(String operationId);
Future<List<MediaSubscription>> fetchSubscriptionMapping({
required String providerId,
required List<String> ratingKeys,
bool includeStorage = true,
});
// ── Providers and sessions ──────────────────────────────────────
Future<List<MediaProviderInfo>> fetchMediaProviders();
Future<void> registerMediaProvider(String url);
Future<void> refreshMediaProviders();
Future<void> unregisterMediaProvider(String providerId);
Future<List<LiveTvSession>> fetchLiveTvSessionsDetailed();
Future<LiveTvSession?> fetchLiveTvSession(String sessionId);
Uri buildNotificationWebSocketUri({List<String>? filters});
Uri buildNotificationEventSourceUri({List<String>? filters});
}
+43 -7
View File
@@ -1,21 +1,37 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
import 'media_subscription.dart';
part 'livetv_dvr.g.dart';
List<ChannelMapping> _parseChannelMappings(Object? raw) {
final result = <ChannelMapping>[];
if (raw is List) {
for (final item in raw) {
try {
result.add(ChannelMapping.fromJson(item as Map<String, dynamic>));
} catch (_) {}
}
final list = flexibleList(raw) ?? const [];
for (final item in list) {
try {
result.add(ChannelMapping.fromJson(item as Map<String, dynamic>));
} catch (_) {}
}
return result;
}
List<SubscriptionSetting> _parseSettings(Object? raw) {
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) SubscriptionSetting.fromJson(item),
];
}
List<Map<String, dynamic>> _parseRawMaps(Object? raw) {
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) item,
];
}
/// Represents a Plex Live TV DVR device (e.g., HDHomeRun tuner, IPTV provider)
@JsonSerializable(createToJson: false)
class LiveTvDvr {
@@ -27,15 +43,28 @@ class LiveTvDvr {
final String? model;
final String? modelNumber;
final String? firmware;
@JsonKey(fromJson: flexibleInt)
final int? tuners;
final String? lineup;
final String? lineupTitle;
final String? lineupURL;
final String? country;
final String? language;
final String? status;
@JsonKey(fromJson: flexibleInt)
final int? status;
@JsonKey(fromJson: flexibleInt)
final int? state;
final String? protocol;
final String? sources;
final String? uri;
@JsonKey(fromJson: flexibleInt)
final int? lastSeenAt;
@JsonKey(name: 'ChannelMapping', fromJson: _parseChannelMappings)
final List<ChannelMapping> channelMappings;
@JsonKey(name: 'Setting', fromJson: _parseSettings)
final List<SubscriptionSetting> settings;
@JsonKey(name: 'Device', fromJson: _parseRawMaps)
final List<Map<String, dynamic>> devices;
LiveTvDvr({
required this.key,
@@ -51,7 +80,14 @@ class LiveTvDvr {
this.country,
this.language,
this.status,
this.state,
this.protocol,
this.sources,
this.uri,
this.lastSeenAt,
this.channelMappings = const [],
this.settings = const [],
this.devices = const [],
});
factory LiveTvDvr.fromJson(Map<String, dynamic> json) => _$LiveTvDvrFromJson(json);
+9 -2
View File
@@ -13,14 +13,21 @@ LiveTvDvr _$LiveTvDvrFromJson(Map<String, dynamic> json) => LiveTvDvr(
model: json['model'] as String?,
modelNumber: json['modelNumber'] as String?,
firmware: json['firmware'] as String?,
tuners: (json['tuners'] as num?)?.toInt(),
tuners: flexibleInt(json['tuners']),
lineup: json['lineup'] as String?,
lineupTitle: json['lineupTitle'] as String?,
lineupURL: json['lineupURL'] as String?,
country: json['country'] as String?,
language: json['language'] as String?,
status: json['status'] as String?,
status: flexibleInt(json['status']),
state: flexibleInt(json['state']),
protocol: json['protocol'] as String?,
sources: json['sources'] as String?,
uri: json['uri'] as String?,
lastSeenAt: flexibleInt(json['lastSeenAt']),
channelMappings: json['ChannelMapping'] == null ? const [] : _parseChannelMappings(json['ChannelMapping']),
settings: json['Setting'] == null ? const [] : _parseSettings(json['Setting']),
devices: json['Device'] == null ? const [] : _parseRawMaps(json['Device']),
);
ChannelMapping _$ChannelMappingFromJson(Map<String, dynamic> json) => ChannelMapping(
+98
View File
@@ -0,0 +1,98 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
import 'livetv_channel.dart';
part 'livetv_lineup.g.dart';
List<LiveTvChannel> _parseChannels(Object? raw) {
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) LiveTvChannel.fromJson(item),
];
}
@JsonSerializable(createToJson: false)
class LiveTvCountry {
final String? key;
final String? type;
@JsonKey(defaultValue: '')
final String title;
@JsonKey(defaultValue: '')
final String code;
final String? language;
final String? languageTitle;
final String? example;
@JsonKey(fromJson: flexibleInt)
final int? flavor;
const LiveTvCountry({
this.key,
this.type,
required this.title,
required this.code,
this.language,
this.languageTitle,
this.example,
this.flavor,
});
factory LiveTvCountry.fromJson(Map<String, dynamic> json) => _$LiveTvCountryFromJson(json);
}
@JsonSerializable(createToJson: false)
class LiveTvLanguage {
@JsonKey(defaultValue: '')
final String code;
@JsonKey(defaultValue: '')
final String title;
const LiveTvLanguage({required this.code, required this.title});
factory LiveTvLanguage.fromJson(Map<String, dynamic> json) => _$LiveTvLanguageFromJson(json);
}
@JsonSerializable(createToJson: false)
class LiveTvRegion {
@JsonKey(defaultValue: '')
final String key;
final String? type;
@JsonKey(defaultValue: '')
final String title;
const LiveTvRegion({required this.key, this.type, required this.title});
factory LiveTvRegion.fromJson(Map<String, dynamic> json) => _$LiveTvRegionFromJson(json);
}
@JsonSerializable(createToJson: false)
class LiveTvLineup {
@JsonKey(defaultValue: '')
final String uuid;
final String? type;
final String? title;
@JsonKey(fromJson: flexibleInt)
final int? lineupType;
final String? location;
@JsonKey(name: 'Channel', fromJson: _parseChannels)
final List<LiveTvChannel> channels;
const LiveTvLineup({
required this.uuid,
this.type,
this.title,
this.lineupType,
this.location,
this.channels = const [],
});
factory LiveTvLineup.fromJson(Map<String, dynamic> json) => _$LiveTvLineupFromJson(json);
}
class LiveTvLineupResult {
final String? lineupGroupUuid;
final List<LiveTvLineup> lineups;
const LiveTvLineupResult({this.lineupGroupUuid, required this.lineups});
}
+36
View File
@@ -0,0 +1,36 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'livetv_lineup.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LiveTvCountry _$LiveTvCountryFromJson(Map<String, dynamic> json) => LiveTvCountry(
key: json['key'] as String?,
type: json['type'] as String?,
title: json['title'] as String? ?? '',
code: json['code'] as String? ?? '',
language: json['language'] as String?,
languageTitle: json['languageTitle'] as String?,
example: json['example'] as String?,
flavor: flexibleInt(json['flavor']),
);
LiveTvLanguage _$LiveTvLanguageFromJson(Map<String, dynamic> json) =>
LiveTvLanguage(code: json['code'] as String? ?? '', title: json['title'] as String? ?? '');
LiveTvRegion _$LiveTvRegionFromJson(Map<String, dynamic> json) => LiveTvRegion(
key: json['key'] as String? ?? '',
type: json['type'] as String?,
title: json['title'] as String? ?? '',
);
LiveTvLineup _$LiveTvLineupFromJson(Map<String, dynamic> json) => LiveTvLineup(
uuid: json['uuid'] as String? ?? '',
type: json['type'] as String?,
title: json['title'] as String?,
lineupType: flexibleInt(json['lineupType']),
location: json['location'] as String?,
channels: json['Channel'] == null ? const [] : _parseChannels(json['Channel']),
);
+41 -5
View File
@@ -21,6 +21,10 @@ class LiveTvProgram {
final String? channelCallSign;
final bool? live;
final bool? premiere;
final String? serverId;
final String? serverName;
final String? liveDvrKey;
final String? providerIdentifier;
LiveTvProgram({
this.key,
@@ -42,6 +46,10 @@ class LiveTvProgram {
this.channelCallSign,
this.live,
this.premiere,
this.serverId,
this.serverName,
this.liveDvrKey,
this.providerIdentifier,
});
factory LiveTvProgram.fromJson(Map<String, dynamic> json, {Map<String, dynamic>? mediaOverride}) {
@@ -54,8 +62,8 @@ class LiveTvProgram {
final channel = (json['Channel'] as List?)?.firstOrNull as Map<String, dynamic>?;
int? pickInt(String key) {
final fromMedia = (media?[key] as num?)?.toInt();
final fromJson = (json[key] as num?)?.toInt();
final fromMedia = flexibleInt(media?[key]);
final fromJson = flexibleInt(json[key]);
return hasOverride ? (fromMedia ?? fromJson) : (fromJson ?? fromMedia);
}
@@ -72,13 +80,13 @@ class LiveTvProgram {
title: json['title'] as String? ?? 'Unknown Program',
summary: json['summary'] as String?,
type: json['type'] as String?,
year: (json['year'] as num?)?.toInt(),
year: flexibleInt(json['year']),
beginsAt: pickInt('beginsAt'),
endsAt: pickInt('endsAt'),
grandparentTitle: json['grandparentTitle'] as String?,
parentTitle: json['parentTitle'] as String?,
index: (json['index'] as num?)?.toInt(),
parentIndex: (json['parentIndex'] as num?)?.toInt(),
index: flexibleInt(json['index']),
parentIndex: flexibleInt(json['parentIndex']),
thumb: json['thumb'] as String? ?? json['grandparentThumb'] as String?,
art: json['art'] as String?,
channelIdentifier: pickString('channelIdentifier') ?? channel?['id']?.toString(),
@@ -88,6 +96,34 @@ class LiveTvProgram {
);
}
LiveTvProgram copyWith({String? serverId, String? serverName, String? liveDvrKey, String? providerIdentifier}) {
return LiveTvProgram(
key: key,
ratingKey: ratingKey,
guid: guid,
title: title,
summary: summary,
type: type,
year: year,
beginsAt: beginsAt,
endsAt: endsAt,
grandparentTitle: grandparentTitle,
parentTitle: parentTitle,
index: index,
parentIndex: parentIndex,
thumb: thumb,
art: art,
channelIdentifier: channelIdentifier,
channelCallSign: channelCallSign,
live: live,
premiere: premiere,
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
liveDvrKey: liveDvrKey ?? this.liveDvrKey,
providerIdentifier: providerIdentifier ?? this.providerIdentifier,
);
}
/// Start time as DateTime
DateTime? get startTime => beginsAt != null ? DateTime.fromMillisecondsSinceEpoch(beginsAt! * 1000) : null;
+26
View File
@@ -0,0 +1,26 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
part 'livetv_server_status.g.dart';
@JsonSerializable(createToJson: false)
class LiveTvServerStatus {
@JsonKey(name: 'livetv', fromJson: flexibleInt)
final int? liveTvCount;
@JsonKey(fromJson: flexibleBoolNullable)
final bool? allowTuners;
final String? ownerFeatures;
const LiveTvServerStatus({this.liveTvCount, this.allowTuners, this.ownerFeatures});
factory LiveTvServerStatus.fromJson(Map<String, dynamic> json) => _$LiveTvServerStatusFromJson(json);
Set<String> get ownerFeatureSet =>
(ownerFeatures ?? '').split(',').map((feature) => feature.trim()).where((feature) => feature.isNotEmpty).toSet();
bool get hasConfiguredDvr => (liveTvCount ?? 0) > 0;
bool get supportsTuners => allowTuners != false;
bool get hasDvrFeature => ownerFeatureSet.contains('dvr');
bool get hasLiveTvFeature => ownerFeatureSet.contains('livetv');
}
+13
View File
@@ -0,0 +1,13 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'livetv_server_status.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LiveTvServerStatus _$LiveTvServerStatusFromJson(Map<String, dynamic> json) => LiveTvServerStatus(
liveTvCount: flexibleInt(json['livetv']),
allowTuners: flexibleBoolNullable(json['allowTuners']),
ownerFeatures: json['ownerFeatures'] as String?,
);
+88
View File
@@ -0,0 +1,88 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
import 'livetv_capture_buffer.dart';
import 'livetv_program.dart';
import 'media_grab_operation.dart';
part 'livetv_session.g.dart';
Map<String, dynamic>? _firstMap(Object? raw) {
if (raw is Map<String, dynamic>) return raw;
if (raw is List && raw.isNotEmpty && raw.first is Map<String, dynamic>) return raw.first as Map<String, dynamic>;
return null;
}
LiveTvProgram? _programFromRaw(Object? raw) {
final map = _firstMap(raw);
if (map == null) return null;
try {
return LiveTvProgram.fromJson(map);
} catch (_) {
return null;
}
}
MediaGrabOperation? _grabOperationFromRaw(Object? raw) {
final map = _firstMap(raw);
if (map == null) return null;
try {
return MediaGrabOperation.fromJson(map);
} catch (_) {
return null;
}
}
CaptureBuffer? _captureBufferFromRaw(Object? raw) {
final map = _firstMap(raw);
if (map == null) return null;
final session = _firstMap(map['TranscodeSession']) ?? map;
return CaptureBuffer.fromTranscodeSession(session);
}
@JsonSerializable(createToJson: false)
class LiveTvSession {
@JsonKey(readValue: readStringField, defaultValue: '')
final String sessionID;
@JsonKey(readValue: readStringField)
final String? dvrID;
final String? channelIdentifier;
final String? channelCallSign;
final String? channelTitle;
final String? activityUUID;
@JsonKey(fromJson: flexibleInt)
final int? currentPosition;
@JsonKey(fromJson: flexibleInt)
final int? nextPosition;
@JsonKey(fromJson: flexibleInt)
final int? startedAt;
@JsonKey(name: 'CaptureBuffer', fromJson: _captureBufferFromRaw)
final CaptureBuffer? captureBuffer;
@JsonKey(name: 'MediaGrabOperation', fromJson: _grabOperationFromRaw)
final MediaGrabOperation? grabOperation;
@JsonKey(name: 'Timeline', fromJson: _firstMap)
final Map<String, dynamic>? timeline;
@JsonKey(name: 'AiringMetadataItem', fromJson: _programFromRaw)
final LiveTvProgram? airingMetadataItem;
@JsonKey(name: 'UpNextMetadataItem', fromJson: _programFromRaw)
final LiveTvProgram? upNextMetadataItem;
const LiveTvSession({
required this.sessionID,
this.dvrID,
this.channelIdentifier,
this.channelCallSign,
this.channelTitle,
this.activityUUID,
this.currentPosition,
this.nextPosition,
this.startedAt,
this.captureBuffer,
this.grabOperation,
this.timeline,
this.airingMetadataItem,
this.upNextMetadataItem,
});
factory LiveTvSession.fromJson(Map<String, dynamic> json) => _$LiveTvSessionFromJson(json);
}
+24
View File
@@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'livetv_session.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LiveTvSession _$LiveTvSessionFromJson(Map<String, dynamic> json) => LiveTvSession(
sessionID: readStringField(json, 'sessionID') as String? ?? '',
dvrID: readStringField(json, 'dvrID') as String?,
channelIdentifier: json['channelIdentifier'] as String?,
channelCallSign: json['channelCallSign'] as String?,
channelTitle: json['channelTitle'] as String?,
activityUUID: json['activityUUID'] as String?,
currentPosition: flexibleInt(json['currentPosition']),
nextPosition: flexibleInt(json['nextPosition']),
startedAt: flexibleInt(json['startedAt']),
captureBuffer: _captureBufferFromRaw(json['CaptureBuffer']),
grabOperation: _grabOperationFromRaw(json['MediaGrabOperation']),
timeline: _firstMap(json['Timeline']),
airingMetadataItem: _programFromRaw(json['AiringMetadataItem']),
upNextMetadataItem: _programFromRaw(json['UpNextMetadataItem']),
);
+73
View File
@@ -0,0 +1,73 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
import 'livetv_program.dart';
part 'media_grab_operation.g.dart';
Map<String, dynamic>? _metadataFromJson(Object? raw) {
if (raw is Map<String, dynamic>) return raw;
if (raw is List && raw.isNotEmpty && raw.first is Map<String, dynamic>) {
return raw.first as Map<String, dynamic>;
}
return null;
}
LiveTvProgram? _programFromMetadata(Object? raw) {
final metadata = _metadataFromJson(raw);
if (metadata == null) return null;
try {
return LiveTvProgram.fromJson(metadata);
} catch (_) {
return null;
}
}
/// A scheduled or active Plex DVR grab operation.
@JsonSerializable(createToJson: false)
class MediaGrabOperation {
@JsonKey(fromJson: flexibleInt)
final int? mediaSubscriptionID;
@JsonKey(fromJson: flexibleInt)
final int? mediaIndex;
@JsonKey(defaultValue: '')
final String id;
final String? key;
final String? grabberIdentifier;
final String? grabberProtocol;
@JsonKey(fromJson: flexibleDouble)
final double? percent;
@JsonKey(fromJson: flexibleInt)
final int? currentSize;
final String? status;
final String? provider;
@JsonKey(fromJson: flexibleBoolNullable)
final bool? rolling;
final String? error;
final String? linkedKey;
@JsonKey(name: 'Metadata', fromJson: _metadataFromJson)
final Map<String, dynamic>? metadata;
const MediaGrabOperation({
this.mediaSubscriptionID,
this.mediaIndex,
required this.id,
this.key,
this.grabberIdentifier,
this.grabberProtocol,
this.percent,
this.currentSize,
this.status,
this.provider,
this.rolling,
this.error,
this.linkedKey,
this.metadata,
});
factory MediaGrabOperation.fromJson(Map<String, dynamic> json) => _$MediaGrabOperationFromJson(json);
String get operationKey => (key != null && key!.isNotEmpty) ? key! : id;
LiveTvProgram? get program => _programFromMetadata(metadata);
}
+24
View File
@@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_grab_operation.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MediaGrabOperation _$MediaGrabOperationFromJson(Map<String, dynamic> json) => MediaGrabOperation(
mediaSubscriptionID: flexibleInt(json['mediaSubscriptionID']),
mediaIndex: flexibleInt(json['mediaIndex']),
id: json['id'] as String? ?? '',
key: json['key'] as String?,
grabberIdentifier: json['grabberIdentifier'] as String?,
grabberProtocol: json['grabberProtocol'] as String?,
percent: flexibleDouble(json['percent']),
currentSize: flexibleInt(json['currentSize']),
status: json['status'] as String?,
provider: json['provider'] as String?,
rolling: flexibleBoolNullable(json['rolling']),
error: json['error'] as String?,
linkedKey: json['linkedKey'] as String?,
metadata: _metadataFromJson(json['Metadata']),
);
+115
View File
@@ -0,0 +1,115 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
import 'livetv_dvr.dart';
import 'media_subscription.dart';
part 'media_grabber_device.g.dart';
List<ChannelMapping> _parseChannelMappings(Object? raw) {
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) ChannelMapping.fromJson(item),
];
}
List<SubscriptionSetting> _parseSettings(Object? raw) {
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) SubscriptionSetting.fromJson(item),
];
}
@JsonSerializable(createToJson: false)
class MediaGrabber {
@JsonKey(defaultValue: '')
final String identifier;
final String? protocol;
final String? title;
const MediaGrabber({required this.identifier, this.protocol, this.title});
factory MediaGrabber.fromJson(Map<String, dynamic> json) => _$MediaGrabberFromJson(json);
}
/// Tuner/grabber device known to Plex Media Server.
@JsonSerializable(createToJson: false)
class MediaGrabberDevice {
@JsonKey(defaultValue: '')
final String key;
@JsonKey(defaultValue: '')
final String uuid;
final String? uri;
final String? protocol;
final String? title;
final String? make;
final String? model;
final String? modelNumber;
final String? firmware;
@JsonKey(fromJson: flexibleInt)
final int? tuners;
final String? sources;
@JsonKey(fromJson: flexibleInt)
final int? status;
@JsonKey(fromJson: flexibleInt)
final int? state;
@JsonKey(fromJson: flexibleInt)
final int? lastSeenAt;
@JsonKey(name: 'ChannelMapping', fromJson: _parseChannelMappings)
final List<ChannelMapping> channelMappings;
@JsonKey(name: 'Setting', fromJson: _parseSettings)
final List<SubscriptionSetting> settings;
const MediaGrabberDevice({
required this.key,
required this.uuid,
this.uri,
this.protocol,
this.title,
this.make,
this.model,
this.modelNumber,
this.firmware,
this.tuners,
this.sources,
this.status,
this.state,
this.lastSeenAt,
this.channelMappings = const [],
this.settings = const [],
});
factory MediaGrabberDevice.fromJson(Map<String, dynamic> json) => _$MediaGrabberDeviceFromJson(json);
}
@JsonSerializable(createToJson: false)
class MediaGrabberDeviceChannel {
@JsonKey(readValue: readStringField, defaultValue: '')
final String identifier;
@JsonKey(readValue: readStringField)
final String? key;
@JsonKey(readValue: readStringField)
final String? name;
@JsonKey(fromJson: flexibleBool)
final bool drm;
@JsonKey(fromJson: flexibleBool)
final bool hd;
const MediaGrabberDeviceChannel({required this.identifier, this.key, this.name, this.drm = false, this.hd = false});
factory MediaGrabberDeviceChannel.fromJson(Map<String, dynamic> json) => _$MediaGrabberDeviceChannelFromJson(json);
}
class MediaGrabberChannelMapRequest {
final List<String> channelsEnabled;
final Map<String, String> channelMapping;
final Map<String, String> channelMappingByKey;
const MediaGrabberChannelMapRequest({
this.channelsEnabled = const [],
this.channelMapping = const {},
this.channelMappingByKey = const {},
});
}
+40
View File
@@ -0,0 +1,40 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_grabber_device.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MediaGrabber _$MediaGrabberFromJson(Map<String, dynamic> json) => MediaGrabber(
identifier: json['identifier'] as String? ?? '',
protocol: json['protocol'] as String?,
title: json['title'] as String?,
);
MediaGrabberDevice _$MediaGrabberDeviceFromJson(Map<String, dynamic> json) => MediaGrabberDevice(
key: json['key'] as String? ?? '',
uuid: json['uuid'] as String? ?? '',
uri: json['uri'] as String?,
protocol: json['protocol'] as String?,
title: json['title'] as String?,
make: json['make'] as String?,
model: json['model'] as String?,
modelNumber: json['modelNumber'] as String?,
firmware: json['firmware'] as String?,
tuners: flexibleInt(json['tuners']),
sources: json['sources'] as String?,
status: flexibleInt(json['status']),
state: flexibleInt(json['state']),
lastSeenAt: flexibleInt(json['lastSeenAt']),
channelMappings: json['ChannelMapping'] == null ? const [] : _parseChannelMappings(json['ChannelMapping']),
settings: json['Setting'] == null ? const [] : _parseSettings(json['Setting']),
);
MediaGrabberDeviceChannel _$MediaGrabberDeviceChannelFromJson(Map<String, dynamic> json) => MediaGrabberDeviceChannel(
identifier: readStringField(json, 'identifier') as String? ?? '',
key: readStringField(json, 'key') as String?,
name: readStringField(json, 'name') as String?,
drm: json['drm'] == null ? false : flexibleBool(json['drm']),
hd: json['hd'] == null ? false : flexibleBool(json['hd']),
);
+83
View File
@@ -0,0 +1,83 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
part 'media_provider_info.g.dart';
List<MediaProviderFeature> _parseFeatures(Object? raw) {
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) MediaProviderFeature.fromJson(item),
];
}
List<Map<String, dynamic>> _parseRawMaps(Object? raw) {
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) item,
];
}
@JsonSerializable(createToJson: false)
class MediaProviderInfo {
@JsonKey(fromJson: flexibleInt)
final int? id;
@JsonKey(fromJson: flexibleInt)
final int? parentID;
@JsonKey(defaultValue: '')
final String identifier;
final String? providerIdentifier;
final String? title;
final String? types;
final String? protocols;
final String? epgSource;
final String? friendlyName;
@JsonKey(name: 'Feature', fromJson: _parseFeatures)
final List<MediaProviderFeature> features;
const MediaProviderInfo({
this.id,
this.parentID,
required this.identifier,
this.providerIdentifier,
this.title,
this.types,
this.protocols,
this.epgSource,
this.friendlyName,
this.features = const [],
});
factory MediaProviderInfo.fromJson(Map<String, dynamic> json) => _$MediaProviderInfoFromJson(json);
}
@JsonSerializable(createToJson: false)
class MediaProviderFeature {
final String? key;
@JsonKey(defaultValue: '')
final String type;
final String? flavor;
final String? scrobbleKey;
final String? unscrobbleKey;
@JsonKey(name: 'Directory', fromJson: _parseRawMaps)
final List<Map<String, dynamic>> directories;
@JsonKey(name: 'Action', fromJson: _parseRawMaps)
final List<Map<String, dynamic>> actions;
@JsonKey(name: 'Pivot', fromJson: _parseRawMaps)
final List<Map<String, dynamic>> pivots;
const MediaProviderFeature({
this.key,
required this.type,
this.flavor,
this.scrobbleKey,
this.unscrobbleKey,
this.directories = const [],
this.actions = const [],
this.pivots = const [],
});
factory MediaProviderFeature.fromJson(Map<String, dynamic> json) => _$MediaProviderFeatureFromJson(json);
}
+31
View File
@@ -0,0 +1,31 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_provider_info.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MediaProviderInfo _$MediaProviderInfoFromJson(Map<String, dynamic> json) => MediaProviderInfo(
id: flexibleInt(json['id']),
parentID: flexibleInt(json['parentID']),
identifier: json['identifier'] as String? ?? '',
providerIdentifier: json['providerIdentifier'] as String?,
title: json['title'] as String?,
types: json['types'] as String?,
protocols: json['protocols'] as String?,
epgSource: json['epgSource'] as String?,
friendlyName: json['friendlyName'] as String?,
features: json['Feature'] == null ? const [] : _parseFeatures(json['Feature']),
);
MediaProviderFeature _$MediaProviderFeatureFromJson(Map<String, dynamic> json) => MediaProviderFeature(
key: json['key'] as String?,
type: json['type'] as String? ?? '',
flavor: json['flavor'] as String?,
scrobbleKey: json['scrobbleKey'] as String?,
unscrobbleKey: json['unscrobbleKey'] as String?,
directories: json['Directory'] == null ? const [] : _parseRawMaps(json['Directory']),
actions: json['Action'] == null ? const [] : _parseRawMaps(json['Action']),
pivots: json['Pivot'] == null ? const [] : _parseRawMaps(json['Pivot']),
);
+201
View File
@@ -0,0 +1,201 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
import 'media_grab_operation.dart';
part 'media_subscription.g.dart';
List<MediaSubscription> _parseSubscriptions(Object? raw) {
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) MediaSubscription.fromJson(item),
];
}
List<SubscriptionSetting> _parseSettings(Object? raw) {
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) SubscriptionSetting.fromJson(item),
];
}
List<MediaGrabOperation> _parseGrabOperations(Object? raw) {
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) MediaGrabOperation.fromJson(item),
];
}
Map<String, dynamic>? _mapFromJson(Object? raw) => raw is Map<String, dynamic> ? raw : null;
/// Template wrapper returned by `/media/subscriptions/template`.
@JsonSerializable(createToJson: false)
class SubscriptionTemplate {
@JsonKey(name: 'MediaSubscription', fromJson: _parseSubscriptions)
final List<MediaSubscription> subscriptions;
const SubscriptionTemplate({this.subscriptions = const []});
factory SubscriptionTemplate.fromJson(Map<String, dynamic> json) => _$SubscriptionTemplateFromJson(json);
}
/// A Plex recording/download rule (`MediaSubscription`).
@JsonSerializable(createToJson: false)
class MediaSubscription {
@JsonKey(defaultValue: '')
final String key;
@JsonKey(fromJson: flexibleInt)
final int? type;
final String? provider;
@JsonKey(fromJson: flexibleInt)
final int? targetLibrarySectionID;
@JsonKey(fromJson: flexibleInt)
final int? targetSectionLocationID;
final String? title;
@JsonKey(fromJson: flexibleBoolNullable)
final bool? selected;
final String? parameters;
@JsonKey(fromJson: flexibleInt)
final int? createdAt;
@JsonKey(fromJson: flexibleInt)
final int? storageTotal;
@JsonKey(fromJson: flexibleInt)
final int? durationTotal;
final String? airingsType;
final String? librarySectionTitle;
final String? locationPath;
@JsonKey(name: 'Video', fromJson: _mapFromJson)
final Map<String, dynamic>? video;
@JsonKey(name: 'Directory', fromJson: _mapFromJson)
final Map<String, dynamic>? directory;
@JsonKey(name: 'Playlist', fromJson: _mapFromJson)
final Map<String, dynamic>? playlist;
@JsonKey(name: 'Setting', fromJson: _parseSettings)
final List<SubscriptionSetting> settings;
@JsonKey(name: 'MediaGrabOperation', fromJson: _parseGrabOperations)
final List<MediaGrabOperation> grabOperations;
const MediaSubscription({
required this.key,
this.type,
this.provider,
this.targetLibrarySectionID,
this.targetSectionLocationID,
this.title,
this.selected,
this.parameters,
this.createdAt,
this.storageTotal,
this.durationTotal,
this.airingsType,
this.librarySectionTitle,
this.locationPath,
this.video,
this.directory,
this.playlist,
this.settings = const [],
this.grabOperations = const [],
});
factory MediaSubscription.fromJson(Map<String, dynamic> json) => _$MediaSubscriptionFromJson(json);
}
@JsonSerializable(createToJson: false)
class SubscriptionSetting {
@JsonKey(defaultValue: '')
final String id;
final String? label;
final String? summary;
final String? type;
@JsonKey(name: 'default')
final Object? defaultValue;
final Object? value;
@JsonKey(fromJson: flexibleBool)
final bool hidden;
@JsonKey(fromJson: flexibleBool)
final bool advanced;
final String? group;
final String? enumValues;
const SubscriptionSetting({
required this.id,
this.label,
this.summary,
this.type,
this.defaultValue,
this.value,
this.hidden = false,
this.advanced = false,
this.group,
this.enumValues,
});
factory SubscriptionSetting.fromJson(Map<String, dynamic> json) => _$SubscriptionSettingFromJson(json);
List<SubscriptionSettingOption> get options {
final raw = enumValues;
if (raw == null || raw.isEmpty) return const [];
return raw.split('|').map((entry) {
final separator = entry.indexOf(':');
if (separator < 0) {
return SubscriptionSettingOption(value: Uri.decodeComponent(entry), label: Uri.decodeComponent(entry));
}
return SubscriptionSettingOption(
value: Uri.decodeComponent(entry.substring(0, separator)),
label: Uri.decodeComponent(entry.substring(separator + 1)),
);
}).toList();
}
}
class SubscriptionSettingOption {
final String value;
final String label;
const SubscriptionSettingOption({required this.value, required this.label});
}
/// Query parameters for `POST /media/subscriptions`.
class MediaSubscriptionCreateRequest {
final int? targetLibrarySectionID;
final int? targetSectionLocationID;
final int? type;
final String? parameters;
final Map<String, Object?> hints;
final Map<String, Object?> prefs;
final Map<String, Object?> params;
final String? providers;
const MediaSubscriptionCreateRequest({
this.targetLibrarySectionID,
this.targetSectionLocationID,
this.type,
this.parameters,
this.hints = const {},
this.prefs = const {},
this.params = const {},
this.providers,
});
factory MediaSubscriptionCreateRequest.fromTemplate(
MediaSubscription subscription, {
int? targetLibrarySectionID,
int? targetSectionLocationID,
Map<String, Object?> prefs = const {},
}) {
final templatePrefs = <String, Object?>{
for (final setting in subscription.settings)
if (setting.id.isNotEmpty) setting.id: setting.value ?? setting.defaultValue,
};
return MediaSubscriptionCreateRequest(
targetLibrarySectionID: targetLibrarySectionID ?? subscription.targetLibrarySectionID,
targetSectionLocationID: targetSectionLocationID ?? subscription.targetSectionLocationID,
type: subscription.type,
parameters: subscription.parameters,
prefs: {...templatePrefs, ...prefs},
);
}
}
+46
View File
@@ -0,0 +1,46 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_subscription.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SubscriptionTemplate _$SubscriptionTemplateFromJson(Map<String, dynamic> json) => SubscriptionTemplate(
subscriptions: json['MediaSubscription'] == null ? const [] : _parseSubscriptions(json['MediaSubscription']),
);
MediaSubscription _$MediaSubscriptionFromJson(Map<String, dynamic> json) => MediaSubscription(
key: json['key'] as String? ?? '',
type: flexibleInt(json['type']),
provider: json['provider'] as String?,
targetLibrarySectionID: flexibleInt(json['targetLibrarySectionID']),
targetSectionLocationID: flexibleInt(json['targetSectionLocationID']),
title: json['title'] as String?,
selected: flexibleBoolNullable(json['selected']),
parameters: json['parameters'] as String?,
createdAt: flexibleInt(json['createdAt']),
storageTotal: flexibleInt(json['storageTotal']),
durationTotal: flexibleInt(json['durationTotal']),
airingsType: json['airingsType'] as String?,
librarySectionTitle: json['librarySectionTitle'] as String?,
locationPath: json['locationPath'] as String?,
video: _mapFromJson(json['Video']),
directory: _mapFromJson(json['Directory']),
playlist: _mapFromJson(json['Playlist']),
settings: json['Setting'] == null ? const [] : _parseSettings(json['Setting']),
grabOperations: json['MediaGrabOperation'] == null ? const [] : _parseGrabOperations(json['MediaGrabOperation']),
);
SubscriptionSetting _$SubscriptionSettingFromJson(Map<String, dynamic> json) => SubscriptionSetting(
id: json['id'] as String? ?? '',
label: json['label'] as String?,
summary: json['summary'] as String?,
type: json['type'] as String?,
defaultValue: json['default'],
value: json['value'],
hidden: json['hidden'] == null ? false : flexibleBool(json['hidden']),
advanced: json['advanced'] == null ? false : flexibleBool(json['advanced']),
group: json['group'] as String?,
enumValues: json['enumValues'] as String?,
);
@@ -65,6 +65,7 @@ mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
channel: channel,
posterUrl: posterUrl,
onTuneChannel: channel != null ? () => tuneChannel(channel) : null,
client: client,
);
}
}
+168 -30
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
@@ -7,6 +8,7 @@ import 'package:provider/provider.dart';
import '../../focus/focusable_action_bar.dart';
import '../../i18n/strings.g.dart';
import '../../media/live_tv_support.dart';
import '../../media/media_server_client.dart';
import '../../models/livetv_channel.dart';
import '../../models/livetv_dvr.dart';
import '../../mixins/refreshable.dart';
@@ -17,13 +19,15 @@ import '../../widgets/settings_builder.dart';
import '../../utils/app_logger.dart';
import '../../utils/desktop_window_padding.dart';
import '../../utils/platform_detector.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/overlay_sheet.dart';
import 'reorder_favorites_sheet.dart';
import 'tabs/guide_tab.dart';
import 'tabs/recordings_tab.dart';
import 'tabs/whats_on_tab.dart';
enum LiveTvTab { guide, whatsOn }
enum LiveTvTab { guide, whatsOn, recordings }
class LiveTvScreen extends StatefulWidget {
const LiveTvScreen({super.key});
@@ -37,8 +41,14 @@ class _LiveTvScreenState extends State<LiveTvScreen>
implements FocusableTab {
final _guideTabFocusNode = FocusNode(debugLabel: 'tab_chip_guide');
final _whatsOnTabFocusNode = FocusNode(debugLabel: 'tab_chip_whats_on');
final _recordingsTabFocusNode = FocusNode(debugLabel: 'tab_chip_recordings');
final _guideTabKey = GlobalKey<GuideTabState>();
final _whatsOnTabKey = GlobalKey<WhatsOnTabState>();
final _recordingsTabKey = GlobalKey<RecordingsTabState>();
/// Visible tabs in the current session. Recordings tab is included only
/// when at least one Live TV server has `liveTvDvr` capability.
List<LiveTvTab> _visibleTabs = [LiveTvTab.guide, LiveTvTab.whatsOn];
// App bar action bar
final _actionBarKey = GlobalKey<FocusableActionBarState>();
@@ -85,7 +95,13 @@ class _LiveTvScreenState extends State<LiveTvScreen>
}
@override
List<FocusNode> get tabChipFocusNodes => [_guideTabFocusNode, _whatsOnTabFocusNode];
List<FocusNode> get tabChipFocusNodes => [for (final tab in _visibleTabs) _focusNodeForTab(tab)];
FocusNode _focusNodeForTab(LiveTvTab tab) => switch (tab) {
LiveTvTab.guide => _guideTabFocusNode,
LiveTvTab.whatsOn => _whatsOnTabFocusNode,
LiveTvTab.recordings => _recordingsTabFocusNode,
};
@override
void initState() {
@@ -100,6 +116,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
void dispose() {
_guideTabFocusNode.dispose();
_whatsOnTabFocusNode.dispose();
_recordingsTabFocusNode.dispose();
disposeTabNavigation();
super.dispose();
}
@@ -109,17 +126,109 @@ class _LiveTvScreenState extends State<LiveTvScreen>
if (!tabController.indexIsChanging) {
super.onTabChanged();
// Pause/resume timers based on active tab
switch (LiveTvTab.values[tabController.index]) {
if (tabController.index >= _visibleTabs.length) return;
switch (_visibleTabs[tabController.index]) {
case LiveTvTab.guide:
_whatsOnTabKey.currentState?.pauseRefresh();
_recordingsTabKey.currentState?.pauseRefresh();
_guideTabKey.currentState?.resumeRefresh();
case LiveTvTab.whatsOn:
_guideTabKey.currentState?.pauseRefresh();
_recordingsTabKey.currentState?.pauseRefresh();
_whatsOnTabKey.currentState?.resumeRefresh();
case LiveTvTab.recordings:
_guideTabKey.currentState?.pauseRefresh();
_whatsOnTabKey.currentState?.pauseRefresh();
_recordingsTabKey.currentState?.resumeRefresh();
}
}
}
LiveTvTab? get _currentTab {
if (tabController.index < 0 || tabController.index >= _visibleTabs.length) return null;
return _visibleTabs[tabController.index];
}
/// Tab-aware refresh handler bound to the AppBar refresh button.
/// - Guide / What's On: server-side `reloadGuide` per DVR-capable client +
/// client-side channel re-fetch.
/// - Recordings: re-fetches scheduled recordings + rules.
Future<void> _onRefresh() async {
if (_currentTab == LiveTvTab.recordings) {
await _recordingsTabKey.currentState?.reload();
return;
}
await _serverReloadGuide();
await _loadChannels();
}
Future<void> _serverReloadGuide() async {
final multiServer = context.read<MultiServerProvider>();
final futures = <Future<void>>[];
for (final serverInfo in multiServer.liveTvServers) {
final client = multiServer.getClientForServer(serverInfo.serverId);
if (client == null || !client.capabilities.liveTvDvr) continue;
futures.add(_reloadGuideSafe(client, serverInfo.dvrKey));
}
if (futures.isEmpty) return;
await Future.wait(futures);
if (!mounted) return;
showSnackBar(context, t.liveTv.guideReloadRequested);
}
Future<void> _reloadGuideSafe(MediaServerClient client, String dvrId) async {
try {
await client.liveTv.reloadGuide(dvrId);
} catch (e) {
// 403 (admin only) and transient errors are non-fatal — caller still
// re-fetches client-side channels.
appLogger.d('Reload guide failed for DVR $dvrId: $e');
}
}
Future<void> _processRecordingRules() async {
final multiServer = context.read<MultiServerProvider>();
final futures = <Future<void>>[];
for (final serverInfo in multiServer.liveTvServers) {
final client = multiServer.getClientForServer(serverInfo.serverId);
if (client == null || !client.capabilities.liveTvDvr) continue;
futures.add(_processRulesSafe(client));
}
if (futures.isEmpty) return;
await Future.wait(futures);
if (!mounted) return;
showSnackBar(context, t.liveTv.rulesProcessRequested);
await _recordingsTabKey.currentState?.reload();
}
Future<void> _processRulesSafe(MediaServerClient client) async {
try {
await client.liveTv.processRecordingRules();
} catch (e) {
appLogger.d('processRecordingRules failed: $e');
}
}
/// Recompute visible tabs from the current MultiServerProvider state.
/// Re-inits the tab controller when the visible set changes (matches the
/// libraries-screen pattern at libraries_screen.dart:365).
void _refreshVisibleTabs(MultiServerProvider multiServer) {
final hasDvr = multiServer.liveTvServers.any((s) {
final c = multiServer.getClientForServer(s.serverId);
return c != null && c.capabilities.liveTvDvr;
});
final newTabs = [LiveTvTab.guide, LiveTvTab.whatsOn, if (hasDvr) LiveTvTab.recordings];
if (listEquals(_visibleTabs, newTabs)) return;
final currentTab = tabController.index < _visibleTabs.length ? _visibleTabs[tabController.index] : null;
disposeTabNavigation();
_visibleTabs = newTabs;
initTabNavigation();
if (currentTab != null) {
final newIndex = newTabs.indexOf(currentTab);
if (newIndex >= 0) tabController.index = newIndex;
}
}
/// Extracts enabled channel keys from DVR mappings, returning null if no DVR has mapping data.
Set<String>? _extractEnabledChannelKeys(List<LiveTvDvr> dvrs) {
final enabledKeys = <String>{};
@@ -238,6 +347,8 @@ class _LiveTvScreenState extends State<LiveTvScreen>
_isLoading = false;
});
_refreshVisibleTabs(multiServer);
// Load favorites by backend store: Plex is cloud/account-scoped, Jellyfin per server.
unawaited(_loadFavorites(multiServer));
@@ -374,11 +485,15 @@ class _LiveTvScreenState extends State<LiveTvScreen>
}
void _focusCurrentTab() {
switch (LiveTvTab.values[tabController.index]) {
case LiveTvTab.guide:
_guideTabKey.currentState?.focusContent();
case LiveTvTab.whatsOn:
_whatsOnTabKey.currentState?.focusFirstHub();
if (tabController.index < _visibleTabs.length) {
switch (_visibleTabs[tabController.index]) {
case LiveTvTab.guide:
_guideTabKey.currentState?.focusContent();
case LiveTvTab.whatsOn:
_whatsOnTabKey.currentState?.focusFirstHub();
case LiveTvTab.recordings:
_recordingsTabKey.currentState?.focusContent();
}
}
setState(() {
suppressAutoFocus = false;
@@ -392,15 +507,16 @@ class _LiveTvScreenState extends State<LiveTvScreen>
return switch (tab) {
LiveTvTab.guide => t.liveTv.guide,
LiveTvTab.whatsOn => t.liveTv.whatsOn,
LiveTvTab.recordings => t.liveTv.recordings,
};
}
List<Widget> _buildTabChipItems() {
return [
for (int i = 0; i < LiveTvTab.values.length; i++) ...[
for (int i = 0; i < _visibleTabs.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
buildTabChip(
_getTabLabel(LiveTvTab.values[i]),
_getTabLabel(_visibleTabs[i]),
i,
onSelectWhenActive: _focusCurrentTab,
onNavigateDown: _focusCurrentTab,
@@ -415,6 +531,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final theme = Theme.of(context);
final useSideNav = PlatformDetector.shouldUseSideNavigation(context);
final isRecordings = _currentTab == LiveTvTab.recordings;
return Scaffold(
appBar: AppBar(
title: useSideNav ? Row(children: _buildTabChipItems()) : Text(t.liveTv.title),
@@ -424,19 +541,30 @@ class _LiveTvScreenState extends State<LiveTvScreen>
onNavigateLeft: () => getTabChipFocusNode(tabCount - 1).requestFocus(),
onNavigateDown: _focusCurrentTab,
actions: [
FocusableAction(
icon: _showFavoritesOnly ? Symbols.star_rounded : Symbols.star_outline_rounded,
iconFill: _showFavoritesOnly ? 1.0 : 0.0,
tooltip: t.liveTv.favorites,
onPressed: _toggleFavoritesFilter,
),
if (_showFavoritesOnly && _favoriteChannels.length > 1)
if (!isRecordings)
FocusableAction(
icon: _showFavoritesOnly ? Symbols.star_rounded : Symbols.star_outline_rounded,
iconFill: _showFavoritesOnly ? 1.0 : 0.0,
tooltip: t.liveTv.favorites,
onPressed: _toggleFavoritesFilter,
),
if (!isRecordings && _showFavoritesOnly && _favoriteChannels.length > 1)
FocusableAction(
icon: Symbols.swap_vert_rounded,
tooltip: t.liveTv.reorderFavorites,
onPressed: _showReorderFavorites,
),
FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.liveTv.reloadGuide, onPressed: _loadChannels),
if (isRecordings)
FocusableAction(
icon: Symbols.bolt_rounded,
tooltip: t.liveTv.processRecordingRules,
onPressed: _processRecordingRules,
),
FocusableAction(
icon: Symbols.refresh_rounded,
tooltip: isRecordings ? t.common.refresh : t.liveTv.reloadGuide,
onPressed: _onRefresh,
),
],
),
]),
@@ -445,6 +573,26 @@ class _LiveTvScreenState extends State<LiveTvScreen>
);
}
Widget _buildTabContent(LiveTvTab tab, List<LiveTvChannel> guideChannels) {
return switch (tab) {
LiveTvTab.guide => GuideTab(
key: _guideTabKey,
channels: guideChannels,
isFavoriteChannel: _isFavoriteChannel,
onToggleFavorite: _toggleFavorite,
onNavigateUp: focusTabBar,
onBack: onTabBarBack,
),
LiveTvTab.whatsOn => WhatsOnTab(
key: _whatsOnTabKey,
channels: _channels,
onNavigateUp: focusTabBar,
onBack: onTabBarBack,
),
LiveTvTab.recordings => RecordingsTab(key: _recordingsTabKey, onNavigateUp: focusTabBar, onBack: onTabBarBack),
};
}
Widget _buildLiveTvBody(ThemeData theme, bool useSideNav) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
@@ -467,7 +615,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
),
);
}
if (_channels.isEmpty) {
if (_channels.isEmpty && !_visibleTabs.contains(LiveTvTab.recordings)) {
return Center(child: Text(t.liveTv.noChannels));
}
@@ -487,17 +635,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
Expanded(
child: TabBarView(
controller: tabController,
children: [
GuideTab(
key: _guideTabKey,
channels: guideChannels,
isFavoriteChannel: _isFavoriteChannel,
onToggleFavorite: _toggleFavorite,
onNavigateUp: focusTabBar,
onBack: onTabBarBack,
),
WhatsOnTab(key: _whatsOnTabKey, channels: _channels, onNavigateUp: focusTabBar, onBack: onTabBarBack),
],
children: [for (final tab in _visibleTabs) _buildTabContent(tab, guideChannels)],
),
),
],
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../models/livetv_channel.dart';
@@ -13,6 +14,7 @@ import '../../widgets/app_icon.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/overlay_sheet.dart';
import 'live_tv_actions_mixin.dart';
import 'livetv_recording_actions.dart';
/// Shows all upcoming airings of a show, matching the Plex "upcoming episodes" view.
class LiveTvShowScheduleScreen extends StatefulWidget {
@@ -80,11 +82,50 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
}
}
/// Resolve the owning client from the [MultiServerProvider]. The show
/// schedule screen is opened with a single [serverId], so no per-program
/// lookup is needed.
bool get _canRecord {
final client = context.read<MultiServerProvider>().getClientForServer(widget.serverId);
return client != null && client.capabilities.liveTvDvr;
}
Future<void> _onRecordShow() async {
final client = context.read<MultiServerProvider>().getClientForServer(widget.serverId);
if (client == null) return;
// Use the first program with a guid as the seed for `getSubscriptionTemplate`.
// The template returned by Plex includes both episode-level and series-level
// entries, so the user can still pick "Record Series" inside the options sheet.
LiveTvProgram? seed;
for (final p in _programs) {
if (p.guid != null && p.guid!.isNotEmpty) {
seed = p;
break;
}
}
if (seed == null) return;
await recordProgram(context, client, seed);
}
@override
Widget build(BuildContext context) {
final showRecord = _canRecord && _programs.any((p) => p.guid != null && p.guid!.isNotEmpty);
return OverlaySheetHost(
child: FocusedScrollScaffold(
title: Text(widget.showTitle),
actions: showRecord
? [
FocusableActionBar(
actions: [
FocusableAction(
icon: Symbols.fiber_manual_record_rounded,
tooltip: t.liveTv.recordShow,
onPressed: _onRecordShow,
),
],
),
]
: null,
slivers: [
if (_isLoading)
const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))
@@ -157,14 +198,7 @@ class _ScheduleListTile extends StatelessWidget {
String _formatAbsoluteTime(DateTime start, DateTime now, {required bool is24Hour}) {
final time = formatClockTime(start, is24Hour: is24Hour);
final today = DateTime(now.year, now.month, now.day);
final startDay = DateTime(start.year, start.month, start.day);
final diff = startDay.difference(today).inDays;
if (diff == 0) return 'Today at $time';
if (diff == 1) return 'Tomorrow at $time';
final weekday = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][start.weekday - 1];
return '$weekday at $time';
return '${formatRelativeDayLabel(start, now: now)} at $time';
}
@override
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import '../../exceptions/media_server_exceptions.dart';
import '../../i18n/strings.g.dart';
import '../../media/media_server_client.dart';
import '../../models/livetv_program.dart';
import '../../models/media_grab_operation.dart';
import '../../models/media_subscription.dart';
import '../../utils/app_logger.dart';
import '../../utils/dialogs.dart';
import '../../utils/snackbar_helper.dart';
import 'record_options_sheet.dart';
/// Outcome of an attempted recording rule create or edit. Returned by
/// [RecordOptionsSheet.push] / [RecordOptionsSheet.pushEdit] so callers can
/// show the right user feedback.
enum RecordOutcome { scheduled, updated, alreadyScheduled, adminRequired, targetMissing, failed, cancelled }
/// Fetch the recording template for [program] and push the options sheet so
/// the user can review prefs and create a recording rule.
///
/// Returns the outcome so callers can decide whether to dismiss the parent
/// sheet (e.g. close the program details sheet on a successful schedule).
///
/// Handles the standard error envelope:
/// - 403: surface "DVR settings require an admin account".
/// - 409 (duplicate): surface "Already scheduled" via info snackbar.
/// - Other: generic failure snackbar.
Future<RecordOutcome?> recordProgram(BuildContext context, MediaServerClient client, LiveTvProgram program) async {
final guid = program.guid;
if (guid == null || guid.isEmpty) {
if (!context.mounted) return null;
showSnackBar(context, t.liveTv.recordNotAvailable, type: SnackBarType.error);
return RecordOutcome.failed;
}
List<SubscriptionTemplate> templates;
try {
templates = await client.liveTv.getSubscriptionTemplate(guid);
} catch (e) {
appLogger.e('Failed to fetch recording template', error: e);
if (!context.mounted) return null;
if (_statusCode(e) == 403) {
showSnackBar(context, t.liveTv.dvrAdminRequired, type: SnackBarType.error);
return RecordOutcome.adminRequired;
}
showSnackBar(context, t.liveTv.recordingFailed, type: SnackBarType.error);
return RecordOutcome.failed;
}
final entries = <MediaSubscription>[for (final template in templates) ...template.subscriptions];
if (entries.isEmpty) {
if (!context.mounted) return null;
showSnackBar(context, t.liveTv.recordNotAvailable, type: SnackBarType.error);
return RecordOutcome.failed;
}
if (!context.mounted) return null;
final outcome = await RecordOptionsSheet.push(context, client: client, program: program, entries: entries);
if (!context.mounted) return outcome;
switch (outcome) {
case RecordOutcome.scheduled:
showSnackBar(context, t.liveTv.recordingScheduled, type: SnackBarType.success);
case RecordOutcome.alreadyScheduled:
showSnackBar(context, t.liveTv.alreadyScheduled);
case RecordOutcome.adminRequired:
showSnackBar(context, t.liveTv.dvrAdminRequired, type: SnackBarType.error);
case RecordOutcome.targetMissing:
showSnackBar(context, t.liveTv.recordingTargetMissing, type: SnackBarType.error);
case RecordOutcome.failed:
showSnackBar(context, t.liveTv.recordingFailed, type: SnackBarType.error);
case RecordOutcome.updated:
case RecordOutcome.cancelled:
case null:
// user dismissed without creating
break;
}
return outcome;
}
/// Push the [RecordOptionsSheet] in edit mode against an existing rule, then
/// surface a success / error snackbar.
Future<RecordOutcome?> editRecordingRule(BuildContext context, MediaServerClient client, MediaSubscription rule) async {
final outcome = await RecordOptionsSheet.pushEdit(context, client: client, rule: rule);
if (!context.mounted) return outcome;
switch (outcome) {
case RecordOutcome.updated:
showSnackBar(context, t.liveTv.recordingRuleUpdated, type: SnackBarType.success);
case RecordOutcome.adminRequired:
showSnackBar(context, t.liveTv.dvrAdminRequired, type: SnackBarType.error);
case RecordOutcome.failed:
showSnackBar(context, t.liveTv.recordingFailed, type: SnackBarType.error);
case RecordOutcome.scheduled:
case RecordOutcome.alreadyScheduled:
case RecordOutcome.targetMissing:
case RecordOutcome.cancelled:
case null:
break;
}
return outcome;
}
Future<bool> confirmCancelGrab(BuildContext context, MediaServerClient client, MediaGrabOperation op) async {
final operationKey = op.operationKey;
if (operationKey.isEmpty) {
showSnackBar(context, t.liveTv.recordingFailed, type: SnackBarType.error);
return false;
}
final title = op.program?.displayTitle ?? '';
final confirmed = await showConfirmDialog(
context,
title: t.liveTv.cancelRecordingTitle,
message: t.liveTv.cancelRecordingMessage(title: title),
confirmText: t.liveTv.cancelRecording,
isDestructive: true,
);
if (!confirmed) return false;
try {
await client.liveTv.cancelGrab(operationKey);
if (context.mounted) {
showSnackBar(context, t.liveTv.recordingCancelled, type: SnackBarType.success);
}
return true;
} catch (e) {
appLogger.e('Failed to cancel grab', error: e);
if (context.mounted) {
showSnackBar(context, t.liveTv.recordingFailed, type: SnackBarType.error);
}
return false;
}
}
Future<bool> confirmDeleteRule(BuildContext context, MediaServerClient client, MediaSubscription rule) async {
final confirmed = await showConfirmDialog(
context,
title: t.liveTv.deleteRuleTitle,
message: t.liveTv.deleteRuleMessage(title: rule.title ?? ''),
confirmText: t.liveTv.deleteRule,
isDestructive: true,
);
if (!confirmed) return false;
try {
await client.liveTv.deleteRecordingRule(rule.key);
if (context.mounted) {
showSnackBar(context, t.liveTv.recordingRuleDeleted, type: SnackBarType.success);
}
return true;
} catch (e) {
appLogger.e('Failed to delete recording rule', error: e);
if (context.mounted) {
showSnackBar(context, t.liveTv.recordingFailed, type: SnackBarType.error);
}
return false;
}
}
int? _statusCode(Object e) {
if (e is MediaServerHttpException) return e.statusCode;
return null;
}
+166 -69
View File
@@ -4,21 +4,31 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_button.dart';
import '../../i18n/strings.g.dart';
import '../../media/media_server_client.dart';
import '../../models/livetv_channel.dart';
import '../../models/livetv_program.dart';
import '../../models/media_subscription.dart';
import '../../services/image_cache_service.dart';
import '../../utils/app_logger.dart';
import '../../utils/formatters.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/overlay_sheet.dart';
import '../../widgets/optimized_media_image.dart' show blurArtwork;
import 'livetv_recording_actions.dart';
/// Shows a bottom sheet with program details and actions (Record, Watch Channel, Play).
/// Shows a bottom sheet with program details and actions (Play / Watch Channel /
/// Record / Manage recording).
///
/// [client] is the resolved [MediaServerClient] for the program's owning server.
/// Recording affordances appear only when the client supports DVR
/// (`capabilities.liveTvDvr`) and the program has a recordable guid.
void showProgramDetailsSheet(
BuildContext context, {
required LiveTvProgram program,
required LiveTvChannel? channel,
required String? posterUrl,
required VoidCallback? onTuneChannel,
MediaServerClient? client,
}) {
OverlaySheetController.showAdaptive(
context,
@@ -28,22 +38,41 @@ void showProgramDetailsSheet(
channel: channel,
posterUrl: posterUrl,
onTuneChannel: onTuneChannel,
client: client,
);
},
);
}
enum _ActionStyle { filled, outlined }
class _SheetAction {
final String label;
final IconData icon;
final VoidCallback onPressed;
final _ActionStyle style;
const _SheetAction({
required this.label,
required this.icon,
required this.onPressed,
this.style = _ActionStyle.outlined,
});
}
class _ProgramDetailsSheetContent extends StatefulWidget {
final LiveTvProgram program;
final LiveTvChannel? channel;
final String? posterUrl;
final VoidCallback? onTuneChannel;
final MediaServerClient? client;
const _ProgramDetailsSheetContent({
required this.program,
required this.channel,
required this.posterUrl,
required this.onTuneChannel,
required this.client,
});
@override
@@ -51,101 +80,169 @@ class _ProgramDetailsSheetContent extends StatefulWidget {
}
class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent> {
final List<FocusNode> _buttonFocusNodes = [];
final List<FocusNode> _focusNodes = [];
MediaSubscription? _existingSubscription;
bool _checkedMapping = false;
bool get _canRecord {
final client = widget.client;
if (client == null) return false;
if (!client.capabilities.liveTvDvr) return false;
final guid = widget.program.guid;
return guid != null && guid.isNotEmpty;
}
@override
void initState() {
super.initState();
_buildButtonFocusNodes();
if (_canRecord) {
_loadMapping();
} else {
_checkedMapping = true;
}
}
@override
void dispose() {
for (final node in _buttonFocusNodes) {
for (final node in _focusNodes) {
node.dispose();
}
super.dispose();
}
void _buildButtonFocusNodes() {
int count = 0;
if (widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++;
if (!widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++;
Future<void> _loadMapping() async {
final client = widget.client;
final providerId = widget.program.providerIdentifier;
final ratingKey = widget.program.ratingKey;
if (client == null || providerId == null || providerId.isEmpty || ratingKey == null || ratingKey.isEmpty) {
if (mounted) setState(() => _checkedMapping = true);
return;
}
try {
final mapped = await client.liveTv.fetchSubscriptionMapping(providerId: providerId, ratingKeys: [ratingKey]);
final match = mapped.where((s) => s.key.isNotEmpty).firstOrNull;
if (!mounted) return;
setState(() {
_existingSubscription = match;
_checkedMapping = true;
});
} catch (e) {
// 403 (no DVR access) or transient — treat as unscheduled.
appLogger.d('Subscription mapping check failed: $e');
if (mounted) setState(() => _checkedMapping = true);
}
}
for (int i = 0; i < count; i++) {
_buttonFocusNodes.add(FocusNode(debugLabel: 'program_sheet_btn_$i'));
void _ensureFocusNodes(int needed) {
while (_focusNodes.length < needed) {
_focusNodes.add(FocusNode(debugLabel: 'program_sheet_btn_${_focusNodes.length}'));
}
while (_focusNodes.length > needed) {
_focusNodes.removeLast().dispose();
}
}
void _focusButton(int index) {
if (index >= 0 && index < _buttonFocusNodes.length) {
_buttonFocusNodes[index].requestFocus();
if (index >= 0 && index < _focusNodes.length) {
_focusNodes[index].requestFocus();
}
}
void _closeSheet() => OverlaySheetController.closeAdaptive(context);
List<_SheetAction> _buildActions() {
final program = widget.program;
final client = widget.client;
final actions = <_SheetAction>[];
if (widget.onTuneChannel != null) {
final isLive = program.isCurrentlyAiring;
actions.add(
_SheetAction(
label: isLive ? t.common.play : t.liveTv.watchChannel,
icon: isLive ? Symbols.play_arrow_rounded : Symbols.live_tv_rounded,
style: isLive ? _ActionStyle.filled : _ActionStyle.outlined,
onPressed: () {
_closeSheet();
widget.onTuneChannel!();
},
),
);
}
if (_canRecord && client != null && _checkedMapping) {
final existing = _existingSubscription;
if (existing != null) {
actions.add(
_SheetAction(
label: t.liveTv.manageRecording,
icon: Symbols.fiber_manual_record_rounded,
style: _ActionStyle.filled,
onPressed: () async {
final deleted = await confirmDeleteRule(context, client, existing);
if (deleted && mounted) _closeSheet();
},
),
);
} else {
actions.add(
_SheetAction(
label: t.liveTv.record,
icon: Symbols.fiber_manual_record_rounded,
style: _ActionStyle.filled,
onPressed: () async {
final outcome = await recordProgram(context, client, program);
if (!mounted) return;
if (outcome == RecordOutcome.scheduled || outcome == RecordOutcome.alreadyScheduled) {
_closeSheet();
}
},
),
);
}
}
return actions;
}
Widget _buildButton(_SheetAction action, int index, int total) {
final node = _focusNodes[index];
final child = action.style == _ActionStyle.filled
? FilledButton.icon(
style: FilledButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: action.onPressed,
icon: AppIcon(action.icon),
label: Text(action.label),
)
: OutlinedButton.icon(
style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: action.onPressed,
icon: AppIcon(action.icon),
label: Text(action.label),
);
return FocusableButton(
focusNode: node,
onPressed: action.onPressed,
onNavigateLeft: index > 0 ? () => _focusButton(index - 1) : null,
onNavigateRight: index < total - 1 ? () => _focusButton(index + 1) : null,
onBack: _closeSheet,
child: child,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final program = widget.program;
final channel = widget.channel;
final actions = _buildActions();
_ensureFocusNodes(actions.length);
// Build the list of action buttons with their focus wrappers
final buttons = <Widget>[];
int buttonIndex = 0;
void closeSheet() => OverlaySheetController.closeAdaptive(context);
if (program.isCurrentlyAiring && widget.onTuneChannel != null) {
final idx = buttonIndex;
buttons.add(
FocusableButton(
focusNode: _buttonFocusNodes[idx],
onPressed: () {
closeSheet();
widget.onTuneChannel!();
},
onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null,
onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null,
onBack: closeSheet,
child: FilledButton.icon(
style: FilledButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: () {
closeSheet();
widget.onTuneChannel!();
},
icon: const AppIcon(Symbols.play_arrow_rounded),
label: Text(t.common.play),
),
),
);
buttonIndex++;
}
if (!program.isCurrentlyAiring && widget.onTuneChannel != null) {
if (buttons.isNotEmpty) buttons.add(const SizedBox(width: 8));
final idx = buttonIndex;
buttons.add(
FocusableButton(
focusNode: _buttonFocusNodes[idx],
onPressed: () {
closeSheet();
widget.onTuneChannel!();
},
onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null,
onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null,
onBack: closeSheet,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: () {
closeSheet();
widget.onTuneChannel!();
},
icon: const AppIcon(Symbols.live_tv_rounded),
label: Text(t.liveTv.watchChannel),
),
),
);
buttonIndex++;
for (var i = 0; i < actions.length; i++) {
if (i > 0) buttons.add(const SizedBox(width: 8));
buttons.add(_buildButton(actions[i], i, actions.length));
}
return Padding(
@@ -220,7 +317,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
],
),
const SizedBox(height: 16),
Row(children: buttons),
if (buttons.isNotEmpty) Row(children: buttons),
],
),
);
@@ -0,0 +1,591 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../exceptions/media_server_exceptions.dart';
import '../../focus/focusable_button.dart';
import '../../focus/focusable_text_field.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../media/media_server_client.dart';
import '../../models/livetv_program.dart';
import '../../models/media_subscription.dart';
import '../../utils/app_logger.dart';
import '../../utils/dialogs.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/overlay_sheet.dart';
import 'livetv_recording_actions.dart';
/// Sub-page rendered inside the open program-details [OverlaySheetController].
/// Displays editable [SubscriptionSetting]s for the chosen template entry and
/// posts a `MediaSubscriptionCreateRequest` on Save.
///
/// Returns one of [RecordOutcome] when the page closes:
/// - [RecordOutcome.scheduled] on successful create
/// - [RecordOutcome.alreadyScheduled] on 409 (duplicate)
/// - [RecordOutcome.adminRequired] on 403
/// - [RecordOutcome.targetMissing] when the template lacks a target library
/// - [RecordOutcome.failed] on other errors
/// - [RecordOutcome.cancelled] when the user dismissed without saving
class RecordOptionsSheet {
RecordOptionsSheet._();
/// Push the options sheet in create mode, seeded by template entries.
static Future<RecordOutcome?> push(
BuildContext context, {
required MediaServerClient client,
required LiveTvProgram program,
required List<MediaSubscription> entries,
}) {
return OverlaySheetController.pushAdaptive<RecordOutcome>(
context,
builder: (sheetContext) =>
_RecordOptionsContent(client: client, headerTitle: program.displayTitle, entries: entries),
);
}
/// Push the options sheet in edit mode against an existing rule. Save calls
/// `updateRecordingRule(id, prefs)` instead of creating a new rule.
static Future<RecordOutcome?> pushEdit(
BuildContext context, {
required MediaServerClient client,
required MediaSubscription rule,
}) {
return OverlaySheetController.pushAdaptive<RecordOutcome>(
context,
builder: (sheetContext) => _RecordOptionsContent(
client: client,
headerTitle: rule.title ?? '',
entries: [rule],
isEdit: true,
editingId: rule.key,
),
);
}
}
class _RecordOptionsContent extends StatefulWidget {
final MediaServerClient client;
final String headerTitle;
final List<MediaSubscription> entries;
final bool isEdit;
final String? editingId;
const _RecordOptionsContent({
required this.client,
required this.headerTitle,
required this.entries,
this.isEdit = false,
this.editingId,
});
@override
State<_RecordOptionsContent> createState() => _RecordOptionsContentState();
}
class _RecordOptionsContentState extends State<_RecordOptionsContent> {
late MediaSubscription _entry;
final Map<String, Object?> _dirtyPrefs = {};
bool _saving = false;
final _saveFocusNode = FocusNode(debugLabel: 'record_options_save');
@override
void initState() {
super.initState();
_entry = widget.entries.firstWhere((e) => e.selected == true, orElse: () => widget.entries.first);
}
@override
void dispose() {
_saveFocusNode.dispose();
super.dispose();
}
void _selectEntry(MediaSubscription entry) {
if (identical(entry, _entry)) return;
setState(() {
_entry = entry;
_dirtyPrefs.clear();
});
}
void _setPref(String id, Object? baseline, Object? value) {
setState(() {
if (_compareValues(value, baseline)) {
_dirtyPrefs.remove(id);
} else {
_dirtyPrefs[id] = value;
}
});
}
bool _compareValues(Object? a, Object? b) {
if (a == null && b == null) return true;
if (a == null || b == null) return false;
return a.toString() == b.toString();
}
Object? _baselineFor(SubscriptionSetting setting) => setting.value ?? setting.defaultValue;
Object? _currentValueFor(SubscriptionSetting setting) {
if (_dirtyPrefs.containsKey(setting.id)) return _dirtyPrefs[setting.id];
return setting.value ?? setting.defaultValue;
}
Future<void> _save() async {
if (!widget.isEdit && _entry.targetLibrarySectionID == null) {
_close(RecordOutcome.targetMissing);
return;
}
setState(() => _saving = true);
try {
if (widget.isEdit) {
final id = widget.editingId;
if (id == null || id.isEmpty) {
_close(RecordOutcome.failed);
return;
}
await widget.client.liveTv.updateRecordingRule(id, Map.of(_dirtyPrefs));
if (!mounted) return;
_close(RecordOutcome.updated);
} else {
final request = MediaSubscriptionCreateRequest.fromTemplate(_entry, prefs: Map.of(_dirtyPrefs));
await widget.client.liveTv.createRecordingRule(request);
if (!mounted) return;
_close(RecordOutcome.scheduled);
}
} catch (e) {
appLogger.e('Failed to save recording rule', error: e);
if (!mounted) return;
final code = e is MediaServerHttpException ? e.statusCode : null;
final outcome = switch (code) {
403 => RecordOutcome.adminRequired,
409 => RecordOutcome.alreadyScheduled,
_ => RecordOutcome.failed,
};
_close(outcome);
}
}
void _close([RecordOutcome? result]) {
OverlaySheetController.popAdaptive(context, result ?? RecordOutcome.cancelled);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visibleSettings = _entry.settings.where((s) => !s.hidden && !s.advanced).toList();
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.isEdit ? t.liveTv.editRule : t.liveTv.recordOptions,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 2),
Text(
widget.headerTitle,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
if (widget.entries.length > 1) ...[
const SizedBox(height: 12),
_EntryChooser(entries: widget.entries, selected: _entry, onSelect: _selectEntry),
],
const SizedBox(height: 8),
Flexible(
child: visibleSettings.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
_entry.airingsType ?? '',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
)
: ListView.builder(
shrinkWrap: true,
itemCount: visibleSettings.length,
itemBuilder: (context, index) {
final setting = visibleSettings[index];
return _SettingRow(
setting: setting,
currentValue: _currentValueFor(setting),
autofocus: index == 0,
onChanged: (value) => _setPref(setting.id, _baselineFor(setting), value),
);
},
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FocusableButton(
onPressed: _saving ? null : _close,
child: TextButton(onPressed: _saving ? null : _close, child: Text(t.common.cancel)),
),
const SizedBox(width: 8),
FocusableButton(
focusNode: _saveFocusNode,
onPressed: _saving ? null : _save,
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: AppIcon(widget.isEdit ? Symbols.save_rounded : Symbols.fiber_manual_record_rounded),
label: Text(widget.isEdit ? t.common.save : t.liveTv.record),
),
),
],
),
],
),
);
}
}
class _EntryChooser extends StatelessWidget {
final List<MediaSubscription> entries;
final MediaSubscription selected;
final void Function(MediaSubscription) onSelect;
const _EntryChooser({required this.entries, required this.selected, required this.onSelect});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final entry in entries)
FocusableButton(
onPressed: () => onSelect(entry),
child: ChoiceChip(
label: Text(entry.title ?? ''),
selected: identical(entry, selected),
onSelected: (_) => onSelect(entry),
labelStyle: TextStyle(
color: identical(entry, selected) ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface,
),
selectedColor: theme.colorScheme.primary,
),
),
],
);
}
}
class _SettingRow extends StatelessWidget {
final SubscriptionSetting setting;
final Object? currentValue;
final bool autofocus;
final void Function(Object?) onChanged;
const _SettingRow({
required this.setting,
required this.currentValue,
required this.autofocus,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final type = setting.type;
if (type == 'bool') {
return _BoolSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged);
}
if (type == 'int' && setting.options.isNotEmpty) {
return _EnumSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged);
}
if (type == 'int') {
return _IntSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged);
}
return _TextSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged);
}
}
bool _coerceBool(Object? value) {
if (value is bool) return value;
if (value is num) return value != 0;
if (value is String) {
final s = value.toLowerCase();
return s == 'true' || s == '1';
}
return false;
}
class _BoolSettingRow extends StatelessWidget {
final SubscriptionSetting setting;
final Object? currentValue;
final bool autofocus;
final void Function(Object?) onChanged;
const _BoolSettingRow({
required this.setting,
required this.currentValue,
required this.autofocus,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final value = _coerceBool(currentValue);
void toggle() => onChanged(!value);
return FocusableWrapper(
autofocus: autofocus,
autoScroll: true,
useBackgroundFocus: true,
onSelect: toggle,
child: InkWell(
canRequestFocus: false,
onTap: toggle,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(setting.label ?? setting.id, style: theme.textTheme.bodyMedium),
if (setting.summary != null && setting.summary!.isNotEmpty)
Text(
setting.summary!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
),
IgnorePointer(
child: Switch(value: value, onChanged: (v) => onChanged(v)),
),
],
),
),
),
);
}
}
class _EnumSettingRow extends StatelessWidget {
final SubscriptionSetting setting;
final Object? currentValue;
final bool autofocus;
final void Function(Object?) onChanged;
const _EnumSettingRow({
required this.setting,
required this.currentValue,
required this.autofocus,
required this.onChanged,
});
Future<void> _openPicker(BuildContext context) async {
final selected = await showOptionPickerDialog<String>(
context,
title: setting.label ?? setting.id,
options: [for (final option in setting.options) (icon: null, label: option.label, value: option.value)],
);
if (selected != null) onChanged(selected);
}
String _labelForValue(Object? value) {
final str = value?.toString() ?? '';
final match = setting.options.where((o) => o.value == str).firstOrNull;
return match?.label ?? str;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FocusableWrapper(
autofocus: autofocus,
autoScroll: true,
useBackgroundFocus: true,
onSelect: () => _openPicker(context),
child: InkWell(
canRequestFocus: false,
onTap: () => _openPicker(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(setting.label ?? setting.id, style: theme.textTheme.bodyMedium),
if (setting.summary != null && setting.summary!.isNotEmpty)
Text(
setting.summary!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
),
const SizedBox(width: 12),
Text(_labelForValue(currentValue), style: theme.textTheme.bodyMedium),
const SizedBox(width: 4),
const AppIcon(Symbols.chevron_right_rounded),
],
),
),
),
);
}
}
class _IntSettingRow extends StatefulWidget {
final SubscriptionSetting setting;
final Object? currentValue;
final bool autofocus;
final void Function(Object?) onChanged;
const _IntSettingRow({
required this.setting,
required this.currentValue,
required this.autofocus,
required this.onChanged,
});
@override
State<_IntSettingRow> createState() => _IntSettingRowState();
}
class _IntSettingRowState extends State<_IntSettingRow> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.currentValue?.toString() ?? '');
}
@override
void didUpdateWidget(covariant _IntSettingRow oldWidget) {
super.didUpdateWidget(oldWidget);
final next = widget.currentValue?.toString() ?? '';
if (next != _controller.text) _controller.text = next;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium),
if (widget.setting.summary != null && widget.setting.summary!.isNotEmpty)
Text(
widget.setting.summary!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 4),
FocusableTextField(
controller: _controller,
autofocus: widget.autofocus,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))],
onChanged: (text) {
final parsed = int.tryParse(text);
widget.onChanged(parsed);
},
),
],
),
);
}
}
class _TextSettingRow extends StatefulWidget {
final SubscriptionSetting setting;
final Object? currentValue;
final bool autofocus;
final void Function(Object?) onChanged;
const _TextSettingRow({
required this.setting,
required this.currentValue,
required this.autofocus,
required this.onChanged,
});
@override
State<_TextSettingRow> createState() => _TextSettingRowState();
}
class _TextSettingRowState extends State<_TextSettingRow> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.currentValue?.toString() ?? '');
}
@override
void didUpdateWidget(covariant _TextSettingRow oldWidget) {
super.didUpdateWidget(oldWidget);
final next = widget.currentValue?.toString() ?? '';
if (next != _controller.text) _controller.text = next;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium),
if (widget.setting.summary != null && widget.setting.summary!.isNotEmpty)
Text(
widget.setting.summary!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 4),
FocusableTextField(
controller: _controller,
autofocus: widget.autofocus,
onChanged: (text) => widget.onChanged(text),
),
],
),
);
}
}
+1
View File
@@ -1089,6 +1089,7 @@ class GuideTabState extends State<GuideTab> {
channel: channel,
posterUrl: posterUrl,
onTuneChannel: () => _tuneChannel(channel),
client: client,
);
}
}
+486
View File
@@ -0,0 +1,486 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../../exceptions/media_server_exceptions.dart';
import '../../../focus/focusable_wrapper.dart';
import '../../../i18n/strings.g.dart';
import '../../../media/media_server_client.dart';
import '../../../models/livetv_program.dart';
import '../../../models/media_grab_operation.dart';
import '../../../models/media_subscription.dart';
import '../../../providers/multi_server_provider.dart';
import '../../../theme/mono_tokens.dart';
import '../../../utils/app_logger.dart';
import '../../../utils/dialogs.dart';
import '../../../utils/formatters.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/overlay_sheet.dart';
import '../livetv_recording_actions.dart';
class RecordingsTab extends StatefulWidget {
final VoidCallback? onNavigateUp;
final VoidCallback? onBack;
const RecordingsTab({super.key, this.onNavigateUp, this.onBack});
@override
State<RecordingsTab> createState() => RecordingsTabState();
}
class _ServerRecordings {
final String serverId;
final MediaServerClient client;
final List<MediaGrabOperation> grabs;
final List<MediaSubscription> rules;
const _ServerRecordings({required this.serverId, required this.client, required this.grabs, required this.rules});
}
class _GrabEntry {
final _ServerRecordings server;
final MediaGrabOperation op;
const _GrabEntry(this.server, this.op);
}
class _RuleEntry {
final _ServerRecordings server;
final MediaSubscription rule;
const _RuleEntry(this.server, this.rule);
}
enum _RuleAction { edit, delete }
class RecordingsTabState extends State<RecordingsTab> {
List<_ServerRecordings> _serverRecordings = [];
bool _isLoading = true;
bool _adminBlocked = false;
String? _error;
Timer? _refreshTimer;
bool _pendingFocus = false;
final _firstTileFocusNode = FocusNode(debugLabel: 'recordings_tab_first_tile');
@override
void initState() {
super.initState();
_load();
_refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) _load();
});
}
@override
void dispose() {
_refreshTimer?.cancel();
_firstTileFocusNode.dispose();
super.dispose();
}
void focusContent() {
if (_isLoading) {
_pendingFocus = true;
return;
}
_pendingFocus = false;
if (_firstTileFocusNode.context != null) {
_firstTileFocusNode.requestFocus();
}
}
void pauseRefresh() => _refreshTimer?.cancel();
void resumeRefresh() {
_refreshTimer?.cancel();
_refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) _load();
});
_load();
}
/// Public reload helper for the parent screen's refresh action.
Future<void> reload() => _load();
Future<void> _load() async {
if (!mounted) return;
setState(() {
_isLoading = _serverRecordings.isEmpty;
_error = null;
});
final multiServer = context.read<MultiServerProvider>();
final results = <_ServerRecordings>[];
var anyAdminError = false;
var anyOtherError = false;
final seenServers = <String>{};
for (final serverInfo in multiServer.liveTvServers) {
if (!seenServers.add(serverInfo.serverId)) continue;
final client = multiServer.getClientForServer(serverInfo.serverId);
if (client == null) continue;
if (!client.capabilities.liveTvDvr) continue;
try {
final grabs = await client.liveTv.fetchScheduledRecordings();
final rules = await client.liveTv.fetchRecordingRules();
results.add(_ServerRecordings(serverId: serverInfo.serverId, client: client, grabs: grabs, rules: rules));
} catch (e) {
appLogger.e('Failed to load recordings for ${serverInfo.serverId}', error: e);
if (e is MediaServerHttpException && e.statusCode == 403) {
anyAdminError = true;
} else {
anyOtherError = true;
}
}
}
if (!mounted) return;
setState(() {
_serverRecordings = results;
_isLoading = false;
_adminBlocked = results.isEmpty && anyAdminError;
_error = (results.isEmpty && anyOtherError && !anyAdminError) ? t.liveTv.recordingFailed : null;
});
if (_pendingFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) focusContent();
});
}
}
List<_GrabEntry> get _allGrabs {
final entries = <_GrabEntry>[
for (final server in _serverRecordings)
for (final op in server.grabs) _GrabEntry(server, op),
];
entries.sort((a, b) {
final aStart = a.op.program?.beginsAt ?? 0;
final bStart = b.op.program?.beginsAt ?? 0;
return aStart.compareTo(bStart);
});
return entries;
}
List<_RuleEntry> get _allRules {
final entries = <_RuleEntry>[
for (final server in _serverRecordings)
for (final rule in server.rules) _RuleEntry(server, rule),
];
entries.sort((a, b) {
final aType = a.rule.type ?? 0;
final bType = b.rule.type ?? 0;
// Series first (type 2), then episodes (type 4)
final typeCompare = aType.compareTo(bType);
if (typeCompare != 0) return typeCompare;
return (a.rule.title ?? '').compareTo(b.rule.title ?? '');
});
return entries;
}
Future<void> _onCancelGrab(_GrabEntry entry) async {
final cancelled = await confirmCancelGrab(context, entry.server.client, entry.op);
if (cancelled) await _load();
}
Future<void> _onRuleTap(_RuleEntry entry) async {
final action = await showOptionPickerDialog<_RuleAction>(
context,
title: entry.rule.title ?? '',
options: [
(icon: Symbols.edit_rounded, label: t.liveTv.editRuleAction, value: _RuleAction.edit),
(icon: Symbols.delete_rounded, label: t.common.delete, value: _RuleAction.delete),
],
);
if (!mounted) return;
switch (action) {
case _RuleAction.edit:
final outcome = await editRecordingRule(context, entry.server.client, entry.rule);
if (outcome == RecordOutcome.updated) await _load();
case _RuleAction.delete:
final deleted = await confirmDeleteRule(context, entry.server.client, entry.rule);
if (deleted) await _load();
case null:
break;
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_serverRecordings.isEmpty && _adminBlocked) {
return Center(child: _EmptyMessage(text: t.liveTv.dvrAdminRequired));
}
if (_serverRecordings.isEmpty && _error != null) {
return Center(child: _EmptyMessage(text: _error!));
}
final grabs = _allGrabs;
final rules = _allRules;
if (grabs.isEmpty && rules.isEmpty) {
return Center(child: _EmptyMessage(text: t.liveTv.noScheduledRecordings));
}
return OverlaySheetHost(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
if (grabs.isNotEmpty) ...[
_SectionHeader(t.liveTv.scheduledRecordings),
for (var i = 0; i < grabs.length; i++)
_GrabTile(
entry: grabs[i],
autofocus: i == 0,
focusNode: i == 0 ? _firstTileFocusNode : null,
onTap: () => _onCancelGrab(grabs[i]),
onNavigateUp: i == 0 ? widget.onNavigateUp : null,
onBack: widget.onBack,
),
],
if (rules.isNotEmpty) ...[
_SectionHeader(t.liveTv.recordingRules),
for (var i = 0; i < rules.length; i++)
_RuleTile(
entry: rules[i],
autofocus: grabs.isEmpty && i == 0,
focusNode: grabs.isEmpty && i == 0 ? _firstTileFocusNode : null,
onTap: () => _onRuleTap(rules[i]),
onNavigateUp: grabs.isEmpty && i == 0 ? widget.onNavigateUp : null,
onBack: widget.onBack,
),
],
],
),
);
}
}
class _EmptyMessage extends StatelessWidget {
final String text;
const _EmptyMessage({required this.text});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppIcon(Symbols.fiber_manual_record_rounded, size: 40, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 12),
Text(text, textAlign: TextAlign.center, style: theme.textTheme.bodyLarge),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String label;
const _SectionHeader(this.label);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
label,
style: Theme.of(context).textTheme.titleSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
);
}
}
class _GrabTile extends StatelessWidget {
final _GrabEntry entry;
final bool autofocus;
final FocusNode? focusNode;
final VoidCallback onTap;
final VoidCallback? onNavigateUp;
final VoidCallback? onBack;
const _GrabTile({
required this.entry,
required this.autofocus,
this.focusNode,
required this.onTap,
this.onNavigateUp,
this.onBack,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final program = entry.op.program;
final status = entry.op.status ?? 'scheduled';
final isRecording = status == 'grabbing';
final isError = status == 'error';
final title = program?.displayTitle ?? entry.op.key ?? '';
final timeStr = _formatTime(program, MediaQuery.alwaysUse24HourFormatOf(context));
final subtitle = [
if (timeStr.isNotEmpty) timeStr,
if (program?.channelCallSign != null && program!.channelCallSign!.isNotEmpty) program.channelCallSign!,
].join(' · ');
return FocusableWrapper(
autofocus: autofocus,
focusNode: focusNode,
autoScroll: true,
useBackgroundFocus: true,
disableScale: true,
onSelect: onTap,
onNavigateUp: onNavigateUp,
onBack: onBack,
child: InkWell(
canRequestFocus: false,
onTap: onTap,
child: Container(
decoration: isRecording
? BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.08),
border: Border(left: BorderSide(color: theme.colorScheme.primary, width: 3)),
)
: null,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(color: tokens(context).textMuted),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (isRecording)
_StatusBadge(label: t.liveTv.recordingInProgress, color: theme.colorScheme.primary)
else if (isError)
_StatusBadge(label: t.common.error, color: theme.colorScheme.error),
],
),
),
),
);
}
String _formatTime(LiveTvProgram? program, bool is24Hour) {
if (program == null) return '';
final start = program.startTime;
final end = program.endTime;
if (start == null) return '';
final clock = formatClockTime(start, is24Hour: is24Hour);
final dayLabel = formatRelativeDayLabel(start);
final duration = end != null ? formatDurationTextual(end.difference(start).inMilliseconds) : '';
return [dayLabel, clock, if (duration.isNotEmpty) duration].join(' · ');
}
}
class _RuleTile extends StatelessWidget {
final _RuleEntry entry;
final bool autofocus;
final FocusNode? focusNode;
final VoidCallback onTap;
final VoidCallback? onNavigateUp;
final VoidCallback? onBack;
const _RuleTile({
required this.entry,
required this.autofocus,
this.focusNode,
required this.onTap,
this.onNavigateUp,
this.onBack,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final rule = entry.rule;
final title = rule.title ?? '';
final isSeries = rule.type == 2;
final subtitleParts = <String>[
if (isSeries) t.liveTv.recordSeries else t.liveTv.recordEpisode,
if (rule.airingsType != null && rule.airingsType!.isNotEmpty) rule.airingsType!,
if (rule.grabOperations.isNotEmpty) t.liveTv.recordingsCount(count: rule.grabOperations.length.toString()),
];
return FocusableWrapper(
autofocus: autofocus,
focusNode: focusNode,
autoScroll: true,
useBackgroundFocus: true,
disableScale: true,
onSelect: onTap,
onNavigateUp: onNavigateUp,
onBack: onBack,
child: InkWell(
canRequestFocus: false,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (subtitleParts.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
subtitleParts.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(color: tokens(context).textMuted),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
);
}
}
class _StatusBadge extends StatelessWidget {
final String label;
final Color color;
const _StatusBadge({required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: color, borderRadius: const BorderRadius.all(Radius.circular(4))),
child: Text(
label,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11),
),
);
}
}
+7
View File
@@ -25,7 +25,14 @@ import '../media/server_capabilities.dart';
import '../models/jellyfin/jellyfin_user_profile.dart';
import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart';
import '../models/livetv_lineup.dart';
import '../models/livetv_program.dart';
import '../models/livetv_server_status.dart';
import '../models/livetv_session.dart';
import '../models/media_grab_operation.dart';
import '../models/media_grabber_device.dart';
import '../models/media_provider_info.dart';
import '../models/media_subscription.dart';
import '../media/media_source_info.dart';
import '../media/media_sort.dart';
import '../utils/app_logger.dart';
@@ -6,6 +6,10 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
final JellyfinClient _client;
_JellyfinLiveTvSupport(this._client);
Future<T> _unsupported<T>() async => throw UnimplementedError('Jellyfin DVR recording API is not implemented');
Never _unsupportedSync() => throw UnimplementedError('Jellyfin DVR recording API is not implemented');
@override
Future<bool> isAvailable() => _client.hasLiveTv();
@@ -95,4 +99,184 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
appLogger.e('Failed to save Jellyfin favorite channels', error: e);
}
}
@override
Future<LiveTvServerStatus> fetchLiveTvServerStatus() => _unsupported();
@override
Future<LiveTvDvr?> fetchDvr(String dvrId) => _unsupported();
@override
Future<LiveTvActivityResult<LiveTvDvr?>> createDvr({
required List<String> devices,
required List<String> lineups,
String? language,
String? country,
String? postalCode,
}) => _unsupported();
@override
Future<void> deleteDvr(String dvrId) => _unsupported();
@override
Future<void> updateDvrPrefs(String dvrId, Map<String, Object?> prefs) => _unsupported();
@override
Future<void> attachDeviceToDvr(String dvrId, String deviceId) => _unsupported();
@override
Future<void> detachDeviceFromDvr(String dvrId, String deviceId) => _unsupported();
@override
Future<void> addLineupToDvr(String dvrId, String lineupUri) => _unsupported();
@override
Future<void> removeLineupFromDvr(String dvrId, String lineupUri) => _unsupported();
@override
Future<LiveTvActivityResult<void>> reloadGuide(String dvrId) => _unsupported();
@override
Future<void> cancelGuideReload(String dvrId) => _unsupported();
@override
Future<List<MediaGrabber>> fetchGrabbers({String? protocol}) => _unsupported();
@override
Future<List<MediaGrabberDevice>> fetchGrabberDevices() => _unsupported();
@override
Future<LiveTvActivityResult<List<MediaGrabberDevice>>> discoverGrabberDevices() => _unsupported();
@override
Future<MediaGrabberDevice?> fetchGrabberDevice(String deviceId) => _unsupported();
@override
Future<MediaGrabberDevice?> addGrabberDevice(String uri, {String? grabberId}) => _unsupported();
@override
Future<void> updateGrabberDevice(String deviceId, {bool? enabled, String? title}) => _unsupported();
@override
Future<void> deleteGrabberDevice(String deviceId) => _unsupported();
@override
Future<List<MediaGrabberDeviceChannel>> fetchGrabberDeviceChannels(String deviceId) => _unsupported();
@override
Future<LiveTvActivityResult<MediaGrabberDevice?>> scanGrabberDevice(
String deviceId, {
String? source,
Map<String, Object?> prefs = const {},
String? network,
String? country,
}) => _unsupported();
@override
Future<MediaGrabberDevice?> cancelGrabberDeviceScan(String deviceId) => _unsupported();
@override
Future<MediaGrabberDevice?> saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request) =>
_unsupported();
@override
Future<void> updateGrabberDevicePrefs(String deviceId, Map<String, Object?> prefs) => _unsupported();
@override
String buildGrabberDeviceThumbUrl(String deviceId, int version) => _unsupportedSync();
@override
Future<List<LiveTvCountry>> fetchEpgCountries() => _unsupported();
@override
Future<List<LiveTvLanguage>> fetchEpgLanguages() => _unsupported();
@override
Future<List<LiveTvRegion>> fetchEpgRegions(String country, String epgId) => _unsupported();
@override
Future<LiveTvLineupResult> fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}) =>
_unsupported();
@override
Future<List<LiveTvChannel>> fetchEpgChannelsForLineup(String lineupUri) => _unsupported();
@override
Future<List<LiveTvLineup>> fetchEpgChannelsForLineups(List<String> lineupUris) => _unsupported();
@override
Future<List<ChannelMapping>> computeEpgChannelMap({required String deviceUri, required String lineupUri}) =>
_unsupported();
@override
Future<LiveTvActivityResult<Map<String, dynamic>?>> findBestLineup({
required String deviceUri,
required String lineupGroupUri,
}) => _unsupported();
@override
Future<List<SubscriptionTemplate>> getSubscriptionTemplate(String guid) => _unsupported();
@override
Future<List<MediaSubscription>> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}) =>
_unsupported();
@override
Future<MediaSubscription?> fetchRecordingRule(
String subscriptionId, {
bool includeGrabs = true,
bool includeStorage = true,
}) => _unsupported();
@override
Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request) => _unsupported();
@override
Future<MediaSubscription?> updateRecordingRule(String subscriptionId, Map<String, Object?> prefs) => _unsupported();
@override
Future<void> deleteRecordingRule(String subscriptionId) => _unsupported();
@override
Future<MediaSubscription?> moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) => _unsupported();
@override
Future<void> processRecordingRules() => _unsupported();
@override
Future<List<MediaGrabOperation>> fetchScheduledRecordings() => _unsupported();
@override
Future<void> cancelGrab(String operationId) => _unsupported();
@override
Future<List<MediaSubscription>> fetchSubscriptionMapping({
required String providerId,
required List<String> ratingKeys,
bool includeStorage = true,
}) => _unsupported();
@override
Future<List<MediaProviderInfo>> fetchMediaProviders() => _unsupported();
@override
Future<void> registerMediaProvider(String url) => _unsupported();
@override
Future<void> refreshMediaProviders() => _unsupported();
@override
Future<void> unregisterMediaProvider(String providerId) => _unsupported();
@override
Future<List<LiveTvSession>> fetchLiveTvSessionsDetailed() => _unsupported();
@override
Future<LiveTvSession?> fetchLiveTvSession(String sessionId) => _unsupported();
@override
Uri buildNotificationWebSocketUri({List<String>? filters}) => _unsupportedSync();
@override
Uri buildNotificationEventSourceUri({List<String>? filters}) => _unsupportedSync();
}
+7
View File
@@ -28,7 +28,14 @@ import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart';
import '../models/livetv_hub_result.dart';
import '../models/livetv_lineup.dart';
import '../models/livetv_program.dart';
import '../models/livetv_server_status.dart';
import '../models/livetv_session.dart';
import '../models/media_grab_operation.dart';
import '../models/media_grabber_device.dart';
import '../models/media_provider_info.dart';
import '../models/media_subscription.dart';
import '../models/plex/plex_activity.dart';
import '../models/plex/plex_config.dart';
import '../models/plex/play_queue_response.dart';
+671 -12
View File
@@ -15,7 +15,15 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
List<({String identifier, String gridEndpoint})> get _providerEpg;
Future<MediaServerResponse> _getWithFailover(String path, {Map<String, dynamic>? queryParameters});
Future<MediaServerResponse> _getWithFailover(
String path, {
Map<String, dynamic>? queryParameters,
// ignore: unused_element_parameter
Map<String, String>? headers,
Duration? timeout,
// ignore: unused_element_parameter
AbortController? abort,
});
Map<String, dynamic>? _getMediaContainer(MediaServerResponse response);
PlexMetadataDto _createTaggedMetadata(Map<String, dynamic> json);
@@ -39,6 +47,64 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
}
}
String? _activityUuid(MediaServerResponse response) => response.headers['x-plex-activity'];
List<T> _extractContainerList<T>(
MediaServerResponse response,
Iterable<String> keys,
T Function(Map<String, dynamic> json) fromJson,
) {
final container = _getMediaContainer(response);
if (container == null) return const [];
for (final key in keys) {
final raw = container[key];
final list = flexibleList(raw);
if (list == null) continue;
return [
for (final item in list)
if (item is Map<String, dynamic>) fromJson(item),
];
}
return const [];
}
T? _extractFirst<T>(MediaServerResponse response, Iterable<String> keys, T Function(Map<String, dynamic>) fromJson) {
final list = _extractContainerList(response, keys, fromJson);
return list.firstOrNull;
}
void _throwIfFailed(MediaServerResponse response) => throwIfHttpError(response);
Future<void> _expectOk(Future<MediaServerResponse> Function() call) async {
final response = await call();
_throwIfFailed(response);
}
String _withQuery(String path, String query) => query.isEmpty ? path : '$path?$query';
String _subscriptionCreateQuery(MediaSubscriptionCreateRequest request) {
final parts = <String>[];
final parameters = request.parameters;
if (parameters != null && parameters.isNotEmpty) parts.add(parameters);
final flat = <String, Object?>{
'targetLibrarySectionID': request.targetLibrarySectionID,
'targetSectionLocationID': request.targetSectionLocationID,
'type': request.type,
if (request.providers != null) 'providers': request.providers,
for (final entry in request.hints.entries) 'hints[${entry.key}]': entry.value,
for (final entry in request.prefs.entries) 'prefs[${entry.key}]': entry.value,
for (final entry in request.params.entries) 'params[${entry.key}]': entry.value,
};
final encoded = MediaServerHttpClient.encodeQueryParameters(flat);
if (encoded.isNotEmpty) parts.add(encoded);
return parts.join('&');
}
Map<String, dynamic> _prefQuery(String prefix, Map<String, Object?> values) => {
for (final entry in values.entries) '$prefix[${entry.key}]': entry.value,
};
/// Send a live TV timeline heartbeat to keep the transcode session alive.
///
/// Returns an updated [CaptureBuffer] if the response contains a
@@ -107,7 +173,12 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
return _wrapListApiCall<LiveTvDvr>(() => _http.get('/livetv/dvrs'), (response) {
final container = _getMediaContainer(response);
if (container != null && container['Dvr'] != null) {
return (container['Dvr'] as List).map((json) => LiveTvDvr.fromJson(json as Map<String, dynamic>)).toList();
final rootMappings = container['ChannelMapping'];
return (container['Dvr'] as List).map((json) {
final map = Map<String, dynamic>.from(json as Map<String, dynamic>);
map['ChannelMapping'] ??= rootMappings;
return LiveTvDvr.fromJson(map);
}).toList();
}
return [];
}, 'Failed to get DVRs');
@@ -119,6 +190,242 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
return dvrs.isNotEmpty;
}
Future<LiveTvServerStatus> getLiveTvServerStatus() async {
final response = await _getWithFailover('/');
final container = _getMediaContainer(response);
return LiveTvServerStatus.fromJson(container ?? const <String, dynamic>{});
}
Future<LiveTvDvr?> getDvr(String dvrId) async {
final response = await _getWithFailover('/livetv/dvrs/$dvrId');
final container = _getMediaContainer(response);
final rootMappings = container?['ChannelMapping'];
return _extractFirst(response, const ['Dvr'], (json) {
final map = Map<String, dynamic>.from(json)..putIfAbsent('ChannelMapping', () => rootMappings);
return LiveTvDvr.fromJson(map);
});
}
Future<LiveTvActivityResult<LiveTvDvr?>> createDvr({
required List<String> devices,
required List<String> lineups,
String? language,
String? country,
String? postalCode,
}) async {
final response = await _http.post(
'/livetv/dvrs',
queryParameters: {
'device': devices,
'lineup': lineups,
...?(language == null ? null : {'language': language}),
...?(country == null ? null : {'country': country}),
...?(postalCode == null ? null : {'postalCode': postalCode}),
},
timeout: MediaServerTimeouts.receive,
);
_throwIfFailed(response);
return LiveTvActivityResult(
value: _extractFirst(response, const ['Dvr'], LiveTvDvr.fromJson),
activityUuid: _activityUuid(response),
);
}
Future<void> deleteDvr(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId'));
Future<void> updateDvrPrefs(String dvrId, Map<String, Object?> prefs) =>
_expectOk(() => _http.put('/livetv/dvrs/$dvrId/prefs', queryParameters: prefs));
Future<void> attachDeviceToDvr(String dvrId, String deviceId) =>
_expectOk(() => _http.put('/livetv/dvrs/$dvrId/devices/$deviceId'));
Future<void> detachDeviceFromDvr(String dvrId, String deviceId) =>
_expectOk(() => _http.delete('/livetv/dvrs/$dvrId/devices/$deviceId'));
Future<void> addLineupToDvr(String dvrId, String lineupUri) =>
_expectOk(() => _http.put('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri}));
Future<void> removeLineupFromDvr(String dvrId, String lineupUri) =>
_expectOk(() => _http.delete('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri}));
Future<LiveTvActivityResult<void>> reloadGuide(String dvrId) async {
final response = await _http.post('/livetv/dvrs/$dvrId/reloadGuide', timeout: MediaServerTimeouts.receive);
_throwIfFailed(response);
return LiveTvActivityResult(value: null, activityUuid: _activityUuid(response));
}
Future<void> cancelGuideReload(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/reloadGuide'));
Future<List<MediaGrabber>> getGrabbers({String? protocol}) async {
final response = await _getWithFailover(
'/media/grabbers',
queryParameters: {
...?(protocol == null ? null : {'protocol': protocol}),
},
);
return _extractContainerList(response, const ['MediaGrabber'], MediaGrabber.fromJson);
}
Future<List<MediaGrabberDevice>> getGrabberDevices() async {
final response = await _getWithFailover('/media/grabbers/devices');
return _extractContainerList(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
Future<LiveTvActivityResult<List<MediaGrabberDevice>>> discoverGrabberDevices() async {
final response = await _http.post('/media/grabbers/devices/discover', timeout: MediaServerTimeouts.receive);
_throwIfFailed(response);
return LiveTvActivityResult(
value: _extractContainerList(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson),
activityUuid: _activityUuid(response),
);
}
Future<MediaGrabberDevice?> getGrabberDevice(String deviceId) async {
final response = await _getWithFailover('/media/grabbers/devices/$deviceId');
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
Future<MediaGrabberDevice?> addGrabberDevice(String uri, {String? grabberId}) async {
final path = grabberId == null ? '/media/grabbers/devices' : '/media/grabbers/$grabberId/devices';
final response = await _http.post(path, queryParameters: {'uri': uri});
_throwIfFailed(response);
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
Future<void> updateGrabberDevice(String deviceId, {bool? enabled, String? title}) => _expectOk(
() => _http.put(
'/media/grabbers/devices/$deviceId',
queryParameters: {
...?(enabled == null ? null : {'enabled': enabled ? 1 : 0}),
...?(title == null ? null : {'title': title}),
},
),
);
Future<void> deleteGrabberDevice(String deviceId) =>
_expectOk(() => _http.delete('/media/grabbers/devices/$deviceId'));
Future<List<MediaGrabberDeviceChannel>> getGrabberDeviceChannels(String deviceId) async {
final response = await _getWithFailover('/media/grabbers/devices/$deviceId/channels');
return _extractContainerList(response, const ['DeviceChannel'], MediaGrabberDeviceChannel.fromJson);
}
Future<LiveTvActivityResult<MediaGrabberDevice?>> scanGrabberDevice(
String deviceId, {
String? source,
Map<String, Object?> prefs = const {},
String? network,
String? country,
}) async {
final response = await _http.post(
'/media/grabbers/devices/$deviceId/scan',
queryParameters: {
...?(source == null ? null : {'source': source}),
for (final entry in prefs.entries) 'prefs[${entry.key}]': entry.value,
...?(network == null ? null : {'network': network}),
...?(country == null ? null : {'country': country}),
},
timeout: MediaServerTimeouts.receive,
);
_throwIfFailed(response);
return LiveTvActivityResult(
value: _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson),
activityUuid: _activityUuid(response),
);
}
Future<MediaGrabberDevice?> cancelGrabberDeviceScan(String deviceId) async {
final response = await _http.delete('/media/grabbers/devices/$deviceId/scan');
_throwIfFailed(response);
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
Future<MediaGrabberDevice?> saveGrabberDeviceChannelMap(
String deviceId,
MediaGrabberChannelMapRequest request,
) async {
final response = await _http.put(
'/media/grabbers/devices/$deviceId/channelmap',
queryParameters: {
if (request.channelsEnabled.isNotEmpty) 'channelsEnabled': request.channelsEnabled.join(','),
for (final entry in request.channelMapping.entries) 'channelMapping[${entry.key}]': entry.value,
for (final entry in request.channelMappingByKey.entries) 'channelMappingByKey[${entry.key}]': entry.value,
},
);
_throwIfFailed(response);
return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson);
}
Future<void> updateGrabberDevicePrefs(String deviceId, Map<String, Object?> prefs) =>
_expectOk(() => _http.put('/media/grabbers/devices/$deviceId/prefs', queryParameters: prefs));
String buildGrabberDeviceThumbUrl(String deviceId, int version) =>
'${config.baseUrl}/media/grabbers/devices/$deviceId/thumb/$version'.withPlexToken(config.token);
Future<List<LiveTvCountry>> getEpgCountries() async {
final response = await _getWithFailover('/livetv/epg/countries');
return _extractContainerList(response, const ['Country'], LiveTvCountry.fromJson);
}
Future<List<LiveTvLanguage>> getEpgLanguages() async {
final response = await _getWithFailover('/livetv/epg/languages');
return _extractContainerList(response, const ['Language'], LiveTvLanguage.fromJson);
}
Future<List<LiveTvRegion>> getEpgRegions(String country, String epgId) async {
final response = await _getWithFailover('/livetv/epg/countries/$country/$epgId/regions');
return _extractContainerList(response, const ['Region'], LiveTvRegion.fromJson);
}
Future<LiveTvLineupResult> getEpgLineups(String country, String epgId, {String? postalCode, String? region}) async {
final path = region == null
? '/livetv/epg/countries/$country/$epgId/lineups'
: '/livetv/epg/countries/$country/$epgId/regions/$region/lineups';
final response = await _getWithFailover(
path,
queryParameters: {
...?(postalCode == null ? null : {'postalCode': postalCode}),
},
);
final container = _getMediaContainer(response);
return LiveTvLineupResult(
lineupGroupUuid: container?['uuid'] as String?,
lineups: _extractContainerList(response, const ['Lineup'], LiveTvLineup.fromJson),
);
}
Future<List<LiveTvChannel>> getEpgChannelsForLineup(String lineupUri) async {
final response = await _getWithFailover('/livetv/epg/channels', queryParameters: {'lineup': lineupUri});
return _extractContainerList(response, const [
'Channel',
], (json) => LiveTvChannel.fromJson(json).copyWith(serverId: serverId, serverName: serverName));
}
Future<List<LiveTvLineup>> getEpgChannelsForLineups(List<String> lineupUris) async {
final response = await _getWithFailover('/livetv/epg/lineupchannels', queryParameters: {'lineup': lineupUris});
return _extractContainerList(response, const ['Lineup'], LiveTvLineup.fromJson);
}
Future<List<ChannelMapping>> computeEpgChannelMap({required String deviceUri, required String lineupUri}) async {
final response = await _getWithFailover(
'/livetv/epg/channelmap',
queryParameters: {'device': deviceUri, 'lineup': lineupUri},
);
return _extractContainerList(response, const ['ChannelMapping'], ChannelMapping.fromJson);
}
Future<LiveTvActivityResult<Map<String, dynamic>?>> findBestLineup({
required String deviceUri,
required String lineupGroupUri,
}) async {
final response = await _getWithFailover(
'/livetv/epg/lineup',
queryParameters: {'device': deviceUri, 'lineupGroup': lineupGroupUri},
timeout: MediaServerTimeouts.receive,
);
return LiveTvActivityResult(value: _getMediaContainer(response), activityUuid: _activityUuid(response));
}
/// Get EPG channels using provider lineup endpoints (matches official Plex web client)
Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async {
List<LiveTvChannel> parseChannels(MediaServerResponse response) {
@@ -195,7 +502,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
/// Parse a list of JSON items into [LiveTvProgram] objects, skipping any that fail.
/// A single Metadata entry may carry multiple Media entries representing back-to-back
/// airings of the same program on the same channel; emit one program per airing.
List<LiveTvProgram> _parseLiveTvPrograms(List items) {
List<LiveTvProgram> _parseLiveTvPrograms(List items, {String? providerIdentifier, String? liveDvrKey}) {
final programs = <LiveTvProgram>[];
for (final item in items) {
try {
@@ -203,10 +510,24 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
final mediaList = (map['Media'] as List?)?.whereType<Map<String, dynamic>>().toList();
if (mediaList != null && mediaList.length > 1) {
for (final media in mediaList) {
programs.add(LiveTvProgram.fromJson(map, mediaOverride: media));
programs.add(
LiveTvProgram.fromJson(map, mediaOverride: media).copyWith(
serverId: serverId,
serverName: serverName,
liveDvrKey: liveDvrKey,
providerIdentifier: providerIdentifier,
),
);
}
} else {
programs.add(LiveTvProgram.fromJson(map));
programs.add(
LiveTvProgram.fromJson(map).copyWith(
serverId: serverId,
serverName: serverName,
liveDvrKey: liveDvrKey,
providerIdentifier: providerIdentifier,
),
);
}
} catch (e, st) {
appLogger.w('LiveTvProgram parse failed', error: e, stackTrace: st);
@@ -252,13 +573,13 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
}
final programs = <LiveTvProgram>[];
if (container != null && container['Metadata'] != null) {
programs.addAll(_parseLiveTvPrograms(container['Metadata'] as List));
programs.addAll(_parseLiveTvPrograms(container['Metadata'] as List, providerIdentifier: providerIdentifier));
}
// Some responses nest programs inside Hub entries
if (container != null && container['Hub'] != null) {
for (final hub in container['Hub'] as List) {
if (hub is Map && hub['Metadata'] != null) {
programs.addAll(_parseLiveTvPrograms(hub['Metadata'] as List));
programs.addAll(_parseLiveTvPrograms(hub['Metadata'] as List, providerIdentifier: providerIdentifier));
}
}
}
@@ -290,7 +611,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
if (container == null || container['Hub'] == null) continue;
for (final hubJson in container['Hub'] as List) {
final hub = _parseLiveTvHub(hubJson);
final hub = _parseLiveTvHub(hubJson, provider.identifier);
if (hub != null) allHubs.add(hub);
}
} catch (e) {
@@ -302,7 +623,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
}
/// Parse a single hub JSON object into a [LiveTvHubResult], or null if parsing fails.
LiveTvHubResult? _parseLiveTvHub(dynamic hubJson) {
LiveTvHubResult? _parseLiveTvHub(dynamic hubJson, String providerIdentifier) {
try {
final metadataList = hubJson['Metadata'] as List?;
if (metadataList == null || metadataList.isEmpty) return null;
@@ -311,7 +632,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
for (final itemJson in metadataList) {
if (itemJson is! Map<String, dynamic>) continue;
_extractLiveTvImages(itemJson);
final entry = _parseLiveTvHubEntry(itemJson);
final entry = _parseLiveTvHubEntry(itemJson, providerIdentifier);
if (entry != null) entries.add(entry);
}
@@ -328,11 +649,13 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
}
/// Parse a single metadata item into a [LiveTvHubEntry], or null if parsing fails.
LiveTvHubEntry? _parseLiveTvHubEntry(Map<String, dynamic> itemJson) {
LiveTvHubEntry? _parseLiveTvHubEntry(Map<String, dynamic> itemJson, String providerIdentifier) {
try {
final dto = PlexMetadataDto.fromJson(itemJson).copyWith(serverId: serverId, serverName: serverName);
final metadata = PlexMappers.mediaItem(dto);
final program = LiveTvProgram.fromJson(itemJson);
final program = LiveTvProgram.fromJson(
itemJson,
).copyWith(serverId: serverId, serverName: serverName, providerIdentifier: providerIdentifier);
return LiveTvHubEntry(metadata: metadata, program: program);
} catch (_) {
return null;
@@ -367,6 +690,103 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
}
}
Future<List<SubscriptionTemplate>> getSubscriptionTemplate(String guid) async {
final response = await _getWithFailover('/media/subscriptions/template', queryParameters: {'guid': guid});
return _extractContainerList(response, const ['SubscriptionTemplate'], SubscriptionTemplate.fromJson);
}
Future<List<MediaSubscription>> getRecordingRules({bool includeGrabs = true, bool includeStorage = true}) async {
final response = await _getWithFailover(
'/media/subscriptions',
queryParameters: {'includeGrabs': includeGrabs ? 1 : 0, 'includeStorage': includeStorage ? 1 : 0},
);
return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
Future<MediaSubscription?> getRecordingRule(
String subscriptionId, {
bool includeGrabs = true,
bool includeStorage = true,
}) async {
final response = await _getWithFailover(
'/media/subscriptions/$subscriptionId',
queryParameters: {'includeGrabs': includeGrabs ? 1 : 0, 'includeStorage': includeStorage ? 1 : 0},
);
return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request) async {
final response = await _http.post(_withQuery('/media/subscriptions', _subscriptionCreateQuery(request)));
_throwIfFailed(response);
return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
Future<MediaSubscription?> updateRecordingRule(String subscriptionId, Map<String, Object?> prefs) async {
final response = await _http.put(
'/media/subscriptions/$subscriptionId',
queryParameters: _prefQuery('prefs', prefs),
);
_throwIfFailed(response);
return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
Future<void> deleteRecordingRule(String subscriptionId) =>
_expectOk(() => _http.delete('/media/subscriptions/$subscriptionId'));
Future<MediaSubscription?> moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) async {
final response = await _http.put(
'/media/subscriptions/$subscriptionId/move',
queryParameters: {
...?(afterSubscriptionId == null ? null : {'after': afterSubscriptionId}),
},
);
_throwIfFailed(response);
return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
Future<void> processRecordingRules() => _expectOk(() => _http.post('/media/subscriptions/process'));
Future<List<MediaGrabOperation>> getScheduledRecordings() async {
final response = await _getWithFailover('/media/subscriptions/scheduled');
return _extractContainerList(response, const ['MediaGrabOperation'], MediaGrabOperation.fromJson);
}
Future<void> cancelGrab(String operationId) {
if (operationId.isEmpty) throw ArgumentError.value(operationId, 'operationId', 'must not be empty');
final path = operationId.startsWith('/media/grabbers/operations/')
? operationId
: operationId.startsWith('media/grabbers/operations/')
? '/$operationId'
: '/media/grabbers/operations/$operationId';
return _expectOk(() => _http.delete(path));
}
Future<List<MediaSubscription>> getSubscriptionMapping({
required String providerId,
required List<String> ratingKeys,
bool includeStorage = true,
}) async {
if (ratingKeys.isEmpty) return const [];
final response = await _getWithFailover(
'/media/providers/$providerId/media/subscriptions/mapping/${ratingKeys.join(',')}',
queryParameters: {'includeStorage': includeStorage ? 1 : 0},
);
return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson);
}
Future<List<MediaProviderInfo>> getMediaProviders() async {
final response = await _getWithFailover('/media/providers');
return _extractContainerList(response, const ['MediaProvider'], MediaProviderInfo.fromJson);
}
Future<void> registerMediaProvider(String url) =>
_expectOk(() => _http.post('/media/providers', queryParameters: {'url': url}));
Future<void> refreshMediaProviders() => _expectOk(() => _http.post('/media/providers/refresh'));
Future<void> unregisterMediaProvider(String providerId) =>
_expectOk(() => _http.delete('/media/providers/$providerId'));
/// Tune to a live TV channel.
///
/// POSTs to the tune endpoint and extracts metadata, session info, and
@@ -637,6 +1057,49 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
);
}
Future<List<LiveTvSession>> getLiveTvSessionsDetailed() async {
final response = await _getWithFailover('/livetv/sessions');
return _extractContainerList(response, const [
'LiveTVSession',
'LiveTvSession',
'Session',
'Metadata',
], LiveTvSession.fromJson);
}
Future<LiveTvSession?> getLiveTvSession(String sessionId) async {
final response = await _getWithFailover('/livetv/sessions/$sessionId');
return _extractFirst(response, const [
'LiveTVSession',
'LiveTvSession',
'Session',
'Metadata',
], LiveTvSession.fromJson);
}
Uri buildNotificationWebSocketUri({List<String>? filters}) {
final base = Uri.parse(config.baseUrl);
return base.replace(
scheme: base.scheme == 'https' ? 'wss' : 'ws',
path: '/:/websocket/notifications',
queryParameters: {
if (config.token != null) 'X-Plex-Token': config.token!,
if (filters != null) 'filters': filters.join(','),
},
);
}
Uri buildNotificationEventSourceUri({List<String>? filters}) {
final base = Uri.parse(config.baseUrl);
return base.replace(
path: '/:/eventsource/notifications',
queryParameters: {
if (config.token != null) 'X-Plex-Token': config.token!,
if (filters != null) 'filters': filters.join(','),
},
);
}
/// Build the source URI for favorite channels: `server://{machineIdentifier}/{providerIdentifier}`
Future<String> buildFavoriteChannelSource({String? lineup}) async {
final providers = _epgProvidersForLineup(lineup);
@@ -726,4 +1189,200 @@ class _PlexLiveTvSupport implements LiveTvSupport {
@override
Future<void> setFavoriteChannels(List<FavoriteChannel> channels) => _client.setFavoriteChannels(channels);
@override
Future<LiveTvServerStatus> fetchLiveTvServerStatus() => _client.getLiveTvServerStatus();
@override
Future<LiveTvDvr?> fetchDvr(String dvrId) => _client.getDvr(dvrId);
@override
Future<LiveTvActivityResult<LiveTvDvr?>> createDvr({
required List<String> devices,
required List<String> lineups,
String? language,
String? country,
String? postalCode,
}) => _client.createDvr(
devices: devices,
lineups: lineups,
language: language,
country: country,
postalCode: postalCode,
);
@override
Future<void> deleteDvr(String dvrId) => _client.deleteDvr(dvrId);
@override
Future<void> updateDvrPrefs(String dvrId, Map<String, Object?> prefs) => _client.updateDvrPrefs(dvrId, prefs);
@override
Future<void> attachDeviceToDvr(String dvrId, String deviceId) => _client.attachDeviceToDvr(dvrId, deviceId);
@override
Future<void> detachDeviceFromDvr(String dvrId, String deviceId) => _client.detachDeviceFromDvr(dvrId, deviceId);
@override
Future<void> addLineupToDvr(String dvrId, String lineupUri) => _client.addLineupToDvr(dvrId, lineupUri);
@override
Future<void> removeLineupFromDvr(String dvrId, String lineupUri) => _client.removeLineupFromDvr(dvrId, lineupUri);
@override
Future<LiveTvActivityResult<void>> reloadGuide(String dvrId) => _client.reloadGuide(dvrId);
@override
Future<void> cancelGuideReload(String dvrId) => _client.cancelGuideReload(dvrId);
@override
Future<List<MediaGrabber>> fetchGrabbers({String? protocol}) => _client.getGrabbers(protocol: protocol);
@override
Future<List<MediaGrabberDevice>> fetchGrabberDevices() => _client.getGrabberDevices();
@override
Future<LiveTvActivityResult<List<MediaGrabberDevice>>> discoverGrabberDevices() => _client.discoverGrabberDevices();
@override
Future<MediaGrabberDevice?> fetchGrabberDevice(String deviceId) => _client.getGrabberDevice(deviceId);
@override
Future<MediaGrabberDevice?> addGrabberDevice(String uri, {String? grabberId}) =>
_client.addGrabberDevice(uri, grabberId: grabberId);
@override
Future<void> updateGrabberDevice(String deviceId, {bool? enabled, String? title}) =>
_client.updateGrabberDevice(deviceId, enabled: enabled, title: title);
@override
Future<void> deleteGrabberDevice(String deviceId) => _client.deleteGrabberDevice(deviceId);
@override
Future<List<MediaGrabberDeviceChannel>> fetchGrabberDeviceChannels(String deviceId) =>
_client.getGrabberDeviceChannels(deviceId);
@override
Future<LiveTvActivityResult<MediaGrabberDevice?>> scanGrabberDevice(
String deviceId, {
String? source,
Map<String, Object?> prefs = const {},
String? network,
String? country,
}) => _client.scanGrabberDevice(deviceId, source: source, prefs: prefs, network: network, country: country);
@override
Future<MediaGrabberDevice?> cancelGrabberDeviceScan(String deviceId) => _client.cancelGrabberDeviceScan(deviceId);
@override
Future<MediaGrabberDevice?> saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request) =>
_client.saveGrabberDeviceChannelMap(deviceId, request);
@override
Future<void> updateGrabberDevicePrefs(String deviceId, Map<String, Object?> prefs) =>
_client.updateGrabberDevicePrefs(deviceId, prefs);
@override
String buildGrabberDeviceThumbUrl(String deviceId, int version) =>
_client.buildGrabberDeviceThumbUrl(deviceId, version);
@override
Future<List<LiveTvCountry>> fetchEpgCountries() => _client.getEpgCountries();
@override
Future<List<LiveTvLanguage>> fetchEpgLanguages() => _client.getEpgLanguages();
@override
Future<List<LiveTvRegion>> fetchEpgRegions(String country, String epgId) => _client.getEpgRegions(country, epgId);
@override
Future<LiveTvLineupResult> fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}) =>
_client.getEpgLineups(country, epgId, postalCode: postalCode, region: region);
@override
Future<List<LiveTvChannel>> fetchEpgChannelsForLineup(String lineupUri) => _client.getEpgChannelsForLineup(lineupUri);
@override
Future<List<LiveTvLineup>> fetchEpgChannelsForLineups(List<String> lineupUris) =>
_client.getEpgChannelsForLineups(lineupUris);
@override
Future<List<ChannelMapping>> computeEpgChannelMap({required String deviceUri, required String lineupUri}) =>
_client.computeEpgChannelMap(deviceUri: deviceUri, lineupUri: lineupUri);
@override
Future<LiveTvActivityResult<Map<String, dynamic>?>> findBestLineup({
required String deviceUri,
required String lineupGroupUri,
}) => _client.findBestLineup(deviceUri: deviceUri, lineupGroupUri: lineupGroupUri);
@override
Future<List<SubscriptionTemplate>> getSubscriptionTemplate(String guid) => _client.getSubscriptionTemplate(guid);
@override
Future<List<MediaSubscription>> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}) =>
_client.getRecordingRules(includeGrabs: includeGrabs, includeStorage: includeStorage);
@override
Future<MediaSubscription?> fetchRecordingRule(
String subscriptionId, {
bool includeGrabs = true,
bool includeStorage = true,
}) => _client.getRecordingRule(subscriptionId, includeGrabs: includeGrabs, includeStorage: includeStorage);
@override
Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request) =>
_client.createRecordingRule(request);
@override
Future<MediaSubscription?> updateRecordingRule(String subscriptionId, Map<String, Object?> prefs) =>
_client.updateRecordingRule(subscriptionId, prefs);
@override
Future<void> deleteRecordingRule(String subscriptionId) => _client.deleteRecordingRule(subscriptionId);
@override
Future<MediaSubscription?> moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) =>
_client.moveRecordingRule(subscriptionId, afterSubscriptionId: afterSubscriptionId);
@override
Future<void> processRecordingRules() => _client.processRecordingRules();
@override
Future<List<MediaGrabOperation>> fetchScheduledRecordings() => _client.getScheduledRecordings();
@override
Future<void> cancelGrab(String operationId) => _client.cancelGrab(operationId);
@override
Future<List<MediaSubscription>> fetchSubscriptionMapping({
required String providerId,
required List<String> ratingKeys,
bool includeStorage = true,
}) => _client.getSubscriptionMapping(providerId: providerId, ratingKeys: ratingKeys, includeStorage: includeStorage);
@override
Future<List<MediaProviderInfo>> fetchMediaProviders() => _client.getMediaProviders();
@override
Future<void> registerMediaProvider(String url) => _client.registerMediaProvider(url);
@override
Future<void> refreshMediaProviders() => _client.refreshMediaProviders();
@override
Future<void> unregisterMediaProvider(String providerId) => _client.unregisterMediaProvider(providerId);
@override
Future<List<LiveTvSession>> fetchLiveTvSessionsDetailed() => _client.getLiveTvSessionsDetailed();
@override
Future<LiveTvSession?> fetchLiveTvSession(String sessionId) => _client.getLiveTvSession(sessionId);
@override
Uri buildNotificationWebSocketUri({List<String>? filters}) => _client.buildNotificationWebSocketUri(filters: filters);
@override
Uri buildNotificationEventSourceUri({List<String>? filters}) =>
_client.buildNotificationEventSourceUri(filters: filters);
}
+13
View File
@@ -167,6 +167,19 @@ String formatClockTime(DateTime time, {required bool is24Hour}) {
return formatter.format(time);
}
/// Formats a date as Today/Tomorrow or a localized abbreviated weekday.
String formatRelativeDayLabel(DateTime date, {DateTime? now}) {
final reference = now ?? DateTime.now();
final today = DateTime(reference.year, reference.month, reference.day);
final targetDay = DateTime(date.year, date.month, date.day);
final diff = targetDay.difference(today).inDays;
return switch (diff) {
0 => t.liveTv.today,
1 => t.liveTv.tomorrow,
_ => DateFormat.E(LocaleSettings.currentLocale.languageCode).format(date),
};
}
/// Formats the clock time at which media will finish playing, given the remaining duration.
/// Returns a localized time string like "6:12 PM" or "18:12" depending on system setting.
String formatFinishTime(Duration remaining, {double rate = 1.0, required bool is24Hour}) {
+19 -8
View File
@@ -263,7 +263,7 @@ class MediaServerHttpClient {
Uri _buildUri(String path, Map<String, dynamic>? queryParameters) {
final base = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
final query = _encodeQuery(queryParameters);
final query = MediaServerHttpClient.encodeQueryParameters(queryParameters);
final full = query.isEmpty ? '$base$cleanPath' : '$base$cleanPath?$query';
return Uri.parse(full);
}
@@ -271,7 +271,7 @@ class MediaServerHttpClient {
/// Append query parameters to an already-parsed URI.
Uri _appendQuery(Uri uri, Map<String, dynamic>? queryParameters) {
if (queryParameters == null || queryParameters.isEmpty) return uri;
final query = _encodeQuery(queryParameters);
final query = MediaServerHttpClient.encodeQueryParameters(queryParameters);
if (query.isEmpty) return uri;
final existing = uri.query;
final combined = existing.isEmpty ? query : '$existing&$query';
@@ -279,17 +279,28 @@ class MediaServerHttpClient {
}
/// Encode query params with `%20` for spaces (not `+`).
/// Null values are omitted (supports Dart's `?value` map entries).
static String _encodeQuery(Map<String, dynamic>? params) {
/// Null values are omitted and iterable values are emitted as repeated keys.
static String encodeQueryParameters(Map<String, Object?>? params) {
if (params == null || params.isEmpty) return '';
final parts = <String>[];
for (final entry in params.entries) {
if (entry.value == null) continue;
void add(String key, Object? value) {
if (value == null) return;
if (value is Iterable && value is! String) {
for (final item in value) {
add(key, item);
}
return;
}
parts.add(
'${Uri.encodeComponent(entry.key)}='
'${Uri.encodeComponent(entry.value.toString())}',
'${Uri.encodeComponent(key)}='
'${Uri.encodeComponent(value.toString())}',
);
}
for (final entry in params.entries) {
add(entry.key, entry.value);
}
return parts.join('&');
}
+245 -22
View File
@@ -1,8 +1,11 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:drift/native.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/models/media_subscription.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
@@ -19,18 +22,36 @@ void main() {
await db.close();
});
test('favorite source follows requested lineup provider', () async {
final client = PlexClient.forTesting(
PlexClient makeClient(
Future<http.Response> Function(http.Request request) handler, {
String token = 'tok',
String clientIdentifier = 'client',
List<({String identifier, String gridEndpoint})> epgProviders = const [
(identifier: 'provider-a', gridEndpoint: '/provider-a/grid'),
],
}) {
return PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'tok',
clientIdentifier: 'client',
token: token,
clientIdentifier: clientIdentifier,
product: 'Plezy',
version: '1',
machineIdentifier: 'machine-1',
),
serverId: 'machine-1',
httpClient: MockClient((_) async => http.Response('{}', 200)),
httpClient: MockClient(handler),
epgProviders: epgProviders,
);
}
http.Response jsonResponse(Map<String, dynamic> body, {Map<String, String>? headers}) {
return http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json', ...?headers});
}
test('favorite source follows requested lineup provider', () async {
final client = makeClient(
(_) async => http.Response('{}', 200),
epgProviders: const [
(identifier: 'provider-a', gridEndpoint: '/provider-a/grid'),
(identifier: 'provider-b', gridEndpoint: '/provider-b/grid'),
@@ -42,26 +63,228 @@ void main() {
});
test('favorite store is account device scoped instead of token scoped', () {
PlexClient makeClient(String token) {
return PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: token,
clientIdentifier: 'account-device',
product: 'Plezy',
version: '1',
machineIdentifier: 'machine-1',
),
serverId: 'machine-1',
httpClient: MockClient((_) async => http.Response('{}', 200)),
);
}
final a = makeClient('server-token-a');
final b = makeClient('server-token-b');
final a = makeClient(
(_) async => http.Response('{}', 200),
token: 'server-token-a',
clientIdentifier: 'account-device',
);
final b = makeClient(
(_) async => http.Response('{}', 200),
token: 'server-token-b',
clientIdentifier: 'account-device',
);
addTearDown(a.close);
addTearDown(b.close);
expect(a.liveTv.favoriteStoreKey, b.liveTv.favoriteStoreKey);
});
test('DVR list applies root channel mapping to each DVR and parses string numbers', () async {
final client = makeClient((request) async {
expect(request.url.path, '/livetv/dvrs');
return jsonResponse({
'MediaContainer': {
'Dvr': [
{'key': '1', 'uuid': 'dvr-1', 'tuners': '2', 'status': '1'},
],
'ChannelMapping': [
{'channelKey': 'ch-1', 'enabled': '1', 'lineupIdentifier': '001'},
],
},
});
});
addTearDown(client.close);
final dvrs = await client.liveTv.fetchDvrs();
expect(dvrs, hasLength(1));
expect(dvrs.single.tuners, 2);
expect(dvrs.single.status, 1);
expect(dvrs.single.channelMappings.single.channelKey, 'ch-1');
expect(dvrs.single.channelMappings.single.enabled, isTrue);
});
test('createDvr sends repeated device and lineup query params and exposes activity id', () async {
late http.Request captured;
final client = makeClient((request) async {
captured = request;
return jsonResponse(
{
'MediaContainer': {
'Dvr': [
{'key': '42', 'uuid': 'dvr-42'},
],
},
},
headers: {'x-plex-activity': 'activity-1'},
);
});
addTearDown(client.close);
final result = await client.liveTv.createDvr(
devices: const ['dev-a', 'dev-b'],
lineups: const ['lineup-a', 'lineup-b'],
language: 'eng',
);
expect(captured.url.path, '/livetv/dvrs');
expect(captured.url.queryParametersAll['device'], ['dev-a', 'dev-b']);
expect(captured.url.queryParametersAll['lineup'], ['lineup-a', 'lineup-b']);
expect(captured.url.queryParameters['language'], 'eng');
expect(result.activityUuid, 'activity-1');
expect(result.value?.key, '42');
});
test('subscription template parses settings and URL-encoded enum labels', () async {
final client = makeClient((request) async {
expect(request.url.path, '/media/subscriptions/template');
expect(request.url.queryParameters['guid'], 'plex://episode/1');
return jsonResponse({
'MediaContainer': {
'SubscriptionTemplate': [
{
'MediaSubscription': [
{
'key': '',
'type': 4,
'selected': '1',
'title': 'This Episode',
'parameters': 'hints%5Bguid%5D=plex%3A%2F%2Fepisode%2F1',
'Setting': [
{'id': 'startTimeslot', 'type': 'int', 'value': '-1', 'enumValues': ':Any|1715319000:12%3A30%20AM'},
],
},
],
},
],
},
});
});
addTearDown(client.close);
final templates = await client.liveTv.getSubscriptionTemplate('plex://episode/1');
final subscription = templates.single.subscriptions.single;
expect(subscription.selected, isTrue);
expect(subscription.parameters, contains('hints%5Bguid%5D'));
expect(subscription.settings.single.options.map((o) => o.label), ['Any', '12:30 AM']);
});
test('createRecordingRule preserves template parameters and all prefs as query params', () async {
late http.Request captured;
final client = makeClient((request) async {
captured = request;
return jsonResponse({
'MediaContainer': {
'MediaSubscription': [
{'key': '18', 'type': 4, 'title': 'Episode'},
],
},
});
});
addTearDown(client.close);
final request = MediaSubscriptionCreateRequest.fromTemplate(
const MediaSubscription(
key: '',
targetLibrarySectionID: 2,
type: 4,
parameters: 'hints%5Bguid%5D=plex%3A%2F%2Fepisode%2F1',
settings: [
SubscriptionSetting(id: 'oneShot', value: 'true', defaultValue: 'false', hidden: true),
SubscriptionSetting(id: 'remoteMedia', value: 'false', defaultValue: 'false', hidden: true),
SubscriptionSetting(id: 'startOffsetMinutes', value: '0', defaultValue: '0'),
],
),
prefs: const {'startOffsetMinutes': 5},
);
final created = await client.liveTv.createRecordingRule(request);
expect(captured.method, 'POST');
expect(captured.url.path, '/media/subscriptions');
expect(captured.body, isEmpty);
expect(captured.headers.containsKey('content-type'), isFalse);
expect(captured.url.queryParameters['hints[guid]'], 'plex://episode/1');
expect(captured.url.queryParameters['targetLibrarySectionID'], '2');
expect(captured.url.queryParameters['type'], '4');
expect(captured.url.queryParameters['prefs[oneShot]'], 'true');
expect(captured.url.queryParameters['prefs[remoteMedia]'], 'false');
expect(captured.url.queryParameters['prefs[startOffsetMinutes]'], '5');
expect(created?.key, '18');
});
test('updateRecordingRule sends prefs as query params', () async {
late http.Request captured;
final client = makeClient((request) async {
captured = request;
return jsonResponse({
'MediaContainer': {
'MediaSubscription': [
{'key': '18', 'type': 4, 'title': 'Episode'},
],
},
});
});
addTearDown(client.close);
final updated = await client.liveTv.updateRecordingRule('18', const {'startOffsetMinutes': 5});
expect(captured.method, 'PUT');
expect(captured.url.path, '/media/subscriptions/18');
expect(captured.body, isEmpty);
expect(captured.url.queryParameters['prefs[startOffsetMinutes]'], '5');
expect(updated?.key, '18');
});
test('cancelGrab deletes the operation key path', () async {
late http.Request captured;
final client = makeClient((request) async {
captured = request;
return jsonResponse({'MediaContainer': <String, dynamic>{}});
});
addTearDown(client.close);
await client.liveTv.cancelGrab('/media/grabbers/operations/grab-1');
expect(captured.method, 'DELETE');
expect(captured.url.path, '/media/grabbers/operations/grab-1');
});
test('scheduled recordings parse grab operation metadata', () async {
final client = makeClient((request) async {
expect(request.url.path, '/media/subscriptions/scheduled');
return jsonResponse({
'MediaContainer': {
'MediaGrabOperation': [
{
'id': 'grab-1',
'key': '/media/grabbers/operations/grab-1',
'mediaSubscriptionID': '7',
'status': 'scheduled',
'percent': '42.5',
'Metadata': {
'title': 'Miracle on Dead Street',
'grandparentTitle': 'Fresh Off the Boat',
'type': 'episode',
'Media': [
{'beginsAt': '1466060400', 'endsAt': '1466062200', 'channelIdentifier': '004'},
],
},
},
],
},
});
});
addTearDown(client.close);
final operations = await client.liveTv.fetchScheduledRecordings();
expect(operations.single.id, 'grab-1');
expect(operations.single.operationKey, '/media/grabbers/operations/grab-1');
expect(operations.single.mediaSubscriptionID, 7);
expect(operations.single.percent, 42.5);
expect(operations.single.program?.grandparentTitle, 'Fresh Off the Boat');
expect(operations.single.program?.channelIdentifier, '004');
});
}