diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 53f61f51..8c335cb3 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -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", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 7faec3e3..0c12a512 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index f6bb47a0..42652ce4 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 178d6573..3aae31fd 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 8e157d1e..fdc0362a 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 981d3684..d9f6b99e 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 46b3d48f..c60c1607 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -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": "コレクション", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 3975fb97..003fb87e 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -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": "컬렉션", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index d8228baa..b9f760b9 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -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", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 42475996..0cd38d18 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 2ec4674f..b708d90f 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -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", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 32ece3af..01ad79ee 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -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", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index fd07f251..c7fec1a4 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -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": "Коллекции", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 9a22dd45..a23d1a41 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 65aeb814..e18b4637 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -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', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index dc3ffd52..4f494fea 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index a7a29a99..4f292032 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 8f0c00ef..28f511a6 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 3823ae60..2ae186d7 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -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 »', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 49783911..0ad85038 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -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', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index dc5f6523..80ef0620 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -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' => '視聴済みステータスを同期', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index ce0ab18f..292a2acd 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -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' => '시청 상태 동기화', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index b77d655c..f1d3e240 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -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', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 9a95a942..6db5f8b1 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -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', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index d0ae00be..a6f76d25 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -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', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 7ff074ca..c0605f4f 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -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', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index d18180e9..ffe30f23 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -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' => 'Синхронизация статуса просмотра', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index f616d9f6..81eb5ef5 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 947c2f09..e906c741 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -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' => '同步已观看状态', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index fc3441ec..46d22c77 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index ef3f0b70..f9f59ee2 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -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": "合集", diff --git a/lib/media/live_tv_support.dart b/lib/media/live_tv_support.dart index d2a8af92..381ac88c 100644 --- a/lib/media/live_tv_support.dart +++ b/lib/media/live_tv_support.dart @@ -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 { + 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 setFavoriteChannels(List channels); + + // ── DVR setup/lifecycle ───────────────────────────────────────── + Future fetchLiveTvServerStatus(); + Future fetchDvr(String dvrId); + Future> createDvr({ + required List devices, + required List lineups, + String? language, + String? country, + String? postalCode, + }); + Future deleteDvr(String dvrId); + Future updateDvrPrefs(String dvrId, Map prefs); + Future attachDeviceToDvr(String dvrId, String deviceId); + Future detachDeviceFromDvr(String dvrId, String deviceId); + Future addLineupToDvr(String dvrId, String lineupUri); + Future removeLineupFromDvr(String dvrId, String lineupUri); + Future> reloadGuide(String dvrId); + Future cancelGuideReload(String dvrId); + + // ── Grabber devices/tuners ────────────────────────────────────── + Future> fetchGrabbers({String? protocol}); + Future> fetchGrabberDevices(); + Future>> discoverGrabberDevices(); + Future fetchGrabberDevice(String deviceId); + Future addGrabberDevice(String uri, {String? grabberId}); + Future updateGrabberDevice(String deviceId, {bool? enabled, String? title}); + Future deleteGrabberDevice(String deviceId); + Future> fetchGrabberDeviceChannels(String deviceId); + Future> scanGrabberDevice( + String deviceId, { + String? source, + Map prefs = const {}, + String? network, + String? country, + }); + Future cancelGrabberDeviceScan(String deviceId); + Future saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request); + Future updateGrabberDevicePrefs(String deviceId, Map prefs); + String buildGrabberDeviceThumbUrl(String deviceId, int version); + + // ── EPG setup/lineups ─────────────────────────────────────────── + Future> fetchEpgCountries(); + Future> fetchEpgLanguages(); + Future> fetchEpgRegions(String country, String epgId); + Future fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}); + Future> fetchEpgChannelsForLineup(String lineupUri); + Future> fetchEpgChannelsForLineups(List lineupUris); + Future> computeEpgChannelMap({required String deviceUri, required String lineupUri}); + Future?>> findBestLineup({ + required String deviceUri, + required String lineupGroupUri, + }); + + // ── Recording rules / scheduled grabs ─────────────────────────── + Future> getSubscriptionTemplate(String guid); + Future> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}); + Future fetchRecordingRule( + String subscriptionId, { + bool includeGrabs = true, + bool includeStorage = true, + }); + Future createRecordingRule(MediaSubscriptionCreateRequest request); + Future updateRecordingRule(String subscriptionId, Map prefs); + Future deleteRecordingRule(String subscriptionId); + Future moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}); + Future processRecordingRules(); + Future> fetchScheduledRecordings(); + Future cancelGrab(String operationId); + Future> fetchSubscriptionMapping({ + required String providerId, + required List ratingKeys, + bool includeStorage = true, + }); + + // ── Providers and sessions ────────────────────────────────────── + Future> fetchMediaProviders(); + Future registerMediaProvider(String url); + Future refreshMediaProviders(); + Future unregisterMediaProvider(String providerId); + Future> fetchLiveTvSessionsDetailed(); + Future fetchLiveTvSession(String sessionId); + Uri buildNotificationWebSocketUri({List? filters}); + Uri buildNotificationEventSourceUri({List? filters}); } diff --git a/lib/models/livetv_dvr.dart b/lib/models/livetv_dvr.dart index 5f00cdf8..3951e9ac 100644 --- a/lib/models/livetv_dvr.dart +++ b/lib/models/livetv_dvr.dart @@ -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 _parseChannelMappings(Object? raw) { final result = []; - if (raw is List) { - for (final item in raw) { - try { - result.add(ChannelMapping.fromJson(item as Map)); - } catch (_) {} - } + final list = flexibleList(raw) ?? const []; + for (final item in list) { + try { + result.add(ChannelMapping.fromJson(item as Map)); + } catch (_) {} } return result; } +List _parseSettings(Object? raw) { + final list = flexibleList(raw) ?? const []; + return [ + for (final item in list) + if (item is Map) SubscriptionSetting.fromJson(item), + ]; +} + +List> _parseRawMaps(Object? raw) { + final list = flexibleList(raw) ?? const []; + return [ + for (final item in list) + if (item is Map) 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 channelMappings; + @JsonKey(name: 'Setting', fromJson: _parseSettings) + final List settings; + @JsonKey(name: 'Device', fromJson: _parseRawMaps) + final List> 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 json) => _$LiveTvDvrFromJson(json); diff --git a/lib/models/livetv_dvr.g.dart b/lib/models/livetv_dvr.g.dart index 11776174..61d1d330 100644 --- a/lib/models/livetv_dvr.g.dart +++ b/lib/models/livetv_dvr.g.dart @@ -13,14 +13,21 @@ LiveTvDvr _$LiveTvDvrFromJson(Map 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 json) => ChannelMapping( diff --git a/lib/models/livetv_lineup.dart b/lib/models/livetv_lineup.dart new file mode 100644 index 00000000..ae0bcf35 --- /dev/null +++ b/lib/models/livetv_lineup.dart @@ -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 _parseChannels(Object? raw) { + final list = flexibleList(raw) ?? const []; + return [ + for (final item in list) + if (item is Map) 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 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 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 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 channels; + + const LiveTvLineup({ + required this.uuid, + this.type, + this.title, + this.lineupType, + this.location, + this.channels = const [], + }); + + factory LiveTvLineup.fromJson(Map json) => _$LiveTvLineupFromJson(json); +} + +class LiveTvLineupResult { + final String? lineupGroupUuid; + final List lineups; + + const LiveTvLineupResult({this.lineupGroupUuid, required this.lineups}); +} diff --git a/lib/models/livetv_lineup.g.dart b/lib/models/livetv_lineup.g.dart new file mode 100644 index 00000000..f034eddd --- /dev/null +++ b/lib/models/livetv_lineup.g.dart @@ -0,0 +1,36 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'livetv_lineup.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LiveTvCountry _$LiveTvCountryFromJson(Map 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 json) => + LiveTvLanguage(code: json['code'] as String? ?? '', title: json['title'] as String? ?? ''); + +LiveTvRegion _$LiveTvRegionFromJson(Map json) => LiveTvRegion( + key: json['key'] as String? ?? '', + type: json['type'] as String?, + title: json['title'] as String? ?? '', +); + +LiveTvLineup _$LiveTvLineupFromJson(Map 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']), +); diff --git a/lib/models/livetv_program.dart b/lib/models/livetv_program.dart index 3c2f24ea..6d45be74 100644 --- a/lib/models/livetv_program.dart +++ b/lib/models/livetv_program.dart @@ -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 json, {Map? mediaOverride}) { @@ -54,8 +62,8 @@ class LiveTvProgram { final channel = (json['Channel'] as List?)?.firstOrNull as Map?; 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; diff --git a/lib/models/livetv_server_status.dart b/lib/models/livetv_server_status.dart new file mode 100644 index 00000000..eea63382 --- /dev/null +++ b/lib/models/livetv_server_status.dart @@ -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 json) => _$LiveTvServerStatusFromJson(json); + + Set 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'); +} diff --git a/lib/models/livetv_server_status.g.dart b/lib/models/livetv_server_status.g.dart new file mode 100644 index 00000000..a37e94dd --- /dev/null +++ b/lib/models/livetv_server_status.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'livetv_server_status.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LiveTvServerStatus _$LiveTvServerStatusFromJson(Map json) => LiveTvServerStatus( + liveTvCount: flexibleInt(json['livetv']), + allowTuners: flexibleBoolNullable(json['allowTuners']), + ownerFeatures: json['ownerFeatures'] as String?, +); diff --git a/lib/models/livetv_session.dart b/lib/models/livetv_session.dart new file mode 100644 index 00000000..c1f34f95 --- /dev/null +++ b/lib/models/livetv_session.dart @@ -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? _firstMap(Object? raw) { + if (raw is Map) return raw; + if (raw is List && raw.isNotEmpty && raw.first is Map) return raw.first as Map; + 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? 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 json) => _$LiveTvSessionFromJson(json); +} diff --git a/lib/models/livetv_session.g.dart b/lib/models/livetv_session.g.dart new file mode 100644 index 00000000..53fbf184 --- /dev/null +++ b/lib/models/livetv_session.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'livetv_session.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LiveTvSession _$LiveTvSessionFromJson(Map 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']), +); diff --git a/lib/models/media_grab_operation.dart b/lib/models/media_grab_operation.dart new file mode 100644 index 00000000..d4c7aac5 --- /dev/null +++ b/lib/models/media_grab_operation.dart @@ -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? _metadataFromJson(Object? raw) { + if (raw is Map) return raw; + if (raw is List && raw.isNotEmpty && raw.first is Map) { + return raw.first as Map; + } + 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? 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 json) => _$MediaGrabOperationFromJson(json); + + String get operationKey => (key != null && key!.isNotEmpty) ? key! : id; + + LiveTvProgram? get program => _programFromMetadata(metadata); +} diff --git a/lib/models/media_grab_operation.g.dart b/lib/models/media_grab_operation.g.dart new file mode 100644 index 00000000..5068fbde --- /dev/null +++ b/lib/models/media_grab_operation.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'media_grab_operation.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MediaGrabOperation _$MediaGrabOperationFromJson(Map 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']), +); diff --git a/lib/models/media_grabber_device.dart b/lib/models/media_grabber_device.dart new file mode 100644 index 00000000..f4f77006 --- /dev/null +++ b/lib/models/media_grabber_device.dart @@ -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 _parseChannelMappings(Object? raw) { + final list = flexibleList(raw) ?? const []; + return [ + for (final item in list) + if (item is Map) ChannelMapping.fromJson(item), + ]; +} + +List _parseSettings(Object? raw) { + final list = flexibleList(raw) ?? const []; + return [ + for (final item in list) + if (item is Map) 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 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 channelMappings; + @JsonKey(name: 'Setting', fromJson: _parseSettings) + final List 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 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 json) => _$MediaGrabberDeviceChannelFromJson(json); +} + +class MediaGrabberChannelMapRequest { + final List channelsEnabled; + final Map channelMapping; + final Map channelMappingByKey; + + const MediaGrabberChannelMapRequest({ + this.channelsEnabled = const [], + this.channelMapping = const {}, + this.channelMappingByKey = const {}, + }); +} diff --git a/lib/models/media_grabber_device.g.dart b/lib/models/media_grabber_device.g.dart new file mode 100644 index 00000000..d87956cd --- /dev/null +++ b/lib/models/media_grabber_device.g.dart @@ -0,0 +1,40 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'media_grabber_device.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MediaGrabber _$MediaGrabberFromJson(Map json) => MediaGrabber( + identifier: json['identifier'] as String? ?? '', + protocol: json['protocol'] as String?, + title: json['title'] as String?, +); + +MediaGrabberDevice _$MediaGrabberDeviceFromJson(Map 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 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']), +); diff --git a/lib/models/media_provider_info.dart b/lib/models/media_provider_info.dart new file mode 100644 index 00000000..10d57bfa --- /dev/null +++ b/lib/models/media_provider_info.dart @@ -0,0 +1,83 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../utils/json_utils.dart'; + +part 'media_provider_info.g.dart'; + +List _parseFeatures(Object? raw) { + final list = flexibleList(raw) ?? const []; + return [ + for (final item in list) + if (item is Map) MediaProviderFeature.fromJson(item), + ]; +} + +List> _parseRawMaps(Object? raw) { + final list = flexibleList(raw) ?? const []; + return [ + for (final item in list) + if (item is Map) 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 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 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> directories; + @JsonKey(name: 'Action', fromJson: _parseRawMaps) + final List> actions; + @JsonKey(name: 'Pivot', fromJson: _parseRawMaps) + final List> 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 json) => _$MediaProviderFeatureFromJson(json); +} diff --git a/lib/models/media_provider_info.g.dart b/lib/models/media_provider_info.g.dart new file mode 100644 index 00000000..8b8e91d7 --- /dev/null +++ b/lib/models/media_provider_info.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'media_provider_info.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MediaProviderInfo _$MediaProviderInfoFromJson(Map 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 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']), +); diff --git a/lib/models/media_subscription.dart b/lib/models/media_subscription.dart new file mode 100644 index 00000000..72ec137b --- /dev/null +++ b/lib/models/media_subscription.dart @@ -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 _parseSubscriptions(Object? raw) { + final list = flexibleList(raw) ?? const []; + return [ + for (final item in list) + if (item is Map) MediaSubscription.fromJson(item), + ]; +} + +List _parseSettings(Object? raw) { + final list = flexibleList(raw) ?? const []; + return [ + for (final item in list) + if (item is Map) SubscriptionSetting.fromJson(item), + ]; +} + +List _parseGrabOperations(Object? raw) { + final list = flexibleList(raw) ?? const []; + return [ + for (final item in list) + if (item is Map) MediaGrabOperation.fromJson(item), + ]; +} + +Map? _mapFromJson(Object? raw) => raw is Map ? raw : null; + +/// Template wrapper returned by `/media/subscriptions/template`. +@JsonSerializable(createToJson: false) +class SubscriptionTemplate { + @JsonKey(name: 'MediaSubscription', fromJson: _parseSubscriptions) + final List subscriptions; + + const SubscriptionTemplate({this.subscriptions = const []}); + + factory SubscriptionTemplate.fromJson(Map 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? video; + @JsonKey(name: 'Directory', fromJson: _mapFromJson) + final Map? directory; + @JsonKey(name: 'Playlist', fromJson: _mapFromJson) + final Map? playlist; + @JsonKey(name: 'Setting', fromJson: _parseSettings) + final List settings; + @JsonKey(name: 'MediaGrabOperation', fromJson: _parseGrabOperations) + final List 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 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 json) => _$SubscriptionSettingFromJson(json); + + List 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 hints; + final Map prefs; + final Map 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 prefs = const {}, + }) { + final templatePrefs = { + 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}, + ); + } +} diff --git a/lib/models/media_subscription.g.dart b/lib/models/media_subscription.g.dart new file mode 100644 index 00000000..95d22c55 --- /dev/null +++ b/lib/models/media_subscription.g.dart @@ -0,0 +1,46 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'media_subscription.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SubscriptionTemplate _$SubscriptionTemplateFromJson(Map json) => SubscriptionTemplate( + subscriptions: json['MediaSubscription'] == null ? const [] : _parseSubscriptions(json['MediaSubscription']), +); + +MediaSubscription _$MediaSubscriptionFromJson(Map 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 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?, +); diff --git a/lib/screens/livetv/live_tv_actions_mixin.dart b/lib/screens/livetv/live_tv_actions_mixin.dart index 5931e80c..667c859f 100644 --- a/lib/screens/livetv/live_tv_actions_mixin.dart +++ b/lib/screens/livetv/live_tv_actions_mixin.dart @@ -65,6 +65,7 @@ mixin LiveTvActionsMixin on State { channel: channel, posterUrl: posterUrl, onTuneChannel: channel != null ? () => tuneChannel(channel) : null, + client: client, ); } } diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index c37fdddc..ff40c828 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -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 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(); final _whatsOnTabKey = GlobalKey(); + final _recordingsTabKey = GlobalKey(); + + /// Visible tabs in the current session. Recordings tab is included only + /// when at least one Live TV server has `liveTvDvr` capability. + List _visibleTabs = [LiveTvTab.guide, LiveTvTab.whatsOn]; // App bar action bar final _actionBarKey = GlobalKey(); @@ -85,7 +95,13 @@ class _LiveTvScreenState extends State } @override - List get tabChipFocusNodes => [_guideTabFocusNode, _whatsOnTabFocusNode]; + List 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 void dispose() { _guideTabFocusNode.dispose(); _whatsOnTabFocusNode.dispose(); + _recordingsTabFocusNode.dispose(); disposeTabNavigation(); super.dispose(); } @@ -109,17 +126,109 @@ class _LiveTvScreenState extends State 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 _onRefresh() async { + if (_currentTab == LiveTvTab.recordings) { + await _recordingsTabKey.currentState?.reload(); + return; + } + await _serverReloadGuide(); + await _loadChannels(); + } + + Future _serverReloadGuide() async { + final multiServer = context.read(); + final futures = >[]; + 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 _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 _processRecordingRules() async { + final multiServer = context.read(); + final futures = >[]; + 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 _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? _extractEnabledChannelKeys(List dvrs) { final enabledKeys = {}; @@ -238,6 +347,8 @@ class _LiveTvScreenState extends State _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 } 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 return switch (tab) { LiveTvTab.guide => t.liveTv.guide, LiveTvTab.whatsOn => t.liveTv.whatsOn, + LiveTvTab.recordings => t.liveTv.recordings, }; } List _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 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 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 ); } + Widget _buildTabContent(LiveTvTab tab, List 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 ), ); } - 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 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)], ), ), ], diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index 5a106ec8..979d22b0 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -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 } } + /// 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().getClientForServer(widget.serverId); + return client != null && client.capabilities.liveTvDvr; + } + + Future _onRecordShow() async { + final client = context.read().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 diff --git a/lib/screens/livetv/livetv_recording_actions.dart b/lib/screens/livetv/livetv_recording_actions.dart new file mode 100644 index 00000000..29c53f1f --- /dev/null +++ b/lib/screens/livetv/livetv_recording_actions.dart @@ -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 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 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 = [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 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 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 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; +} diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index 09219f5c..218161d8 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -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 _buttonFocusNodes = []; + final List _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 _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 = []; - 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), ], ), ); diff --git a/lib/screens/livetv/record_options_sheet.dart b/lib/screens/livetv/record_options_sheet.dart new file mode 100644 index 00000000..43e77f97 --- /dev/null +++ b/lib/screens/livetv/record_options_sheet.dart @@ -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 push( + BuildContext context, { + required MediaServerClient client, + required LiveTvProgram program, + required List entries, + }) { + return OverlaySheetController.pushAdaptive( + 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 pushEdit( + BuildContext context, { + required MediaServerClient client, + required MediaSubscription rule, + }) { + return OverlaySheetController.pushAdaptive( + 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 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 _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 _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 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 _openPicker(BuildContext context) async { + final selected = await showOptionPickerDialog( + 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), + ), + ], + ), + ); + } +} diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 9f9c281b..ca07ccec 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -1089,6 +1089,7 @@ class GuideTabState extends State { channel: channel, posterUrl: posterUrl, onTuneChannel: () => _tuneChannel(channel), + client: client, ); } } diff --git a/lib/screens/livetv/tabs/recordings_tab.dart b/lib/screens/livetv/tabs/recordings_tab.dart new file mode 100644 index 00000000..7c06b7e5 --- /dev/null +++ b/lib/screens/livetv/tabs/recordings_tab.dart @@ -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 createState() => RecordingsTabState(); +} + +class _ServerRecordings { + final String serverId; + final MediaServerClient client; + final List grabs; + final List 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 { + 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 reload() => _load(); + + Future _load() async { + if (!mounted) return; + setState(() { + _isLoading = _serverRecordings.isEmpty; + _error = null; + }); + + final multiServer = context.read(); + final results = <_ServerRecordings>[]; + var anyAdminError = false; + var anyOtherError = false; + final seenServers = {}; + + 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 _onCancelGrab(_GrabEntry entry) async { + final cancelled = await confirmCancelGrab(context, entry.server.client, entry.op); + if (cancelled) await _load(); + } + + Future _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 = [ + 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), + ), + ); + } +} diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 81a85e8a..effacce7 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -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'; diff --git a/lib/services/jellyfin_client/live_tv_support.dart b/lib/services/jellyfin_client/live_tv_support.dart index be94f801..d873e467 100644 --- a/lib/services/jellyfin_client/live_tv_support.dart +++ b/lib/services/jellyfin_client/live_tv_support.dart @@ -6,6 +6,10 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { final JellyfinClient _client; _JellyfinLiveTvSupport(this._client); + Future _unsupported() async => throw UnimplementedError('Jellyfin DVR recording API is not implemented'); + + Never _unsupportedSync() => throw UnimplementedError('Jellyfin DVR recording API is not implemented'); + @override Future isAvailable() => _client.hasLiveTv(); @@ -95,4 +99,184 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { appLogger.e('Failed to save Jellyfin favorite channels', error: e); } } + + @override + Future fetchLiveTvServerStatus() => _unsupported(); + + @override + Future fetchDvr(String dvrId) => _unsupported(); + + @override + Future> createDvr({ + required List devices, + required List lineups, + String? language, + String? country, + String? postalCode, + }) => _unsupported(); + + @override + Future deleteDvr(String dvrId) => _unsupported(); + + @override + Future updateDvrPrefs(String dvrId, Map prefs) => _unsupported(); + + @override + Future attachDeviceToDvr(String dvrId, String deviceId) => _unsupported(); + + @override + Future detachDeviceFromDvr(String dvrId, String deviceId) => _unsupported(); + + @override + Future addLineupToDvr(String dvrId, String lineupUri) => _unsupported(); + + @override + Future removeLineupFromDvr(String dvrId, String lineupUri) => _unsupported(); + + @override + Future> reloadGuide(String dvrId) => _unsupported(); + + @override + Future cancelGuideReload(String dvrId) => _unsupported(); + + @override + Future> fetchGrabbers({String? protocol}) => _unsupported(); + + @override + Future> fetchGrabberDevices() => _unsupported(); + + @override + Future>> discoverGrabberDevices() => _unsupported(); + + @override + Future fetchGrabberDevice(String deviceId) => _unsupported(); + + @override + Future addGrabberDevice(String uri, {String? grabberId}) => _unsupported(); + + @override + Future updateGrabberDevice(String deviceId, {bool? enabled, String? title}) => _unsupported(); + + @override + Future deleteGrabberDevice(String deviceId) => _unsupported(); + + @override + Future> fetchGrabberDeviceChannels(String deviceId) => _unsupported(); + + @override + Future> scanGrabberDevice( + String deviceId, { + String? source, + Map prefs = const {}, + String? network, + String? country, + }) => _unsupported(); + + @override + Future cancelGrabberDeviceScan(String deviceId) => _unsupported(); + + @override + Future saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request) => + _unsupported(); + + @override + Future updateGrabberDevicePrefs(String deviceId, Map prefs) => _unsupported(); + + @override + String buildGrabberDeviceThumbUrl(String deviceId, int version) => _unsupportedSync(); + + @override + Future> fetchEpgCountries() => _unsupported(); + + @override + Future> fetchEpgLanguages() => _unsupported(); + + @override + Future> fetchEpgRegions(String country, String epgId) => _unsupported(); + + @override + Future fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}) => + _unsupported(); + + @override + Future> fetchEpgChannelsForLineup(String lineupUri) => _unsupported(); + + @override + Future> fetchEpgChannelsForLineups(List lineupUris) => _unsupported(); + + @override + Future> computeEpgChannelMap({required String deviceUri, required String lineupUri}) => + _unsupported(); + + @override + Future?>> findBestLineup({ + required String deviceUri, + required String lineupGroupUri, + }) => _unsupported(); + + @override + Future> getSubscriptionTemplate(String guid) => _unsupported(); + + @override + Future> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}) => + _unsupported(); + + @override + Future fetchRecordingRule( + String subscriptionId, { + bool includeGrabs = true, + bool includeStorage = true, + }) => _unsupported(); + + @override + Future createRecordingRule(MediaSubscriptionCreateRequest request) => _unsupported(); + + @override + Future updateRecordingRule(String subscriptionId, Map prefs) => _unsupported(); + + @override + Future deleteRecordingRule(String subscriptionId) => _unsupported(); + + @override + Future moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) => _unsupported(); + + @override + Future processRecordingRules() => _unsupported(); + + @override + Future> fetchScheduledRecordings() => _unsupported(); + + @override + Future cancelGrab(String operationId) => _unsupported(); + + @override + Future> fetchSubscriptionMapping({ + required String providerId, + required List ratingKeys, + bool includeStorage = true, + }) => _unsupported(); + + @override + Future> fetchMediaProviders() => _unsupported(); + + @override + Future registerMediaProvider(String url) => _unsupported(); + + @override + Future refreshMediaProviders() => _unsupported(); + + @override + Future unregisterMediaProvider(String providerId) => _unsupported(); + + @override + Future> fetchLiveTvSessionsDetailed() => _unsupported(); + + @override + Future fetchLiveTvSession(String sessionId) => _unsupported(); + + @override + Uri buildNotificationWebSocketUri({List? filters}) => _unsupportedSync(); + + @override + Uri buildNotificationEventSourceUri({List? filters}) => _unsupportedSync(); } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 29770b2f..b1d39a0a 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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'; diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index be38a225..277a700f 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -15,7 +15,15 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { List<({String identifier, String gridEndpoint})> get _providerEpg; - Future _getWithFailover(String path, {Map? queryParameters}); + Future _getWithFailover( + String path, { + Map? queryParameters, + // ignore: unused_element_parameter + Map? headers, + Duration? timeout, + // ignore: unused_element_parameter + AbortController? abort, + }); Map? _getMediaContainer(MediaServerResponse response); PlexMetadataDto _createTaggedMetadata(Map json); @@ -39,6 +47,64 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { } } + String? _activityUuid(MediaServerResponse response) => response.headers['x-plex-activity']; + + List _extractContainerList( + MediaServerResponse response, + Iterable keys, + T Function(Map 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) fromJson(item), + ]; + } + return const []; + } + + T? _extractFirst(MediaServerResponse response, Iterable keys, T Function(Map) fromJson) { + final list = _extractContainerList(response, keys, fromJson); + return list.firstOrNull; + } + + void _throwIfFailed(MediaServerResponse response) => throwIfHttpError(response); + + Future _expectOk(Future 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 = []; + final parameters = request.parameters; + if (parameters != null && parameters.isNotEmpty) parts.add(parameters); + + final flat = { + '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 _prefQuery(String prefix, Map 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(() => _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)).toList(); + final rootMappings = container['ChannelMapping']; + return (container['Dvr'] as List).map((json) { + final map = Map.from(json as Map); + 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 getLiveTvServerStatus() async { + final response = await _getWithFailover('/'); + final container = _getMediaContainer(response); + return LiveTvServerStatus.fromJson(container ?? const {}); + } + + Future 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.from(json)..putIfAbsent('ChannelMapping', () => rootMappings); + return LiveTvDvr.fromJson(map); + }); + } + + Future> createDvr({ + required List devices, + required List 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 deleteDvr(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId')); + + Future updateDvrPrefs(String dvrId, Map prefs) => + _expectOk(() => _http.put('/livetv/dvrs/$dvrId/prefs', queryParameters: prefs)); + + Future attachDeviceToDvr(String dvrId, String deviceId) => + _expectOk(() => _http.put('/livetv/dvrs/$dvrId/devices/$deviceId')); + + Future detachDeviceFromDvr(String dvrId, String deviceId) => + _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/devices/$deviceId')); + + Future addLineupToDvr(String dvrId, String lineupUri) => + _expectOk(() => _http.put('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri})); + + Future removeLineupFromDvr(String dvrId, String lineupUri) => + _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri})); + + Future> 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 cancelGuideReload(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/reloadGuide')); + + Future> getGrabbers({String? protocol}) async { + final response = await _getWithFailover( + '/media/grabbers', + queryParameters: { + ...?(protocol == null ? null : {'protocol': protocol}), + }, + ); + return _extractContainerList(response, const ['MediaGrabber'], MediaGrabber.fromJson); + } + + Future> getGrabberDevices() async { + final response = await _getWithFailover('/media/grabbers/devices'); + return _extractContainerList(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); + } + + Future>> 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 getGrabberDevice(String deviceId) async { + final response = await _getWithFailover('/media/grabbers/devices/$deviceId'); + return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); + } + + Future 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 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 deleteGrabberDevice(String deviceId) => + _expectOk(() => _http.delete('/media/grabbers/devices/$deviceId')); + + Future> getGrabberDeviceChannels(String deviceId) async { + final response = await _getWithFailover('/media/grabbers/devices/$deviceId/channels'); + return _extractContainerList(response, const ['DeviceChannel'], MediaGrabberDeviceChannel.fromJson); + } + + Future> scanGrabberDevice( + String deviceId, { + String? source, + Map 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 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 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 updateGrabberDevicePrefs(String deviceId, Map 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> getEpgCountries() async { + final response = await _getWithFailover('/livetv/epg/countries'); + return _extractContainerList(response, const ['Country'], LiveTvCountry.fromJson); + } + + Future> getEpgLanguages() async { + final response = await _getWithFailover('/livetv/epg/languages'); + return _extractContainerList(response, const ['Language'], LiveTvLanguage.fromJson); + } + + Future> getEpgRegions(String country, String epgId) async { + final response = await _getWithFailover('/livetv/epg/countries/$country/$epgId/regions'); + return _extractContainerList(response, const ['Region'], LiveTvRegion.fromJson); + } + + Future 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> 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> getEpgChannelsForLineups(List lineupUris) async { + final response = await _getWithFailover('/livetv/epg/lineupchannels', queryParameters: {'lineup': lineupUris}); + return _extractContainerList(response, const ['Lineup'], LiveTvLineup.fromJson); + } + + Future> 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?>> 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> getEpgChannels({String? lineup}) async { List 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 _parseLiveTvPrograms(List items) { + List _parseLiveTvPrograms(List items, {String? providerIdentifier, String? liveDvrKey}) { final programs = []; for (final item in items) { try { @@ -203,10 +510,24 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { final mediaList = (map['Media'] as List?)?.whereType>().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 = []; 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) 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 itemJson) { + LiveTvHubEntry? _parseLiveTvHubEntry(Map 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> getSubscriptionTemplate(String guid) async { + final response = await _getWithFailover('/media/subscriptions/template', queryParameters: {'guid': guid}); + return _extractContainerList(response, const ['SubscriptionTemplate'], SubscriptionTemplate.fromJson); + } + + Future> 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 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 createRecordingRule(MediaSubscriptionCreateRequest request) async { + final response = await _http.post(_withQuery('/media/subscriptions', _subscriptionCreateQuery(request))); + _throwIfFailed(response); + return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson); + } + + Future updateRecordingRule(String subscriptionId, Map prefs) async { + final response = await _http.put( + '/media/subscriptions/$subscriptionId', + queryParameters: _prefQuery('prefs', prefs), + ); + _throwIfFailed(response); + return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson); + } + + Future deleteRecordingRule(String subscriptionId) => + _expectOk(() => _http.delete('/media/subscriptions/$subscriptionId')); + + Future 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 processRecordingRules() => _expectOk(() => _http.post('/media/subscriptions/process')); + + Future> getScheduledRecordings() async { + final response = await _getWithFailover('/media/subscriptions/scheduled'); + return _extractContainerList(response, const ['MediaGrabOperation'], MediaGrabOperation.fromJson); + } + + Future 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> getSubscriptionMapping({ + required String providerId, + required List 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> getMediaProviders() async { + final response = await _getWithFailover('/media/providers'); + return _extractContainerList(response, const ['MediaProvider'], MediaProviderInfo.fromJson); + } + + Future registerMediaProvider(String url) => + _expectOk(() => _http.post('/media/providers', queryParameters: {'url': url})); + + Future refreshMediaProviders() => _expectOk(() => _http.post('/media/providers/refresh')); + + Future 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> getLiveTvSessionsDetailed() async { + final response = await _getWithFailover('/livetv/sessions'); + return _extractContainerList(response, const [ + 'LiveTVSession', + 'LiveTvSession', + 'Session', + 'Metadata', + ], LiveTvSession.fromJson); + } + + Future getLiveTvSession(String sessionId) async { + final response = await _getWithFailover('/livetv/sessions/$sessionId'); + return _extractFirst(response, const [ + 'LiveTVSession', + 'LiveTvSession', + 'Session', + 'Metadata', + ], LiveTvSession.fromJson); + } + + Uri buildNotificationWebSocketUri({List? 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? 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 buildFavoriteChannelSource({String? lineup}) async { final providers = _epgProvidersForLineup(lineup); @@ -726,4 +1189,200 @@ class _PlexLiveTvSupport implements LiveTvSupport { @override Future setFavoriteChannels(List channels) => _client.setFavoriteChannels(channels); + + @override + Future fetchLiveTvServerStatus() => _client.getLiveTvServerStatus(); + + @override + Future fetchDvr(String dvrId) => _client.getDvr(dvrId); + + @override + Future> createDvr({ + required List devices, + required List lineups, + String? language, + String? country, + String? postalCode, + }) => _client.createDvr( + devices: devices, + lineups: lineups, + language: language, + country: country, + postalCode: postalCode, + ); + + @override + Future deleteDvr(String dvrId) => _client.deleteDvr(dvrId); + + @override + Future updateDvrPrefs(String dvrId, Map prefs) => _client.updateDvrPrefs(dvrId, prefs); + + @override + Future attachDeviceToDvr(String dvrId, String deviceId) => _client.attachDeviceToDvr(dvrId, deviceId); + + @override + Future detachDeviceFromDvr(String dvrId, String deviceId) => _client.detachDeviceFromDvr(dvrId, deviceId); + + @override + Future addLineupToDvr(String dvrId, String lineupUri) => _client.addLineupToDvr(dvrId, lineupUri); + + @override + Future removeLineupFromDvr(String dvrId, String lineupUri) => _client.removeLineupFromDvr(dvrId, lineupUri); + + @override + Future> reloadGuide(String dvrId) => _client.reloadGuide(dvrId); + + @override + Future cancelGuideReload(String dvrId) => _client.cancelGuideReload(dvrId); + + @override + Future> fetchGrabbers({String? protocol}) => _client.getGrabbers(protocol: protocol); + + @override + Future> fetchGrabberDevices() => _client.getGrabberDevices(); + + @override + Future>> discoverGrabberDevices() => _client.discoverGrabberDevices(); + + @override + Future fetchGrabberDevice(String deviceId) => _client.getGrabberDevice(deviceId); + + @override + Future addGrabberDevice(String uri, {String? grabberId}) => + _client.addGrabberDevice(uri, grabberId: grabberId); + + @override + Future updateGrabberDevice(String deviceId, {bool? enabled, String? title}) => + _client.updateGrabberDevice(deviceId, enabled: enabled, title: title); + + @override + Future deleteGrabberDevice(String deviceId) => _client.deleteGrabberDevice(deviceId); + + @override + Future> fetchGrabberDeviceChannels(String deviceId) => + _client.getGrabberDeviceChannels(deviceId); + + @override + Future> scanGrabberDevice( + String deviceId, { + String? source, + Map prefs = const {}, + String? network, + String? country, + }) => _client.scanGrabberDevice(deviceId, source: source, prefs: prefs, network: network, country: country); + + @override + Future cancelGrabberDeviceScan(String deviceId) => _client.cancelGrabberDeviceScan(deviceId); + + @override + Future saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request) => + _client.saveGrabberDeviceChannelMap(deviceId, request); + + @override + Future updateGrabberDevicePrefs(String deviceId, Map prefs) => + _client.updateGrabberDevicePrefs(deviceId, prefs); + + @override + String buildGrabberDeviceThumbUrl(String deviceId, int version) => + _client.buildGrabberDeviceThumbUrl(deviceId, version); + + @override + Future> fetchEpgCountries() => _client.getEpgCountries(); + + @override + Future> fetchEpgLanguages() => _client.getEpgLanguages(); + + @override + Future> fetchEpgRegions(String country, String epgId) => _client.getEpgRegions(country, epgId); + + @override + Future fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}) => + _client.getEpgLineups(country, epgId, postalCode: postalCode, region: region); + + @override + Future> fetchEpgChannelsForLineup(String lineupUri) => _client.getEpgChannelsForLineup(lineupUri); + + @override + Future> fetchEpgChannelsForLineups(List lineupUris) => + _client.getEpgChannelsForLineups(lineupUris); + + @override + Future> computeEpgChannelMap({required String deviceUri, required String lineupUri}) => + _client.computeEpgChannelMap(deviceUri: deviceUri, lineupUri: lineupUri); + + @override + Future?>> findBestLineup({ + required String deviceUri, + required String lineupGroupUri, + }) => _client.findBestLineup(deviceUri: deviceUri, lineupGroupUri: lineupGroupUri); + + @override + Future> getSubscriptionTemplate(String guid) => _client.getSubscriptionTemplate(guid); + + @override + Future> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}) => + _client.getRecordingRules(includeGrabs: includeGrabs, includeStorage: includeStorage); + + @override + Future fetchRecordingRule( + String subscriptionId, { + bool includeGrabs = true, + bool includeStorage = true, + }) => _client.getRecordingRule(subscriptionId, includeGrabs: includeGrabs, includeStorage: includeStorage); + + @override + Future createRecordingRule(MediaSubscriptionCreateRequest request) => + _client.createRecordingRule(request); + + @override + Future updateRecordingRule(String subscriptionId, Map prefs) => + _client.updateRecordingRule(subscriptionId, prefs); + + @override + Future deleteRecordingRule(String subscriptionId) => _client.deleteRecordingRule(subscriptionId); + + @override + Future moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) => + _client.moveRecordingRule(subscriptionId, afterSubscriptionId: afterSubscriptionId); + + @override + Future processRecordingRules() => _client.processRecordingRules(); + + @override + Future> fetchScheduledRecordings() => _client.getScheduledRecordings(); + + @override + Future cancelGrab(String operationId) => _client.cancelGrab(operationId); + + @override + Future> fetchSubscriptionMapping({ + required String providerId, + required List ratingKeys, + bool includeStorage = true, + }) => _client.getSubscriptionMapping(providerId: providerId, ratingKeys: ratingKeys, includeStorage: includeStorage); + + @override + Future> fetchMediaProviders() => _client.getMediaProviders(); + + @override + Future registerMediaProvider(String url) => _client.registerMediaProvider(url); + + @override + Future refreshMediaProviders() => _client.refreshMediaProviders(); + + @override + Future unregisterMediaProvider(String providerId) => _client.unregisterMediaProvider(providerId); + + @override + Future> fetchLiveTvSessionsDetailed() => _client.getLiveTvSessionsDetailed(); + + @override + Future fetchLiveTvSession(String sessionId) => _client.getLiveTvSession(sessionId); + + @override + Uri buildNotificationWebSocketUri({List? filters}) => _client.buildNotificationWebSocketUri(filters: filters); + + @override + Uri buildNotificationEventSourceUri({List? filters}) => + _client.buildNotificationEventSourceUri(filters: filters); } diff --git a/lib/utils/formatters.dart b/lib/utils/formatters.dart index 4a6079a5..119ed57f 100644 --- a/lib/utils/formatters.dart +++ b/lib/utils/formatters.dart @@ -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}) { diff --git a/lib/utils/media_server_http_client.dart b/lib/utils/media_server_http_client.dart index 83a82bb1..a58d07f8 100644 --- a/lib/utils/media_server_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -263,7 +263,7 @@ class MediaServerHttpClient { Uri _buildUri(String path, Map? 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? 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? params) { + /// Null values are omitted and iterable values are emitted as repeated keys. + static String encodeQueryParameters(Map? params) { if (params == null || params.isEmpty) return ''; final parts = []; - 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('&'); } diff --git a/test/services/plex_live_tv_support_test.dart b/test/services/plex_live_tv_support_test.dart index dbdd772f..2a83d5f3 100644 --- a/test/services/plex_live_tv_support_test.dart +++ b/test/services/plex_live_tv_support_test.dart @@ -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 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 body, {Map? 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': {}}); + }); + 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'); + }); }