feat: trakt integration

close #617
This commit is contained in:
edde746
2026-04-19 11:31:27 +02:00
parent 8b34307975
commit ff2928b18f
54 changed files with 3040 additions and 58 deletions
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><circle cx="24" cy="24" r="24" fill="transparent"/><path fill="currentColor" d="M45.99 14.39 30.17 30.22l-1.46-1.46 16.31-16.32c-.15-.3-.32-.58-.5-.87l-17.2 17.2 2.16 2.16-1.46 1.46-3.62-3.62L43.34 9.82c-.18-.27-.38-.53-.6-.78L23.01 28.78l4.31 4.31-1.46 1.46-14.4-14.4 1.46-1.46 8.62 8.62L41.37 7.46c-.12-.14-.26-.28-.4-.42C36.62 2.69 30.62 0 24 0S11.38 2.69 7.03 7.03C2.69 11.37 0 17.37 0 24c0 13.25 10.75 24 24 24 6.62 0 12.62-2.69 16.97-7.03C45.31 36.64 48 30.64 48 24c0-3.42-.71-6.67-2.01-9.61ZM21.54 25.9l-7.93-7.92 1.46-1.46L23 24.44l-1.46 1.46Zm2.16-2.16-7.93-7.92 1.46-1.46 7.93 7.92-1.46 1.46Zm.3 19.65c-10.7 0-19.39-8.7-19.39-19.39S13.3 4.61 24 4.61c2.91 0 5.72.63 8.33 1.88l-.89 1.88c-2.33-1.11-4.84-1.68-7.43-1.68-9.55 0-17.33 7.77-17.33 17.32s7.78 17.33 17.33 17.33c6.82 0 13.03-4.03 15.82-10.25l1.89.84C38.6 38.9 31.65 43.41 24.01 43.41Z"/></svg>

After

Width:  |  Height:  |  Size: 921 B

+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence", "discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Vis hvad du ser på Discord", "discordRichPresenceDescription": "Vis hvad du ser på Discord",
"trakt": "Trakt",
"traktDescription": "Synkroniser visningshistorik med Trakt",
"autoPip": "Auto billede-i-billede", "autoPip": "Auto billede-i-billede",
"autoPipDescription": "Gå automatisk til billede-i-billede når du forlader appen under afspilning", "autoPipDescription": "Gå automatisk til billede-i-billede når du forlader appen under afspilning",
"matchContentFrameRate": "Match indholdets billedhastighed", "matchContentFrameRate": "Match indholdets billedhastighed",
@@ -976,5 +978,22 @@
"title": "Serveropgaver", "title": "Serveropgaver",
"failedToLoad": "Kunne ikke indlæse opgaver", "failedToLoad": "Kunne ikke indlæse opgaver",
"noTasks": "Ingen opgaver kører" "noTasks": "Ingen opgaver kører"
},
"trakt": {
"title": "Trakt",
"connected": "Forbundet",
"connectedAs": "Forbundet som @${username}",
"disconnectConfirm": "Frakobl Trakt-konto?",
"disconnectConfirmBody": "Plezy stopper med at sende afspilningsbegivenheder til Trakt. Du kan genoprette forbindelse når som helst.",
"scrobble": "Realtids-scrobbling",
"scrobbleDescription": "Send afspil-, pause- og stop-begivenheder til Trakt under afspilning.",
"watchedSync": "Synkroniser sét-status",
"watchedSyncDescription": "Når du markerer ting som sét i Plezy, markeres de også på Trakt.",
"deviceCodeTitle": "Aktivér Plezy på Trakt",
"deviceCodeBody": "Besøg ${url} og indtast denne kode:",
"openTraktActivate": "Åbn Trakt for at aktivere",
"waitingForAuthorization": "Venter på godkendelse…",
"codeCopied": "Kode kopieret",
"connectFailed": "Kunne ikke forbinde til Trakt. Prøv igen."
} }
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence", "discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Zeige auf Discord, was du gerade schaust", "discordRichPresenceDescription": "Zeige auf Discord, was du gerade schaust",
"trakt": "Trakt",
"traktDescription": "Wiedergabeverlauf mit Trakt synchronisieren",
"autoPip": "Automatisches Bild-in-Bild", "autoPip": "Automatisches Bild-in-Bild",
"autoPipDescription": "Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird", "autoPipDescription": "Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird",
"matchContentFrameRate": "Inhalts-Bildrate anpassen", "matchContentFrameRate": "Inhalts-Bildrate anpassen",
@@ -976,5 +978,22 @@
"title": "Serveraufgaben", "title": "Serveraufgaben",
"failedToLoad": "Aufgaben konnten nicht geladen werden", "failedToLoad": "Aufgaben konnten nicht geladen werden",
"noTasks": "Keine laufenden Aufgaben" "noTasks": "Keine laufenden Aufgaben"
},
"trakt": {
"title": "Trakt",
"connected": "Verbunden",
"connectedAs": "Verbunden als @${username}",
"disconnectConfirm": "Trakt-Konto trennen?",
"disconnectConfirmBody": "Plezy sendet keine Wiedergabe-Ereignisse mehr an Trakt. Du kannst dich jederzeit erneut verbinden.",
"scrobble": "Echtzeit-Scrobbling",
"scrobbleDescription": "Sende Play-, Pause- und Stopp-Ereignisse während der Wiedergabe an Trakt.",
"watchedSync": "Gesehen-Status synchronisieren",
"watchedSyncDescription": "Wenn du Inhalte in Plezy als gesehen markierst, werden sie auch auf Trakt markiert.",
"deviceCodeTitle": "Plezy auf Trakt aktivieren",
"deviceCodeBody": "Öffne ${url} und gib diesen Code ein:",
"openTraktActivate": "Trakt zum Aktivieren öffnen",
"waitingForAuthorization": "Warte auf Autorisierung…",
"codeCopied": "Code kopiert",
"connectFailed": "Verbindung zu Trakt fehlgeschlagen. Versuche es erneut."
} }
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence", "discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Show what you're watching on Discord", "discordRichPresenceDescription": "Show what you're watching on Discord",
"trakt": "Trakt",
"traktDescription": "Sync watch history with Trakt",
"companionRemoteServer": "Companion Remote Server", "companionRemoteServer": "Companion Remote Server",
"companionRemoteServerDescription": "Allow mobile devices on your network to control this app", "companionRemoteServerDescription": "Allow mobile devices on your network to control this app",
"autoPip": "Auto Picture-in-Picture", "autoPip": "Auto Picture-in-Picture",
@@ -976,5 +978,22 @@
"title": "Server Tasks", "title": "Server Tasks",
"failedToLoad": "Failed to load tasks", "failedToLoad": "Failed to load tasks",
"noTasks": "No tasks running" "noTasks": "No tasks running"
},
"trakt": {
"title": "Trakt",
"connected": "Connected",
"connectedAs": "Connected as @${username}",
"disconnectConfirm": "Disconnect Trakt account?",
"disconnectConfirmBody": "Plezy will stop sending playback events to Trakt. You can reconnect at any time.",
"scrobble": "Real-time scrobbling",
"scrobbleDescription": "Send play, pause, and stop events to Trakt during playback.",
"watchedSync": "Sync watched status",
"watchedSyncDescription": "When you mark items watched in Plezy, mark them on Trakt.",
"deviceCodeTitle": "Activate Plezy on Trakt",
"deviceCodeBody": "Visit ${url} and enter this code:",
"openTraktActivate": "Open Trakt to activate",
"waitingForAuthorization": "Waiting for authorization…",
"codeCopied": "Code copied",
"connectFailed": "Couldn't connect to Trakt. Try again."
} }
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Presencia de Discord", "discordRichPresence": "Presencia de Discord",
"discordRichPresenceDescription": "Mostrar lo que estás viendo en Discord", "discordRichPresenceDescription": "Mostrar lo que estás viendo en Discord",
"trakt": "Trakt",
"traktDescription": "Sincronizar historial de visualización con Trakt",
"autoPip": "Imagen en imagen automática", "autoPip": "Imagen en imagen automática",
"autoPipDescription": "Activar automáticamente imagen en imagen al salir de la app durante la reproducción", "autoPipDescription": "Activar automáticamente imagen en imagen al salir de la app durante la reproducción",
"matchContentFrameRate": "Ajustar frecuencia de actualización", "matchContentFrameRate": "Ajustar frecuencia de actualización",
@@ -976,5 +978,22 @@
"title": "Tareas del servidor", "title": "Tareas del servidor",
"failedToLoad": "Error al cargar tareas", "failedToLoad": "Error al cargar tareas",
"noTasks": "No hay tareas en ejecución" "noTasks": "No hay tareas en ejecución"
},
"trakt": {
"title": "Trakt",
"connected": "Conectado",
"connectedAs": "Conectado como @${username}",
"disconnectConfirm": "¿Desconectar cuenta de Trakt?",
"disconnectConfirmBody": "Plezy dejará de enviar eventos de reproducción a Trakt. Puedes volver a conectar cuando quieras.",
"scrobble": "Scrobbling en tiempo real",
"scrobbleDescription": "Enviar eventos de reproducción, pausa y parada a Trakt durante la reproducción.",
"watchedSync": "Sincronizar estado de visto",
"watchedSyncDescription": "Cuando marques elementos como vistos en Plezy, también se marcarán en Trakt.",
"deviceCodeTitle": "Activa Plezy en Trakt",
"deviceCodeBody": "Visita ${url} e introduce este código:",
"openTraktActivate": "Abrir Trakt para activar",
"waitingForAuthorization": "Esperando autorización…",
"codeCopied": "Código copiado",
"connectFailed": "No se pudo conectar a Trakt. Inténtalo de nuevo."
} }
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence", "discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Montrez ce que vous regardez sur Discord", "discordRichPresenceDescription": "Montrez ce que vous regardez sur Discord",
"trakt": "Trakt",
"traktDescription": "Synchroniser l'historique de visionnage avec Trakt",
"autoPip": "Image dans l'image automatique", "autoPip": "Image dans l'image automatique",
"autoPipDescription": "Activer automatiquement l'image dans l'image en quittant l'application pendant la lecture", "autoPipDescription": "Activer automatiquement l'image dans l'image en quittant l'application pendant la lecture",
"matchContentFrameRate": "Fréquence d'images du contenu correspondant", "matchContentFrameRate": "Fréquence d'images du contenu correspondant",
@@ -976,5 +978,22 @@
"title": "Tâches du serveur", "title": "Tâches du serveur",
"failedToLoad": "Échec du chargement des tâches", "failedToLoad": "Échec du chargement des tâches",
"noTasks": "Aucune tâche en cours" "noTasks": "Aucune tâche en cours"
},
"trakt": {
"title": "Trakt",
"connected": "Connecté",
"connectedAs": "Connecté en tant que @${username}",
"disconnectConfirm": "Déconnecter le compte Trakt ?",
"disconnectConfirmBody": "Plezy n'enverra plus d'événements de lecture à Trakt. Vous pouvez vous reconnecter à tout moment.",
"scrobble": "Scrobbling en temps réel",
"scrobbleDescription": "Envoyer les événements de lecture, pause et arrêt à Trakt pendant la lecture.",
"watchedSync": "Synchroniser le statut « vu »",
"watchedSyncDescription": "Lorsque vous marquez un élément comme vu dans Plezy, il l'est aussi sur Trakt.",
"deviceCodeTitle": "Activer Plezy sur Trakt",
"deviceCodeBody": "Rendez-vous sur ${url} et entrez ce code :",
"openTraktActivate": "Ouvrir Trakt pour activer",
"waitingForAuthorization": "En attente d'autorisation…",
"codeCopied": "Code copié",
"connectFailed": "Échec de la connexion à Trakt. Réessayez."
} }
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence", "discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Mostra su Discord cosa stai guardando", "discordRichPresenceDescription": "Mostra su Discord cosa stai guardando",
"trakt": "Trakt",
"traktDescription": "Sincronizza la cronologia di visione con Trakt",
"autoPip": "Picture-in-Picture automatico", "autoPip": "Picture-in-Picture automatico",
"autoPipDescription": "Attiva automaticamente il picture-in-picture quando si esce dall'app durante la riproduzione", "autoPipDescription": "Attiva automaticamente il picture-in-picture quando si esce dall'app durante la riproduzione",
"matchContentFrameRate": "Adatta frequenza fotogrammi", "matchContentFrameRate": "Adatta frequenza fotogrammi",
@@ -976,5 +978,22 @@
"title": "Attività del server", "title": "Attività del server",
"failedToLoad": "Impossibile caricare le attività", "failedToLoad": "Impossibile caricare le attività",
"noTasks": "Nessuna attività in corso" "noTasks": "Nessuna attività in corso"
},
"trakt": {
"title": "Trakt",
"connected": "Connesso",
"connectedAs": "Connesso come @${username}",
"disconnectConfirm": "Disconnettere l'account Trakt?",
"disconnectConfirmBody": "Plezy smetterà di inviare eventi di riproduzione a Trakt. Puoi riconnetterti in qualsiasi momento.",
"scrobble": "Scrobbling in tempo reale",
"scrobbleDescription": "Invia eventi di riproduzione, pausa e arresto a Trakt durante la riproduzione.",
"watchedSync": "Sincronizza stato visualizzato",
"watchedSyncDescription": "Quando segni elementi come visti in Plezy, vengono segnati anche su Trakt.",
"deviceCodeTitle": "Attiva Plezy su Trakt",
"deviceCodeBody": "Visita ${url} e inserisci questo codice:",
"openTraktActivate": "Apri Trakt per attivare",
"waitingForAuthorization": "In attesa di autorizzazione…",
"codeCopied": "Codice copiato",
"connectFailed": "Impossibile connettersi a Trakt. Riprova."
} }
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence", "discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Discordで視聴中の内容を表示", "discordRichPresenceDescription": "Discordで視聴中の内容を表示",
"trakt": "Trakt",
"traktDescription": "視聴履歴を Trakt と同期",
"autoPip": "自動ピクチャーインピクチャー", "autoPip": "自動ピクチャーインピクチャー",
"autoPipDescription": "再生中にアプリを離れると自動的にピクチャーインピクチャーに移行", "autoPipDescription": "再生中にアプリを離れると自動的にピクチャーインピクチャーに移行",
"matchContentFrameRate": "コンテンツのフレームレートに合わせる", "matchContentFrameRate": "コンテンツのフレームレートに合わせる",
@@ -976,5 +978,22 @@
"title": "サーバータスク", "title": "サーバータスク",
"failedToLoad": "タスクの読み込みに失敗しました", "failedToLoad": "タスクの読み込みに失敗しました",
"noTasks": "実行中のタスクはありません" "noTasks": "実行中のタスクはありません"
},
"trakt": {
"title": "Trakt",
"connected": "接続済み",
"connectedAs": "@${username} として接続済み",
"disconnectConfirm": "Trakt アカウントを切断しますか?",
"disconnectConfirmBody": "Plezy から Trakt への再生イベント送信が停止します。いつでも再接続できます。",
"scrobble": "リアルタイムのスクロブル",
"scrobbleDescription": "再生中に再生・一時停止・停止イベントを Trakt に送信します。",
"watchedSync": "視聴済みステータスを同期",
"watchedSyncDescription": "Plezy で項目を視聴済みにすると、Trakt でも視聴済みになります。",
"deviceCodeTitle": "Trakt で Plezy をアクティベート",
"deviceCodeBody": "${url} を開き、このコードを入力してください:",
"openTraktActivate": "アクティベートするには Trakt を開く",
"waitingForAuthorization": "認証を待機中…",
"codeCopied": "コードをコピーしました",
"connectFailed": "Trakt に接続できませんでした。もう一度お試しください。"
} }
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence", "discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시", "discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시",
"trakt": "Trakt",
"traktDescription": "Trakt와 시청 기록 동기화",
"autoPip": "자동 PIP 모드", "autoPip": "자동 PIP 모드",
"autoPipDescription": "재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환", "autoPipDescription": "재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환",
"matchContentFrameRate": "콘텐츠 프레임 레이트 맞춤", "matchContentFrameRate": "콘텐츠 프레임 레이트 맞춤",
@@ -976,5 +978,22 @@
"title": "서버 작업", "title": "서버 작업",
"failedToLoad": "작업을 불러올 수 없습니다", "failedToLoad": "작업을 불러올 수 없습니다",
"noTasks": "실행 중인 작업 없음" "noTasks": "실행 중인 작업 없음"
},
"trakt": {
"title": "Trakt",
"connected": "연결됨",
"connectedAs": "@${username}(으)로 연결됨",
"disconnectConfirm": "Trakt 계정 연결을 끊으시겠습니까?",
"disconnectConfirmBody": "Plezy가 Trakt로 재생 이벤트를 보내지 않습니다. 언제든지 다시 연결할 수 있습니다.",
"scrobble": "실시간 스크로블",
"scrobbleDescription": "재생 중 재생, 일시정지, 정지 이벤트를 Trakt로 전송합니다.",
"watchedSync": "시청 상태 동기화",
"watchedSyncDescription": "Plezy에서 시청 완료로 표시한 항목이 Trakt에도 시청 완료로 표시됩니다.",
"deviceCodeTitle": "Trakt에서 Plezy 활성화",
"deviceCodeBody": "${url}을(를) 방문하여 이 코드를 입력하세요:",
"openTraktActivate": "활성화하려면 Trakt 열기",
"waitingForAuthorization": "인증 대기 중…",
"codeCopied": "코드가 복사됨",
"connectFailed": "Trakt에 연결할 수 없습니다. 다시 시도하세요."
} }
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence", "discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Vis hva du ser på Discord", "discordRichPresenceDescription": "Vis hva du ser på Discord",
"trakt": "Trakt",
"traktDescription": "Synkroniser visningshistorikk med Trakt",
"autoPip": "Auto bilde-i-bilde", "autoPip": "Auto bilde-i-bilde",
"autoPipDescription": "Gå automatisk til bilde-i-bilde når du forlater appen under avspilling", "autoPipDescription": "Gå automatisk til bilde-i-bilde når du forlater appen under avspilling",
"matchContentFrameRate": "Tilpass innholdets bildefrekvens", "matchContentFrameRate": "Tilpass innholdets bildefrekvens",
@@ -976,5 +978,22 @@
"title": "Serveroppgaver", "title": "Serveroppgaver",
"failedToLoad": "Kunne ikke laste oppgaver", "failedToLoad": "Kunne ikke laste oppgaver",
"noTasks": "Ingen oppgaver kjører" "noTasks": "Ingen oppgaver kjører"
},
"trakt": {
"title": "Trakt",
"connected": "Tilkoblet",
"connectedAs": "Tilkoblet som @${username}",
"disconnectConfirm": "Koble fra Trakt-konto?",
"disconnectConfirmBody": "Plezy slutter å sende avspillingshendelser til Trakt. Du kan koble til igjen når som helst.",
"scrobble": "Sanntids-scrobbling",
"scrobbleDescription": "Send avspillings-, pause- og stopphendelser til Trakt under avspilling.",
"watchedSync": "Synkroniser sett-status",
"watchedSyncDescription": "Når du markerer noe som sett i Plezy, markeres det også på Trakt.",
"deviceCodeTitle": "Aktiver Plezy på Trakt",
"deviceCodeBody": "Besøk ${url} og skriv inn denne koden:",
"openTraktActivate": "Åpne Trakt for å aktivere",
"waitingForAuthorization": "Venter på autorisering…",
"codeCopied": "Kode kopiert",
"connectFailed": "Kunne ikke koble til Trakt. Prøv igjen."
} }
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence", "discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Toon op Discord wat je aan het kijken bent", "discordRichPresenceDescription": "Toon op Discord wat je aan het kijken bent",
"trakt": "Trakt",
"traktDescription": "Kijkgeschiedenis synchroniseren met Trakt",
"autoPip": "Automatische beeld-in-beeld", "autoPip": "Automatische beeld-in-beeld",
"autoPipDescription": "Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen", "autoPipDescription": "Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen",
"matchContentFrameRate": "Inhoudsframesnelheid afstemmen", "matchContentFrameRate": "Inhoudsframesnelheid afstemmen",
@@ -976,5 +978,22 @@
"title": "Servertaken", "title": "Servertaken",
"failedToLoad": "Taken konden niet worden geladen", "failedToLoad": "Taken konden niet worden geladen",
"noTasks": "Geen actieve taken" "noTasks": "Geen actieve taken"
},
"trakt": {
"title": "Trakt",
"connected": "Verbonden",
"connectedAs": "Verbonden als @${username}",
"disconnectConfirm": "Trakt-account loskoppelen?",
"disconnectConfirmBody": "Plezy stopt met het versturen van afspeelgebeurtenissen naar Trakt. Je kunt op elk moment opnieuw verbinden.",
"scrobble": "Realtime scrobbling",
"scrobbleDescription": "Verstuur play-, pauze- en stopgebeurtenissen tijdens afspelen naar Trakt.",
"watchedSync": "Bekeken-status synchroniseren",
"watchedSyncDescription": "Wanneer je items als bekeken markeert in Plezy, worden ze ook op Trakt gemarkeerd.",
"deviceCodeTitle": "Activeer Plezy op Trakt",
"deviceCodeBody": "Ga naar ${url} en voer deze code in:",
"openTraktActivate": "Open Trakt om te activeren",
"waitingForAuthorization": "Wacht op autorisatie…",
"codeCopied": "Code gekopieerd",
"connectFailed": "Kan niet verbinden met Trakt. Probeer opnieuw."
} }
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence", "discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Pokaż, co oglądasz na Discordzie", "discordRichPresenceDescription": "Pokaż, co oglądasz na Discordzie",
"trakt": "Trakt",
"traktDescription": "Synchronizuj historię oglądania z Trakt",
"autoPip": "Automatyczny obraz w obrazie", "autoPip": "Automatyczny obraz w obrazie",
"autoPipDescription": "Automatycznie przejdź do trybu obraz w obrazie przy wyjściu z aplikacji podczas odtwarzania", "autoPipDescription": "Automatycznie przejdź do trybu obraz w obrazie przy wyjściu z aplikacji podczas odtwarzania",
"matchContentFrameRate": "Dopasuj częstotliwość klatek do treści", "matchContentFrameRate": "Dopasuj częstotliwość klatek do treści",
@@ -976,5 +978,22 @@
"title": "Zadania serwera", "title": "Zadania serwera",
"failedToLoad": "Nie udało się załadować zadań", "failedToLoad": "Nie udało się załadować zadań",
"noTasks": "Brak uruchomionych zadań" "noTasks": "Brak uruchomionych zadań"
},
"trakt": {
"title": "Trakt",
"connected": "Połączono",
"connectedAs": "Połączono jako @${username}",
"disconnectConfirm": "Rozłączyć konto Trakt?",
"disconnectConfirmBody": "Plezy przestanie wysyłać zdarzenia odtwarzania do Trakt. Możesz połączyć się ponownie w dowolnej chwili.",
"scrobble": "Scrobbling w czasie rzeczywistym",
"scrobbleDescription": "Wysyłaj zdarzenia odtwarzania, pauzy i zatrzymania do Trakt podczas odtwarzania.",
"watchedSync": "Synchronizuj status obejrzane",
"watchedSyncDescription": "Gdy oznaczysz pozycje jako obejrzane w Plezy, zostaną też oznaczone w Trakt.",
"deviceCodeTitle": "Aktywuj Plezy w Trakt",
"deviceCodeBody": "Otwórz ${url} i wpisz ten kod:",
"openTraktActivate": "Otwórz Trakt, aby aktywować",
"waitingForAuthorization": "Czekanie na autoryzację…",
"codeCopied": "Kod skopiowany",
"connectFailed": "Nie udało się połączyć z Trakt. Spróbuj ponownie."
} }
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence", "discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Mostrar o que você está assistindo no Discord", "discordRichPresenceDescription": "Mostrar o que você está assistindo no Discord",
"trakt": "Trakt",
"traktDescription": "Sincronizar histórico de exibição com Trakt",
"autoPip": "Picture-in-Picture Automático", "autoPip": "Picture-in-Picture Automático",
"autoPipDescription": "Entrar automaticamente em picture-in-picture ao sair do app durante a reprodução", "autoPipDescription": "Entrar automaticamente em picture-in-picture ao sair do app durante a reprodução",
"matchContentFrameRate": "Corresponder Taxa de Quadros do Conteúdo", "matchContentFrameRate": "Corresponder Taxa de Quadros do Conteúdo",
@@ -976,5 +978,22 @@
"title": "Tarefas do servidor", "title": "Tarefas do servidor",
"failedToLoad": "Falha ao carregar tarefas", "failedToLoad": "Falha ao carregar tarefas",
"noTasks": "Nenhuma tarefa em execução" "noTasks": "Nenhuma tarefa em execução"
},
"trakt": {
"title": "Trakt",
"connected": "Conectado",
"connectedAs": "Conectado como @${username}",
"disconnectConfirm": "Desconectar conta do Trakt?",
"disconnectConfirmBody": "O Plezy deixará de enviar eventos de reprodução ao Trakt. Você pode reconectar a qualquer momento.",
"scrobble": "Scrobbling em tempo real",
"scrobbleDescription": "Envia eventos de reprodução, pausa e parada ao Trakt durante a exibição.",
"watchedSync": "Sincronizar status de assistido",
"watchedSyncDescription": "Ao marcar itens como assistidos no Plezy, eles também serão marcados no Trakt.",
"deviceCodeTitle": "Ativar o Plezy no Trakt",
"deviceCodeBody": "Acesse ${url} e digite este código:",
"openTraktActivate": "Abrir o Trakt para ativar",
"waitingForAuthorization": "Aguardando autorização…",
"codeCopied": "Código copiado",
"connectFailed": "Não foi possível conectar ao Trakt. Tente novamente."
} }
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence", "discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Показывать, что вы смотрите, в Discord", "discordRichPresenceDescription": "Показывать, что вы смотрите, в Discord",
"trakt": "Trakt",
"traktDescription": "Синхронизировать историю просмотров с Trakt",
"autoPip": "Автоматический «картинка в картинке»", "autoPip": "Автоматический «картинка в картинке»",
"autoPipDescription": "Автоматически переходить в режим «картинка в картинке» при выходе из приложения во время воспроизведения", "autoPipDescription": "Автоматически переходить в режим «картинка в картинке» при выходе из приложения во время воспроизведения",
"matchContentFrameRate": "Соответствие частоты кадров контента", "matchContentFrameRate": "Соответствие частоты кадров контента",
@@ -976,5 +978,22 @@
"title": "Задачи сервера", "title": "Задачи сервера",
"failedToLoad": "Не удалось загрузить задачи", "failedToLoad": "Не удалось загрузить задачи",
"noTasks": "Нет выполняемых задач" "noTasks": "Нет выполняемых задач"
},
"trakt": {
"title": "Trakt",
"connected": "Подключено",
"connectedAs": "Подключено как @${username}",
"disconnectConfirm": "Отключить аккаунт Trakt?",
"disconnectConfirmBody": "Plezy перестанет отправлять события воспроизведения в Trakt. Вы можете подключиться снова в любое время.",
"scrobble": "Скробблинг в реальном времени",
"scrobbleDescription": "Отправлять события воспроизведения, паузы и остановки в Trakt во время просмотра.",
"watchedSync": "Синхронизация статуса просмотра",
"watchedSyncDescription": "Когда вы отмечаете элементы как просмотренные в Plezy, они отмечаются и в Trakt.",
"deviceCodeTitle": "Активация Plezy в Trakt",
"deviceCodeBody": "Откройте ${url} и введите этот код:",
"openTraktActivate": "Открыть Trakt для активации",
"waitingForAuthorization": "Ожидание авторизации…",
"codeCopied": "Код скопирован",
"connectFailed": "Не удалось подключиться к Trakt. Попробуйте ещё раз."
} }
} }
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 15 /// Locales: 15
/// Strings: 13320 (888 per locale) /// Strings: 13575 (905 per locale)
/// ///
/// Built on 2026-04-17 at 17:39 UTC /// Built on 2026-04-19 at 09:09 UTC
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsDa with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerDa externalPlayer = _TranslationsExternalPlayerDa._(_root); @override late final _TranslationsExternalPlayerDa externalPlayer = _TranslationsExternalPlayerDa._(_root);
@override late final _TranslationsMetadataEditDa metadataEdit = _TranslationsMetadataEditDa._(_root); @override late final _TranslationsMetadataEditDa metadataEdit = _TranslationsMetadataEditDa._(_root);
@override late final _TranslationsServerTasksDa serverTasks = _TranslationsServerTasksDa._(_root); @override late final _TranslationsServerTasksDa serverTasks = _TranslationsServerTasksDa._(_root);
@override late final _TranslationsTraktDa trakt = _TranslationsTraktDa._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsDa implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresence => 'Discord Rich Presence';
@override String get discordRichPresenceDescription => 'Vis hvad du ser på Discord'; @override String get discordRichPresenceDescription => 'Vis hvad du ser på Discord';
@override String get trakt => 'Trakt';
@override String get traktDescription => 'Synkroniser visningshistorik med Trakt';
@override String get autoPip => 'Auto billede-i-billede'; @override String get autoPip => 'Auto billede-i-billede';
@override String get autoPipDescription => 'Gå automatisk til billede-i-billede når du forlader appen under afspilning'; @override String get autoPipDescription => 'Gå automatisk til billede-i-billede når du forlader appen under afspilning';
@override String get matchContentFrameRate => 'Match indholdets billedhastighed'; @override String get matchContentFrameRate => 'Match indholdets billedhastighed';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksDa implements TranslationsServerTasksEn {
@override String get noTasks => 'Ingen opgaver kører'; @override String get noTasks => 'Ingen opgaver kører';
} }
// Path: trakt
class _TranslationsTraktDa implements TranslationsTraktEn {
_TranslationsTraktDa._(this._root);
final TranslationsDa _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => 'Forbundet';
@override String connectedAs({required Object username}) => 'Forbundet som @${username}';
@override String get disconnectConfirm => 'Frakobl Trakt-konto?';
@override String get disconnectConfirmBody => 'Plezy stopper med at sende afspilningsbegivenheder til Trakt. Du kan genoprette forbindelse når som helst.';
@override String get scrobble => 'Realtids-scrobbling';
@override String get scrobbleDescription => 'Send afspil-, pause- og stop-begivenheder til Trakt under afspilning.';
@override String get watchedSync => 'Synkroniser sét-status';
@override String get watchedSyncDescription => 'Når du markerer ting som sét i Plezy, markeres de også på Trakt.';
@override String get deviceCodeTitle => 'Aktivér Plezy på Trakt';
@override String deviceCodeBody({required Object url}) => 'Besøg ${url} og indtast denne kode:';
@override String get openTraktActivate => 'Åbn Trakt for at aktivere';
@override String get waitingForAuthorization => 'Venter på godkendelse…';
@override String get codeCopied => 'Kode kopieret';
@override String get connectFailed => 'Kunne ikke forbinde til Trakt. Prøv igen.';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsDa implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsDa implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsDa._(this._root); _TranslationsHotkeysActionsDa._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsDa {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresence' => 'Discord Rich Presence',
'settings.discordRichPresenceDescription' => 'Vis hvad du ser på Discord', 'settings.discordRichPresenceDescription' => 'Vis hvad du ser på Discord',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => 'Synkroniser visningshistorik med Trakt',
'settings.autoPip' => 'Auto billede-i-billede', 'settings.autoPip' => 'Auto billede-i-billede',
'settings.autoPipDescription' => 'Gå automatisk til billede-i-billede når du forlader appen under afspilning', 'settings.autoPipDescription' => 'Gå automatisk til billede-i-billede når du forlader appen under afspilning',
'settings.matchContentFrameRate' => 'Match indholdets billedhastighed', 'settings.matchContentFrameRate' => 'Match indholdets billedhastighed',
@@ -1895,10 +1924,10 @@ extension on TranslationsDa {
'libraries.tabs.recommended' => 'Anbefalet', 'libraries.tabs.recommended' => 'Anbefalet',
'libraries.tabs.browse' => 'Gennemse', 'libraries.tabs.browse' => 'Gennemse',
'libraries.tabs.collections' => 'Samlinger', 'libraries.tabs.collections' => 'Samlinger',
'libraries.tabs.playlists' => 'Playlister',
'libraries.groupings.title' => 'Gruppering',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => 'Playlister',
'libraries.groupings.title' => 'Gruppering',
'libraries.groupings.all' => 'Alle', 'libraries.groupings.all' => 'Alle',
'libraries.groupings.movies' => 'Film', 'libraries.groupings.movies' => 'Film',
'libraries.groupings.shows' => 'TV-serier', 'libraries.groupings.shows' => 'TV-serier',
@@ -2275,6 +2304,21 @@ extension on TranslationsDa {
'serverTasks.title' => 'Serveropgaver', 'serverTasks.title' => 'Serveropgaver',
'serverTasks.failedToLoad' => 'Kunne ikke indlæse opgaver', 'serverTasks.failedToLoad' => 'Kunne ikke indlæse opgaver',
'serverTasks.noTasks' => 'Ingen opgaver kører', 'serverTasks.noTasks' => 'Ingen opgaver kører',
'trakt.title' => 'Trakt',
'trakt.connected' => 'Forbundet',
'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.',
'trakt.scrobble' => 'Realtids-scrobbling',
'trakt.scrobbleDescription' => 'Send afspil-, pause- og stop-begivenheder til Trakt under afspilning.',
'trakt.watchedSync' => 'Synkroniser sét-status',
'trakt.watchedSyncDescription' => 'Når du markerer ting som sét i Plezy, markeres de også på Trakt.',
'trakt.deviceCodeTitle' => 'Aktivér Plezy på Trakt',
'trakt.deviceCodeBody' => ({required Object url}) => 'Besøg ${url} og indtast denne kode:',
'trakt.openTraktActivate' => 'Åbn Trakt for at aktivere',
'trakt.waitingForAuthorization' => 'Venter på godkendelse…',
'trakt.codeCopied' => 'Kode kopieret',
'trakt.connectFailed' => 'Kunne ikke forbinde til Trakt. Prøv igen.',
_ => null, _ => null,
}; };
} }
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsDe with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerDe externalPlayer = _TranslationsExternalPlayerDe._(_root); @override late final _TranslationsExternalPlayerDe externalPlayer = _TranslationsExternalPlayerDe._(_root);
@override late final _TranslationsMetadataEditDe metadataEdit = _TranslationsMetadataEditDe._(_root); @override late final _TranslationsMetadataEditDe metadataEdit = _TranslationsMetadataEditDe._(_root);
@override late final _TranslationsServerTasksDe serverTasks = _TranslationsServerTasksDe._(_root); @override late final _TranslationsServerTasksDe serverTasks = _TranslationsServerTasksDe._(_root);
@override late final _TranslationsTraktDe trakt = _TranslationsTraktDe._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresence => 'Discord Rich Presence';
@override String get discordRichPresenceDescription => 'Zeige auf Discord, was du gerade schaust'; @override String get discordRichPresenceDescription => 'Zeige auf Discord, was du gerade schaust';
@override String get trakt => 'Trakt';
@override String get traktDescription => 'Wiedergabeverlauf mit Trakt synchronisieren';
@override String get autoPip => 'Automatisches Bild-in-Bild'; @override String get autoPip => 'Automatisches Bild-in-Bild';
@override String get autoPipDescription => 'Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird'; @override String get autoPipDescription => 'Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird';
@override String get matchContentFrameRate => 'Inhalts-Bildrate anpassen'; @override String get matchContentFrameRate => 'Inhalts-Bildrate anpassen';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksDe implements TranslationsServerTasksEn {
@override String get noTasks => 'Keine laufenden Aufgaben'; @override String get noTasks => 'Keine laufenden Aufgaben';
} }
// Path: trakt
class _TranslationsTraktDe implements TranslationsTraktEn {
_TranslationsTraktDe._(this._root);
final TranslationsDe _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => 'Verbunden';
@override String connectedAs({required Object username}) => 'Verbunden als @${username}';
@override String get disconnectConfirm => 'Trakt-Konto trennen?';
@override String get disconnectConfirmBody => 'Plezy sendet keine Wiedergabe-Ereignisse mehr an Trakt. Du kannst dich jederzeit erneut verbinden.';
@override String get scrobble => 'Echtzeit-Scrobbling';
@override String get scrobbleDescription => 'Sende Play-, Pause- und Stopp-Ereignisse während der Wiedergabe an Trakt.';
@override String get watchedSync => 'Gesehen-Status synchronisieren';
@override String get watchedSyncDescription => 'Wenn du Inhalte in Plezy als gesehen markierst, werden sie auch auf Trakt markiert.';
@override String get deviceCodeTitle => 'Plezy auf Trakt aktivieren';
@override String deviceCodeBody({required Object url}) => 'Öffne ${url} und gib diesen Code ein:';
@override String get openTraktActivate => 'Trakt zum Aktivieren öffnen';
@override String get waitingForAuthorization => 'Warte auf Autorisierung…';
@override String get codeCopied => 'Code kopiert';
@override String get connectFailed => 'Verbindung zu Trakt fehlgeschlagen. Versuche es erneut.';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsDe implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsDe implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsDe._(this._root); _TranslationsHotkeysActionsDe._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsDe {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresence' => 'Discord Rich Presence',
'settings.discordRichPresenceDescription' => 'Zeige auf Discord, was du gerade schaust', 'settings.discordRichPresenceDescription' => 'Zeige auf Discord, was du gerade schaust',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => 'Wiedergabeverlauf mit Trakt synchronisieren',
'settings.autoPip' => 'Automatisches Bild-in-Bild', 'settings.autoPip' => 'Automatisches Bild-in-Bild',
'settings.autoPipDescription' => 'Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird', 'settings.autoPipDescription' => 'Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird',
'settings.matchContentFrameRate' => 'Inhalts-Bildrate anpassen', 'settings.matchContentFrameRate' => 'Inhalts-Bildrate anpassen',
@@ -1895,10 +1924,10 @@ extension on TranslationsDe {
'libraries.tabs.recommended' => 'Empfohlen', 'libraries.tabs.recommended' => 'Empfohlen',
'libraries.tabs.browse' => 'Durchsuchen', 'libraries.tabs.browse' => 'Durchsuchen',
'libraries.tabs.collections' => 'Sammlungen', 'libraries.tabs.collections' => 'Sammlungen',
'libraries.tabs.playlists' => 'Wiedergabelisten',
'libraries.groupings.title' => 'Gruppierung',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => 'Wiedergabelisten',
'libraries.groupings.title' => 'Gruppierung',
'libraries.groupings.all' => 'Alle', 'libraries.groupings.all' => 'Alle',
'libraries.groupings.movies' => 'Filme', 'libraries.groupings.movies' => 'Filme',
'libraries.groupings.shows' => 'Serien', 'libraries.groupings.shows' => 'Serien',
@@ -2275,6 +2304,21 @@ extension on TranslationsDe {
'serverTasks.title' => 'Serveraufgaben', 'serverTasks.title' => 'Serveraufgaben',
'serverTasks.failedToLoad' => 'Aufgaben konnten nicht geladen werden', 'serverTasks.failedToLoad' => 'Aufgaben konnten nicht geladen werden',
'serverTasks.noTasks' => 'Keine laufenden Aufgaben', 'serverTasks.noTasks' => 'Keine laufenden Aufgaben',
'trakt.title' => 'Trakt',
'trakt.connected' => 'Verbunden',
'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.',
'trakt.scrobble' => 'Echtzeit-Scrobbling',
'trakt.scrobbleDescription' => 'Sende Play-, Pause- und Stopp-Ereignisse während der Wiedergabe an Trakt.',
'trakt.watchedSync' => 'Gesehen-Status synchronisieren',
'trakt.watchedSyncDescription' => 'Wenn du Inhalte in Plezy als gesehen markierst, werden sie auch auf Trakt markiert.',
'trakt.deviceCodeTitle' => 'Plezy auf Trakt aktivieren',
'trakt.deviceCodeBody' => ({required Object url}) => 'Öffne ${url} und gib diesen Code ein:',
'trakt.openTraktActivate' => 'Trakt zum Aktivieren öffnen',
'trakt.waitingForAuthorization' => 'Warte auf Autorisierung…',
'trakt.codeCopied' => 'Code kopiert',
'trakt.connectFailed' => 'Verbindung zu Trakt fehlgeschlagen. Versuche es erneut.',
_ => null, _ => null,
}; };
} }
+80 -2
View File
@@ -78,6 +78,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final TranslationsExternalPlayerEn externalPlayer = TranslationsExternalPlayerEn._(_root); late final TranslationsExternalPlayerEn externalPlayer = TranslationsExternalPlayerEn._(_root);
late final TranslationsMetadataEditEn metadataEdit = TranslationsMetadataEditEn._(_root); late final TranslationsMetadataEditEn metadataEdit = TranslationsMetadataEditEn._(_root);
late final TranslationsServerTasksEn serverTasks = TranslationsServerTasksEn._(_root); late final TranslationsServerTasksEn serverTasks = TranslationsServerTasksEn._(_root);
late final TranslationsTraktEn trakt = TranslationsTraktEn._(_root);
} }
// Path: app // Path: app
@@ -730,6 +731,12 @@ class TranslationsSettingsEn {
/// en: 'Show what you're watching on Discord' /// en: 'Show what you're watching on Discord'
String get discordRichPresenceDescription => 'Show what you\'re watching on Discord'; String get discordRichPresenceDescription => 'Show what you\'re watching on Discord';
/// en: 'Trakt'
String get trakt => 'Trakt';
/// en: 'Sync watch history with Trakt'
String get traktDescription => 'Sync watch history with Trakt';
/// en: 'Companion Remote Server' /// en: 'Companion Remote Server'
String get companionRemoteServer => 'Companion Remote Server'; String get companionRemoteServer => 'Companion Remote Server';
@@ -2815,6 +2822,60 @@ class TranslationsServerTasksEn {
String get noTasks => 'No tasks running'; String get noTasks => 'No tasks running';
} }
// Path: trakt
class TranslationsTraktEn {
TranslationsTraktEn._(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Trakt'
String get title => 'Trakt';
/// en: 'Connected'
String get connected => 'Connected';
/// en: 'Connected as @${username}'
String connectedAs({required Object username}) => 'Connected as @${username}';
/// en: 'Disconnect Trakt account?'
String get disconnectConfirm => 'Disconnect Trakt account?';
/// en: 'Plezy will stop sending playback events to Trakt. You can reconnect at any time.'
String get disconnectConfirmBody => 'Plezy will stop sending playback events to Trakt. You can reconnect at any time.';
/// en: 'Real-time scrobbling'
String get scrobble => 'Real-time scrobbling';
/// en: 'Send play, pause, and stop events to Trakt during playback.'
String get scrobbleDescription => 'Send play, pause, and stop events to Trakt during playback.';
/// en: 'Sync watched status'
String get watchedSync => 'Sync watched status';
/// en: 'When you mark items watched in Plezy, mark them on Trakt.'
String get watchedSyncDescription => 'When you mark items watched in Plezy, mark them on Trakt.';
/// en: 'Activate Plezy on Trakt'
String get deviceCodeTitle => 'Activate Plezy on Trakt';
/// en: 'Visit ${url} and enter this code:'
String deviceCodeBody({required Object url}) => 'Visit ${url} and enter this code:';
/// en: 'Open Trakt to activate'
String get openTraktActivate => 'Open Trakt to activate';
/// en: 'Waiting for authorization…'
String get waitingForAuthorization => 'Waiting for authorization…';
/// en: 'Code copied'
String get codeCopied => 'Code copied';
/// en: 'Couldn't connect to Trakt. Try again.'
String get connectFailed => 'Couldn\'t connect to Trakt. Try again.';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class TranslationsHotkeysActionsEn { class TranslationsHotkeysActionsEn {
TranslationsHotkeysActionsEn._(this._root); TranslationsHotkeysActionsEn._(this._root);
@@ -3367,6 +3428,8 @@ extension on Translations {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresence' => 'Discord Rich Presence',
'settings.discordRichPresenceDescription' => 'Show what you\'re watching on Discord', 'settings.discordRichPresenceDescription' => 'Show what you\'re watching on Discord',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => 'Sync watch history with Trakt',
'settings.companionRemoteServer' => 'Companion Remote Server', 'settings.companionRemoteServer' => 'Companion Remote Server',
'settings.companionRemoteServerDescription' => 'Allow mobile devices on your network to control this app', 'settings.companionRemoteServerDescription' => 'Allow mobile devices on your network to control this app',
'settings.autoPip' => 'Auto Picture-in-Picture', 'settings.autoPip' => 'Auto Picture-in-Picture',
@@ -3678,10 +3741,10 @@ extension on Translations {
'libraries.tabs.recommended' => 'Recommended', 'libraries.tabs.recommended' => 'Recommended',
'libraries.tabs.browse' => 'Browse', 'libraries.tabs.browse' => 'Browse',
'libraries.tabs.collections' => 'Collections', 'libraries.tabs.collections' => 'Collections',
'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Grouping',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Grouping',
'libraries.groupings.all' => 'All', 'libraries.groupings.all' => 'All',
'libraries.groupings.movies' => 'Movies', 'libraries.groupings.movies' => 'Movies',
'libraries.groupings.shows' => 'TV Shows', 'libraries.groupings.shows' => 'TV Shows',
@@ -4058,6 +4121,21 @@ extension on Translations {
'serverTasks.title' => 'Server Tasks', 'serverTasks.title' => 'Server Tasks',
'serverTasks.failedToLoad' => 'Failed to load tasks', 'serverTasks.failedToLoad' => 'Failed to load tasks',
'serverTasks.noTasks' => 'No tasks running', 'serverTasks.noTasks' => 'No tasks running',
'trakt.title' => 'Trakt',
'trakt.connected' => 'Connected',
'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.',
'trakt.scrobble' => 'Real-time scrobbling',
'trakt.scrobbleDescription' => 'Send play, pause, and stop events to Trakt during playback.',
'trakt.watchedSync' => 'Sync watched status',
'trakt.watchedSyncDescription' => 'When you mark items watched in Plezy, mark them on Trakt.',
'trakt.deviceCodeTitle' => 'Activate Plezy on Trakt',
'trakt.deviceCodeBody' => ({required Object url}) => 'Visit ${url} and enter this code:',
'trakt.openTraktActivate' => 'Open Trakt to activate',
'trakt.waitingForAuthorization' => 'Waiting for authorization…',
'trakt.codeCopied' => 'Code copied',
'trakt.connectFailed' => 'Couldn\'t connect to Trakt. Try again.',
_ => null, _ => null,
}; };
} }
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsEs with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerEs externalPlayer = _TranslationsExternalPlayerEs._(_root); @override late final _TranslationsExternalPlayerEs externalPlayer = _TranslationsExternalPlayerEs._(_root);
@override late final _TranslationsMetadataEditEs metadataEdit = _TranslationsMetadataEditEs._(_root); @override late final _TranslationsMetadataEditEs metadataEdit = _TranslationsMetadataEditEs._(_root);
@override late final _TranslationsServerTasksEs serverTasks = _TranslationsServerTasksEs._(_root); @override late final _TranslationsServerTasksEs serverTasks = _TranslationsServerTasksEs._(_root);
@override late final _TranslationsTraktEs trakt = _TranslationsTraktEs._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsEs implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Presencia de Discord'; @override String get discordRichPresence => 'Presencia de Discord';
@override String get discordRichPresenceDescription => 'Mostrar lo que estás viendo en Discord'; @override String get discordRichPresenceDescription => 'Mostrar lo que estás viendo en Discord';
@override String get trakt => 'Trakt';
@override String get traktDescription => 'Sincronizar historial de visualización con Trakt';
@override String get autoPip => 'Imagen en imagen automática'; @override String get autoPip => 'Imagen en imagen automática';
@override String get autoPipDescription => 'Activar automáticamente imagen en imagen al salir de la app durante la reproducción'; @override String get autoPipDescription => 'Activar automáticamente imagen en imagen al salir de la app durante la reproducción';
@override String get matchContentFrameRate => 'Ajustar frecuencia de actualización'; @override String get matchContentFrameRate => 'Ajustar frecuencia de actualización';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksEs implements TranslationsServerTasksEn {
@override String get noTasks => 'No hay tareas en ejecución'; @override String get noTasks => 'No hay tareas en ejecución';
} }
// Path: trakt
class _TranslationsTraktEs implements TranslationsTraktEn {
_TranslationsTraktEs._(this._root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => 'Conectado';
@override String connectedAs({required Object username}) => 'Conectado como @${username}';
@override String get disconnectConfirm => '¿Desconectar cuenta de Trakt?';
@override String get disconnectConfirmBody => 'Plezy dejará de enviar eventos de reproducción a Trakt. Puedes volver a conectar cuando quieras.';
@override String get scrobble => 'Scrobbling en tiempo real';
@override String get scrobbleDescription => 'Enviar eventos de reproducción, pausa y parada a Trakt durante la reproducción.';
@override String get watchedSync => 'Sincronizar estado de visto';
@override String get watchedSyncDescription => 'Cuando marques elementos como vistos en Plezy, también se marcarán en Trakt.';
@override String get deviceCodeTitle => 'Activa Plezy en Trakt';
@override String deviceCodeBody({required Object url}) => 'Visita ${url} e introduce este código:';
@override String get openTraktActivate => 'Abrir Trakt para activar';
@override String get waitingForAuthorization => 'Esperando autorización…';
@override String get codeCopied => 'Código copiado';
@override String get connectFailed => 'No se pudo conectar a Trakt. Inténtalo de nuevo.';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsEs implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsEs implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsEs._(this._root); _TranslationsHotkeysActionsEs._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsEs {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Presencia de Discord', 'settings.discordRichPresence' => 'Presencia de Discord',
'settings.discordRichPresenceDescription' => 'Mostrar lo que estás viendo en Discord', 'settings.discordRichPresenceDescription' => 'Mostrar lo que estás viendo en Discord',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => 'Sincronizar historial de visualización con Trakt',
'settings.autoPip' => 'Imagen en imagen automática', 'settings.autoPip' => 'Imagen en imagen automática',
'settings.autoPipDescription' => 'Activar automáticamente imagen en imagen al salir de la app durante la reproducción', 'settings.autoPipDescription' => 'Activar automáticamente imagen en imagen al salir de la app durante la reproducción',
'settings.matchContentFrameRate' => 'Ajustar frecuencia de actualización', 'settings.matchContentFrameRate' => 'Ajustar frecuencia de actualización',
@@ -1895,10 +1924,10 @@ extension on TranslationsEs {
'libraries.tabs.recommended' => 'Recomendado', 'libraries.tabs.recommended' => 'Recomendado',
'libraries.tabs.browse' => 'Explorar', 'libraries.tabs.browse' => 'Explorar',
'libraries.tabs.collections' => 'Colecciones', 'libraries.tabs.collections' => 'Colecciones',
'libraries.tabs.playlists' => 'Listas',
'libraries.groupings.title' => 'Agrupación',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => 'Listas',
'libraries.groupings.title' => 'Agrupación',
'libraries.groupings.all' => 'Todo', 'libraries.groupings.all' => 'Todo',
'libraries.groupings.movies' => 'Películas', 'libraries.groupings.movies' => 'Películas',
'libraries.groupings.shows' => 'Series', 'libraries.groupings.shows' => 'Series',
@@ -2275,6 +2304,21 @@ extension on TranslationsEs {
'serverTasks.title' => 'Tareas del servidor', 'serverTasks.title' => 'Tareas del servidor',
'serverTasks.failedToLoad' => 'Error al cargar tareas', 'serverTasks.failedToLoad' => 'Error al cargar tareas',
'serverTasks.noTasks' => 'No hay tareas en ejecución', 'serverTasks.noTasks' => 'No hay tareas en ejecución',
'trakt.title' => 'Trakt',
'trakt.connected' => 'Conectado',
'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.',
'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',
'trakt.watchedSyncDescription' => 'Cuando marques elementos como vistos en Plezy, también se marcarán en Trakt.',
'trakt.deviceCodeTitle' => 'Activa Plezy en Trakt',
'trakt.deviceCodeBody' => ({required Object url}) => 'Visita ${url} e introduce este código:',
'trakt.openTraktActivate' => 'Abrir Trakt para activar',
'trakt.waitingForAuthorization' => 'Esperando autorización…',
'trakt.codeCopied' => 'Código copiado',
'trakt.connectFailed' => 'No se pudo conectar a Trakt. Inténtalo de nuevo.',
_ => null, _ => null,
}; };
} }
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsFr with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerFr externalPlayer = _TranslationsExternalPlayerFr._(_root); @override late final _TranslationsExternalPlayerFr externalPlayer = _TranslationsExternalPlayerFr._(_root);
@override late final _TranslationsMetadataEditFr metadataEdit = _TranslationsMetadataEditFr._(_root); @override late final _TranslationsMetadataEditFr metadataEdit = _TranslationsMetadataEditFr._(_root);
@override late final _TranslationsServerTasksFr serverTasks = _TranslationsServerTasksFr._(_root); @override late final _TranslationsServerTasksFr serverTasks = _TranslationsServerTasksFr._(_root);
@override late final _TranslationsTraktFr trakt = _TranslationsTraktFr._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresence => 'Discord Rich Presence';
@override String get discordRichPresenceDescription => 'Montrez ce que vous regardez sur Discord'; @override String get discordRichPresenceDescription => 'Montrez ce que vous regardez sur Discord';
@override String get trakt => 'Trakt';
@override String get traktDescription => 'Synchroniser l\'historique de visionnage avec Trakt';
@override String get autoPip => 'Image dans l\'image automatique'; @override String get autoPip => 'Image dans l\'image automatique';
@override String get autoPipDescription => 'Activer automatiquement l\'image dans l\'image en quittant l\'application pendant la lecture'; @override String get autoPipDescription => 'Activer automatiquement l\'image dans l\'image en quittant l\'application pendant la lecture';
@override String get matchContentFrameRate => 'Fréquence d\'images du contenu correspondant'; @override String get matchContentFrameRate => 'Fréquence d\'images du contenu correspondant';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksFr implements TranslationsServerTasksEn {
@override String get noTasks => 'Aucune tâche en cours'; @override String get noTasks => 'Aucune tâche en cours';
} }
// Path: trakt
class _TranslationsTraktFr implements TranslationsTraktEn {
_TranslationsTraktFr._(this._root);
final TranslationsFr _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => 'Connecté';
@override String connectedAs({required Object username}) => 'Connecté en tant que @${username}';
@override String get disconnectConfirm => 'Déconnecter le compte Trakt ?';
@override String get disconnectConfirmBody => 'Plezy n\'enverra plus d\'événements de lecture à Trakt. Vous pouvez vous reconnecter à tout moment.';
@override String get scrobble => 'Scrobbling en temps réel';
@override String get scrobbleDescription => 'Envoyer les événements de lecture, pause et arrêt à Trakt pendant la lecture.';
@override String get watchedSync => 'Synchroniser le statut « vu »';
@override String get watchedSyncDescription => 'Lorsque vous marquez un élément comme vu dans Plezy, il l\'est aussi sur Trakt.';
@override String get deviceCodeTitle => 'Activer Plezy sur Trakt';
@override String deviceCodeBody({required Object url}) => 'Rendez-vous sur ${url} et entrez ce code :';
@override String get openTraktActivate => 'Ouvrir Trakt pour activer';
@override String get waitingForAuthorization => 'En attente d\'autorisation…';
@override String get codeCopied => 'Code copié';
@override String get connectFailed => 'Échec de la connexion à Trakt. Réessayez.';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsFr implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsFr implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsFr._(this._root); _TranslationsHotkeysActionsFr._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsFr {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresence' => 'Discord Rich Presence',
'settings.discordRichPresenceDescription' => 'Montrez ce que vous regardez sur Discord', 'settings.discordRichPresenceDescription' => 'Montrez ce que vous regardez sur Discord',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => 'Synchroniser l\'historique de visionnage avec Trakt',
'settings.autoPip' => 'Image dans l\'image automatique', 'settings.autoPip' => 'Image dans l\'image automatique',
'settings.autoPipDescription' => 'Activer automatiquement l\'image dans l\'image en quittant l\'application pendant la lecture', 'settings.autoPipDescription' => 'Activer automatiquement l\'image dans l\'image en quittant l\'application pendant la lecture',
'settings.matchContentFrameRate' => 'Fréquence d\'images du contenu correspondant', 'settings.matchContentFrameRate' => 'Fréquence d\'images du contenu correspondant',
@@ -1895,10 +1924,10 @@ extension on TranslationsFr {
'libraries.tabs.recommended' => 'Recommandé', 'libraries.tabs.recommended' => 'Recommandé',
'libraries.tabs.browse' => 'Parcourir', 'libraries.tabs.browse' => 'Parcourir',
'libraries.tabs.collections' => 'Collections', 'libraries.tabs.collections' => 'Collections',
'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Regroupement',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Regroupement',
'libraries.groupings.all' => 'Tous', 'libraries.groupings.all' => 'Tous',
'libraries.groupings.movies' => 'Films', 'libraries.groupings.movies' => 'Films',
'libraries.groupings.shows' => 'Show TV', 'libraries.groupings.shows' => 'Show TV',
@@ -2275,6 +2304,21 @@ extension on TranslationsFr {
'serverTasks.title' => 'Tâches du serveur', 'serverTasks.title' => 'Tâches du serveur',
'serverTasks.failedToLoad' => 'Échec du chargement des tâches', 'serverTasks.failedToLoad' => 'Échec du chargement des tâches',
'serverTasks.noTasks' => 'Aucune tâche en cours', 'serverTasks.noTasks' => 'Aucune tâche en cours',
'trakt.title' => 'Trakt',
'trakt.connected' => 'Connecté',
'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.',
'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 »',
'trakt.watchedSyncDescription' => 'Lorsque vous marquez un élément comme vu dans Plezy, il l\'est aussi sur Trakt.',
'trakt.deviceCodeTitle' => 'Activer Plezy sur Trakt',
'trakt.deviceCodeBody' => ({required Object url}) => 'Rendez-vous sur ${url} et entrez ce code :',
'trakt.openTraktActivate' => 'Ouvrir Trakt pour activer',
'trakt.waitingForAuthorization' => 'En attente d\'autorisation…',
'trakt.codeCopied' => 'Code copié',
'trakt.connectFailed' => 'Échec de la connexion à Trakt. Réessayez.',
_ => null, _ => null,
}; };
} }
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsIt with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerIt externalPlayer = _TranslationsExternalPlayerIt._(_root); @override late final _TranslationsExternalPlayerIt externalPlayer = _TranslationsExternalPlayerIt._(_root);
@override late final _TranslationsMetadataEditIt metadataEdit = _TranslationsMetadataEditIt._(_root); @override late final _TranslationsMetadataEditIt metadataEdit = _TranslationsMetadataEditIt._(_root);
@override late final _TranslationsServerTasksIt serverTasks = _TranslationsServerTasksIt._(_root); @override late final _TranslationsServerTasksIt serverTasks = _TranslationsServerTasksIt._(_root);
@override late final _TranslationsTraktIt trakt = _TranslationsTraktIt._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresence => 'Discord Rich Presence';
@override String get discordRichPresenceDescription => 'Mostra su Discord cosa stai guardando'; @override String get discordRichPresenceDescription => 'Mostra su Discord cosa stai guardando';
@override String get trakt => 'Trakt';
@override String get traktDescription => 'Sincronizza la cronologia di visione con Trakt';
@override String get autoPip => 'Picture-in-Picture automatico'; @override String get autoPip => 'Picture-in-Picture automatico';
@override String get autoPipDescription => 'Attiva automaticamente il picture-in-picture quando si esce dall\'app durante la riproduzione'; @override String get autoPipDescription => 'Attiva automaticamente il picture-in-picture quando si esce dall\'app durante la riproduzione';
@override String get matchContentFrameRate => 'Adatta frequenza fotogrammi'; @override String get matchContentFrameRate => 'Adatta frequenza fotogrammi';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksIt implements TranslationsServerTasksEn {
@override String get noTasks => 'Nessuna attività in corso'; @override String get noTasks => 'Nessuna attività in corso';
} }
// Path: trakt
class _TranslationsTraktIt implements TranslationsTraktEn {
_TranslationsTraktIt._(this._root);
final TranslationsIt _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => 'Connesso';
@override String connectedAs({required Object username}) => 'Connesso come @${username}';
@override String get disconnectConfirm => 'Disconnettere l\'account Trakt?';
@override String get disconnectConfirmBody => 'Plezy smetterà di inviare eventi di riproduzione a Trakt. Puoi riconnetterti in qualsiasi momento.';
@override String get scrobble => 'Scrobbling in tempo reale';
@override String get scrobbleDescription => 'Invia eventi di riproduzione, pausa e arresto a Trakt durante la riproduzione.';
@override String get watchedSync => 'Sincronizza stato visualizzato';
@override String get watchedSyncDescription => 'Quando segni elementi come visti in Plezy, vengono segnati anche su Trakt.';
@override String get deviceCodeTitle => 'Attiva Plezy su Trakt';
@override String deviceCodeBody({required Object url}) => 'Visita ${url} e inserisci questo codice:';
@override String get openTraktActivate => 'Apri Trakt per attivare';
@override String get waitingForAuthorization => 'In attesa di autorizzazione…';
@override String get codeCopied => 'Codice copiato';
@override String get connectFailed => 'Impossibile connettersi a Trakt. Riprova.';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsIt implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsIt implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsIt._(this._root); _TranslationsHotkeysActionsIt._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsIt {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresence' => 'Discord Rich Presence',
'settings.discordRichPresenceDescription' => 'Mostra su Discord cosa stai guardando', 'settings.discordRichPresenceDescription' => 'Mostra su Discord cosa stai guardando',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => 'Sincronizza la cronologia di visione con Trakt',
'settings.autoPip' => 'Picture-in-Picture automatico', 'settings.autoPip' => 'Picture-in-Picture automatico',
'settings.autoPipDescription' => 'Attiva automaticamente il picture-in-picture quando si esce dall\'app durante la riproduzione', 'settings.autoPipDescription' => 'Attiva automaticamente il picture-in-picture quando si esce dall\'app durante la riproduzione',
'settings.matchContentFrameRate' => 'Adatta frequenza fotogrammi', 'settings.matchContentFrameRate' => 'Adatta frequenza fotogrammi',
@@ -1895,10 +1924,10 @@ extension on TranslationsIt {
'libraries.tabs.recommended' => 'Consigliati', 'libraries.tabs.recommended' => 'Consigliati',
'libraries.tabs.browse' => 'Esplora', 'libraries.tabs.browse' => 'Esplora',
'libraries.tabs.collections' => 'Raccolte', 'libraries.tabs.collections' => 'Raccolte',
'libraries.tabs.playlists' => 'Playlist',
'libraries.groupings.title' => 'Raggruppamento',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => 'Playlist',
'libraries.groupings.title' => 'Raggruppamento',
'libraries.groupings.all' => 'Tutti', 'libraries.groupings.all' => 'Tutti',
'libraries.groupings.movies' => 'Film', 'libraries.groupings.movies' => 'Film',
'libraries.groupings.shows' => 'Serie TV', 'libraries.groupings.shows' => 'Serie TV',
@@ -2275,6 +2304,21 @@ extension on TranslationsIt {
'serverTasks.title' => 'Attività del server', 'serverTasks.title' => 'Attività del server',
'serverTasks.failedToLoad' => 'Impossibile caricare le attività', 'serverTasks.failedToLoad' => 'Impossibile caricare le attività',
'serverTasks.noTasks' => 'Nessuna attività in corso', 'serverTasks.noTasks' => 'Nessuna attività in corso',
'trakt.title' => 'Trakt',
'trakt.connected' => 'Connesso',
'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.',
'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',
'trakt.watchedSyncDescription' => 'Quando segni elementi come visti in Plezy, vengono segnati anche su Trakt.',
'trakt.deviceCodeTitle' => 'Attiva Plezy su Trakt',
'trakt.deviceCodeBody' => ({required Object url}) => 'Visita ${url} e inserisci questo codice:',
'trakt.openTraktActivate' => 'Apri Trakt per attivare',
'trakt.waitingForAuthorization' => 'In attesa di autorizzazione…',
'trakt.codeCopied' => 'Codice copiato',
'trakt.connectFailed' => 'Impossibile connettersi a Trakt. Riprova.',
_ => null, _ => null,
}; };
} }
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsJa with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerJa externalPlayer = _TranslationsExternalPlayerJa._(_root); @override late final _TranslationsExternalPlayerJa externalPlayer = _TranslationsExternalPlayerJa._(_root);
@override late final _TranslationsMetadataEditJa metadataEdit = _TranslationsMetadataEditJa._(_root); @override late final _TranslationsMetadataEditJa metadataEdit = _TranslationsMetadataEditJa._(_root);
@override late final _TranslationsServerTasksJa serverTasks = _TranslationsServerTasksJa._(_root); @override late final _TranslationsServerTasksJa serverTasks = _TranslationsServerTasksJa._(_root);
@override late final _TranslationsTraktJa trakt = _TranslationsTraktJa._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsJa implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresence => 'Discord Rich Presence';
@override String get discordRichPresenceDescription => 'Discordで視聴中の内容を表示'; @override String get discordRichPresenceDescription => 'Discordで視聴中の内容を表示';
@override String get trakt => 'Trakt';
@override String get traktDescription => '視聴履歴を Trakt と同期';
@override String get autoPip => '自動ピクチャーインピクチャー'; @override String get autoPip => '自動ピクチャーインピクチャー';
@override String get autoPipDescription => '再生中にアプリを離れると自動的にピクチャーインピクチャーに移行'; @override String get autoPipDescription => '再生中にアプリを離れると自動的にピクチャーインピクチャーに移行';
@override String get matchContentFrameRate => 'コンテンツのフレームレートに合わせる'; @override String get matchContentFrameRate => 'コンテンツのフレームレートに合わせる';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksJa implements TranslationsServerTasksEn {
@override String get noTasks => '実行中のタスクはありません'; @override String get noTasks => '実行中のタスクはありません';
} }
// Path: trakt
class _TranslationsTraktJa implements TranslationsTraktEn {
_TranslationsTraktJa._(this._root);
final TranslationsJa _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => '接続済み';
@override String connectedAs({required Object username}) => '@${username} として接続済み';
@override String get disconnectConfirm => 'Trakt アカウントを切断しますか?';
@override String get disconnectConfirmBody => 'Plezy から Trakt への再生イベント送信が停止します。いつでも再接続できます。';
@override String get scrobble => 'リアルタイムのスクロブル';
@override String get scrobbleDescription => '再生中に再生・一時停止・停止イベントを Trakt に送信します。';
@override String get watchedSync => '視聴済みステータスを同期';
@override String get watchedSyncDescription => 'Plezy で項目を視聴済みにすると、Trakt でも視聴済みになります。';
@override String get deviceCodeTitle => 'Trakt で Plezy をアクティベート';
@override String deviceCodeBody({required Object url}) => '${url} を開き、このコードを入力してください:';
@override String get openTraktActivate => 'アクティベートするには Trakt を開く';
@override String get waitingForAuthorization => '認証を待機中…';
@override String get codeCopied => 'コードをコピーしました';
@override String get connectFailed => 'Trakt に接続できませんでした。もう一度お試しください。';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsJa implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsJa implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsJa._(this._root); _TranslationsHotkeysActionsJa._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsJa {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresence' => 'Discord Rich Presence',
'settings.discordRichPresenceDescription' => 'Discordで視聴中の内容を表示', 'settings.discordRichPresenceDescription' => 'Discordで視聴中の内容を表示',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => '視聴履歴を Trakt と同期',
'settings.autoPip' => '自動ピクチャーインピクチャー', 'settings.autoPip' => '自動ピクチャーインピクチャー',
'settings.autoPipDescription' => '再生中にアプリを離れると自動的にピクチャーインピクチャーに移行', 'settings.autoPipDescription' => '再生中にアプリを離れると自動的にピクチャーインピクチャーに移行',
'settings.matchContentFrameRate' => 'コンテンツのフレームレートに合わせる', 'settings.matchContentFrameRate' => 'コンテンツのフレームレートに合わせる',
@@ -1895,10 +1924,10 @@ extension on TranslationsJa {
'libraries.tabs.recommended' => 'おすすめ', 'libraries.tabs.recommended' => 'おすすめ',
'libraries.tabs.browse' => 'ブラウズ', 'libraries.tabs.browse' => 'ブラウズ',
'libraries.tabs.collections' => 'コレクション', 'libraries.tabs.collections' => 'コレクション',
'libraries.tabs.playlists' => 'プレイリスト',
'libraries.groupings.title' => 'グループ',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => 'プレイリスト',
'libraries.groupings.title' => 'グループ',
'libraries.groupings.all' => 'すべて', 'libraries.groupings.all' => 'すべて',
'libraries.groupings.movies' => '映画', 'libraries.groupings.movies' => '映画',
'libraries.groupings.shows' => 'テレビ番組', 'libraries.groupings.shows' => 'テレビ番組',
@@ -2275,6 +2304,21 @@ extension on TranslationsJa {
'serverTasks.title' => 'サーバータスク', 'serverTasks.title' => 'サーバータスク',
'serverTasks.failedToLoad' => 'タスクの読み込みに失敗しました', 'serverTasks.failedToLoad' => 'タスクの読み込みに失敗しました',
'serverTasks.noTasks' => '実行中のタスクはありません', 'serverTasks.noTasks' => '実行中のタスクはありません',
'trakt.title' => 'Trakt',
'trakt.connected' => '接続済み',
'trakt.connectedAs' => ({required Object username}) => '@${username} として接続済み',
'trakt.disconnectConfirm' => 'Trakt アカウントを切断しますか?',
'trakt.disconnectConfirmBody' => 'Plezy から Trakt への再生イベント送信が停止します。いつでも再接続できます。',
'trakt.scrobble' => 'リアルタイムのスクロブル',
'trakt.scrobbleDescription' => '再生中に再生・一時停止・停止イベントを Trakt に送信します。',
'trakt.watchedSync' => '視聴済みステータスを同期',
'trakt.watchedSyncDescription' => 'Plezy で項目を視聴済みにすると、Trakt でも視聴済みになります。',
'trakt.deviceCodeTitle' => 'Trakt で Plezy をアクティベート',
'trakt.deviceCodeBody' => ({required Object url}) => '${url} を開き、このコードを入力してください:',
'trakt.openTraktActivate' => 'アクティベートするには Trakt を開く',
'trakt.waitingForAuthorization' => '認証を待機中…',
'trakt.codeCopied' => 'コードをコピーしました',
'trakt.connectFailed' => 'Trakt に接続できませんでした。もう一度お試しください。',
_ => null, _ => null,
}; };
} }
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsKo with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerKo externalPlayer = _TranslationsExternalPlayerKo._(_root); @override late final _TranslationsExternalPlayerKo externalPlayer = _TranslationsExternalPlayerKo._(_root);
@override late final _TranslationsMetadataEditKo metadataEdit = _TranslationsMetadataEditKo._(_root); @override late final _TranslationsMetadataEditKo metadataEdit = _TranslationsMetadataEditKo._(_root);
@override late final _TranslationsServerTasksKo serverTasks = _TranslationsServerTasksKo._(_root); @override late final _TranslationsServerTasksKo serverTasks = _TranslationsServerTasksKo._(_root);
@override late final _TranslationsTraktKo trakt = _TranslationsTraktKo._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresence => 'Discord Rich Presence';
@override String get discordRichPresenceDescription => 'Discord에서 시청 중인 콘텐츠 표시'; @override String get discordRichPresenceDescription => 'Discord에서 시청 중인 콘텐츠 표시';
@override String get trakt => 'Trakt';
@override String get traktDescription => 'Trakt와 시청 기록 동기화';
@override String get autoPip => '자동 PIP 모드'; @override String get autoPip => '자동 PIP 모드';
@override String get autoPipDescription => '재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환'; @override String get autoPipDescription => '재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환';
@override String get matchContentFrameRate => '콘텐츠 프레임 레이트 맞춤'; @override String get matchContentFrameRate => '콘텐츠 프레임 레이트 맞춤';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksKo implements TranslationsServerTasksEn {
@override String get noTasks => '실행 중인 작업 없음'; @override String get noTasks => '실행 중인 작업 없음';
} }
// Path: trakt
class _TranslationsTraktKo implements TranslationsTraktEn {
_TranslationsTraktKo._(this._root);
final TranslationsKo _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => '연결됨';
@override String connectedAs({required Object username}) => '@${username}(으)로 연결됨';
@override String get disconnectConfirm => 'Trakt 계정 연결을 끊으시겠습니까?';
@override String get disconnectConfirmBody => 'Plezy가 Trakt로 재생 이벤트를 보내지 않습니다. 언제든지 다시 연결할 수 있습니다.';
@override String get scrobble => '실시간 스크로블';
@override String get scrobbleDescription => '재생 중 재생, 일시정지, 정지 이벤트를 Trakt로 전송합니다.';
@override String get watchedSync => '시청 상태 동기화';
@override String get watchedSyncDescription => 'Plezy에서 시청 완료로 표시한 항목이 Trakt에도 시청 완료로 표시됩니다.';
@override String get deviceCodeTitle => 'Trakt에서 Plezy 활성화';
@override String deviceCodeBody({required Object url}) => '${url}을(를) 방문하여 이 코드를 입력하세요:';
@override String get openTraktActivate => '활성화하려면 Trakt 열기';
@override String get waitingForAuthorization => '인증 대기 중…';
@override String get codeCopied => '코드가 복사됨';
@override String get connectFailed => 'Trakt에 연결할 수 없습니다. 다시 시도하세요.';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsKo implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsKo implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsKo._(this._root); _TranslationsHotkeysActionsKo._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsKo {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresence' => 'Discord Rich Presence',
'settings.discordRichPresenceDescription' => 'Discord에서 시청 중인 콘텐츠 표시', 'settings.discordRichPresenceDescription' => 'Discord에서 시청 중인 콘텐츠 표시',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => 'Trakt와 시청 기록 동기화',
'settings.autoPip' => '자동 PIP 모드', 'settings.autoPip' => '자동 PIP 모드',
'settings.autoPipDescription' => '재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환', 'settings.autoPipDescription' => '재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환',
'settings.matchContentFrameRate' => '콘텐츠 프레임 레이트 맞춤', 'settings.matchContentFrameRate' => '콘텐츠 프레임 레이트 맞춤',
@@ -1895,10 +1924,10 @@ extension on TranslationsKo {
'libraries.tabs.recommended' => '추천', 'libraries.tabs.recommended' => '추천',
'libraries.tabs.browse' => '찾아보기', 'libraries.tabs.browse' => '찾아보기',
'libraries.tabs.collections' => '컬렉션', 'libraries.tabs.collections' => '컬렉션',
'libraries.tabs.playlists' => '재생 목록',
'libraries.groupings.title' => '그룹',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => '재생 목록',
'libraries.groupings.title' => '그룹',
'libraries.groupings.all' => '전체', 'libraries.groupings.all' => '전체',
'libraries.groupings.movies' => '영화', 'libraries.groupings.movies' => '영화',
'libraries.groupings.shows' => 'TV 프로그램', 'libraries.groupings.shows' => 'TV 프로그램',
@@ -2275,6 +2304,21 @@ extension on TranslationsKo {
'serverTasks.title' => '서버 작업', 'serverTasks.title' => '서버 작업',
'serverTasks.failedToLoad' => '작업을 불러올 수 없습니다', 'serverTasks.failedToLoad' => '작업을 불러올 수 없습니다',
'serverTasks.noTasks' => '실행 중인 작업 없음', 'serverTasks.noTasks' => '실행 중인 작업 없음',
'trakt.title' => 'Trakt',
'trakt.connected' => '연결됨',
'trakt.connectedAs' => ({required Object username}) => '@${username}(으)로 연결됨',
'trakt.disconnectConfirm' => 'Trakt 계정 연결을 끊으시겠습니까?',
'trakt.disconnectConfirmBody' => 'Plezy가 Trakt로 재생 이벤트를 보내지 않습니다. 언제든지 다시 연결할 수 있습니다.',
'trakt.scrobble' => '실시간 스크로블',
'trakt.scrobbleDescription' => '재생 중 재생, 일시정지, 정지 이벤트를 Trakt로 전송합니다.',
'trakt.watchedSync' => '시청 상태 동기화',
'trakt.watchedSyncDescription' => 'Plezy에서 시청 완료로 표시한 항목이 Trakt에도 시청 완료로 표시됩니다.',
'trakt.deviceCodeTitle' => 'Trakt에서 Plezy 활성화',
'trakt.deviceCodeBody' => ({required Object url}) => '${url}을(를) 방문하여 이 코드를 입력하세요:',
'trakt.openTraktActivate' => '활성화하려면 Trakt 열기',
'trakt.waitingForAuthorization' => '인증 대기 중…',
'trakt.codeCopied' => '코드가 복사됨',
'trakt.connectFailed' => 'Trakt에 연결할 수 없습니다. 다시 시도하세요.',
_ => null, _ => null,
}; };
} }
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsNb with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerNb externalPlayer = _TranslationsExternalPlayerNb._(_root); @override late final _TranslationsExternalPlayerNb externalPlayer = _TranslationsExternalPlayerNb._(_root);
@override late final _TranslationsMetadataEditNb metadataEdit = _TranslationsMetadataEditNb._(_root); @override late final _TranslationsMetadataEditNb metadataEdit = _TranslationsMetadataEditNb._(_root);
@override late final _TranslationsServerTasksNb serverTasks = _TranslationsServerTasksNb._(_root); @override late final _TranslationsServerTasksNb serverTasks = _TranslationsServerTasksNb._(_root);
@override late final _TranslationsTraktNb trakt = _TranslationsTraktNb._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsNb implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresence => 'Discord Rich Presence';
@override String get discordRichPresenceDescription => 'Vis hva du ser på Discord'; @override String get discordRichPresenceDescription => 'Vis hva du ser på Discord';
@override String get trakt => 'Trakt';
@override String get traktDescription => 'Synkroniser visningshistorikk med Trakt';
@override String get autoPip => 'Auto bilde-i-bilde'; @override String get autoPip => 'Auto bilde-i-bilde';
@override String get autoPipDescription => 'Gå automatisk til bilde-i-bilde når du forlater appen under avspilling'; @override String get autoPipDescription => 'Gå automatisk til bilde-i-bilde når du forlater appen under avspilling';
@override String get matchContentFrameRate => 'Tilpass innholdets bildefrekvens'; @override String get matchContentFrameRate => 'Tilpass innholdets bildefrekvens';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksNb implements TranslationsServerTasksEn {
@override String get noTasks => 'Ingen oppgaver kjører'; @override String get noTasks => 'Ingen oppgaver kjører';
} }
// Path: trakt
class _TranslationsTraktNb implements TranslationsTraktEn {
_TranslationsTraktNb._(this._root);
final TranslationsNb _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => 'Tilkoblet';
@override String connectedAs({required Object username}) => 'Tilkoblet som @${username}';
@override String get disconnectConfirm => 'Koble fra Trakt-konto?';
@override String get disconnectConfirmBody => 'Plezy slutter å sende avspillingshendelser til Trakt. Du kan koble til igjen når som helst.';
@override String get scrobble => 'Sanntids-scrobbling';
@override String get scrobbleDescription => 'Send avspillings-, pause- og stopphendelser til Trakt under avspilling.';
@override String get watchedSync => 'Synkroniser sett-status';
@override String get watchedSyncDescription => 'Når du markerer noe som sett i Plezy, markeres det også på Trakt.';
@override String get deviceCodeTitle => 'Aktiver Plezy på Trakt';
@override String deviceCodeBody({required Object url}) => 'Besøk ${url} og skriv inn denne koden:';
@override String get openTraktActivate => 'Åpne Trakt for å aktivere';
@override String get waitingForAuthorization => 'Venter på autorisering…';
@override String get codeCopied => 'Kode kopiert';
@override String get connectFailed => 'Kunne ikke koble til Trakt. Prøv igjen.';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsNb implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsNb implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsNb._(this._root); _TranslationsHotkeysActionsNb._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsNb {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresence' => 'Discord Rich Presence',
'settings.discordRichPresenceDescription' => 'Vis hva du ser på Discord', 'settings.discordRichPresenceDescription' => 'Vis hva du ser på Discord',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => 'Synkroniser visningshistorikk med Trakt',
'settings.autoPip' => 'Auto bilde-i-bilde', 'settings.autoPip' => 'Auto bilde-i-bilde',
'settings.autoPipDescription' => 'Gå automatisk til bilde-i-bilde når du forlater appen under avspilling', 'settings.autoPipDescription' => 'Gå automatisk til bilde-i-bilde når du forlater appen under avspilling',
'settings.matchContentFrameRate' => 'Tilpass innholdets bildefrekvens', 'settings.matchContentFrameRate' => 'Tilpass innholdets bildefrekvens',
@@ -1895,10 +1924,10 @@ extension on TranslationsNb {
'libraries.tabs.recommended' => 'Anbefalt', 'libraries.tabs.recommended' => 'Anbefalt',
'libraries.tabs.browse' => 'Bla gjennom', 'libraries.tabs.browse' => 'Bla gjennom',
'libraries.tabs.collections' => 'Samlinger', 'libraries.tabs.collections' => 'Samlinger',
'libraries.tabs.playlists' => 'Spillelister',
'libraries.groupings.title' => 'Gruppering',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => 'Spillelister',
'libraries.groupings.title' => 'Gruppering',
'libraries.groupings.all' => 'Alle', 'libraries.groupings.all' => 'Alle',
'libraries.groupings.movies' => 'Filmer', 'libraries.groupings.movies' => 'Filmer',
'libraries.groupings.shows' => 'TV-serier', 'libraries.groupings.shows' => 'TV-serier',
@@ -2275,6 +2304,21 @@ extension on TranslationsNb {
'serverTasks.title' => 'Serveroppgaver', 'serverTasks.title' => 'Serveroppgaver',
'serverTasks.failedToLoad' => 'Kunne ikke laste oppgaver', 'serverTasks.failedToLoad' => 'Kunne ikke laste oppgaver',
'serverTasks.noTasks' => 'Ingen oppgaver kjører', 'serverTasks.noTasks' => 'Ingen oppgaver kjører',
'trakt.title' => 'Trakt',
'trakt.connected' => 'Tilkoblet',
'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.',
'trakt.scrobble' => 'Sanntids-scrobbling',
'trakt.scrobbleDescription' => 'Send avspillings-, pause- og stopphendelser til Trakt under avspilling.',
'trakt.watchedSync' => 'Synkroniser sett-status',
'trakt.watchedSyncDescription' => 'Når du markerer noe som sett i Plezy, markeres det også på Trakt.',
'trakt.deviceCodeTitle' => 'Aktiver Plezy på Trakt',
'trakt.deviceCodeBody' => ({required Object url}) => 'Besøk ${url} og skriv inn denne koden:',
'trakt.openTraktActivate' => 'Åpne Trakt for å aktivere',
'trakt.waitingForAuthorization' => 'Venter på autorisering…',
'trakt.codeCopied' => 'Kode kopiert',
'trakt.connectFailed' => 'Kunne ikke koble til Trakt. Prøv igjen.',
_ => null, _ => null,
}; };
} }
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsNl with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerNl externalPlayer = _TranslationsExternalPlayerNl._(_root); @override late final _TranslationsExternalPlayerNl externalPlayer = _TranslationsExternalPlayerNl._(_root);
@override late final _TranslationsMetadataEditNl metadataEdit = _TranslationsMetadataEditNl._(_root); @override late final _TranslationsMetadataEditNl metadataEdit = _TranslationsMetadataEditNl._(_root);
@override late final _TranslationsServerTasksNl serverTasks = _TranslationsServerTasksNl._(_root); @override late final _TranslationsServerTasksNl serverTasks = _TranslationsServerTasksNl._(_root);
@override late final _TranslationsTraktNl trakt = _TranslationsTraktNl._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresence => 'Discord Rich Presence';
@override String get discordRichPresenceDescription => 'Toon op Discord wat je aan het kijken bent'; @override String get discordRichPresenceDescription => 'Toon op Discord wat je aan het kijken bent';
@override String get trakt => 'Trakt';
@override String get traktDescription => 'Kijkgeschiedenis synchroniseren met Trakt';
@override String get autoPip => 'Automatische beeld-in-beeld'; @override String get autoPip => 'Automatische beeld-in-beeld';
@override String get autoPipDescription => 'Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen'; @override String get autoPipDescription => 'Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen';
@override String get matchContentFrameRate => 'Inhoudsframesnelheid afstemmen'; @override String get matchContentFrameRate => 'Inhoudsframesnelheid afstemmen';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksNl implements TranslationsServerTasksEn {
@override String get noTasks => 'Geen actieve taken'; @override String get noTasks => 'Geen actieve taken';
} }
// Path: trakt
class _TranslationsTraktNl implements TranslationsTraktEn {
_TranslationsTraktNl._(this._root);
final TranslationsNl _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => 'Verbonden';
@override String connectedAs({required Object username}) => 'Verbonden als @${username}';
@override String get disconnectConfirm => 'Trakt-account loskoppelen?';
@override String get disconnectConfirmBody => 'Plezy stopt met het versturen van afspeelgebeurtenissen naar Trakt. Je kunt op elk moment opnieuw verbinden.';
@override String get scrobble => 'Realtime scrobbling';
@override String get scrobbleDescription => 'Verstuur play-, pauze- en stopgebeurtenissen tijdens afspelen naar Trakt.';
@override String get watchedSync => 'Bekeken-status synchroniseren';
@override String get watchedSyncDescription => 'Wanneer je items als bekeken markeert in Plezy, worden ze ook op Trakt gemarkeerd.';
@override String get deviceCodeTitle => 'Activeer Plezy op Trakt';
@override String deviceCodeBody({required Object url}) => 'Ga naar ${url} en voer deze code in:';
@override String get openTraktActivate => 'Open Trakt om te activeren';
@override String get waitingForAuthorization => 'Wacht op autorisatie…';
@override String get codeCopied => 'Code gekopieerd';
@override String get connectFailed => 'Kan niet verbinden met Trakt. Probeer opnieuw.';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsNl implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsNl implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsNl._(this._root); _TranslationsHotkeysActionsNl._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsNl {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresence' => 'Discord Rich Presence',
'settings.discordRichPresenceDescription' => 'Toon op Discord wat je aan het kijken bent', 'settings.discordRichPresenceDescription' => 'Toon op Discord wat je aan het kijken bent',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => 'Kijkgeschiedenis synchroniseren met Trakt',
'settings.autoPip' => 'Automatische beeld-in-beeld', 'settings.autoPip' => 'Automatische beeld-in-beeld',
'settings.autoPipDescription' => 'Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen', 'settings.autoPipDescription' => 'Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen',
'settings.matchContentFrameRate' => 'Inhoudsframesnelheid afstemmen', 'settings.matchContentFrameRate' => 'Inhoudsframesnelheid afstemmen',
@@ -1895,10 +1924,10 @@ extension on TranslationsNl {
'libraries.tabs.recommended' => 'Aanbevolen', 'libraries.tabs.recommended' => 'Aanbevolen',
'libraries.tabs.browse' => 'Bladeren', 'libraries.tabs.browse' => 'Bladeren',
'libraries.tabs.collections' => 'Collecties', 'libraries.tabs.collections' => 'Collecties',
'libraries.tabs.playlists' => 'Afspeellijsten',
'libraries.groupings.title' => 'Groepering',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => 'Afspeellijsten',
'libraries.groupings.title' => 'Groepering',
'libraries.groupings.all' => 'Alles', 'libraries.groupings.all' => 'Alles',
'libraries.groupings.movies' => 'Films', 'libraries.groupings.movies' => 'Films',
'libraries.groupings.shows' => 'Series', 'libraries.groupings.shows' => 'Series',
@@ -2275,6 +2304,21 @@ extension on TranslationsNl {
'serverTasks.title' => 'Servertaken', 'serverTasks.title' => 'Servertaken',
'serverTasks.failedToLoad' => 'Taken konden niet worden geladen', 'serverTasks.failedToLoad' => 'Taken konden niet worden geladen',
'serverTasks.noTasks' => 'Geen actieve taken', 'serverTasks.noTasks' => 'Geen actieve taken',
'trakt.title' => 'Trakt',
'trakt.connected' => 'Verbonden',
'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.',
'trakt.scrobble' => 'Realtime scrobbling',
'trakt.scrobbleDescription' => 'Verstuur play-, pauze- en stopgebeurtenissen tijdens afspelen naar Trakt.',
'trakt.watchedSync' => 'Bekeken-status synchroniseren',
'trakt.watchedSyncDescription' => 'Wanneer je items als bekeken markeert in Plezy, worden ze ook op Trakt gemarkeerd.',
'trakt.deviceCodeTitle' => 'Activeer Plezy op Trakt',
'trakt.deviceCodeBody' => ({required Object url}) => 'Ga naar ${url} en voer deze code in:',
'trakt.openTraktActivate' => 'Open Trakt om te activeren',
'trakt.waitingForAuthorization' => 'Wacht op autorisatie…',
'trakt.codeCopied' => 'Code gekopieerd',
'trakt.connectFailed' => 'Kan niet verbinden met Trakt. Probeer opnieuw.',
_ => null, _ => null,
}; };
} }
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsPl with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerPl externalPlayer = _TranslationsExternalPlayerPl._(_root); @override late final _TranslationsExternalPlayerPl externalPlayer = _TranslationsExternalPlayerPl._(_root);
@override late final _TranslationsMetadataEditPl metadataEdit = _TranslationsMetadataEditPl._(_root); @override late final _TranslationsMetadataEditPl metadataEdit = _TranslationsMetadataEditPl._(_root);
@override late final _TranslationsServerTasksPl serverTasks = _TranslationsServerTasksPl._(_root); @override late final _TranslationsServerTasksPl serverTasks = _TranslationsServerTasksPl._(_root);
@override late final _TranslationsTraktPl trakt = _TranslationsTraktPl._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsPl implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresence => 'Discord Rich Presence';
@override String get discordRichPresenceDescription => 'Pokaż, co oglądasz na Discordzie'; @override String get discordRichPresenceDescription => 'Pokaż, co oglądasz na Discordzie';
@override String get trakt => 'Trakt';
@override String get traktDescription => 'Synchronizuj historię oglądania z Trakt';
@override String get autoPip => 'Automatyczny obraz w obrazie'; @override String get autoPip => 'Automatyczny obraz w obrazie';
@override String get autoPipDescription => 'Automatycznie przejdź do trybu obraz w obrazie przy wyjściu z aplikacji podczas odtwarzania'; @override String get autoPipDescription => 'Automatycznie przejdź do trybu obraz w obrazie przy wyjściu z aplikacji podczas odtwarzania';
@override String get matchContentFrameRate => 'Dopasuj częstotliwość klatek do treści'; @override String get matchContentFrameRate => 'Dopasuj częstotliwość klatek do treści';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksPl implements TranslationsServerTasksEn {
@override String get noTasks => 'Brak uruchomionych zadań'; @override String get noTasks => 'Brak uruchomionych zadań';
} }
// Path: trakt
class _TranslationsTraktPl implements TranslationsTraktEn {
_TranslationsTraktPl._(this._root);
final TranslationsPl _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => 'Połączono';
@override String connectedAs({required Object username}) => 'Połączono jako @${username}';
@override String get disconnectConfirm => 'Rozłączyć konto Trakt?';
@override String get disconnectConfirmBody => 'Plezy przestanie wysyłać zdarzenia odtwarzania do Trakt. Możesz połączyć się ponownie w dowolnej chwili.';
@override String get scrobble => 'Scrobbling w czasie rzeczywistym';
@override String get scrobbleDescription => 'Wysyłaj zdarzenia odtwarzania, pauzy i zatrzymania do Trakt podczas odtwarzania.';
@override String get watchedSync => 'Synchronizuj status obejrzane';
@override String get watchedSyncDescription => 'Gdy oznaczysz pozycje jako obejrzane w Plezy, zostaną też oznaczone w Trakt.';
@override String get deviceCodeTitle => 'Aktywuj Plezy w Trakt';
@override String deviceCodeBody({required Object url}) => 'Otwórz ${url} i wpisz ten kod:';
@override String get openTraktActivate => 'Otwórz Trakt, aby aktywować';
@override String get waitingForAuthorization => 'Czekanie na autoryzację…';
@override String get codeCopied => 'Kod skopiowany';
@override String get connectFailed => 'Nie udało się połączyć z Trakt. Spróbuj ponownie.';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsPl implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsPl implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsPl._(this._root); _TranslationsHotkeysActionsPl._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsPl {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresence' => 'Discord Rich Presence',
'settings.discordRichPresenceDescription' => 'Pokaż, co oglądasz na Discordzie', 'settings.discordRichPresenceDescription' => 'Pokaż, co oglądasz na Discordzie',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => 'Synchronizuj historię oglądania z Trakt',
'settings.autoPip' => 'Automatyczny obraz w obrazie', 'settings.autoPip' => 'Automatyczny obraz w obrazie',
'settings.autoPipDescription' => 'Automatycznie przejdź do trybu obraz w obrazie przy wyjściu z aplikacji podczas odtwarzania', 'settings.autoPipDescription' => 'Automatycznie przejdź do trybu obraz w obrazie przy wyjściu z aplikacji podczas odtwarzania',
'settings.matchContentFrameRate' => 'Dopasuj częstotliwość klatek do treści', 'settings.matchContentFrameRate' => 'Dopasuj częstotliwość klatek do treści',
@@ -1895,10 +1924,10 @@ extension on TranslationsPl {
'libraries.tabs.recommended' => 'Rekomendowane', 'libraries.tabs.recommended' => 'Rekomendowane',
'libraries.tabs.browse' => 'Przeglądaj', 'libraries.tabs.browse' => 'Przeglądaj',
'libraries.tabs.collections' => 'Kolekcje', 'libraries.tabs.collections' => 'Kolekcje',
'libraries.tabs.playlists' => 'Playlisty',
'libraries.groupings.title' => 'Grupowanie',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => 'Playlisty',
'libraries.groupings.title' => 'Grupowanie',
'libraries.groupings.all' => 'Wszystkie', 'libraries.groupings.all' => 'Wszystkie',
'libraries.groupings.movies' => 'Filmy', 'libraries.groupings.movies' => 'Filmy',
'libraries.groupings.shows' => 'Seriale TV', 'libraries.groupings.shows' => 'Seriale TV',
@@ -2275,6 +2304,21 @@ extension on TranslationsPl {
'serverTasks.title' => 'Zadania serwera', 'serverTasks.title' => 'Zadania serwera',
'serverTasks.failedToLoad' => 'Nie udało się załadować zadań', 'serverTasks.failedToLoad' => 'Nie udało się załadować zadań',
'serverTasks.noTasks' => 'Brak uruchomionych zadań', 'serverTasks.noTasks' => 'Brak uruchomionych zadań',
'trakt.title' => 'Trakt',
'trakt.connected' => 'Połączono',
'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.',
'trakt.scrobble' => 'Scrobbling w czasie rzeczywistym',
'trakt.scrobbleDescription' => 'Wysyłaj zdarzenia odtwarzania, pauzy i zatrzymania do Trakt podczas odtwarzania.',
'trakt.watchedSync' => 'Synchronizuj status obejrzane',
'trakt.watchedSyncDescription' => 'Gdy oznaczysz pozycje jako obejrzane w Plezy, zostaną też oznaczone w Trakt.',
'trakt.deviceCodeTitle' => 'Aktywuj Plezy w Trakt',
'trakt.deviceCodeBody' => ({required Object url}) => 'Otwórz ${url} i wpisz ten kod:',
'trakt.openTraktActivate' => 'Otwórz Trakt, aby aktywować',
'trakt.waitingForAuthorization' => 'Czekanie na autoryzację…',
'trakt.codeCopied' => 'Kod skopiowany',
'trakt.connectFailed' => 'Nie udało się połączyć z Trakt. Spróbuj ponownie.',
_ => null, _ => null,
}; };
} }
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsPt with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerPt externalPlayer = _TranslationsExternalPlayerPt._(_root); @override late final _TranslationsExternalPlayerPt externalPlayer = _TranslationsExternalPlayerPt._(_root);
@override late final _TranslationsMetadataEditPt metadataEdit = _TranslationsMetadataEditPt._(_root); @override late final _TranslationsMetadataEditPt metadataEdit = _TranslationsMetadataEditPt._(_root);
@override late final _TranslationsServerTasksPt serverTasks = _TranslationsServerTasksPt._(_root); @override late final _TranslationsServerTasksPt serverTasks = _TranslationsServerTasksPt._(_root);
@override late final _TranslationsTraktPt trakt = _TranslationsTraktPt._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsPt implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresence => 'Discord Rich Presence';
@override String get discordRichPresenceDescription => 'Mostrar o que você está assistindo no Discord'; @override String get discordRichPresenceDescription => 'Mostrar o que você está assistindo no Discord';
@override String get trakt => 'Trakt';
@override String get traktDescription => 'Sincronizar histórico de exibição com Trakt';
@override String get autoPip => 'Picture-in-Picture Automático'; @override String get autoPip => 'Picture-in-Picture Automático';
@override String get autoPipDescription => 'Entrar automaticamente em picture-in-picture ao sair do app durante a reprodução'; @override String get autoPipDescription => 'Entrar automaticamente em picture-in-picture ao sair do app durante a reprodução';
@override String get matchContentFrameRate => 'Corresponder Taxa de Quadros do Conteúdo'; @override String get matchContentFrameRate => 'Corresponder Taxa de Quadros do Conteúdo';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksPt implements TranslationsServerTasksEn {
@override String get noTasks => 'Nenhuma tarefa em execução'; @override String get noTasks => 'Nenhuma tarefa em execução';
} }
// Path: trakt
class _TranslationsTraktPt implements TranslationsTraktEn {
_TranslationsTraktPt._(this._root);
final TranslationsPt _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => 'Conectado';
@override String connectedAs({required Object username}) => 'Conectado como @${username}';
@override String get disconnectConfirm => 'Desconectar conta do Trakt?';
@override String get disconnectConfirmBody => 'O Plezy deixará de enviar eventos de reprodução ao Trakt. Você pode reconectar a qualquer momento.';
@override String get scrobble => 'Scrobbling em tempo real';
@override String get scrobbleDescription => 'Envia eventos de reprodução, pausa e parada ao Trakt durante a exibição.';
@override String get watchedSync => 'Sincronizar status de assistido';
@override String get watchedSyncDescription => 'Ao marcar itens como assistidos no Plezy, eles também serão marcados no Trakt.';
@override String get deviceCodeTitle => 'Ativar o Plezy no Trakt';
@override String deviceCodeBody({required Object url}) => 'Acesse ${url} e digite este código:';
@override String get openTraktActivate => 'Abrir o Trakt para ativar';
@override String get waitingForAuthorization => 'Aguardando autorização…';
@override String get codeCopied => 'Código copiado';
@override String get connectFailed => 'Não foi possível conectar ao Trakt. Tente novamente.';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsPt implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsPt implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsPt._(this._root); _TranslationsHotkeysActionsPt._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsPt {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresence' => 'Discord Rich Presence',
'settings.discordRichPresenceDescription' => 'Mostrar o que você está assistindo no Discord', 'settings.discordRichPresenceDescription' => 'Mostrar o que você está assistindo no Discord',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => 'Sincronizar histórico de exibição com Trakt',
'settings.autoPip' => 'Picture-in-Picture Automático', 'settings.autoPip' => 'Picture-in-Picture Automático',
'settings.autoPipDescription' => 'Entrar automaticamente em picture-in-picture ao sair do app durante a reprodução', 'settings.autoPipDescription' => 'Entrar automaticamente em picture-in-picture ao sair do app durante a reprodução',
'settings.matchContentFrameRate' => 'Corresponder Taxa de Quadros do Conteúdo', 'settings.matchContentFrameRate' => 'Corresponder Taxa de Quadros do Conteúdo',
@@ -1895,10 +1924,10 @@ extension on TranslationsPt {
'libraries.tabs.recommended' => 'Recomendados', 'libraries.tabs.recommended' => 'Recomendados',
'libraries.tabs.browse' => 'Navegar', 'libraries.tabs.browse' => 'Navegar',
'libraries.tabs.collections' => 'Coleções', 'libraries.tabs.collections' => 'Coleções',
'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Agrupamento',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Agrupamento',
'libraries.groupings.all' => 'Todos', 'libraries.groupings.all' => 'Todos',
'libraries.groupings.movies' => 'Filmes', 'libraries.groupings.movies' => 'Filmes',
'libraries.groupings.shows' => 'Séries de TV', 'libraries.groupings.shows' => 'Séries de TV',
@@ -2275,6 +2304,21 @@ extension on TranslationsPt {
'serverTasks.title' => 'Tarefas do servidor', 'serverTasks.title' => 'Tarefas do servidor',
'serverTasks.failedToLoad' => 'Falha ao carregar tarefas', 'serverTasks.failedToLoad' => 'Falha ao carregar tarefas',
'serverTasks.noTasks' => 'Nenhuma tarefa em execução', 'serverTasks.noTasks' => 'Nenhuma tarefa em execução',
'trakt.title' => 'Trakt',
'trakt.connected' => 'Conectado',
'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.',
'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',
'trakt.watchedSyncDescription' => 'Ao marcar itens como assistidos no Plezy, eles também serão marcados no Trakt.',
'trakt.deviceCodeTitle' => 'Ativar o Plezy no Trakt',
'trakt.deviceCodeBody' => ({required Object url}) => 'Acesse ${url} e digite este código:',
'trakt.openTraktActivate' => 'Abrir o Trakt para ativar',
'trakt.waitingForAuthorization' => 'Aguardando autorização…',
'trakt.codeCopied' => 'Código copiado',
'trakt.connectFailed' => 'Não foi possível conectar ao Trakt. Tente novamente.',
_ => null, _ => null,
}; };
} }
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsRu with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerRu externalPlayer = _TranslationsExternalPlayerRu._(_root); @override late final _TranslationsExternalPlayerRu externalPlayer = _TranslationsExternalPlayerRu._(_root);
@override late final _TranslationsMetadataEditRu metadataEdit = _TranslationsMetadataEditRu._(_root); @override late final _TranslationsMetadataEditRu metadataEdit = _TranslationsMetadataEditRu._(_root);
@override late final _TranslationsServerTasksRu serverTasks = _TranslationsServerTasksRu._(_root); @override late final _TranslationsServerTasksRu serverTasks = _TranslationsServerTasksRu._(_root);
@override late final _TranslationsTraktRu trakt = _TranslationsTraktRu._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsRu implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresence => 'Discord Rich Presence';
@override String get discordRichPresenceDescription => 'Показывать, что вы смотрите, в Discord'; @override String get discordRichPresenceDescription => 'Показывать, что вы смотрите, в Discord';
@override String get trakt => 'Trakt';
@override String get traktDescription => 'Синхронизировать историю просмотров с Trakt';
@override String get autoPip => 'Автоматический «картинка в картинке»'; @override String get autoPip => 'Автоматический «картинка в картинке»';
@override String get autoPipDescription => 'Автоматически переходить в режим «картинка в картинке» при выходе из приложения во время воспроизведения'; @override String get autoPipDescription => 'Автоматически переходить в режим «картинка в картинке» при выходе из приложения во время воспроизведения';
@override String get matchContentFrameRate => 'Соответствие частоты кадров контента'; @override String get matchContentFrameRate => 'Соответствие частоты кадров контента';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksRu implements TranslationsServerTasksEn {
@override String get noTasks => 'Нет выполняемых задач'; @override String get noTasks => 'Нет выполняемых задач';
} }
// Path: trakt
class _TranslationsTraktRu implements TranslationsTraktEn {
_TranslationsTraktRu._(this._root);
final TranslationsRu _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => 'Подключено';
@override String connectedAs({required Object username}) => 'Подключено как @${username}';
@override String get disconnectConfirm => 'Отключить аккаунт Trakt?';
@override String get disconnectConfirmBody => 'Plezy перестанет отправлять события воспроизведения в Trakt. Вы можете подключиться снова в любое время.';
@override String get scrobble => 'Скробблинг в реальном времени';
@override String get scrobbleDescription => 'Отправлять события воспроизведения, паузы и остановки в Trakt во время просмотра.';
@override String get watchedSync => 'Синхронизация статуса просмотра';
@override String get watchedSyncDescription => 'Когда вы отмечаете элементы как просмотренные в Plezy, они отмечаются и в Trakt.';
@override String get deviceCodeTitle => 'Активация Plezy в Trakt';
@override String deviceCodeBody({required Object url}) => 'Откройте ${url} и введите этот код:';
@override String get openTraktActivate => 'Открыть Trakt для активации';
@override String get waitingForAuthorization => 'Ожидание авторизации…';
@override String get codeCopied => 'Код скопирован';
@override String get connectFailed => 'Не удалось подключиться к Trakt. Попробуйте ещё раз.';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsRu implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsRu implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsRu._(this._root); _TranslationsHotkeysActionsRu._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsRu {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresence' => 'Discord Rich Presence',
'settings.discordRichPresenceDescription' => 'Показывать, что вы смотрите, в Discord', 'settings.discordRichPresenceDescription' => 'Показывать, что вы смотрите, в Discord',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => 'Синхронизировать историю просмотров с Trakt',
'settings.autoPip' => 'Автоматический «картинка в картинке»', 'settings.autoPip' => 'Автоматический «картинка в картинке»',
'settings.autoPipDescription' => 'Автоматически переходить в режим «картинка в картинке» при выходе из приложения во время воспроизведения', 'settings.autoPipDescription' => 'Автоматически переходить в режим «картинка в картинке» при выходе из приложения во время воспроизведения',
'settings.matchContentFrameRate' => 'Соответствие частоты кадров контента', 'settings.matchContentFrameRate' => 'Соответствие частоты кадров контента',
@@ -1895,10 +1924,10 @@ extension on TranslationsRu {
'libraries.tabs.recommended' => 'Рекомендуемые', 'libraries.tabs.recommended' => 'Рекомендуемые',
'libraries.tabs.browse' => 'Обзор', 'libraries.tabs.browse' => 'Обзор',
'libraries.tabs.collections' => 'Коллекции', 'libraries.tabs.collections' => 'Коллекции',
'libraries.tabs.playlists' => 'Плейлисты',
'libraries.groupings.title' => 'Группировка',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => 'Плейлисты',
'libraries.groupings.title' => 'Группировка',
'libraries.groupings.all' => 'Все', 'libraries.groupings.all' => 'Все',
'libraries.groupings.movies' => 'Фильмы', 'libraries.groupings.movies' => 'Фильмы',
'libraries.groupings.shows' => 'Сериалы', 'libraries.groupings.shows' => 'Сериалы',
@@ -2275,6 +2304,21 @@ extension on TranslationsRu {
'serverTasks.title' => 'Задачи сервера', 'serverTasks.title' => 'Задачи сервера',
'serverTasks.failedToLoad' => 'Не удалось загрузить задачи', 'serverTasks.failedToLoad' => 'Не удалось загрузить задачи',
'serverTasks.noTasks' => 'Нет выполняемых задач', 'serverTasks.noTasks' => 'Нет выполняемых задач',
'trakt.title' => 'Trakt',
'trakt.connected' => 'Подключено',
'trakt.connectedAs' => ({required Object username}) => 'Подключено как @${username}',
'trakt.disconnectConfirm' => 'Отключить аккаунт Trakt?',
'trakt.disconnectConfirmBody' => 'Plezy перестанет отправлять события воспроизведения в Trakt. Вы можете подключиться снова в любое время.',
'trakt.scrobble' => 'Скробблинг в реальном времени',
'trakt.scrobbleDescription' => 'Отправлять события воспроизведения, паузы и остановки в Trakt во время просмотра.',
'trakt.watchedSync' => 'Синхронизация статуса просмотра',
'trakt.watchedSyncDescription' => 'Когда вы отмечаете элементы как просмотренные в Plezy, они отмечаются и в Trakt.',
'trakt.deviceCodeTitle' => 'Активация Plezy в Trakt',
'trakt.deviceCodeBody' => ({required Object url}) => 'Откройте ${url} и введите этот код:',
'trakt.openTraktActivate' => 'Открыть Trakt для активации',
'trakt.waitingForAuthorization' => 'Ожидание авторизации…',
'trakt.codeCopied' => 'Код скопирован',
'trakt.connectFailed' => 'Не удалось подключиться к Trakt. Попробуйте ещё раз.',
_ => null, _ => null,
}; };
} }
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsSv with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerSv externalPlayer = _TranslationsExternalPlayerSv._(_root); @override late final _TranslationsExternalPlayerSv externalPlayer = _TranslationsExternalPlayerSv._(_root);
@override late final _TranslationsMetadataEditSv metadataEdit = _TranslationsMetadataEditSv._(_root); @override late final _TranslationsMetadataEditSv metadataEdit = _TranslationsMetadataEditSv._(_root);
@override late final _TranslationsServerTasksSv serverTasks = _TranslationsServerTasksSv._(_root); @override late final _TranslationsServerTasksSv serverTasks = _TranslationsServerTasksSv._(_root);
@override late final _TranslationsTraktSv trakt = _TranslationsTraktSv._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresence => 'Discord Rich Presence';
@override String get discordRichPresenceDescription => 'Visa vad du tittar på i Discord'; @override String get discordRichPresenceDescription => 'Visa vad du tittar på i Discord';
@override String get trakt => 'Trakt';
@override String get traktDescription => 'Synkronisera visningshistorik med Trakt';
@override String get autoPip => 'Automatisk bild-i-bild'; @override String get autoPip => 'Automatisk bild-i-bild';
@override String get autoPipDescription => 'Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning'; @override String get autoPipDescription => 'Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning';
@override String get matchContentFrameRate => 'Matcha innehållets bildfrekvens'; @override String get matchContentFrameRate => 'Matcha innehållets bildfrekvens';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksSv implements TranslationsServerTasksEn {
@override String get noTasks => 'Inga pågående uppgifter'; @override String get noTasks => 'Inga pågående uppgifter';
} }
// Path: trakt
class _TranslationsTraktSv implements TranslationsTraktEn {
_TranslationsTraktSv._(this._root);
final TranslationsSv _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => 'Ansluten';
@override String connectedAs({required Object username}) => 'Ansluten som @${username}';
@override String get disconnectConfirm => 'Koppla från Trakt-konto?';
@override String get disconnectConfirmBody => 'Plezy slutar skicka uppspelningshändelser till Trakt. Du kan ansluta igen när som helst.';
@override String get scrobble => 'Realtids-scrobbling';
@override String get scrobbleDescription => 'Skicka uppspelnings-, paus- och stopphändelser till Trakt under uppspelning.';
@override String get watchedSync => 'Synkronisera tittad-status';
@override String get watchedSyncDescription => 'När du markerar något som tittat i Plezy markeras det också på Trakt.';
@override String get deviceCodeTitle => 'Aktivera Plezy på Trakt';
@override String deviceCodeBody({required Object url}) => 'Besök ${url} och ange den här koden:';
@override String get openTraktActivate => 'Öppna Trakt för att aktivera';
@override String get waitingForAuthorization => 'Väntar på auktorisering…';
@override String get codeCopied => 'Kod kopierad';
@override String get connectFailed => 'Kunde inte ansluta till Trakt. Försök igen.';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsSv implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsSv implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsSv._(this._root); _TranslationsHotkeysActionsSv._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsSv {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresence' => 'Discord Rich Presence',
'settings.discordRichPresenceDescription' => 'Visa vad du tittar på i Discord', 'settings.discordRichPresenceDescription' => 'Visa vad du tittar på i Discord',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => 'Synkronisera visningshistorik med Trakt',
'settings.autoPip' => 'Automatisk bild-i-bild', 'settings.autoPip' => 'Automatisk bild-i-bild',
'settings.autoPipDescription' => 'Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning', 'settings.autoPipDescription' => 'Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning',
'settings.matchContentFrameRate' => 'Matcha innehållets bildfrekvens', 'settings.matchContentFrameRate' => 'Matcha innehållets bildfrekvens',
@@ -1895,10 +1924,10 @@ extension on TranslationsSv {
'libraries.tabs.recommended' => 'Rekommenderat', 'libraries.tabs.recommended' => 'Rekommenderat',
'libraries.tabs.browse' => 'Bläddra', 'libraries.tabs.browse' => 'Bläddra',
'libraries.tabs.collections' => 'Samlingar', 'libraries.tabs.collections' => 'Samlingar',
'libraries.tabs.playlists' => 'Spellistor',
'libraries.groupings.title' => 'Gruppering',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => 'Spellistor',
'libraries.groupings.title' => 'Gruppering',
'libraries.groupings.all' => 'Alla', 'libraries.groupings.all' => 'Alla',
'libraries.groupings.movies' => 'Filmer', 'libraries.groupings.movies' => 'Filmer',
'libraries.groupings.shows' => 'Serier', 'libraries.groupings.shows' => 'Serier',
@@ -2275,6 +2304,21 @@ extension on TranslationsSv {
'serverTasks.title' => 'Serveruppgifter', 'serverTasks.title' => 'Serveruppgifter',
'serverTasks.failedToLoad' => 'Kunde inte ladda uppgifter', 'serverTasks.failedToLoad' => 'Kunde inte ladda uppgifter',
'serverTasks.noTasks' => 'Inga pågående uppgifter', 'serverTasks.noTasks' => 'Inga pågående uppgifter',
'trakt.title' => 'Trakt',
'trakt.connected' => 'Ansluten',
'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.',
'trakt.scrobble' => 'Realtids-scrobbling',
'trakt.scrobbleDescription' => 'Skicka uppspelnings-, paus- och stopphändelser till Trakt under uppspelning.',
'trakt.watchedSync' => 'Synkronisera tittad-status',
'trakt.watchedSyncDescription' => 'När du markerar något som tittat i Plezy markeras det också på Trakt.',
'trakt.deviceCodeTitle' => 'Aktivera Plezy på Trakt',
'trakt.deviceCodeBody' => ({required Object url}) => 'Besök ${url} och ange den här koden:',
'trakt.openTraktActivate' => 'Öppna Trakt för att aktivera',
'trakt.waitingForAuthorization' => 'Väntar på auktorisering…',
'trakt.codeCopied' => 'Kod kopierad',
'trakt.connectFailed' => 'Kunde inte ansluta till Trakt. Försök igen.',
_ => null, _ => null,
}; };
} }
+46 -2
View File
@@ -75,6 +75,7 @@ class TranslationsZh with BaseTranslations<AppLocale, Translations> implements T
@override late final _TranslationsExternalPlayerZh externalPlayer = _TranslationsExternalPlayerZh._(_root); @override late final _TranslationsExternalPlayerZh externalPlayer = _TranslationsExternalPlayerZh._(_root);
@override late final _TranslationsMetadataEditZh metadataEdit = _TranslationsMetadataEditZh._(_root); @override late final _TranslationsMetadataEditZh metadataEdit = _TranslationsMetadataEditZh._(_root);
@override late final _TranslationsServerTasksZh serverTasks = _TranslationsServerTasksZh._(_root); @override late final _TranslationsServerTasksZh serverTasks = _TranslationsServerTasksZh._(_root);
@override late final _TranslationsTraktZh trakt = _TranslationsTraktZh._(_root);
} }
// Path: app // Path: app
@@ -328,6 +329,8 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn {
@override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String maxVolumePercent({required Object percent}) => '${percent}%';
@override String get discordRichPresence => 'Discord 动态状态'; @override String get discordRichPresence => 'Discord 动态状态';
@override String get discordRichPresenceDescription => '在 Discord 上显示您正在观看的内容'; @override String get discordRichPresenceDescription => '在 Discord 上显示您正在观看的内容';
@override String get trakt => 'Trakt';
@override String get traktDescription => '将观看历史与 Trakt 同步';
@override String get autoPip => '自动画中画'; @override String get autoPip => '自动画中画';
@override String get autoPipDescription => '在播放期间离开应用时自动进入画中画模式'; @override String get autoPipDescription => '在播放期间离开应用时自动进入画中画模式';
@override String get matchContentFrameRate => '匹配内容帧率'; @override String get matchContentFrameRate => '匹配内容帧率';
@@ -1220,6 +1223,30 @@ class _TranslationsServerTasksZh implements TranslationsServerTasksEn {
@override String get noTasks => '没有正在运行的任务'; @override String get noTasks => '没有正在运行的任务';
} }
// Path: trakt
class _TranslationsTraktZh implements TranslationsTraktEn {
_TranslationsTraktZh._(this._root);
final TranslationsZh _root; // ignore: unused_field
// Translations
@override String get title => 'Trakt';
@override String get connected => '已连接';
@override String connectedAs({required Object username}) => '已以 @${username} 身份连接';
@override String get disconnectConfirm => '断开 Trakt 账户?';
@override String get disconnectConfirmBody => 'Plezy 将停止向 Trakt 发送播放事件。您随时可以重新连接。';
@override String get scrobble => '实时 Scrobble';
@override String get scrobbleDescription => '在播放时向 Trakt 发送播放、暂停和停止事件。';
@override String get watchedSync => '同步已观看状态';
@override String get watchedSyncDescription => '在 Plezy 中将内容标记为已观看时,也会在 Trakt 上标记为已观看。';
@override String get deviceCodeTitle => '在 Trakt 上激活 Plezy';
@override String deviceCodeBody({required Object url}) => '请访问 ${url} 并输入此代码:';
@override String get openTraktActivate => '打开 Trakt 进行激活';
@override String get waitingForAuthorization => '等待授权…';
@override String get codeCopied => '代码已复制';
@override String get connectFailed => '无法连接到 Trakt。请重试。';
}
// Path: hotkeys.actions // Path: hotkeys.actions
class _TranslationsHotkeysActionsZh implements TranslationsHotkeysActionsEn { class _TranslationsHotkeysActionsZh implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsZh._(this._root); _TranslationsHotkeysActionsZh._(this._root);
@@ -1584,6 +1611,8 @@ extension on TranslationsZh {
'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%',
'settings.discordRichPresence' => 'Discord 动态状态', 'settings.discordRichPresence' => 'Discord 动态状态',
'settings.discordRichPresenceDescription' => '在 Discord 上显示您正在观看的内容', 'settings.discordRichPresenceDescription' => '在 Discord 上显示您正在观看的内容',
'settings.trakt' => 'Trakt',
'settings.traktDescription' => '将观看历史与 Trakt 同步',
'settings.autoPip' => '自动画中画', 'settings.autoPip' => '自动画中画',
'settings.autoPipDescription' => '在播放期间离开应用时自动进入画中画模式', 'settings.autoPipDescription' => '在播放期间离开应用时自动进入画中画模式',
'settings.matchContentFrameRate' => '匹配内容帧率', 'settings.matchContentFrameRate' => '匹配内容帧率',
@@ -1895,10 +1924,10 @@ extension on TranslationsZh {
'libraries.tabs.recommended' => '推荐', 'libraries.tabs.recommended' => '推荐',
'libraries.tabs.browse' => '浏览', 'libraries.tabs.browse' => '浏览',
'libraries.tabs.collections' => '合集', 'libraries.tabs.collections' => '合集',
'libraries.tabs.playlists' => '播放列表',
'libraries.groupings.title' => '分组',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'libraries.tabs.playlists' => '播放列表',
'libraries.groupings.title' => '分组',
'libraries.groupings.all' => '全部', 'libraries.groupings.all' => '全部',
'libraries.groupings.movies' => '电影', 'libraries.groupings.movies' => '电影',
'libraries.groupings.shows' => '剧集', 'libraries.groupings.shows' => '剧集',
@@ -2275,6 +2304,21 @@ extension on TranslationsZh {
'serverTasks.title' => '服务器任务', 'serverTasks.title' => '服务器任务',
'serverTasks.failedToLoad' => '加载任务失败', 'serverTasks.failedToLoad' => '加载任务失败',
'serverTasks.noTasks' => '没有正在运行的任务', 'serverTasks.noTasks' => '没有正在运行的任务',
'trakt.title' => 'Trakt',
'trakt.connected' => '已连接',
'trakt.connectedAs' => ({required Object username}) => '已以 @${username} 身份连接',
'trakt.disconnectConfirm' => '断开 Trakt 账户?',
'trakt.disconnectConfirmBody' => 'Plezy 将停止向 Trakt 发送播放事件。您随时可以重新连接。',
'trakt.scrobble' => '实时 Scrobble',
'trakt.scrobbleDescription' => '在播放时向 Trakt 发送播放、暂停和停止事件。',
'trakt.watchedSync' => '同步已观看状态',
'trakt.watchedSyncDescription' => '在 Plezy 中将内容标记为已观看时,也会在 Trakt 上标记为已观看。',
'trakt.deviceCodeTitle' => '在 Trakt 上激活 Plezy',
'trakt.deviceCodeBody' => ({required Object url}) => '请访问 ${url} 并输入此代码:',
'trakt.openTraktActivate' => '打开 Trakt 进行激活',
'trakt.waitingForAuthorization' => '等待授权…',
'trakt.codeCopied' => '代码已复制',
'trakt.connectFailed' => '无法连接到 Trakt。请重试。',
_ => null, _ => null,
}; };
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord Rich Presence", "discordRichPresence": "Discord Rich Presence",
"discordRichPresenceDescription": "Visa vad du tittar på i Discord", "discordRichPresenceDescription": "Visa vad du tittar på i Discord",
"trakt": "Trakt",
"traktDescription": "Synkronisera visningshistorik med Trakt",
"autoPip": "Automatisk bild-i-bild", "autoPip": "Automatisk bild-i-bild",
"autoPipDescription": "Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning", "autoPipDescription": "Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning",
"matchContentFrameRate": "Matcha innehållets bildfrekvens", "matchContentFrameRate": "Matcha innehållets bildfrekvens",
@@ -976,5 +978,22 @@
"title": "Serveruppgifter", "title": "Serveruppgifter",
"failedToLoad": "Kunde inte ladda uppgifter", "failedToLoad": "Kunde inte ladda uppgifter",
"noTasks": "Inga pågående uppgifter" "noTasks": "Inga pågående uppgifter"
},
"trakt": {
"title": "Trakt",
"connected": "Ansluten",
"connectedAs": "Ansluten som @${username}",
"disconnectConfirm": "Koppla från Trakt-konto?",
"disconnectConfirmBody": "Plezy slutar skicka uppspelningshändelser till Trakt. Du kan ansluta igen när som helst.",
"scrobble": "Realtids-scrobbling",
"scrobbleDescription": "Skicka uppspelnings-, paus- och stopphändelser till Trakt under uppspelning.",
"watchedSync": "Synkronisera tittad-status",
"watchedSyncDescription": "När du markerar något som tittat i Plezy markeras det också på Trakt.",
"deviceCodeTitle": "Aktivera Plezy på Trakt",
"deviceCodeBody": "Besök ${url} och ange den här koden:",
"openTraktActivate": "Öppna Trakt för att aktivera",
"waitingForAuthorization": "Väntar på auktorisering…",
"codeCopied": "Kod kopierad",
"connectFailed": "Kunde inte ansluta till Trakt. Försök igen."
} }
} }
+19
View File
@@ -209,6 +209,8 @@
"maxVolumePercent": "${percent}%", "maxVolumePercent": "${percent}%",
"discordRichPresence": "Discord 动态状态", "discordRichPresence": "Discord 动态状态",
"discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容", "discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容",
"trakt": "Trakt",
"traktDescription": "将观看历史与 Trakt 同步",
"autoPip": "自动画中画", "autoPip": "自动画中画",
"autoPipDescription": "在播放期间离开应用时自动进入画中画模式", "autoPipDescription": "在播放期间离开应用时自动进入画中画模式",
"matchContentFrameRate": "匹配内容帧率", "matchContentFrameRate": "匹配内容帧率",
@@ -976,5 +978,22 @@
"title": "服务器任务", "title": "服务器任务",
"failedToLoad": "加载任务失败", "failedToLoad": "加载任务失败",
"noTasks": "没有正在运行的任务" "noTasks": "没有正在运行的任务"
},
"trakt": {
"title": "Trakt",
"connected": "已连接",
"connectedAs": "已以 @${username} 身份连接",
"disconnectConfirm": "断开 Trakt 账户?",
"disconnectConfirmBody": "Plezy 将停止向 Trakt 发送播放事件。您随时可以重新连接。",
"scrobble": "实时 Scrobble",
"scrobbleDescription": "在播放时向 Trakt 发送播放、暂停和停止事件。",
"watchedSync": "同步已观看状态",
"watchedSyncDescription": "在 Plezy 中将内容标记为已观看时,也会在 Trakt 上标记为已观看。",
"deviceCodeTitle": "在 Trakt 上激活 Plezy",
"deviceCodeBody": "请访问 ${url} 并输入此代码:",
"openTraktActivate": "打开 Trakt 进行激活",
"waitingForAuthorization": "等待授权…",
"codeCopied": "代码已复制",
"connectFailed": "无法连接到 Trakt。请重试。"
} }
} }
+91 -20
View File
@@ -19,6 +19,9 @@ import 'services/settings_service.dart';
import 'utils/platform_detector.dart'; import 'utils/platform_detector.dart';
import 'services/discord_rpc_service.dart'; import 'services/discord_rpc_service.dart';
import 'services/gamepad_service.dart'; import 'services/gamepad_service.dart';
import 'services/trakt/trakt_scrobble_service.dart';
import 'services/trakt/trakt_sync_service.dart';
import 'providers/trakt_account_provider.dart';
import 'providers/user_profile_provider.dart'; import 'providers/user_profile_provider.dart';
import 'providers/multi_server_provider.dart'; import 'providers/multi_server_provider.dart';
import 'providers/theme_provider.dart'; import 'providers/theme_provider.dart';
@@ -187,6 +190,9 @@ Future<void> _bootstrapApp() async {
DiscordRPCService.instance.initialize(); DiscordRPCService.instance.initialize();
} }
// Trakt scrobble service (all platforms)
await TraktScrobbleService.instance.initialize();
// DTD service is available for MCP tooling connection if needed // DTD service is available for MCP tooling connection if needed
// Register bundled shader licenses // Register bundled shader licenses
@@ -404,6 +410,10 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
_offlineWatchSyncService = OfflineWatchSyncService(database: _appDatabase, serverManager: _serverManager); _offlineWatchSyncService = OfflineWatchSyncService(database: _appDatabase, serverManager: _serverManager);
// Trakt sync service (subscribes to WatchStateNotifier, requires serverManager
// to resolve PlexClients for GUID lookups).
TraktSyncService.instance.initialize(serverManager: _serverManager);
_appLifecycleListener = AppLifecycleListener( _appLifecycleListener = AppLifecycleListener(
onExitRequested: () async { onExitRequested: () async {
await _appDatabase.close(); await _appDatabase.close();
@@ -471,6 +481,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
// App came back to foreground - trigger sync check and start new session // App came back to foreground - trigger sync check and start new session
_offlineWatchSyncService.onAppResumed(); _offlineWatchSyncService.onAppResumed();
TraktSyncService.instance.flushQueue();
InAppReviewService.instance.startSession(); InAppReviewService.instance.startSession();
// Re-probe servers — mobile OS may have dropped TCP connections during doze/sleep. // Re-probe servers — mobile OS may have dropped TCP connections during doze/sleep.
// On desktop, resumed fires on every window focus (alt-tab), so apply a cooldown // On desktop, resumed fires on every window focus (alt-tab), so apply a cooldown
@@ -565,6 +576,9 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
// Existing providers // Existing providers
ChangeNotifierProvider(create: (context) => UserProfileProvider()), ChangeNotifierProvider(create: (context) => UserProfileProvider()),
ChangeNotifierProvider(create: (context) => ThemeProvider()), ChangeNotifierProvider(create: (context) => ThemeProvider()),
// Trakt account — depends on UserProfileProvider for per-profile token scoping.
// Hydrated and rebound in `_TraktBootstrap` below.
ChangeNotifierProvider(create: (context) => TraktAccountProvider()),
ChangeNotifierProvider(create: (context) => SettingsProvider(), lazy: true), ChangeNotifierProvider(create: (context) => SettingsProvider(), lazy: true),
ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true), ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true),
ChangeNotifierProvider(create: (context) => LibrariesProvider()), ChangeNotifierProvider(create: (context) => LibrariesProvider()),
@@ -576,26 +590,28 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
child: Consumer<ThemeProvider>( child: Consumer<ThemeProvider>(
builder: (context, themeProvider, child) { builder: (context, themeProvider, child) {
return TranslationProvider( return TranslationProvider(
child: Listener( child: _TraktProfileBootstrap(
onPointerDown: (event) { child: Listener(
if ((event.buttons & kBackMouseButton) != 0) { onPointerDown: (event) {
rootNavigatorKey.currentState?.maybePop(); if ((event.buttons & kBackMouseButton) != 0) {
} rootNavigatorKey.currentState?.maybePop();
}, }
behavior: HitTestBehavior.translucent, },
child: InputModeTracker( behavior: HitTestBehavior.translucent,
child: MaterialApp( child: InputModeTracker(
title: t.app.title, child: MaterialApp(
debugShowCheckedModeBanner: false, title: t.app.title,
theme: themeProvider.lightTheme, debugShowCheckedModeBanner: false,
darkTheme: themeProvider.darkTheme, theme: themeProvider.lightTheme,
themeMode: themeProvider.materialThemeMode, darkTheme: themeProvider.darkTheme,
navigatorKey: rootNavigatorKey, themeMode: themeProvider.materialThemeMode,
navigatorObservers: [routeObserver, BackKeySuppressorObserver()], navigatorKey: rootNavigatorKey,
home: const OrientationAwareSetup(), navigatorObservers: [routeObserver, BackKeySuppressorObserver()],
builder: (context, child) => ScaffoldMessenger( home: const OrientationAwareSetup(),
key: rootScaffoldMessengerKey, builder: (context, child) => ScaffoldMessenger(
child: Scaffold(backgroundColor: Colors.transparent, body: child), key: rootScaffoldMessengerKey,
child: Scaffold(backgroundColor: Colors.transparent, body: child),
),
), ),
), ),
), ),
@@ -607,6 +623,61 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
} }
} }
/// Hydrates the [TraktAccountProvider] with the active Plex profile's session
/// and rebinds the scrobble + sync services whenever the user switches.
///
/// Lives high in the widget tree (above MaterialApp) so the listener survives
/// route changes.
class _TraktProfileBootstrap extends StatefulWidget {
final Widget child;
const _TraktProfileBootstrap({required this.child});
@override
State<_TraktProfileBootstrap> createState() => _TraktProfileBootstrapState();
}
class _TraktProfileBootstrapState extends State<_TraktProfileBootstrap> {
UserProfileProvider? _profile;
TraktAccountProvider? _trakt;
String? _lastUuid;
bool _hydrated = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final profile = context.read<UserProfileProvider>();
final trakt = context.read<TraktAccountProvider>();
if (!identical(_profile, profile)) {
_profile?.removeListener(_onProfileChanged);
_profile = profile;
_profile!.addListener(_onProfileChanged);
}
_trakt = trakt;
if (!_hydrated) {
_hydrated = true;
_onProfileChanged();
}
}
void _onProfileChanged() {
final uuid = _profile?.currentUser?.uuid;
if (uuid == _lastUuid) return;
_lastUuid = uuid;
_trakt?.onActiveProfileChanged(uuid);
}
@override
void dispose() {
_profile?.removeListener(_onProfileChanged);
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
class OrientationAwareSetup extends StatefulWidget { class OrientationAwareSetup extends StatefulWidget {
const OrientationAwareSetup({super.key}); const OrientationAwareSetup({super.key});
+68
View File
@@ -0,0 +1,68 @@
/// Response from `POST /oauth/device/code`.
///
/// The user enters [userCode] at [verificationUrl]; the app polls
/// `/oauth/device/token` with [deviceCode] every [interval] seconds until
/// [expiresIn] seconds elapse.
class TraktDeviceCode {
final String deviceCode;
final String userCode;
final String verificationUrl;
/// URL with the user code prefilled (e.g. `https://trakt.tv/activate/ABC12345`).
/// Useful for `url_launcher` so the user doesn't have to type the code.
final String? verificationUrlComplete;
/// Seconds until the device code expires (typically 600).
final int expiresIn;
/// Seconds the app should wait between polls (typically 5).
final int interval;
const TraktDeviceCode({
required this.deviceCode,
required this.userCode,
required this.verificationUrl,
required this.expiresIn,
required this.interval,
this.verificationUrlComplete,
});
factory TraktDeviceCode.fromJson(Map<String, dynamic> json) {
final verificationUrl = json['verification_url'] as String;
final userCode = json['user_code'] as String;
return TraktDeviceCode(
deviceCode: json['device_code'] as String,
userCode: userCode,
verificationUrl: verificationUrl,
verificationUrlComplete: '$verificationUrl/$userCode',
expiresIn: (json['expires_in'] as num).toInt(),
interval: (json['interval'] as num).toInt(),
);
}
}
/// Discriminated event emitted by `TraktAuthService.pollDeviceCode`.
sealed class TraktDevicePollEvent {
const TraktDevicePollEvent();
}
class TraktDevicePollPending extends TraktDevicePollEvent {
const TraktDevicePollPending();
}
class TraktDevicePollSlowDown extends TraktDevicePollEvent {
const TraktDevicePollSlowDown();
}
class TraktDevicePollDenied extends TraktDevicePollEvent {
const TraktDevicePollDenied();
}
class TraktDevicePollExpired extends TraktDevicePollEvent {
const TraktDevicePollExpired();
}
class TraktDevicePollSuccess extends TraktDevicePollEvent {
final Map<String, dynamic> tokenResponse;
const TraktDevicePollSuccess(this.tokenResponse);
}
+54
View File
@@ -0,0 +1,54 @@
/// External IDs for matching Plex items against Trakt's catalog.
///
/// Trakt prefers (in order): trakt > slug > imdb > tmdb > tvdb. Movies use
/// imdb/tmdb; episodes use the show's tvdb/tmdb/imdb plus season/episode index.
class TraktIds {
final int? trakt;
final String? slug;
final String? imdb;
final int? tmdb;
final int? tvdb;
const TraktIds({this.trakt, this.slug, this.imdb, this.tmdb, this.tvdb});
/// True when at least one external ID is set (i.e. usable for Trakt matching).
bool get hasAny => imdb != null || tmdb != null || tvdb != null || trakt != null || slug != null;
Map<String, dynamic> toJson() => {
if (trakt != null) 'trakt': trakt,
if (slug != null) 'slug': slug,
if (imdb != null) 'imdb': imdb,
if (tmdb != null) 'tmdb': tmdb,
if (tvdb != null) 'tvdb': tvdb,
};
factory TraktIds.fromJson(Map<String, dynamic> json) => TraktIds(
trakt: (json['trakt'] as num?)?.toInt(),
slug: json['slug'] as String?,
imdb: json['imdb'] as String?,
tmdb: (json['tmdb'] as num?)?.toInt(),
tvdb: (json['tvdb'] as num?)?.toInt(),
);
/// Parse a Plex GUID array (`[{id: 'imdb://tt123'}, {id: 'tmdb://456'}, ...]`)
/// into a [TraktIds] struct. The Plex `Guid` field is unparsed in
/// `PlexMetadata`; this parser reads the array returned by `?includeGuids=1`.
factory TraktIds.fromPlexGuids(List<dynamic> guids) {
String? imdb;
int? tmdb;
int? tvdb;
for (final g in guids) {
if (g is! Map) continue;
final id = g['id'];
if (id is! String) continue;
if (id.startsWith('imdb://')) {
imdb = id.substring(7);
} else if (id.startsWith('tmdb://')) {
tmdb = int.tryParse(id.substring(7));
} else if (id.startsWith('tvdb://')) {
tvdb = int.tryParse(id.substring(7));
}
}
return TraktIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb);
}
}
@@ -0,0 +1,110 @@
import 'trakt_ids.dart';
/// Body for `POST /scrobble/{start|pause|stop}` and `POST /sync/history`.
///
/// Either movie IDs or show IDs + season/episode are set, never both.
/// [progress] is the percent (0100) for scrobble; ignored for `/sync/history`.
class TraktScrobbleRequest {
final TraktIds? _movieIds;
final TraktIds? _showIds;
final int? _season;
final int? _episode;
final double? progress;
const TraktScrobbleRequest._({TraktIds? movieIds, TraktIds? showIds, int? season, int? episode, this.progress})
: _movieIds = movieIds,
_showIds = showIds,
_season = season,
_episode = episode;
bool get isMovie => _movieIds != null;
bool get isEpisode => _showIds != null;
TraktScrobbleRequest copyWith({double? progress}) => TraktScrobbleRequest._(
movieIds: _movieIds,
showIds: _showIds,
season: _season,
episode: _episode,
progress: progress ?? this.progress,
);
/// Build a movie scrobble payload.
factory TraktScrobbleRequest.movie({required TraktIds ids, double? progress}) {
return TraktScrobbleRequest._(movieIds: ids, progress: progress);
}
/// Build an episode scrobble payload using the show's external IDs plus
/// season/episode index. Trakt prefers this shape over an episode-IDs-only
/// payload because it works even when the episode itself isn't in Trakt's
/// catalog yet.
factory TraktScrobbleRequest.episode({
required TraktIds showIds,
required int season,
required int number,
double? progress,
}) {
return TraktScrobbleRequest._(showIds: showIds, season: season, episode: number, progress: progress);
}
Map<String, dynamic> toJson() => {
if (_movieIds != null) 'movie': {'ids': _movieIds.toJson()},
if (_showIds != null) 'show': {'ids': _showIds.toJson()},
if (_season != null && _episode != null) 'episode': {'season': _season, 'number': _episode},
'progress': ?progress,
};
/// Build a `POST /sync/history` body that adds this item to history.
///
/// Optional [watchedAt] (ISO-8601 UTC) lets the server attribute the play
/// to a specific point in time; defaults to "now" on Trakt's side.
Map<String, dynamic> toHistoryAddBody({String? watchedAt}) {
if (isMovie) {
return {
'movies': [
{'watched_at': ?watchedAt, 'ids': _movieIds!.toJson()},
],
};
}
return {
'shows': [
{
'ids': _showIds!.toJson(),
'seasons': [
{
'number': _season,
'episodes': [
{'watched_at': ?watchedAt, 'number': _episode},
],
},
],
},
],
};
}
/// Build a `POST /sync/history/remove` body that removes this item from history.
Map<String, dynamic> toHistoryRemoveBody() {
if (isMovie) {
return {
'movies': [
{'ids': _movieIds!.toJson()},
],
};
}
return {
'shows': [
{
'ids': _showIds!.toJson(),
'seasons': [
{
'number': _season,
'episodes': [
{'number': _episode},
],
},
],
},
],
};
}
}
+15
View File
@@ -0,0 +1,15 @@
/// Minimal Trakt user info parsed from `GET /users/settings`.
class TraktUser {
final String username;
final String? name;
const TraktUser({required this.username, this.name});
factory TraktUser.fromJson(Map<String, dynamic> json) {
final user = json['user'] as Map<String, dynamic>?;
if (user == null) {
throw const FormatException('Trakt /users/settings response missing "user" field');
}
return TraktUser(username: user['username'] as String, name: user['name'] as String?);
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/trakt/trakt_device_code.dart';
import '../services/trakt/trakt_account_store.dart';
import '../services/trakt/trakt_auth_service.dart';
import '../services/trakt/trakt_client.dart';
import '../services/trakt/trakt_scrobble_service.dart';
import '../services/trakt/trakt_session.dart';
import '../services/trakt/trakt_sync_service.dart';
import '../utils/app_logger.dart';
/// Owns the active Trakt session for the currently-selected Plex profile.
///
/// Single rebind seam: `onActiveProfileChanged` loads the new profile's
/// session from `TraktAccountStore` and pushes it to both
/// `TraktScrobbleService` and `TraktSyncService`.
class TraktAccountProvider extends ChangeNotifier {
final TraktAuthService _auth = TraktAuthService();
final TraktAccountStore _store = TraktAccountStore.instance;
TraktSession? _session;
String _activeUserUuid = '';
bool _isConnecting = false;
bool _cancelRequested = false;
TraktSession? get session => _session;
bool get isConnected => _session != null;
String? get username => _session?.username;
bool get isConnecting => _isConnecting;
/// Cancel an in-flight `connect()` (e.g. user dismissed the device-code
/// dialog). The poll loop checks this flag between iterations.
void cancelConnect() {
_cancelRequested = true;
}
/// Called whenever the active Plex profile changes (or on initial load).
Future<void> onActiveProfileChanged(String? newUserUuid) async {
_activeUserUuid = newUserUuid ?? '';
final loaded = await _store.load(_activeUserUuid);
_setSessionAndRebind(loaded);
}
/// Run the device-code OAuth flow.
///
/// [onCodeReady] is invoked once with the user code + verification URL so
/// the UI can render the dialog.
Future<bool> connect({required void Function(TraktDeviceCode code) onCodeReady}) async {
if (_isConnecting) return false;
_isConnecting = true;
_cancelRequested = false;
notifyListeners();
try {
final code = await _auth.createDeviceCode();
onCodeReady(code);
await for (final event in _auth.pollDeviceCode(code, shouldCancel: () => _cancelRequested)) {
if (event is TraktDevicePollSuccess) {
await _completeConnect(event.tokenResponse);
return true;
}
if (event is TraktDevicePollDenied || event is TraktDevicePollExpired) {
return false;
}
}
return false;
} catch (e) {
appLogger.w('Trakt connect failed', error: e);
return false;
} finally {
_isConnecting = false;
notifyListeners();
}
}
Future<void> _completeConnect(Map<String, dynamic> tokenResponse) async {
final raw = TraktSession.fromTokenResponse(tokenResponse);
// Best-effort fetch of the username for display.
TraktSession enriched = raw;
try {
final tmpClient = TraktClient(raw, onSessionInvalidated: () {});
final user = await tmpClient.getUserSettings();
enriched = raw.copyWith(username: user.username);
tmpClient.dispose();
} catch (e) {
appLogger.d('Trakt: getUserSettings failed (non-fatal)', error: e);
}
await _store.save(_activeUserUuid, enriched);
_setSessionAndRebind(enriched);
}
/// Revoke the access token and clear local state.
Future<void> disconnect() async {
final session = _session;
if (session != null) {
final client = TraktClient(session, onSessionInvalidated: () {});
await client.revoke();
client.dispose();
}
await _store.clear(_activeUserUuid);
_setSessionAndRebind(null);
}
void _setSessionAndRebind(TraktSession? session) {
_session = session;
TraktScrobbleService.instance.rebindToProfile(session, onSessionInvalidated: _handleSessionInvalidated);
TraktSyncService.instance.rebindToProfile(
_activeUserUuid,
session,
onSessionInvalidated: _handleSessionInvalidated,
);
notifyListeners();
}
/// Called by [TraktClient] when refresh fails permanently. Clears local state
/// so the UI shows "not connected" and the user can re-link.
void _handleSessionInvalidated() {
_store.clear(_activeUserUuid);
_setSessionAndRebind(null);
}
@override
void dispose() {
_auth.dispose();
super.dispose();
}
}
+38
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -18,6 +19,7 @@ import '../../services/file_picker_service.dart';
import '../../services/saf_storage_service.dart'; import '../../services/saf_storage_service.dart';
import '../../providers/settings_provider.dart'; import '../../providers/settings_provider.dart';
import '../../providers/theme_provider.dart'; import '../../providers/theme_provider.dart';
import '../../providers/trakt_account_provider.dart';
import '../../services/keyboard_shortcuts_service.dart'; import '../../services/keyboard_shortcuts_service.dart';
import '../../services/settings_service.dart' as settings; import '../../services/settings_service.dart' as settings;
import '../../services/update_service.dart'; import '../../services/update_service.dart';
@@ -30,6 +32,7 @@ import 'appearance_settings_screen.dart';
import 'keyboard_shortcuts_screen.dart'; import 'keyboard_shortcuts_screen.dart';
import 'logs_screen.dart'; import 'logs_screen.dart';
import 'playback_settings_screen.dart'; import 'playback_settings_screen.dart';
import 'trakt_settings_screen.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -45,6 +48,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
// Focus tracking keys // Focus tracking keys
static const _kAppearance = 'appearance'; static const _kAppearance = 'appearance';
static const _kPlayback = 'playback'; static const _kPlayback = 'playback';
static const _kTrakt = 'trakt';
static const _kDownloadLocation = 'download_location'; static const _kDownloadLocation = 'download_location';
static const _kDownloadOnWifiOnly = 'download_on_wifi_only'; static const _kDownloadOnWifiOnly = 'download_on_wifi_only';
static const _kAutoRemoveWatchedDownloads = 'auto_remove_watched_downloads'; static const _kAutoRemoveWatchedDownloads = 'auto_remove_watched_downloads';
@@ -151,6 +155,9 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
// --- Playback (navigation tile) --- // --- Playback (navigation tile) ---
_buildPlaybackTile(), _buildPlaybackTile(),
// --- Trakt.tv (navigation tile) ---
_buildTraktTile(),
// --- Downloads (inline) --- // --- Downloads (inline) ---
_buildDownloadsSection(), _buildDownloadsSection(),
@@ -215,6 +222,37 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
); );
} }
Widget _buildTraktTile() {
return Consumer<TraktAccountProvider>(
builder: (context, account, _) {
final connected = account.isConnected;
final username = account.username;
final iconColor = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface;
return ListTile(
focusNode: _focusTracker.get(_kTrakt),
leading: SvgPicture.asset(
'assets/trakt_circlemark.svg',
width: 24,
height: 24,
colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn),
),
title: Text(t.settings.trakt),
subtitle: Text(
connected && username != null ? t.trakt.connectedAs(username: username) : t.settings.traktDescription,
),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () {
if (account.isConnected) {
Navigator.push(context, MaterialPageRoute(builder: (context) => const TraktSettingsScreen()));
} else {
startTraktConnection(context);
}
},
);
},
);
}
Widget _buildDownloadsSection() { Widget _buildDownloadsSection() {
final storageService = DownloadStorageService.instance; final storageService = DownloadStorageService.instance;
final isCustom = storageService.isUsingCustomPath(); final isCustom = storageService.isUsingCustomPath();
@@ -0,0 +1,275 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../focus/input_mode_tracker.dart';
import '../../i18n/strings.g.dart';
import '../../models/trakt/trakt_device_code.dart';
import '../../providers/trakt_account_provider.dart';
import '../../services/settings_service.dart';
import '../../services/trakt/trakt_scrobble_service.dart';
import '../../services/trakt/trakt_sync_service.dart';
import '../../utils/app_logger.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/settings_section.dart';
/// Start the Trakt device-code OAuth flow from anywhere in the app.
///
/// Shows the code dialog + polls until the user completes authorization.
/// On pointer-driven platforms (desktop + mobile when dpad/keyboard mode
/// isn't active) this additionally auto-launches the browser at
/// `trakt.tv/activate` with the code prefilled — the dialog still shows
/// as a fallback/progress indicator.
Future<void> startTraktConnection(BuildContext context) async {
final account = context.read<TraktAccountProvider>();
if (account.isConnecting || account.isConnected) return;
final autoLaunchBrowser = !InputModeTracker.isKeyboardMode(context);
var dialogOpen = false;
final ok = await account.connect(
onCodeReady: (code) {
if (!context.mounted) return;
dialogOpen = true;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => _DeviceCodeDialog(code: code, account: account),
).whenComplete(() => dialogOpen = false);
if (autoLaunchBrowser) {
final url = code.verificationUrlComplete ?? code.verificationUrl;
unawaited(
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication).catchError((Object e) {
appLogger.d('Trakt: failed to auto-launch browser', error: e);
return false;
}),
);
}
},
);
if (!context.mounted) return;
// Close the dialog iff we showed one and it's still up (not already closed by
// the Cancel button). This is the ONLY site that dismisses the dialog —
// popping here and having the dialog self-pop would pop the screen behind.
if (dialogOpen) {
Navigator.of(context, rootNavigator: true).pop();
}
if (!ok && account.session == null) {
showAppSnackBar(context, t.trakt.connectFailed);
}
}
class TraktSettingsScreen extends StatefulWidget {
const TraktSettingsScreen({super.key});
@override
State<TraktSettingsScreen> createState() => _TraktSettingsScreenState();
}
class _TraktSettingsScreenState extends State<TraktSettingsScreen> {
SettingsService? _settings;
bool _scrobbleEnabled = true;
bool _watchedSyncEnabled = true;
bool _loaded = false;
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _loadSettings() async {
final s = await SettingsService.getInstance();
if (!mounted) return;
setState(() {
_settings = s;
_scrobbleEnabled = s.getEnableTraktScrobble();
_watchedSyncEnabled = s.getEnableTraktWatchedSync();
_loaded = true;
});
}
Future<void> _disconnect(TraktAccountProvider account) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.trakt.disconnectConfirm),
content: Text(t.trakt.disconnectConfirmBody),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.common.cancel)),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(t.common.disconnect, style: TextStyle(color: Theme.of(ctx).colorScheme.error)),
),
],
),
);
if (confirmed != true) return;
await account.disconnect();
// build()'s post-frame handler pops the screen once the provider rebuilds
// with isConnected == false — don't pop here too.
}
@override
Widget build(BuildContext context) {
if (!_loaded) {
return FocusedScrollScaffold(
title: Text(t.trakt.title),
slivers: const [SliverFillRemaining(child: Center(child: CircularProgressIndicator()))],
);
}
return Consumer<TraktAccountProvider>(
builder: (context, account, _) {
// Safety net: if we end up here while not connected (e.g. refresh failed
// in the background and cleared the session), bail out. The settings
// tile is the only supported entry point for the unauthed flow.
if (!account.isConnected) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).pop();
});
return FocusedScrollScaffold(
title: Text(t.trakt.title),
slivers: const [SliverFillRemaining(child: SizedBox.shrink())],
);
}
final username = account.username;
return FocusedScrollScaffold(
title: Text(t.trakt.title),
slivers: [
SliverList(
delegate: SliverChildListDelegate([
ListTile(
leading: const AppIcon(Symbols.account_circle_rounded, fill: 1),
title: Text(username != null ? t.trakt.connectedAs(username: username) : t.trakt.connected),
subtitle: Text(t.trakt.connected),
),
SettingsSectionHeader(t.settings.behavior),
SwitchListTile(
secondary: const AppIcon(Symbols.auto_timer, fill: 1),
title: Text(t.trakt.scrobble),
subtitle: Text(t.trakt.scrobbleDescription),
value: _scrobbleEnabled,
onChanged: (value) async {
setState(() => _scrobbleEnabled = value);
await _settings!.setEnableTraktScrobble(value);
await TraktScrobbleService.instance.setEnabled(value);
},
),
SwitchListTile(
secondary: const AppIcon(Symbols.check_circle_rounded, fill: 1),
title: Text(t.trakt.watchedSync),
subtitle: Text(t.trakt.watchedSyncDescription),
value: _watchedSyncEnabled,
onChanged: (value) async {
setState(() => _watchedSyncEnabled = value);
await _settings!.setEnableTraktWatchedSync(value);
await TraktSyncService.instance.setEnabled(value);
},
),
const Divider(height: 32),
ListTile(
leading: AppIcon(Symbols.link_off_rounded, fill: 1, color: Theme.of(context).colorScheme.error),
title: Text(t.common.disconnect, style: TextStyle(color: Theme.of(context).colorScheme.error)),
onTap: () => _disconnect(account),
),
const SizedBox(height: 24),
]),
),
],
);
},
);
}
}
class _DeviceCodeDialog extends StatefulWidget {
final TraktDeviceCode code;
final TraktAccountProvider account;
const _DeviceCodeDialog({required this.code, required this.account});
@override
State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState();
}
class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
Future<void> _open() async {
final url = widget.code.verificationUrlComplete ?? widget.code.verificationUrl;
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
Future<void> _copy() async {
await Clipboard.setData(ClipboardData(text: widget.code.userCode));
if (!mounted) return;
showAppSnackBar(context, t.trakt.codeCopied);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Text(t.trakt.deviceCodeTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t.trakt.deviceCodeBody(url: widget.code.verificationUrl), style: theme.textTheme.bodyMedium),
const SizedBox(height: 16),
Center(
child: InkWell(
onTap: _copy,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text(
widget.code.userCode,
style: theme.textTheme.displaySmall?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
letterSpacing: 4,
fontWeight: FontWeight.w600,
),
),
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
icon: const Icon(Icons.open_in_new),
label: Text(t.trakt.openTraktActivate),
onPressed: _open,
),
),
const SizedBox(height: 16),
Row(
children: [
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
const SizedBox(width: 12),
Expanded(child: Text(t.trakt.waitingForAuthorization, style: theme.textTheme.bodySmall)),
],
),
],
),
actions: [
TextButton(
onPressed: () {
widget.account.cancelConnect();
Navigator.of(context, rootNavigator: true).pop();
},
child: Text(t.common.cancel),
),
],
);
}
}
+16 -3
View File
@@ -36,6 +36,7 @@ import '../providers/companion_remote_provider.dart';
import '../services/companion_remote/companion_remote_receiver.dart'; import '../services/companion_remote/companion_remote_receiver.dart';
import '../services/fullscreen_state_manager.dart'; import '../services/fullscreen_state_manager.dart';
import '../services/discord_rpc_service.dart'; import '../services/discord_rpc_service.dart';
import '../services/trakt/trakt_scrobble_service.dart';
import '../services/episode_navigation_service.dart'; import '../services/episode_navigation_service.dart';
import '../services/media_controls_manager.dart'; import '../services/media_controls_manager.dart';
import '../services/playback_initialization_service.dart'; import '../services/playback_initialization_service.dart';
@@ -1061,6 +1062,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
speed: player!.state.rate, speed: player!.state.rate,
); );
DiscordRPCService.instance.updatePosition(position); DiscordRPCService.instance.updatePosition(position);
TraktScrobbleService.instance.updatePosition(position);
// Keep Trakt's known duration current — mpv only emits on the duration
// stream once per load, but this is cheap and avoids an extra listener.
TraktScrobbleService.instance.updateDuration(player!.state.duration);
}); });
// Listen to playback rate changes for Discord Rich Presence // Listen to playback rate changes for Discord Rich Presence
@@ -1075,6 +1080,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Start Discord Rich Presence for current media // Start Discord Rich Presence for current media
if (client != null) { if (client != null) {
DiscordRPCService.instance.startPlayback(_currentMetadata, client); DiscordRPCService.instance.startPlayback(_currentMetadata, client);
TraktScrobbleService.instance.startPlayback(_currentMetadata, client, isLive: widget.isLive);
} }
} }
@@ -2106,8 +2112,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_mediaControlsManager?.clear(); _mediaControlsManager?.clear();
_mediaControlsManager?.dispose(); _mediaControlsManager?.dispose();
// Clear Discord Rich Presence // Clear Discord Rich Presence + send Trakt stop scrobble
DiscordRPCService.instance.stopPlayback(); DiscordRPCService.instance.stopPlayback();
TraktScrobbleService.instance.stopPlayback();
// Clean up Windows display mode service // Clean up Windows display mode service
if (Platform.isWindows && _displayModeService != null) { if (Platform.isWindows && _displayModeService != null) {
@@ -2195,11 +2202,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Update OS media controls playback state // Update OS media controls playback state
_updateMediaControlsPlaybackState(); _updateMediaControlsPlaybackState();
// Update Discord Rich Presence // Update Discord Rich Presence + Trakt scrobble
if (isPlaying) { if (isPlaying) {
DiscordRPCService.instance.resumePlayback(); DiscordRPCService.instance.resumePlayback();
TraktScrobbleService.instance.resumePlayback();
} else { } else {
DiscordRPCService.instance.pausePlayback(); DiscordRPCService.instance.pausePlayback();
TraktScrobbleService.instance.pausePlayback();
} }
// Update auto-PiP readiness // Update auto-PiP readiness
@@ -2222,6 +2231,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_progressTracker?.sendProgress('paused'); _progressTracker?.sendProgress('paused');
_updateMediaControlsPlaybackState(); _updateMediaControlsPlaybackState();
DiscordRPCService.instance.pausePlayback(); DiscordRPCService.instance.pausePlayback();
TraktScrobbleService.instance.pausePlayback();
if (_autoPipEnabled) { if (_autoPipEnabled) {
_videoPIPManager?.updateAutoPipState(isPlaying: false); _videoPIPManager?.updateAutoPipState(isPlaying: false);
} }
@@ -2858,8 +2868,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Set flag to skip orientation restoration in dispose() // Set flag to skip orientation restoration in dispose()
_isReplacingWithVideo = true; _isReplacingWithVideo = true;
// Clear Discord Rich Presence before switching episodes // Clear Discord Rich Presence + Trakt scrobble before switching episodes
DiscordRPCService.instance.stopPlayback(); DiscordRPCService.instance.stopPlayback();
TraktScrobbleService.instance.stopPlayback();
// If player isn't available, navigate without preserving settings // If player isn't available, navigate without preserving settings
if (player == null) { if (player == null) {
@@ -2939,6 +2950,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_progressTracker?.dispose(); _progressTracker?.dispose();
_progressTracker = null; _progressTracker = null;
DiscordRPCService.instance.stopPlayback(); DiscordRPCService.instance.stopPlayback();
TraktScrobbleService.instance.stopPlayback();
_currentMetadata = episodeMetadata; _currentMetadata = episodeMetadata;
_activeRatingKey = episodeMetadata.ratingKey; _activeRatingKey = episodeMetadata.ratingKey;
@@ -3051,6 +3063,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (client != null) { if (client != null) {
DiscordRPCService.instance.startPlayback(episodeMetadata, client); DiscordRPCService.instance.startPlayback(episodeMetadata, client);
TraktScrobbleService.instance.startPlayback(episodeMetadata, client, isLive: widget.isLive);
} }
try { try {
+43 -3
View File
@@ -3,9 +3,11 @@ import 'dart:io' show Platform;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../database/app_database.dart'; import '../database/app_database.dart';
import '../models/plex_metadata.dart';
import '../providers/offline_mode_provider.dart'; import '../providers/offline_mode_provider.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/plex_cache_parser.dart'; import '../utils/plex_cache_parser.dart';
import '../utils/watch_state_notifier.dart';
import 'multi_server_manager.dart'; import 'multi_server_manager.dart';
import 'plex_api_cache.dart'; import 'plex_api_cache.dart';
import 'plex_client.dart'; import 'plex_client.dart';
@@ -395,13 +397,30 @@ class OfflineWatchSyncService extends ChangeNotifier {
/// Sync a single action to the server. /// Sync a single action to the server.
Future<void> _syncAction(PlexClient client, OfflineWatchProgressItem action) async { Future<void> _syncAction(PlexClient client, OfflineWatchProgressItem action) async {
// Fetch metadata so the WatchStateNotifier emission inside
// markAsWatched/markAsUnwatched carries enough context for downstream
// listeners (UI invalidation, Trakt sync). Best-effort: a missed metadata
// fetch only suppresses the event, not the Plex API call.
final emitsEvent =
action.actionType == 'watched' ||
action.actionType == 'unwatched' ||
(action.actionType == 'progress' && action.shouldMarkWatched);
PlexMetadata? metadata;
if (emitsEvent) {
try {
metadata = await client.getMetadataWithImages(action.ratingKey);
} catch (_) {
// Proceed without metadata — Plex still gets the call.
}
}
switch (action.actionType) { switch (action.actionType) {
case 'watched': case 'watched':
await client.markAsWatched(action.ratingKey); await client.markAsWatched(action.ratingKey, metadata: metadata);
break; break;
case 'unwatched': case 'unwatched':
await client.markAsUnwatched(action.ratingKey); await client.markAsUnwatched(action.ratingKey, metadata: metadata);
break; break;
case 'progress': case 'progress':
@@ -417,7 +436,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
// If progress exceeded threshold, also mark as watched // If progress exceeded threshold, also mark as watched
if (action.shouldMarkWatched) { if (action.shouldMarkWatched) {
await client.markAsWatched(action.ratingKey); await client.markAsWatched(action.ratingKey, metadata: metadata);
} }
break; break;
} }
@@ -444,6 +463,12 @@ class OfflineWatchSyncService extends ChangeNotifier {
final existingMeta = PlexCacheParser.extractFirstMetadata(existing); final existingMeta = PlexCacheParser.extractFirstMetadata(existing);
if (existingMeta != null) { if (existingMeta != null) {
// Detect a watched-status flip from another device so listeners
// (UI, Trakt sync) get the change. `viewCount` going from 0 to >0
// (or back) is the canonical "marked watched" signal.
final wasWatched = ((existingMeta['viewCount'] as num?)?.toInt() ?? 0) > 0;
final isWatched = (episode.viewCount ?? 0) > 0;
// Only update watch-state fields — preserves Chapter/Marker/Media/etc. // Only update watch-state fields — preserves Chapter/Marker/Media/etc.
existingMeta['viewCount'] = episode.viewCount; existingMeta['viewCount'] = episode.viewCount;
existingMeta['viewOffset'] = episode.viewOffset; existingMeta['viewOffset'] = episode.viewOffset;
@@ -455,6 +480,10 @@ class OfflineWatchSyncService extends ChangeNotifier {
}, },
}); });
if (wasWatched != isWatched) {
WatchStateNotifier().notifyWatched(metadata: episode, isNowWatched: isWatched);
}
// Repair corrupted entries (missing Media/Chapter from previous overwrites) // Repair corrupted entries (missing Media/Chapter from previous overwrites)
if (existingMeta['Media'] == null) { if (existingMeta['Media'] == null) {
try { try {
@@ -544,11 +573,22 @@ class OfflineWatchSyncService extends ChangeNotifier {
await _withOnlineClient(serverId, (client) async { await _withOnlineClient(serverId, (client) async {
for (final ratingKey in ratingKeys) { for (final ratingKey in ratingKeys) {
try { try {
// Snapshot prior viewCount before the cache-refreshing fetch so we
// can detect a watched-status change from another device.
final cacheKey = '/library/metadata/$ratingKey';
final priorCached = await PlexApiCache.instance.get(serverId, cacheKey);
final priorMeta = PlexCacheParser.extractFirstMetadata(priorCached);
final wasWatched = ((priorMeta?['viewCount'] as num?)?.toInt() ?? 0) > 0;
// getMetadataWithImages already caches the full API response // getMetadataWithImages already caches the full API response
// (with chapters/markers) via _fetchWithCacheFallback internally // (with chapters/markers) via _fetchWithCacheFallback internally
final metadata = await client.getMetadataWithImages(ratingKey); final metadata = await client.getMetadataWithImages(ratingKey);
if (metadata != null) { if (metadata != null) {
syncedCount++; syncedCount++;
final isWatched = (metadata.viewCount ?? 0) > 0;
if (wasWatched != isWatched) {
WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: isWatched);
}
} }
} catch (e) { } catch (e) {
appLogger.d('Failed to sync watch state for $ratingKey: $e'); appLogger.d('Failed to sync watch state for $ratingKey: $e');
+24
View File
@@ -1348,6 +1348,30 @@ class PlexClient {
} }
} }
/// Fetch the raw `Guid` array for a metadata item (`includeGuids=1`).
///
/// Returns the list of `{id: 'imdb://tt...'}` maps as Plex returns them, or
/// an empty list if the item has no external IDs / can't be fetched.
/// Used by the Trakt integration to match Plex items against Trakt's catalog.
Future<List<dynamic>> fetchExternalGuids(String ratingKey) async {
try {
final response = await _getWithFailover('/library/metadata/$ratingKey', queryParameters: {'includeGuids': 1});
final data = response.data;
if (data is! Map) return const [];
final container = data['MediaContainer'] as Map?;
final metadata = container?['Metadata'];
if (metadata is! List || metadata.isEmpty) return const [];
final first = metadata.first;
if (first is! Map) return const [];
final guids = first['Guid'];
if (guids is List) return guids;
return const [];
} catch (e) {
appLogger.d('fetchExternalGuids failed for $ratingKey', error: e);
return const [];
}
}
/// Mark media as watched /// Mark media as watched
/// ///
/// If [metadata] is provided, emits a [WatchStateEvent] for UI updates. /// If [metadata] is provided, emits a [WatchStateEvent] for UI updates.
+22
View File
@@ -92,6 +92,8 @@ class SettingsService extends BaseSharedPreferencesService {
static const String _keyMpvConfigPresets = 'mpv_config_presets'; static const String _keyMpvConfigPresets = 'mpv_config_presets';
static const String _keyMaxVolume = 'max_volume'; static const String _keyMaxVolume = 'max_volume';
static const String _keyEnableDiscordRPC = 'enable_discord_rpc'; static const String _keyEnableDiscordRPC = 'enable_discord_rpc';
static const String _keyEnableTraktScrobble = 'enable_trakt_scrobble';
static const String _keyEnableTraktWatchedSync = 'enable_trakt_watched_sync';
static const String _keyEnableCompanionRemoteServer = 'enable_companion_remote_server'; static const String _keyEnableCompanionRemoteServer = 'enable_companion_remote_server';
static const String _keyAutoPip = 'auto_pip'; static const String _keyAutoPip = 'auto_pip';
static const String _keyMatchContentFrameRate = 'match_content_frame_rate'; static const String _keyMatchContentFrameRate = 'match_content_frame_rate';
@@ -1124,6 +1126,24 @@ class SettingsService extends BaseSharedPreferencesService {
return prefs.getBool(_keyEnableDiscordRPC) ?? false; // Default disabled return prefs.getBool(_keyEnableDiscordRPC) ?? false; // Default disabled
} }
// Trakt Scrobbling (sends play/pause/stop during playback to Trakt)
Future<void> setEnableTraktScrobble(bool enabled) async {
await prefs.setBool(_keyEnableTraktScrobble, enabled);
}
bool getEnableTraktScrobble() {
return prefs.getBool(_keyEnableTraktScrobble) ?? true; // Default enabled when account is linked
}
// Trakt Watched-Status Sync (Plezy → Trakt only)
Future<void> setEnableTraktWatchedSync(bool enabled) async {
await prefs.setBool(_keyEnableTraktWatchedSync, enabled);
}
bool getEnableTraktWatchedSync() {
return prefs.getBool(_keyEnableTraktWatchedSync) ?? true; // Default enabled when account is linked
}
// Companion Remote Server // Companion Remote Server
Future<void> setEnableCompanionRemoteServer(bool enabled) async { Future<void> setEnableCompanionRemoteServer(bool enabled) async {
await prefs.setBool(_keyEnableCompanionRemoteServer, enabled); await prefs.setBool(_keyEnableCompanionRemoteServer, enabled);
@@ -1451,6 +1471,8 @@ class SettingsService extends BaseSharedPreferencesService {
prefs.remove(_keyMpvConfigText), prefs.remove(_keyMpvConfigText),
prefs.remove(_keyMpvConfigPresets), prefs.remove(_keyMpvConfigPresets),
prefs.remove(_keyEnableDiscordRPC), prefs.remove(_keyEnableDiscordRPC),
prefs.remove(_keyEnableTraktScrobble),
prefs.remove(_keyEnableTraktWatchedSync),
prefs.remove(_keyAutoPip), prefs.remove(_keyAutoPip),
prefs.remove(_keyMatchContentFrameRate), prefs.remove(_keyMatchContentFrameRate),
prefs.remove(_keyTunneledPlayback), prefs.remove(_keyTunneledPlayback),
@@ -0,0 +1,37 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'trakt_constants.dart';
import 'trakt_session.dart';
/// Per-Plex-profile persistence of Trakt OAuth sessions.
///
/// Each Plex Home user can link a different Trakt account. Pass an empty
/// `userUuid` to fall back to a single global slot (used before the user has
/// selected a profile).
class TraktAccountStore {
static const String _baseKey = 'trakt_session';
TraktAccountStore._();
static final TraktAccountStore instance = TraktAccountStore._();
Future<TraktSession?> load(String userUuid) async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(traktUserKey(userUuid, _baseKey));
if (raw == null) return null;
try {
return TraktSession.decode(raw);
} catch (_) {
return null;
}
}
Future<void> save(String userUuid, TraktSession session) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(traktUserKey(userUuid, _baseKey), session.encode());
}
Future<void> clear(String userUuid) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(traktUserKey(userUuid, _baseKey));
}
}
+128
View File
@@ -0,0 +1,128 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../models/trakt/trakt_device_code.dart';
import '../../utils/app_logger.dart';
import '../../utils/platform_http_client_stub.dart'
if (dart.library.io) '../../utils/platform_http_client_io.dart'
as platform;
import 'trakt_constants.dart';
/// Trakt OAuth Device Authorization Grant flow (RFC 8628).
///
/// Mirrors the shape of `PlexAuthService.pollPinUntilClaimed`. The user enters
/// a short code at `trakt.tv/activate` (in any browser); the app polls
/// `/oauth/device/token` until the user completes the flow.
class TraktAuthService {
final http.Client _http;
TraktAuthService({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient();
void dispose() => _http.close();
/// Request a device code. The returned [TraktDeviceCode.userCode] is what
/// the user must enter at [TraktDeviceCode.verificationUrl].
Future<TraktDeviceCode> createDeviceCode() async {
final uri = Uri.parse(TraktConstants.deviceCodeUrl);
final sw = Stopwatch()..start();
final res = await _http
.post(uri, headers: TraktConstants.headers(), body: json.encode({'client_id': TraktConstants.clientId}))
.timeout(const Duration(seconds: 15));
sw.stop();
appLogger.d('Trakt POST ${uri.path}${res.statusCode} (${sw.elapsedMilliseconds}ms)');
if (res.statusCode != 200) {
throw TraktAuthFlowException('Device code request failed: HTTP ${res.statusCode}: ${res.body}');
}
return TraktDeviceCode.fromJson(json.decode(res.body) as Map<String, dynamic>);
}
/// Poll the device-token endpoint until the user authorizes, denies, or the
/// code expires. Yields one event per poll attempt; the stream completes
/// after the first terminal event ([TraktDevicePollSuccess],
/// [TraktDevicePollDenied], [TraktDevicePollExpired]).
///
/// Pass [shouldCancel] to abort polling early (e.g. user dismissed the
/// dialog).
Stream<TraktDevicePollEvent> pollDeviceCode(TraktDeviceCode code, {bool Function()? shouldCancel}) async* {
var interval = Duration(seconds: code.interval);
final deadline = DateTime.now().add(Duration(seconds: code.expiresIn));
while (DateTime.now().isBefore(deadline)) {
if (shouldCancel != null && shouldCancel()) return;
await Future<void>.delayed(interval);
if (shouldCancel != null && shouldCancel()) return;
http.Response res;
final tokenUri = Uri.parse(TraktConstants.deviceTokenUrl);
final sw = Stopwatch()..start();
try {
res = await _http
.post(
tokenUri,
headers: TraktConstants.headers(),
body: json.encode({
'code': code.deviceCode,
'client_id': TraktConstants.clientId,
'client_secret': TraktConstants.clientSecret,
}),
)
.timeout(const Duration(seconds: 15));
sw.stop();
appLogger.d('Trakt POST ${tokenUri.path}${res.statusCode} (${sw.elapsedMilliseconds}ms)');
} catch (e) {
appLogger.d('Trakt device-code poll error (transient)', error: e);
yield const TraktDevicePollPending();
continue;
}
switch (res.statusCode) {
case 200:
final body = json.decode(res.body) as Map<String, dynamic>;
yield TraktDevicePollSuccess(body);
return;
case 400:
// Pending — keep polling
yield const TraktDevicePollPending();
break;
case 404:
// Code not found / expired
yield const TraktDevicePollExpired();
return;
case 409:
// Already used
yield const TraktDevicePollDenied();
return;
case 410:
yield const TraktDevicePollExpired();
return;
case 418:
// Denied
yield const TraktDevicePollDenied();
return;
case 429:
// Slow down — increase interval by 5s
interval += const Duration(seconds: 5);
yield const TraktDevicePollSlowDown();
break;
default:
appLogger.w('Trakt device-code unexpected HTTP ${res.statusCode}: ${res.body}');
yield const TraktDevicePollPending();
}
}
yield const TraktDevicePollExpired();
}
}
class TraktAuthFlowException implements Exception {
final String message;
const TraktAuthFlowException(this.message);
@override
String toString() => 'TraktAuthFlowException: $message';
}
+196
View File
@@ -0,0 +1,196 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../models/trakt/trakt_scrobble_request.dart';
import '../../models/trakt/trakt_user.dart';
import '../../utils/app_logger.dart';
import '../../utils/platform_http_client_stub.dart'
if (dart.library.io) '../../utils/platform_http_client_io.dart'
as platform;
import 'trakt_constants.dart';
import 'trakt_session.dart';
/// HTTP wrapper for the Trakt REST API.
///
/// Holds a [TraktSession] (refreshed in place on 401). Concurrent 401s are
/// coalesced via [_refreshLock] so we only hit `/oauth/token` once per refresh.
class TraktClient {
static const Duration _requestTimeout = Duration(seconds: 20);
static const Duration _refreshTimeout = Duration(seconds: 15);
static const Duration _revokeTimeout = Duration(seconds: 10);
static const Set<int> _scrobbleAllowedStatuses = {200, 201, 409};
TraktSession _session;
final http.Client _http;
/// Fired when refresh fails permanently (e.g. `invalid_grant`). The provider
/// uses this to clear the stored session and notify the UI.
final void Function() onSessionInvalidated;
Future<TraktSession>? _refreshLock;
TraktClient(TraktSession session, {required this.onSessionInvalidated, http.Client? httpClient})
: _session = session,
_http = httpClient ?? platform.createPlatformClient();
TraktSession get session => _session;
void dispose() => _http.close();
Future<TraktUser> getUserSettings() async {
final res = await _request('GET', '/users/settings');
return TraktUser.fromJson(res as Map<String, dynamic>);
}
Future<void> scrobbleStart(TraktScrobbleRequest body) =>
_request('POST', '/scrobble/start', body: body.toJson(), allowStatuses: _scrobbleAllowedStatuses);
Future<void> scrobblePause(TraktScrobbleRequest body) =>
_request('POST', '/scrobble/pause', body: body.toJson(), allowStatuses: _scrobbleAllowedStatuses);
Future<void> scrobbleStop(TraktScrobbleRequest body) =>
_request('POST', '/scrobble/stop', body: body.toJson(), allowStatuses: _scrobbleAllowedStatuses);
Future<void> addToHistory(TraktScrobbleRequest item, {String? watchedAt}) =>
_request('POST', '/sync/history', body: item.toHistoryAddBody(watchedAt: watchedAt));
Future<void> removeFromHistory(TraktScrobbleRequest item) =>
_request('POST', '/sync/history/remove', body: item.toHistoryRemoveBody());
/// Refresh the access token. Coalesces concurrent calls via [_refreshLock] so
/// duplicate POSTs don't race when multiple in-flight requests hit 401.
Future<TraktSession> refresh() {
final existing = _refreshLock;
if (existing != null) return existing;
final lock = _doRefresh();
_refreshLock = lock;
return lock.whenComplete(() => _refreshLock = null);
}
Future<TraktSession> _doRefresh() async {
appLogger.d('Trakt: refreshing access token');
final res = await _http
.post(
Uri.parse(TraktConstants.tokenUrl),
headers: TraktConstants.headers(),
body: json.encode({
'refresh_token': _session.refreshToken,
'client_id': TraktConstants.clientId,
'client_secret': TraktConstants.clientSecret,
'grant_type': 'refresh_token',
}),
)
.timeout(_refreshTimeout);
if (res.statusCode == 200) {
final body = json.decode(res.body) as Map<String, dynamic>;
_session = TraktSession.fromTokenResponse(body).copyWith(username: _session.username);
return _session;
}
appLogger.w('Trakt: refresh failed (${res.statusCode}), session invalidated');
onSessionInvalidated();
throw TraktAuthException('Refresh failed: HTTP ${res.statusCode}');
}
/// Revoke the access token at Trakt. Best-effort; swallows network errors.
Future<void> revoke() async {
try {
await _http
.post(
Uri.parse(TraktConstants.revokeUrl),
headers: TraktConstants.headers(),
body: json.encode({
'token': _session.accessToken,
'client_id': TraktConstants.clientId,
'client_secret': TraktConstants.clientSecret,
}),
)
.timeout(_revokeTimeout);
} catch (e) {
appLogger.d('Trakt: revoke failed (non-fatal)', error: e);
}
}
/// Send an authenticated request, refreshing on 401 and retrying once.
Future<dynamic> _request(
String method,
String path, {
Map<String, dynamic>? body,
Set<int> allowStatuses = const {200, 201, 204},
}) async {
if (_session.needsRefresh) {
try {
await refresh();
} catch (_) {
// Fall through; the request will hit 401 naturally and retry.
}
}
var res = await _send(method, path, body: body);
if (res.statusCode == 401) {
await refresh();
res = await _send(method, path, body: body);
}
if (allowStatuses.contains(res.statusCode)) {
if (res.body.isEmpty) return null;
try {
return json.decode(res.body);
} catch (_) {
return null;
}
}
if (res.statusCode == 429) {
throw TraktRateLimitException(retryAfterSeconds: int.tryParse(res.headers['retry-after'] ?? ''));
}
throw TraktApiException(statusCode: res.statusCode, body: res.body);
}
Future<http.Response> _send(String method, String path, {Map<String, dynamic>? body}) async {
final uri = Uri.parse('${TraktConstants.apiBase}$path');
final headers = TraktConstants.headers(accessToken: _session.accessToken);
final encoded = body == null ? null : json.encode(body);
final sw = Stopwatch()..start();
final res = await switch (method) {
'GET' => _http.get(uri, headers: headers),
'POST' => _http.post(uri, headers: headers, body: encoded),
'PUT' => _http.put(uri, headers: headers, body: encoded),
'DELETE' => _http.delete(uri, headers: headers),
_ => throw ArgumentError('Unsupported HTTP method: $method'),
}.timeout(_requestTimeout);
sw.stop();
appLogger.d('Trakt $method ${uri.path}${res.statusCode} (${sw.elapsedMilliseconds}ms)');
return res;
}
}
class TraktApiException implements Exception {
final int statusCode;
final String body;
const TraktApiException({required this.statusCode, required this.body});
@override
String toString() => 'TraktApiException(HTTP $statusCode): $body';
}
class TraktRateLimitException implements Exception {
final int? retryAfterSeconds;
const TraktRateLimitException({this.retryAfterSeconds});
@override
String toString() => 'TraktRateLimitException(retry-after: $retryAfterSeconds s)';
}
class TraktAuthException implements Exception {
final String message;
const TraktAuthException(this.message);
@override
String toString() => 'TraktAuthException: $message';
}
+70
View File
@@ -0,0 +1,70 @@
/// Bundled Trakt API credentials and base URLs.
///
/// The client_id/client_secret are extractable from the binary; this is
/// the standard pattern for native Trakt apps and is acceptable given the
/// same threat model as the Plex token already in `SharedPreferences`.
class TraktConstants {
TraktConstants._();
// Registered Trakt app credentials. Same threat model as the Plex token in
// SharedPreferences — extractable from the binary, but acceptable for a
// native client app. To rotate, update the registration at
// https://trakt.tv/oauth/applications.
static const String clientId = '9861e686e95c13409dd321736f903973cb9b8e5c6abd0634bec8962f52ea30f4';
static const String clientSecret = 'acfa17b9d77fabd7e51175b7da6631aea69423530a6d49b3b3c38cd107cbd207';
static const String apiBase = 'https://api.trakt.tv';
static const String apiVersion = '2';
// OAuth endpoints
static const String deviceCodeUrl = '$apiBase/oauth/device/code';
static const String deviceTokenUrl = '$apiBase/oauth/device/token';
static const String tokenUrl = '$apiBase/oauth/token';
static const String revokeUrl = '$apiBase/oauth/revoke';
// Scopes — Trakt's OAuth doesn't use granular scopes; "public" is the only value
static const String scope = 'public';
/// Headers required on every Trakt API call.
static Map<String, String> headers({String? accessToken}) {
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
'trakt-api-version': apiVersion,
'trakt-api-key': clientId,
if (accessToken != null) 'Authorization': 'Bearer $accessToken',
};
}
}
/// Build a `SharedPreferences` key scoped to the given Plex profile UUID.
/// Mirrors the `_userPrefix` pattern in [StorageService] so each profile gets
/// its own Trakt session and sync queue.
String traktUserKey(String userUuid, String baseKey) => userUuid.isEmpty ? baseKey : 'user_${userUuid}_$baseKey';
/// Scrobble lifecycle state sent to Trakt's `/scrobble/{name}` endpoints.
enum TraktScrobbleState { start, pause, stop }
/// Direction of a watched-status sync push.
enum TraktSyncOp {
add,
remove;
static TraktSyncOp fromName(String name) =>
values.firstWhere((v) => v.name == name, orElse: () => throw ArgumentError('Unknown TraktSyncOp: $name'));
}
/// Trakt-relevant Plex media types.
enum TraktMediaKind {
movie,
episode;
static TraktMediaKind? tryFromPlexType(String type) => switch (type) {
'movie' => movie,
'episode' => episode,
_ => null,
};
static TraktMediaKind fromName(String name) =>
values.firstWhere((v) => v.name == name, orElse: () => throw ArgumentError('Unknown TraktMediaKind: $name'));
}
@@ -0,0 +1,51 @@
import '../../models/plex_metadata.dart';
import '../../models/trakt/trakt_ids.dart';
import '../plex_client.dart';
/// Resolves Plex rating keys to external IDs (imdb/tmdb/tvdb) for Trakt
/// matching. Caches per ratingKey for the duration of a play session.
///
/// Trakt prefers a show-IDs-plus-season/episode shape for episode scrobbles
/// rather than episode-IDs alone, so episode resolution requires a separate
/// fetch on `grandparentRatingKey` to get the show's GUIDs.
class TraktGuidResolver {
final PlexClient _client;
/// Cache of `ratingKey → TraktIds`. Holds both movies and shows.
final Map<String, TraktIds> _cache = {};
TraktGuidResolver(this._client);
/// Resolve external IDs for a movie rating key.
Future<TraktIds> resolveForMovie(String ratingKey) async {
final cached = _cache[ratingKey];
if (cached != null) return cached;
final guids = await _client.fetchExternalGuids(ratingKey);
final ids = TraktIds.fromPlexGuids(guids);
_cache[ratingKey] = ids;
return ids;
}
/// Resolve external IDs for an episode. Returns the show's GUIDs (Trakt's
/// preferred shape); season/episode index live on the [PlexMetadata] itself.
///
/// Returns null if the episode is missing `grandparentRatingKey` (in which
/// case Trakt has no way to match the show).
Future<TraktIds?> resolveShowForEpisode(PlexMetadata episode) async {
final showRatingKey = episode.grandparentRatingKey;
if (showRatingKey == null || showRatingKey.isEmpty) return null;
final cached = _cache[showRatingKey];
if (cached != null) return cached;
final guids = await _client.fetchExternalGuids(showRatingKey);
final ids = TraktIds.fromPlexGuids(guids);
_cache[showRatingKey] = ids;
return ids;
}
/// Drop all cached IDs. Called on stopPlayback / profile switch so a
/// re-matched item gets fresh GUIDs the next time around.
void clearCache() => _cache.clear();
}
@@ -0,0 +1,228 @@
import 'dart:async';
import '../../models/plex_metadata.dart';
import '../../models/trakt/trakt_scrobble_request.dart';
import '../../utils/app_logger.dart';
import '../plex_client.dart';
import '../settings_service.dart';
import 'trakt_client.dart';
import 'trakt_constants.dart';
import 'trakt_guid_resolver.dart';
import 'trakt_session.dart';
/// Real-time scrobble service for Trakt.
///
/// Mirrors the lifecycle shape of `DiscordRPCService`: invoked from
/// `video_player_screen.dart` at the same call sites (start/pause/resume/stop,
/// position updates).
class TraktScrobbleService {
/// Drop a duplicate state transition within this window — mpv emits multiple
/// playing-state events on seek.
static const Duration _duplicateStateDebounce = Duration(seconds: 1);
/// Drop a `start` re-send within this window of the previous start.
/// Trakt enforces "max one scrobble per 15 min per item"; this avoids
/// spamming 409s during rapid pause/play cycles.
static const Duration _startResendThrottle = Duration(seconds: 30);
/// Position-jump magnitude that counts as a seek (matches DiscordRPCService).
static const Duration _seekDetectionThreshold = Duration(seconds: 5);
/// Max one seek-checkpoint per this window — slider drag fires many position
/// updates per second; we only want to ship one to Trakt.
static const Duration _seekCheckpointThrottle = Duration(seconds: 5);
static TraktScrobbleService? _instance;
static TraktScrobbleService get instance => _instance ??= TraktScrobbleService._();
TraktScrobbleService._();
bool _isInitialized = false;
bool _isEnabled = false;
TraktClient? _client;
TraktGuidResolver? _resolver;
TraktScrobbleRequest? _currentBody;
Duration _currentPosition = Duration.zero;
Duration _currentDuration = Duration.zero;
TraktScrobbleState? _lastSentState;
DateTime? _lastSentAt;
DateTime? _lastSeekCheckpointAt;
Future<void> initialize() async {
if (_isInitialized) return;
_isInitialized = true;
final settings = await SettingsService.getInstance();
_isEnabled = settings.getEnableTraktScrobble();
}
Future<void> setEnabled(bool enabled) async {
_isEnabled = enabled;
if (!enabled) cancelInFlight();
}
/// Switch to a different account. Cancels any in-flight scrobble for the
/// previous account so we don't send a stop event to the wrong user.
void rebindToProfile(TraktSession? session, {required void Function() onSessionInvalidated}) {
_client?.dispose();
_client = session != null ? TraktClient(session, onSessionInvalidated: onSessionInvalidated) : null;
cancelInFlight();
}
/// Drop the current scrobble state without sending a stop. Called on profile
/// switch and when the service is disabled mid-playback.
void cancelInFlight() {
_currentBody = null;
_lastSentState = null;
_lastSentAt = null;
_resolver?.clearCache();
_resolver = null;
_currentPosition = Duration.zero;
_currentDuration = Duration.zero;
}
bool get _canScrobble => _isEnabled && _client != null;
Future<void> startPlayback(PlexMetadata metadata, PlexClient plexClient, {bool isLive = false}) async {
if (!_canScrobble) return;
if (isLive) return;
final type = metadata.mediaType;
if (type != PlexMediaType.movie && type != PlexMediaType.episode) return;
// Seed with the resume offset so the first real position update doesn't
// look like a seek when resuming mid-item.
_currentPosition = metadata.viewOffset != null ? Duration(milliseconds: metadata.viewOffset!) : Duration.zero;
_currentDuration = metadata.duration != null ? Duration(milliseconds: metadata.duration!) : Duration.zero;
_lastSeekCheckpointAt = null;
_resolver = TraktGuidResolver(plexClient);
final body = await _buildBody(metadata);
if (body == null) {
appLogger.d('Trakt: skipping scrobble — no usable IDs for ${metadata.ratingKey}');
cancelInFlight();
return;
}
_currentBody = body;
await _send(TraktScrobbleState.start, progress: _progressPercent());
}
void updatePosition(Duration position) {
final previous = _currentPosition;
_currentPosition = position;
// Trakt has no seek event — instead, official apps send pause+start with
// the new progress to checkpoint. Without this, the "resume on another
// device" feature is stuck on the pre-seek position until the next
// pause/stop.
if (_currentBody == null) return;
if (_lastSentState != TraktScrobbleState.start) return;
if ((position - previous).abs() <= _seekDetectionThreshold) return;
final now = DateTime.now();
if (_lastSeekCheckpointAt != null && now.difference(_lastSeekCheckpointAt!) < _seekCheckpointThrottle) return;
_lastSeekCheckpointAt = now;
unawaited(_sendSeekCheckpoint());
}
void updateDuration(Duration duration) {
if (duration.inMilliseconds == 0) return;
if (duration == _currentDuration) return;
_currentDuration = duration;
}
Future<void> pausePlayback() async {
if (_currentBody == null) return;
await _send(TraktScrobbleState.pause, progress: _progressPercent());
}
Future<void> resumePlayback() async {
if (_currentBody == null) return;
await _send(TraktScrobbleState.start, progress: _progressPercent());
}
Future<void> stopPlayback() async {
if (_currentBody == null) return;
await _send(TraktScrobbleState.stop, progress: _progressPercent());
cancelInFlight();
}
Future<TraktScrobbleRequest?> _buildBody(PlexMetadata metadata) async {
final resolver = _resolver;
if (resolver == null) return null;
if (metadata.mediaType == PlexMediaType.movie) {
final ids = await resolver.resolveForMovie(metadata.ratingKey);
if (!ids.hasAny) return null;
return TraktScrobbleRequest.movie(ids: ids);
}
final season = metadata.parentIndex;
final number = metadata.index;
if (season == null || number == null) return null;
final showIds = await resolver.resolveShowForEpisode(metadata);
if (showIds == null || !showIds.hasAny) return null;
return TraktScrobbleRequest.episode(showIds: showIds, season: season, number: number);
}
double _progressPercent() {
if (_currentDuration.inMilliseconds == 0) return 0;
final pct = (_currentPosition.inMilliseconds / _currentDuration.inMilliseconds) * 100;
return pct.clamp(0.0, 100.0);
}
/// Send pause→start to Trakt so the playback-progress endpoint reflects the
/// new position. Bypasses [_send]'s state throttle (this is a checkpoint,
/// not a state change) but updates the throttle bookkeeping so a regular
/// `start` immediately after won't double-fire.
Future<void> _sendSeekCheckpoint() async {
final client = _client;
final body = _currentBody;
if (client == null || body == null) return;
final progress = _progressPercent();
final scrobble = body.copyWith(progress: progress);
try {
await client.scrobblePause(scrobble);
await client.scrobbleStart(scrobble);
_lastSentState = TraktScrobbleState.start;
_lastSentAt = DateTime.now();
appLogger.d('Trakt: seek checkpoint @ ${progress.toStringAsFixed(1)}%');
} catch (e) {
appLogger.d('Trakt: seek checkpoint failed', error: e);
}
}
Future<void> _send(TraktScrobbleState state, {required double progress}) async {
final client = _client;
final body = _currentBody;
if (client == null || body == null) return;
final now = DateTime.now();
if (_lastSentState == state && _lastSentAt != null) {
final elapsed = now.difference(_lastSentAt!);
if (elapsed < _duplicateStateDebounce) return;
if (state == TraktScrobbleState.start && elapsed < _startResendThrottle) return;
}
_lastSentState = state;
_lastSentAt = now;
final scrobble = body.copyWith(progress: progress);
try {
switch (state) {
case TraktScrobbleState.start:
await client.scrobbleStart(scrobble);
case TraktScrobbleState.pause:
await client.scrobblePause(scrobble);
case TraktScrobbleState.stop:
await client.scrobbleStop(scrobble);
}
appLogger.d('Trakt: scrobble ${state.name} @ ${progress.toStringAsFixed(1)}%');
} catch (e) {
// Never let scrobble errors block playback.
appLogger.d('Trakt: scrobble ${state.name} failed', error: e);
}
}
}
+92
View File
@@ -0,0 +1,92 @@
import 'dart:convert';
/// Immutable Trakt OAuth session.
///
/// Persisted as a JSON blob under `user_{uuid}_trakt_session` in
/// `SharedPreferences`. Tokens are stored in plaintext, matching the security
/// model of the existing Plex token.
class TraktSession {
final String accessToken;
final String refreshToken;
/// Epoch seconds at which the access token expires.
final int expiresAt;
/// Trakt username (`@handle`), populated after `getUserSettings`.
final String? username;
final String scope;
/// Epoch seconds at which the session was first created.
final int createdAt;
const TraktSession({
required this.accessToken,
required this.refreshToken,
required this.expiresAt,
required this.scope,
required this.createdAt,
this.username,
});
/// Whether the access token has already expired.
bool get isExpired => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt;
/// Whether the access token will expire in the next 5 minutes.
bool get needsRefresh => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt - 300;
TraktSession copyWith({
String? accessToken,
String? refreshToken,
int? expiresAt,
String? username,
String? scope,
int? createdAt,
}) {
return TraktSession(
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
expiresAt: expiresAt ?? this.expiresAt,
username: username ?? this.username,
scope: scope ?? this.scope,
createdAt: createdAt ?? this.createdAt,
);
}
Map<String, dynamic> toJson() => {
'access_token': accessToken,
'refresh_token': refreshToken,
'expires_at': expiresAt,
'username': username,
'scope': scope,
'created_at': createdAt,
};
factory TraktSession.fromJson(Map<String, dynamic> json) {
return TraktSession(
accessToken: json['access_token'] as String,
refreshToken: json['refresh_token'] as String,
expiresAt: (json['expires_at'] as num).toInt(),
username: json['username'] as String?,
scope: json['scope'] as String? ?? 'public',
createdAt: (json['created_at'] as num).toInt(),
);
}
/// Build a session from Trakt's `/oauth/token` or `/oauth/device/token` response,
/// which uses `expires_in` (relative seconds) rather than `expires_at`.
factory TraktSession.fromTokenResponse(Map<String, dynamic> json) {
final createdAt = (json['created_at'] as num?)?.toInt() ?? DateTime.now().millisecondsSinceEpoch ~/ 1000;
final expiresIn = (json['expires_in'] as num).toInt();
return TraktSession(
accessToken: json['access_token'] as String,
refreshToken: json['refresh_token'] as String,
expiresAt: createdAt + expiresIn,
scope: json['scope'] as String? ?? 'public',
createdAt: createdAt,
);
}
String encode() => json.encode(toJson());
static TraktSession decode(String raw) => TraktSession.fromJson(json.decode(raw) as Map<String, dynamic>);
}
+107
View File
@@ -0,0 +1,107 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../../models/trakt/trakt_ids.dart';
import 'trakt_constants.dart';
/// One pending watched/unwatched push waiting to be drained to Trakt.
class TraktSyncQueueItem {
final TraktSyncOp op;
final String ratingKey;
final String serverId;
final TraktMediaKind kind;
final TraktIds ids;
/// For episodes only.
final int? season;
final int? number;
final String watchedAtIso;
final int attempts;
const TraktSyncQueueItem({
required this.op,
required this.ratingKey,
required this.serverId,
required this.kind,
required this.ids,
required this.watchedAtIso,
this.season,
this.number,
this.attempts = 0,
});
TraktSyncQueueItem incrementAttempts() => TraktSyncQueueItem(
op: op,
ratingKey: ratingKey,
serverId: serverId,
kind: kind,
ids: ids,
watchedAtIso: watchedAtIso,
season: season,
number: number,
attempts: attempts + 1,
);
Map<String, dynamic> toJson() => {
'op': op.name,
'ratingKey': ratingKey,
'serverId': serverId,
'kind': kind.name,
'ids': ids.toJson(),
if (season != null) 'season': season,
if (number != null) 'number': number,
'watchedAtIso': watchedAtIso,
'attempts': attempts,
};
factory TraktSyncQueueItem.fromJson(Map<String, dynamic> json) => TraktSyncQueueItem(
op: TraktSyncOp.fromName(json['op'] as String),
ratingKey: json['ratingKey'] as String,
serverId: json['serverId'] as String,
kind: TraktMediaKind.fromName(json['kind'] as String),
ids: TraktIds.fromJson(json['ids'] as Map<String, dynamic>),
season: (json['season'] as num?)?.toInt(),
number: (json['number'] as num?)?.toInt(),
watchedAtIso: json['watchedAtIso'] as String,
attempts: (json['attempts'] as num?)?.toInt() ?? 0,
);
}
/// Per-profile persisted retry queue for failed Trakt history pushes.
///
/// Cap at [maxAttempts] before dropping permanently — matches
/// `OfflineWatchSyncService.maxSyncAttempts`.
class TraktSyncQueue {
static const String _baseKey = 'trakt_sync_queue';
static const int maxAttempts = 5;
Future<List<TraktSyncQueueItem>> load(String userUuid) async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(traktUserKey(userUuid, _baseKey));
if (raw == null) return [];
try {
final list = json.decode(raw) as List<dynamic>;
return list.map((e) => TraktSyncQueueItem.fromJson(e as Map<String, dynamic>)).toList();
} catch (_) {
return [];
}
}
Future<void> save(String userUuid, List<TraktSyncQueueItem> items) async {
final prefs = await SharedPreferences.getInstance();
final key = traktUserKey(userUuid, _baseKey);
if (items.isEmpty) {
await prefs.remove(key);
} else {
await prefs.setString(key, json.encode(items.map((e) => e.toJson()).toList()));
}
}
Future<void> add(String userUuid, TraktSyncQueueItem item) async {
final items = await load(userUuid);
items.add(item);
await save(userUuid, items);
}
}
+231
View File
@@ -0,0 +1,231 @@
import 'dart:async';
import '../../models/trakt/trakt_ids.dart';
import '../../models/trakt/trakt_scrobble_request.dart';
import '../../utils/app_logger.dart';
import '../../utils/watch_state_notifier.dart';
import '../multi_server_manager.dart';
import '../settings_service.dart';
import 'trakt_client.dart';
import 'trakt_constants.dart';
import 'trakt_guid_resolver.dart';
import 'trakt_session.dart';
import 'trakt_sync_queue.dart';
/// One-way push of watched/unwatched events from Plezy to Trakt.
///
/// Subscribes to `WatchStateNotifier` and filters to `{watched, unwatched}`
/// events on movies/episodes. Failures are queued via `TraktSyncQueue` and
/// drained on app foreground, network restore, and at startup.
class TraktSyncService {
/// Inter-request delay during queue drain to stay under Trakt's
/// 1000 req / 5 min rate limit.
static const Duration _queueRequestSpacing = Duration(milliseconds: 50);
static TraktSyncService? _instance;
static TraktSyncService get instance => _instance ??= TraktSyncService._();
TraktSyncService._();
bool _isInitialized = false;
bool _isEnabled = false;
String _activeUserUuid = '';
TraktClient? _client;
MultiServerManager? _serverManager;
StreamSubscription<WatchStateEvent>? _subscription;
final TraktSyncQueue _queue = TraktSyncQueue();
/// One resolver per Plex server, kept alive across events so the per-rating-
/// key GUID cache survives a binge-watch session.
final Map<String, TraktGuidResolver> _resolvers = {};
bool _isFlushing = false;
Future<void> initialize({required MultiServerManager serverManager}) async {
if (_isInitialized) return;
_isInitialized = true;
_serverManager = serverManager;
final settings = await SettingsService.getInstance();
_isEnabled = settings.getEnableTraktWatchedSync();
_subscription = WatchStateNotifier().stream.listen(_onWatchStateEvent);
}
Future<void> setEnabled(bool enabled) async {
_isEnabled = enabled;
}
/// Switch to a different account. Drops cached resolvers (their PlexClients
/// are tied to the previous user's tokens) and rebinds the queue.
void rebindToProfile(String userUuid, TraktSession? session, {required void Function() onSessionInvalidated}) {
_client?.dispose();
_client = session != null ? TraktClient(session, onSessionInvalidated: onSessionInvalidated) : null;
_activeUserUuid = userUuid;
_resolvers.clear();
if (_client != null) {
unawaited(flushQueue());
}
}
Future<void> dispose() async {
await _subscription?.cancel();
_subscription = null;
_client?.dispose();
_client = null;
_resolvers.clear();
}
bool get _canPush => _isEnabled && _client != null;
TraktGuidResolver? _resolverFor(String serverId) {
final cached = _resolvers[serverId];
if (cached != null) return cached;
final plexClient = _serverManager?.getClient(serverId);
if (plexClient == null) return null;
final resolver = TraktGuidResolver(plexClient);
_resolvers[serverId] = resolver;
return resolver;
}
Future<void> _onWatchStateEvent(WatchStateEvent event) async {
if (!_canPush) return;
if (event.changeType == WatchStateChangeType.progressUpdate) return;
final kind = TraktMediaKind.tryFromPlexType(event.mediaType);
if (kind == null) return;
final op = event.changeType == WatchStateChangeType.watched ? TraktSyncOp.add : TraktSyncOp.remove;
await _push(
op: op,
ratingKey: event.ratingKey,
serverId: event.serverId,
kind: kind,
watchedAtIso: DateTime.now().toUtc().toIso8601String(),
);
}
Future<void> _push({
required TraktSyncOp op,
required String ratingKey,
required String serverId,
required TraktMediaKind kind,
required String watchedAtIso,
}) async {
final resolver = _resolverFor(serverId);
if (resolver == null) {
appLogger.d('Trakt sync: no PlexClient for server $serverId, skipping');
return;
}
TraktIds? ids;
int? season;
int? number;
if (kind == TraktMediaKind.movie) {
ids = await resolver.resolveForMovie(ratingKey);
} else {
// Episode — need show IDs + season/episode index. The WatchStateEvent
// doesn't carry the index, so fetch episode metadata.
final plexClient = _serverManager?.getClient(serverId);
if (plexClient == null) return;
final episodeMeta = await plexClient.getMetadataWithImages(ratingKey);
if (episodeMeta == null) return;
season = episodeMeta.parentIndex;
number = episodeMeta.index;
if (season == null || number == null) return;
ids = await resolver.resolveShowForEpisode(episodeMeta);
}
if (ids == null || !ids.hasAny) {
appLogger.d('Trakt sync: no IDs for ${kind.name} $ratingKey, dropping');
return;
}
final body = kind == TraktMediaKind.movie
? TraktScrobbleRequest.movie(ids: ids)
: TraktScrobbleRequest.episode(showIds: ids, season: season!, number: number!);
final item = TraktSyncQueueItem(
op: op,
ratingKey: ratingKey,
serverId: serverId,
kind: kind,
ids: ids,
season: season,
number: number,
watchedAtIso: watchedAtIso,
);
await _trySendOrQueue(item, body);
}
Future<void> _trySendOrQueue(TraktSyncQueueItem item, TraktScrobbleRequest body) async {
final client = _client;
if (client == null) {
await _queue.add(_activeUserUuid, item);
return;
}
try {
await _dispatch(client, item, body);
appLogger.d('Trakt sync: ${item.op.name} ${item.ratingKey} → ok');
} catch (e) {
appLogger.d('Trakt sync: ${item.op.name} ${item.ratingKey} failed, queuing', error: e);
await _queue.add(_activeUserUuid, item);
}
}
Future<void> _dispatch(TraktClient client, TraktSyncQueueItem item, TraktScrobbleRequest body) {
return switch (item.op) {
TraktSyncOp.add => client.addToHistory(body, watchedAt: item.watchedAtIso),
TraktSyncOp.remove => client.removeFromHistory(body),
};
}
/// Drain the persisted queue. Called on init, on app foreground, and when
/// `OfflineModeProvider.isOffline` flips false.
Future<void> flushQueue() async {
if (_isFlushing) return;
final client = _client;
if (client == null) return;
_isFlushing = true;
try {
final items = await _queue.load(_activeUserUuid);
if (items.isEmpty) return;
final remaining = <TraktSyncQueueItem>[];
for (final item in items) {
if (item.attempts >= TraktSyncQueue.maxAttempts) {
appLogger.w('Trakt sync: dropping ${item.op.name} ${item.ratingKey} after ${item.attempts} attempts');
continue;
}
try {
await _dispatch(client, item, _bodyFor(item));
appLogger.d('Trakt sync: drained ${item.op.name} ${item.ratingKey}');
} catch (e) {
appLogger.d('Trakt sync: drain failed for ${item.ratingKey}, will retry', error: e);
remaining.add(item.incrementAttempts());
}
await Future<void>.delayed(_queueRequestSpacing);
}
await _queue.save(_activeUserUuid, remaining);
} finally {
_isFlushing = false;
}
}
TraktScrobbleRequest _bodyFor(TraktSyncQueueItem item) {
return switch (item.kind) {
TraktMediaKind.movie => TraktScrobbleRequest.movie(ids: item.ids),
TraktMediaKind.episode => TraktScrobbleRequest.episode(
showIds: item.ids,
season: item.season!,
number: item.number!,
),
};
}
}
+1
View File
@@ -116,6 +116,7 @@ flutter:
assets: assets:
- assets/plezy.png - assets/plezy.png
- assets/plezy_adaptive_foreground.svg - assets/plezy_adaptive_foreground.svg
- assets/trakt_circlemark.svg
- assets/go-noto-current-regular.ttf - assets/go-noto-current-regular.ttf
- assets/shaders/nvscaler/ - assets/shaders/nvscaler/
- assets/shaders/anime4k/ - assets/shaders/anime4k/