feat(livetv): add DVR and recording support
This commit is contained in:
+36
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "Коллекции",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 »',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '視聴済みステータスを同期',
|
||||
|
||||
@@ -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' => '시청 상태 동기화',
|
||||
|
||||
@@ -965,6 +965,7 @@ class _TranslationsLiveTvNb extends TranslationsLiveTvEn {
|
||||
@override String get reloadGuide => 'Last inn programguide på nytt';
|
||||
@override String get now => 'Nå';
|
||||
@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' => 'Nå',
|
||||
'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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'Синхронизация статуса просмотра',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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": "合集",
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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?,
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
@@ -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 {},
|
||||
});
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
@@ -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},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1089,6 +1089,7 @@ class GuideTabState extends State<GuideTab> {
|
||||
channel: channel,
|
||||
posterUrl: posterUrl,
|
||||
onTuneChannel: () => _tuneChannel(channel),
|
||||
client: client,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
@@ -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('&');
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user