diff --git a/assets/trakt_circlemark.svg b/assets/trakt_circlemark.svg new file mode 100644 index 00000000..d2f16050 --- /dev/null +++ b/assets/trakt_circlemark.svg @@ -0,0 +1 @@ + diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index f24ece28..f44a00d5 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Vis hvad du ser på Discord", + "trakt": "Trakt", + "traktDescription": "Synkroniser visningshistorik med Trakt", "autoPip": "Auto billede-i-billede", "autoPipDescription": "Gå automatisk til billede-i-billede når du forlader appen under afspilning", "matchContentFrameRate": "Match indholdets billedhastighed", @@ -976,5 +978,22 @@ "title": "Serveropgaver", "failedToLoad": "Kunne ikke indlæse opgaver", "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." } } diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 84632d96..42786b2c 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Zeige auf Discord, was du gerade schaust", + "trakt": "Trakt", + "traktDescription": "Wiedergabeverlauf mit Trakt synchronisieren", "autoPip": "Automatisches Bild-in-Bild", "autoPipDescription": "Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird", "matchContentFrameRate": "Inhalts-Bildrate anpassen", @@ -976,5 +978,22 @@ "title": "Serveraufgaben", "failedToLoad": "Aufgaben konnten nicht geladen werden", "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." } } diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index bc48f4b9..bb15c8c5 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Show what you're watching on Discord", + "trakt": "Trakt", + "traktDescription": "Sync watch history with Trakt", "companionRemoteServer": "Companion Remote Server", "companionRemoteServerDescription": "Allow mobile devices on your network to control this app", "autoPip": "Auto Picture-in-Picture", @@ -976,5 +978,22 @@ "title": "Server Tasks", "failedToLoad": "Failed to load tasks", "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." } } diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 47a4b08c..80668253 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Presencia de 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", "autoPipDescription": "Activar automáticamente imagen en imagen al salir de la app durante la reproducción", "matchContentFrameRate": "Ajustar frecuencia de actualización", @@ -976,5 +978,22 @@ "title": "Tareas del servidor", "failedToLoad": "Error al cargar tareas", "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." } } diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index a08bd412..975e2e60 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Montrez ce que vous regardez sur Discord", + "trakt": "Trakt", + "traktDescription": "Synchroniser l'historique de visionnage avec Trakt", "autoPip": "Image dans l'image automatique", "autoPipDescription": "Activer automatiquement l'image dans l'image en quittant l'application pendant la lecture", "matchContentFrameRate": "Fréquence d'images du contenu correspondant", @@ -976,5 +978,22 @@ "title": "Tâches du serveur", "failedToLoad": "Échec du chargement des tâches", "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." } } diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index c0779676..052095bb 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Mostra su Discord cosa stai guardando", + "trakt": "Trakt", + "traktDescription": "Sincronizza la cronologia di visione con Trakt", "autoPip": "Picture-in-Picture automatico", "autoPipDescription": "Attiva automaticamente il picture-in-picture quando si esce dall'app durante la riproduzione", "matchContentFrameRate": "Adatta frequenza fotogrammi", @@ -976,5 +978,22 @@ "title": "Attività del server", "failedToLoad": "Impossibile caricare le attività", "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." } } diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index d196b918..8c5ea41e 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Discordで視聴中の内容を表示", + "trakt": "Trakt", + "traktDescription": "視聴履歴を Trakt と同期", "autoPip": "自動ピクチャーインピクチャー", "autoPipDescription": "再生中にアプリを離れると自動的にピクチャーインピクチャーに移行", "matchContentFrameRate": "コンテンツのフレームレートに合わせる", @@ -976,5 +978,22 @@ "title": "サーバータスク", "failedToLoad": "タスクの読み込みに失敗しました", "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 に接続できませんでした。もう一度お試しください。" } } diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 1924b080..502e42ad 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시", + "trakt": "Trakt", + "traktDescription": "Trakt와 시청 기록 동기화", "autoPip": "자동 PIP 모드", "autoPipDescription": "재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환", "matchContentFrameRate": "콘텐츠 프레임 레이트 맞춤", @@ -976,5 +978,22 @@ "title": "서버 작업", "failedToLoad": "작업을 불러올 수 없습니다", "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에 연결할 수 없습니다. 다시 시도하세요." } } diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index eb3c1425..0ec64496 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Vis hva du ser på Discord", + "trakt": "Trakt", + "traktDescription": "Synkroniser visningshistorikk med Trakt", "autoPip": "Auto bilde-i-bilde", "autoPipDescription": "Gå automatisk til bilde-i-bilde når du forlater appen under avspilling", "matchContentFrameRate": "Tilpass innholdets bildefrekvens", @@ -976,5 +978,22 @@ "title": "Serveroppgaver", "failedToLoad": "Kunne ikke laste oppgaver", "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." } } diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 78372dd7..d832a384 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Toon op Discord wat je aan het kijken bent", + "trakt": "Trakt", + "traktDescription": "Kijkgeschiedenis synchroniseren met Trakt", "autoPip": "Automatische beeld-in-beeld", "autoPipDescription": "Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen", "matchContentFrameRate": "Inhoudsframesnelheid afstemmen", @@ -976,5 +978,22 @@ "title": "Servertaken", "failedToLoad": "Taken konden niet worden geladen", "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." } } diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index e3b05acc..f14c08fe 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Pokaż, co oglądasz na Discordzie", + "trakt": "Trakt", + "traktDescription": "Synchronizuj historię oglądania z Trakt", "autoPip": "Automatyczny obraz w obrazie", "autoPipDescription": "Automatycznie przejdź do trybu obraz w obrazie przy wyjściu z aplikacji podczas odtwarzania", "matchContentFrameRate": "Dopasuj częstotliwość klatek do treści", @@ -976,5 +978,22 @@ "title": "Zadania serwera", "failedToLoad": "Nie udało się załadować 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." } } diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 376aab49..e805dbd1 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "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", "autoPipDescription": "Entrar automaticamente em picture-in-picture ao sair do app durante a reprodução", "matchContentFrameRate": "Corresponder Taxa de Quadros do Conteúdo", @@ -976,5 +978,22 @@ "title": "Tarefas do servidor", "failedToLoad": "Falha ao carregar tarefas", "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." } } diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index ea5df4d6..7045ab29 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Показывать, что вы смотрите, в Discord", + "trakt": "Trakt", + "traktDescription": "Синхронизировать историю просмотров с Trakt", "autoPip": "Автоматический «картинка в картинке»", "autoPipDescription": "Автоматически переходить в режим «картинка в картинке» при выходе из приложения во время воспроизведения", "matchContentFrameRate": "Соответствие частоты кадров контента", @@ -976,5 +978,22 @@ "title": "Задачи сервера", "failedToLoad": "Не удалось загрузить задачи", "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. Попробуйте ещё раз." } } diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index b011efb0..308b15c7 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 15 -/// Strings: 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 // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index a4bb5ea3..428aed21 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -75,6 +75,7 @@ class TranslationsDa with BaseTranslations implements T @override late final _TranslationsExternalPlayerDa externalPlayer = _TranslationsExternalPlayerDa._(_root); @override late final _TranslationsMetadataEditDa metadataEdit = _TranslationsMetadataEditDa._(_root); @override late final _TranslationsServerTasksDa serverTasks = _TranslationsServerTasksDa._(_root); + @override late final _TranslationsTraktDa trakt = _TranslationsTraktDa._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsDa implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @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 autoPipDescription => 'Gå automatisk til billede-i-billede når du forlader appen under afspilning'; @override String get matchContentFrameRate => 'Match indholdets billedhastighed'; @@ -1220,6 +1223,30 @@ class _TranslationsServerTasksDa implements TranslationsServerTasksEn { @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 class _TranslationsHotkeysActionsDa implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsDa._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsDa { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresenceDescription' => 'Vis hvad du ser på Discord', + 'settings.trakt' => 'Trakt', + 'settings.traktDescription' => 'Synkroniser visningshistorik med Trakt', 'settings.autoPip' => 'Auto billede-i-billede', 'settings.autoPipDescription' => 'Gå automatisk til billede-i-billede når du forlader appen under afspilning', 'settings.matchContentFrameRate' => 'Match indholdets billedhastighed', @@ -1895,10 +1924,10 @@ extension on TranslationsDa { 'libraries.tabs.recommended' => 'Anbefalet', 'libraries.tabs.browse' => 'Gennemse', 'libraries.tabs.collections' => 'Samlinger', - 'libraries.tabs.playlists' => 'Playlister', - 'libraries.groupings.title' => 'Gruppering', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => 'Playlister', + 'libraries.groupings.title' => 'Gruppering', 'libraries.groupings.all' => 'Alle', 'libraries.groupings.movies' => 'Film', 'libraries.groupings.shows' => 'TV-serier', @@ -2275,6 +2304,21 @@ extension on TranslationsDa { 'serverTasks.title' => 'Serveropgaver', 'serverTasks.failedToLoad' => 'Kunne ikke indlæse opgaver', '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, }; } diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 25fdb214..4e3c3993 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -75,6 +75,7 @@ class TranslationsDe with BaseTranslations implements T @override late final _TranslationsExternalPlayerDe externalPlayer = _TranslationsExternalPlayerDe._(_root); @override late final _TranslationsMetadataEditDe metadataEdit = _TranslationsMetadataEditDe._(_root); @override late final _TranslationsServerTasksDe serverTasks = _TranslationsServerTasksDe._(_root); + @override late final _TranslationsTraktDe trakt = _TranslationsTraktDe._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @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 autoPipDescription => 'Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird'; @override String get matchContentFrameRate => 'Inhalts-Bildrate anpassen'; @@ -1220,6 +1223,30 @@ class _TranslationsServerTasksDe implements TranslationsServerTasksEn { @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 class _TranslationsHotkeysActionsDe implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsDe._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsDe { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', '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.autoPipDescription' => 'Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird', 'settings.matchContentFrameRate' => 'Inhalts-Bildrate anpassen', @@ -1895,10 +1924,10 @@ extension on TranslationsDe { 'libraries.tabs.recommended' => 'Empfohlen', 'libraries.tabs.browse' => 'Durchsuchen', 'libraries.tabs.collections' => 'Sammlungen', - 'libraries.tabs.playlists' => 'Wiedergabelisten', - 'libraries.groupings.title' => 'Gruppierung', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => 'Wiedergabelisten', + 'libraries.groupings.title' => 'Gruppierung', 'libraries.groupings.all' => 'Alle', 'libraries.groupings.movies' => 'Filme', 'libraries.groupings.shows' => 'Serien', @@ -2275,6 +2304,21 @@ extension on TranslationsDe { 'serverTasks.title' => 'Serveraufgaben', 'serverTasks.failedToLoad' => 'Aufgaben konnten nicht geladen werden', '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, }; } diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index da45f951..c20a7a50 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -78,6 +78,7 @@ class Translations with BaseTranslations { late final TranslationsExternalPlayerEn externalPlayer = TranslationsExternalPlayerEn._(_root); late final TranslationsMetadataEditEn metadataEdit = TranslationsMetadataEditEn._(_root); late final TranslationsServerTasksEn serverTasks = TranslationsServerTasksEn._(_root); + late final TranslationsTraktEn trakt = TranslationsTraktEn._(_root); } // Path: app @@ -730,6 +731,12 @@ class TranslationsSettingsEn { /// en: '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' String get companionRemoteServer => 'Companion Remote Server'; @@ -2815,6 +2822,60 @@ class TranslationsServerTasksEn { 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 class TranslationsHotkeysActionsEn { TranslationsHotkeysActionsEn._(this._root); @@ -3367,6 +3428,8 @@ extension on Translations { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', '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.companionRemoteServerDescription' => 'Allow mobile devices on your network to control this app', 'settings.autoPip' => 'Auto Picture-in-Picture', @@ -3678,10 +3741,10 @@ extension on Translations { 'libraries.tabs.recommended' => 'Recommended', 'libraries.tabs.browse' => 'Browse', 'libraries.tabs.collections' => 'Collections', - 'libraries.tabs.playlists' => 'Playlists', - 'libraries.groupings.title' => 'Grouping', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => 'Playlists', + 'libraries.groupings.title' => 'Grouping', 'libraries.groupings.all' => 'All', 'libraries.groupings.movies' => 'Movies', 'libraries.groupings.shows' => 'TV Shows', @@ -4058,6 +4121,21 @@ extension on Translations { 'serverTasks.title' => 'Server Tasks', 'serverTasks.failedToLoad' => 'Failed to load tasks', '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, }; } diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index b7dadf62..0210c28a 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -75,6 +75,7 @@ class TranslationsEs with BaseTranslations implements T @override late final _TranslationsExternalPlayerEs externalPlayer = _TranslationsExternalPlayerEs._(_root); @override late final _TranslationsMetadataEditEs metadataEdit = _TranslationsMetadataEditEs._(_root); @override late final _TranslationsServerTasksEs serverTasks = _TranslationsServerTasksEs._(_root); + @override late final _TranslationsTraktEs trakt = _TranslationsTraktEs._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsEs implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Presencia de 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 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'; @@ -1220,6 +1223,30 @@ class _TranslationsServerTasksEs implements TranslationsServerTasksEn { @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 class _TranslationsHotkeysActionsEs implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsEs._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsEs { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Presencia de 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.autoPipDescription' => 'Activar automáticamente imagen en imagen al salir de la app durante la reproducción', 'settings.matchContentFrameRate' => 'Ajustar frecuencia de actualización', @@ -1895,10 +1924,10 @@ extension on TranslationsEs { 'libraries.tabs.recommended' => 'Recomendado', 'libraries.tabs.browse' => 'Explorar', 'libraries.tabs.collections' => 'Colecciones', - 'libraries.tabs.playlists' => 'Listas', - 'libraries.groupings.title' => 'Agrupación', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => 'Listas', + 'libraries.groupings.title' => 'Agrupación', 'libraries.groupings.all' => 'Todo', 'libraries.groupings.movies' => 'Películas', 'libraries.groupings.shows' => 'Series', @@ -2275,6 +2304,21 @@ extension on TranslationsEs { 'serverTasks.title' => 'Tareas del servidor', 'serverTasks.failedToLoad' => 'Error al cargar tareas', '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, }; } diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index f7dd0c46..912016f4 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -75,6 +75,7 @@ class TranslationsFr with BaseTranslations implements T @override late final _TranslationsExternalPlayerFr externalPlayer = _TranslationsExternalPlayerFr._(_root); @override late final _TranslationsMetadataEditFr metadataEdit = _TranslationsMetadataEditFr._(_root); @override late final _TranslationsServerTasksFr serverTasks = _TranslationsServerTasksFr._(_root); + @override late final _TranslationsTraktFr trakt = _TranslationsTraktFr._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @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 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'; @@ -1220,6 +1223,30 @@ class _TranslationsServerTasksFr implements TranslationsServerTasksEn { @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 class _TranslationsHotkeysActionsFr implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsFr._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsFr { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', '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.autoPipDescription' => 'Activer automatiquement l\'image dans l\'image en quittant l\'application pendant la lecture', 'settings.matchContentFrameRate' => 'Fréquence d\'images du contenu correspondant', @@ -1895,10 +1924,10 @@ extension on TranslationsFr { 'libraries.tabs.recommended' => 'Recommandé', 'libraries.tabs.browse' => 'Parcourir', 'libraries.tabs.collections' => 'Collections', - 'libraries.tabs.playlists' => 'Playlists', - 'libraries.groupings.title' => 'Regroupement', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => 'Playlists', + 'libraries.groupings.title' => 'Regroupement', 'libraries.groupings.all' => 'Tous', 'libraries.groupings.movies' => 'Films', 'libraries.groupings.shows' => 'Show TV', @@ -2275,6 +2304,21 @@ extension on TranslationsFr { 'serverTasks.title' => 'Tâches du serveur', 'serverTasks.failedToLoad' => 'Échec du chargement des tâches', '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, }; } diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index ea648aec..8a6556c7 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -75,6 +75,7 @@ class TranslationsIt with BaseTranslations implements T @override late final _TranslationsExternalPlayerIt externalPlayer = _TranslationsExternalPlayerIt._(_root); @override late final _TranslationsMetadataEditIt metadataEdit = _TranslationsMetadataEditIt._(_root); @override late final _TranslationsServerTasksIt serverTasks = _TranslationsServerTasksIt._(_root); + @override late final _TranslationsTraktIt trakt = _TranslationsTraktIt._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @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 autoPipDescription => 'Attiva automaticamente il picture-in-picture quando si esce dall\'app durante la riproduzione'; @override String get matchContentFrameRate => 'Adatta frequenza fotogrammi'; @@ -1220,6 +1223,30 @@ class _TranslationsServerTasksIt implements TranslationsServerTasksEn { @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 class _TranslationsHotkeysActionsIt implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsIt._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsIt { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', '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.autoPipDescription' => 'Attiva automaticamente il picture-in-picture quando si esce dall\'app durante la riproduzione', 'settings.matchContentFrameRate' => 'Adatta frequenza fotogrammi', @@ -1895,10 +1924,10 @@ extension on TranslationsIt { 'libraries.tabs.recommended' => 'Consigliati', 'libraries.tabs.browse' => 'Esplora', 'libraries.tabs.collections' => 'Raccolte', - 'libraries.tabs.playlists' => 'Playlist', - 'libraries.groupings.title' => 'Raggruppamento', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => 'Playlist', + 'libraries.groupings.title' => 'Raggruppamento', 'libraries.groupings.all' => 'Tutti', 'libraries.groupings.movies' => 'Film', 'libraries.groupings.shows' => 'Serie TV', @@ -2275,6 +2304,21 @@ extension on TranslationsIt { 'serverTasks.title' => 'Attività del server', 'serverTasks.failedToLoad' => 'Impossibile caricare le attività', '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, }; } diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 68b5ff8a..f843e7e8 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -75,6 +75,7 @@ class TranslationsJa with BaseTranslations implements T @override late final _TranslationsExternalPlayerJa externalPlayer = _TranslationsExternalPlayerJa._(_root); @override late final _TranslationsMetadataEditJa metadataEdit = _TranslationsMetadataEditJa._(_root); @override late final _TranslationsServerTasksJa serverTasks = _TranslationsServerTasksJa._(_root); + @override late final _TranslationsTraktJa trakt = _TranslationsTraktJa._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsJa implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Discordで視聴中の内容を表示'; + @override String get trakt => 'Trakt'; + @override String get traktDescription => '視聴履歴を Trakt と同期'; @override String get autoPip => '自動ピクチャーインピクチャー'; @override String get autoPipDescription => '再生中にアプリを離れると自動的にピクチャーインピクチャーに移行'; @override String get matchContentFrameRate => 'コンテンツのフレームレートに合わせる'; @@ -1220,6 +1223,30 @@ class _TranslationsServerTasksJa implements TranslationsServerTasksEn { @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 class _TranslationsHotkeysActionsJa implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsJa._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsJa { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresenceDescription' => 'Discordで視聴中の内容を表示', + 'settings.trakt' => 'Trakt', + 'settings.traktDescription' => '視聴履歴を Trakt と同期', 'settings.autoPip' => '自動ピクチャーインピクチャー', 'settings.autoPipDescription' => '再生中にアプリを離れると自動的にピクチャーインピクチャーに移行', 'settings.matchContentFrameRate' => 'コンテンツのフレームレートに合わせる', @@ -1895,10 +1924,10 @@ extension on TranslationsJa { 'libraries.tabs.recommended' => 'おすすめ', 'libraries.tabs.browse' => 'ブラウズ', 'libraries.tabs.collections' => 'コレクション', - 'libraries.tabs.playlists' => 'プレイリスト', - 'libraries.groupings.title' => 'グループ', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => 'プレイリスト', + 'libraries.groupings.title' => 'グループ', 'libraries.groupings.all' => 'すべて', 'libraries.groupings.movies' => '映画', 'libraries.groupings.shows' => 'テレビ番組', @@ -2275,6 +2304,21 @@ extension on TranslationsJa { 'serverTasks.title' => 'サーバータスク', 'serverTasks.failedToLoad' => 'タスクの読み込みに失敗しました', '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, }; } diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 99a1a65a..a4d3db23 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -75,6 +75,7 @@ class TranslationsKo with BaseTranslations implements T @override late final _TranslationsExternalPlayerKo externalPlayer = _TranslationsExternalPlayerKo._(_root); @override late final _TranslationsMetadataEditKo metadataEdit = _TranslationsMetadataEditKo._(_root); @override late final _TranslationsServerTasksKo serverTasks = _TranslationsServerTasksKo._(_root); + @override late final _TranslationsTraktKo trakt = _TranslationsTraktKo._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Discord에서 시청 중인 콘텐츠 표시'; + @override String get trakt => 'Trakt'; + @override String get traktDescription => 'Trakt와 시청 기록 동기화'; @override String get autoPip => '자동 PIP 모드'; @override String get autoPipDescription => '재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환'; @override String get matchContentFrameRate => '콘텐츠 프레임 레이트 맞춤'; @@ -1220,6 +1223,30 @@ class _TranslationsServerTasksKo implements TranslationsServerTasksEn { @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 class _TranslationsHotkeysActionsKo implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsKo._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsKo { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresenceDescription' => 'Discord에서 시청 중인 콘텐츠 표시', + 'settings.trakt' => 'Trakt', + 'settings.traktDescription' => 'Trakt와 시청 기록 동기화', 'settings.autoPip' => '자동 PIP 모드', 'settings.autoPipDescription' => '재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환', 'settings.matchContentFrameRate' => '콘텐츠 프레임 레이트 맞춤', @@ -1895,10 +1924,10 @@ extension on TranslationsKo { 'libraries.tabs.recommended' => '추천', 'libraries.tabs.browse' => '찾아보기', 'libraries.tabs.collections' => '컬렉션', - 'libraries.tabs.playlists' => '재생 목록', - 'libraries.groupings.title' => '그룹', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => '재생 목록', + 'libraries.groupings.title' => '그룹', 'libraries.groupings.all' => '전체', 'libraries.groupings.movies' => '영화', 'libraries.groupings.shows' => 'TV 프로그램', @@ -2275,6 +2304,21 @@ extension on TranslationsKo { 'serverTasks.title' => '서버 작업', 'serverTasks.failedToLoad' => '작업을 불러올 수 없습니다', '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, }; } diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index bb006669..2f2a92db 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -75,6 +75,7 @@ class TranslationsNb with BaseTranslations implements T @override late final _TranslationsExternalPlayerNb externalPlayer = _TranslationsExternalPlayerNb._(_root); @override late final _TranslationsMetadataEditNb metadataEdit = _TranslationsMetadataEditNb._(_root); @override late final _TranslationsServerTasksNb serverTasks = _TranslationsServerTasksNb._(_root); + @override late final _TranslationsTraktNb trakt = _TranslationsTraktNb._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsNb implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @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 autoPipDescription => 'Gå automatisk til bilde-i-bilde når du forlater appen under avspilling'; @override String get matchContentFrameRate => 'Tilpass innholdets bildefrekvens'; @@ -1220,6 +1223,30 @@ class _TranslationsServerTasksNb implements TranslationsServerTasksEn { @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 class _TranslationsHotkeysActionsNb implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsNb._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsNb { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresenceDescription' => 'Vis hva du ser på Discord', + 'settings.trakt' => 'Trakt', + 'settings.traktDescription' => 'Synkroniser visningshistorikk med Trakt', 'settings.autoPip' => 'Auto bilde-i-bilde', 'settings.autoPipDescription' => 'Gå automatisk til bilde-i-bilde når du forlater appen under avspilling', 'settings.matchContentFrameRate' => 'Tilpass innholdets bildefrekvens', @@ -1895,10 +1924,10 @@ extension on TranslationsNb { 'libraries.tabs.recommended' => 'Anbefalt', 'libraries.tabs.browse' => 'Bla gjennom', 'libraries.tabs.collections' => 'Samlinger', - 'libraries.tabs.playlists' => 'Spillelister', - 'libraries.groupings.title' => 'Gruppering', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => 'Spillelister', + 'libraries.groupings.title' => 'Gruppering', 'libraries.groupings.all' => 'Alle', 'libraries.groupings.movies' => 'Filmer', 'libraries.groupings.shows' => 'TV-serier', @@ -2275,6 +2304,21 @@ extension on TranslationsNb { 'serverTasks.title' => 'Serveroppgaver', 'serverTasks.failedToLoad' => 'Kunne ikke laste oppgaver', '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, }; } diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 7d87a5d1..bb563a42 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -75,6 +75,7 @@ class TranslationsNl with BaseTranslations implements T @override late final _TranslationsExternalPlayerNl externalPlayer = _TranslationsExternalPlayerNl._(_root); @override late final _TranslationsMetadataEditNl metadataEdit = _TranslationsMetadataEditNl._(_root); @override late final _TranslationsServerTasksNl serverTasks = _TranslationsServerTasksNl._(_root); + @override late final _TranslationsTraktNl trakt = _TranslationsTraktNl._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @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 autoPipDescription => 'Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen'; @override String get matchContentFrameRate => 'Inhoudsframesnelheid afstemmen'; @@ -1220,6 +1223,30 @@ class _TranslationsServerTasksNl implements TranslationsServerTasksEn { @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 class _TranslationsHotkeysActionsNl implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsNl._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsNl { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', '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.autoPipDescription' => 'Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen', 'settings.matchContentFrameRate' => 'Inhoudsframesnelheid afstemmen', @@ -1895,10 +1924,10 @@ extension on TranslationsNl { 'libraries.tabs.recommended' => 'Aanbevolen', 'libraries.tabs.browse' => 'Bladeren', 'libraries.tabs.collections' => 'Collecties', - 'libraries.tabs.playlists' => 'Afspeellijsten', - 'libraries.groupings.title' => 'Groepering', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => 'Afspeellijsten', + 'libraries.groupings.title' => 'Groepering', 'libraries.groupings.all' => 'Alles', 'libraries.groupings.movies' => 'Films', 'libraries.groupings.shows' => 'Series', @@ -2275,6 +2304,21 @@ extension on TranslationsNl { 'serverTasks.title' => 'Servertaken', 'serverTasks.failedToLoad' => 'Taken konden niet worden geladen', '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, }; } diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index a0cc0d3d..54fe2008 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -75,6 +75,7 @@ class TranslationsPl with BaseTranslations implements T @override late final _TranslationsExternalPlayerPl externalPlayer = _TranslationsExternalPlayerPl._(_root); @override late final _TranslationsMetadataEditPl metadataEdit = _TranslationsMetadataEditPl._(_root); @override late final _TranslationsServerTasksPl serverTasks = _TranslationsServerTasksPl._(_root); + @override late final _TranslationsTraktPl trakt = _TranslationsTraktPl._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsPl implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @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 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'; @@ -1220,6 +1223,30 @@ class _TranslationsServerTasksPl implements TranslationsServerTasksEn { @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 class _TranslationsHotkeysActionsPl implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsPl._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsPl { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', '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.autoPipDescription' => 'Automatycznie przejdź do trybu obraz w obrazie przy wyjściu z aplikacji podczas odtwarzania', 'settings.matchContentFrameRate' => 'Dopasuj częstotliwość klatek do treści', @@ -1895,10 +1924,10 @@ extension on TranslationsPl { 'libraries.tabs.recommended' => 'Rekomendowane', 'libraries.tabs.browse' => 'Przeglądaj', 'libraries.tabs.collections' => 'Kolekcje', - 'libraries.tabs.playlists' => 'Playlisty', - 'libraries.groupings.title' => 'Grupowanie', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => 'Playlisty', + 'libraries.groupings.title' => 'Grupowanie', 'libraries.groupings.all' => 'Wszystkie', 'libraries.groupings.movies' => 'Filmy', 'libraries.groupings.shows' => 'Seriale TV', @@ -2275,6 +2304,21 @@ extension on TranslationsPl { 'serverTasks.title' => 'Zadania serwera', 'serverTasks.failedToLoad' => 'Nie udało się załadować 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, }; } diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 486ed4db..11e30c4e 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -75,6 +75,7 @@ class TranslationsPt with BaseTranslations implements T @override late final _TranslationsExternalPlayerPt externalPlayer = _TranslationsExternalPlayerPt._(_root); @override late final _TranslationsMetadataEditPt metadataEdit = _TranslationsMetadataEditPt._(_root); @override late final _TranslationsServerTasksPt serverTasks = _TranslationsServerTasksPt._(_root); + @override late final _TranslationsTraktPt trakt = _TranslationsTraktPt._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsPt implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @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 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'; @@ -1220,6 +1223,30 @@ class _TranslationsServerTasksPt implements TranslationsServerTasksEn { @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 class _TranslationsHotkeysActionsPt implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsPt._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsPt { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', '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.autoPipDescription' => 'Entrar automaticamente em picture-in-picture ao sair do app durante a reprodução', 'settings.matchContentFrameRate' => 'Corresponder Taxa de Quadros do Conteúdo', @@ -1895,10 +1924,10 @@ extension on TranslationsPt { 'libraries.tabs.recommended' => 'Recomendados', 'libraries.tabs.browse' => 'Navegar', 'libraries.tabs.collections' => 'Coleções', - 'libraries.tabs.playlists' => 'Playlists', - 'libraries.groupings.title' => 'Agrupamento', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => 'Playlists', + 'libraries.groupings.title' => 'Agrupamento', 'libraries.groupings.all' => 'Todos', 'libraries.groupings.movies' => 'Filmes', 'libraries.groupings.shows' => 'Séries de TV', @@ -2275,6 +2304,21 @@ extension on TranslationsPt { 'serverTasks.title' => 'Tarefas do servidor', 'serverTasks.failedToLoad' => 'Falha ao carregar tarefas', '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, }; } diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 9f49d4c5..402b460c 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -75,6 +75,7 @@ class TranslationsRu with BaseTranslations implements T @override late final _TranslationsExternalPlayerRu externalPlayer = _TranslationsExternalPlayerRu._(_root); @override late final _TranslationsMetadataEditRu metadataEdit = _TranslationsMetadataEditRu._(_root); @override late final _TranslationsServerTasksRu serverTasks = _TranslationsServerTasksRu._(_root); + @override late final _TranslationsTraktRu trakt = _TranslationsTraktRu._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsRu implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @override String get discordRichPresenceDescription => 'Показывать, что вы смотрите, в Discord'; + @override String get trakt => 'Trakt'; + @override String get traktDescription => 'Синхронизировать историю просмотров с Trakt'; @override String get autoPip => 'Автоматический «картинка в картинке»'; @override String get autoPipDescription => 'Автоматически переходить в режим «картинка в картинке» при выходе из приложения во время воспроизведения'; @override String get matchContentFrameRate => 'Соответствие частоты кадров контента'; @@ -1220,6 +1223,30 @@ class _TranslationsServerTasksRu implements TranslationsServerTasksEn { @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 class _TranslationsHotkeysActionsRu implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsRu._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsRu { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', 'settings.discordRichPresenceDescription' => 'Показывать, что вы смотрите, в Discord', + 'settings.trakt' => 'Trakt', + 'settings.traktDescription' => 'Синхронизировать историю просмотров с Trakt', 'settings.autoPip' => 'Автоматический «картинка в картинке»', 'settings.autoPipDescription' => 'Автоматически переходить в режим «картинка в картинке» при выходе из приложения во время воспроизведения', 'settings.matchContentFrameRate' => 'Соответствие частоты кадров контента', @@ -1895,10 +1924,10 @@ extension on TranslationsRu { 'libraries.tabs.recommended' => 'Рекомендуемые', 'libraries.tabs.browse' => 'Обзор', 'libraries.tabs.collections' => 'Коллекции', - 'libraries.tabs.playlists' => 'Плейлисты', - 'libraries.groupings.title' => 'Группировка', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => 'Плейлисты', + 'libraries.groupings.title' => 'Группировка', 'libraries.groupings.all' => 'Все', 'libraries.groupings.movies' => 'Фильмы', 'libraries.groupings.shows' => 'Сериалы', @@ -2275,6 +2304,21 @@ extension on TranslationsRu { 'serverTasks.title' => 'Задачи сервера', 'serverTasks.failedToLoad' => 'Не удалось загрузить задачи', '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, }; } diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index b8922e56..8d725777 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -75,6 +75,7 @@ class TranslationsSv with BaseTranslations implements T @override late final _TranslationsExternalPlayerSv externalPlayer = _TranslationsExternalPlayerSv._(_root); @override late final _TranslationsMetadataEditSv metadataEdit = _TranslationsMetadataEditSv._(_root); @override late final _TranslationsServerTasksSv serverTasks = _TranslationsServerTasksSv._(_root); + @override late final _TranslationsTraktSv trakt = _TranslationsTraktSv._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord Rich Presence'; @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 autoPipDescription => 'Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning'; @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'; } +// 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 class _TranslationsHotkeysActionsSv implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsSv._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsSv { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord Rich Presence', '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.autoPipDescription' => 'Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning', 'settings.matchContentFrameRate' => 'Matcha innehållets bildfrekvens', @@ -1895,10 +1924,10 @@ extension on TranslationsSv { 'libraries.tabs.recommended' => 'Rekommenderat', 'libraries.tabs.browse' => 'Bläddra', 'libraries.tabs.collections' => 'Samlingar', - 'libraries.tabs.playlists' => 'Spellistor', - 'libraries.groupings.title' => 'Gruppering', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => 'Spellistor', + 'libraries.groupings.title' => 'Gruppering', 'libraries.groupings.all' => 'Alla', 'libraries.groupings.movies' => 'Filmer', 'libraries.groupings.shows' => 'Serier', @@ -2275,6 +2304,21 @@ extension on TranslationsSv { 'serverTasks.title' => 'Serveruppgifter', 'serverTasks.failedToLoad' => 'Kunde inte ladda 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, }; } diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 8a32fa0d..d81a0789 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -75,6 +75,7 @@ class TranslationsZh with BaseTranslations implements T @override late final _TranslationsExternalPlayerZh externalPlayer = _TranslationsExternalPlayerZh._(_root); @override late final _TranslationsMetadataEditZh metadataEdit = _TranslationsMetadataEditZh._(_root); @override late final _TranslationsServerTasksZh serverTasks = _TranslationsServerTasksZh._(_root); + @override late final _TranslationsTraktZh trakt = _TranslationsTraktZh._(_root); } // Path: app @@ -328,6 +329,8 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn { @override String maxVolumePercent({required Object percent}) => '${percent}%'; @override String get discordRichPresence => 'Discord 动态状态'; @override String get discordRichPresenceDescription => '在 Discord 上显示您正在观看的内容'; + @override String get trakt => 'Trakt'; + @override String get traktDescription => '将观看历史与 Trakt 同步'; @override String get autoPip => '自动画中画'; @override String get autoPipDescription => '在播放期间离开应用时自动进入画中画模式'; @override String get matchContentFrameRate => '匹配内容帧率'; @@ -1220,6 +1223,30 @@ class _TranslationsServerTasksZh implements TranslationsServerTasksEn { @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 class _TranslationsHotkeysActionsZh implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsZh._(this._root); @@ -1584,6 +1611,8 @@ extension on TranslationsZh { 'settings.maxVolumePercent' => ({required Object percent}) => '${percent}%', 'settings.discordRichPresence' => 'Discord 动态状态', 'settings.discordRichPresenceDescription' => '在 Discord 上显示您正在观看的内容', + 'settings.trakt' => 'Trakt', + 'settings.traktDescription' => '将观看历史与 Trakt 同步', 'settings.autoPip' => '自动画中画', 'settings.autoPipDescription' => '在播放期间离开应用时自动进入画中画模式', 'settings.matchContentFrameRate' => '匹配内容帧率', @@ -1895,10 +1924,10 @@ extension on TranslationsZh { 'libraries.tabs.recommended' => '推荐', 'libraries.tabs.browse' => '浏览', 'libraries.tabs.collections' => '合集', - 'libraries.tabs.playlists' => '播放列表', - 'libraries.groupings.title' => '分组', _ => null, } ?? switch (path) { + 'libraries.tabs.playlists' => '播放列表', + 'libraries.groupings.title' => '分组', 'libraries.groupings.all' => '全部', 'libraries.groupings.movies' => '电影', 'libraries.groupings.shows' => '剧集', @@ -2275,6 +2304,21 @@ extension on TranslationsZh { 'serverTasks.title' => '服务器任务', 'serverTasks.failedToLoad' => '加载任务失败', '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, }; } diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 5fc4e522..4123247a 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord Rich Presence", "discordRichPresenceDescription": "Visa vad du tittar på i Discord", + "trakt": "Trakt", + "traktDescription": "Synkronisera visningshistorik med Trakt", "autoPip": "Automatisk bild-i-bild", "autoPipDescription": "Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning", "matchContentFrameRate": "Matcha innehållets bildfrekvens", @@ -976,5 +978,22 @@ "title": "Serveruppgifter", "failedToLoad": "Kunde inte ladda 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." } } diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 53f41740..92e05399 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -209,6 +209,8 @@ "maxVolumePercent": "${percent}%", "discordRichPresence": "Discord 动态状态", "discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容", + "trakt": "Trakt", + "traktDescription": "将观看历史与 Trakt 同步", "autoPip": "自动画中画", "autoPipDescription": "在播放期间离开应用时自动进入画中画模式", "matchContentFrameRate": "匹配内容帧率", @@ -976,5 +978,22 @@ "title": "服务器任务", "failedToLoad": "加载任务失败", "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。请重试。" } } diff --git a/lib/main.dart b/lib/main.dart index 051ae58e..404dff54 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,9 @@ import 'services/settings_service.dart'; import 'utils/platform_detector.dart'; import 'services/discord_rpc_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/multi_server_provider.dart'; import 'providers/theme_provider.dart'; @@ -187,6 +190,9 @@ Future _bootstrapApp() async { DiscordRPCService.instance.initialize(); } + // Trakt scrobble service (all platforms) + await TraktScrobbleService.instance.initialize(); + // DTD service is available for MCP tooling connection if needed // Register bundled shader licenses @@ -404,6 +410,10 @@ class _MainAppState extends State with WidgetsBindingObserver { _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( onExitRequested: () async { await _appDatabase.close(); @@ -471,6 +481,7 @@ class _MainAppState extends State with WidgetsBindingObserver { case AppLifecycleState.resumed: // App came back to foreground - trigger sync check and start new session _offlineWatchSyncService.onAppResumed(); + TraktSyncService.instance.flushQueue(); InAppReviewService.instance.startSession(); // 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 @@ -565,6 +576,9 @@ class _MainAppState extends State with WidgetsBindingObserver { // Existing providers ChangeNotifierProvider(create: (context) => UserProfileProvider()), 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) => HiddenLibrariesProvider(), lazy: true), ChangeNotifierProvider(create: (context) => LibrariesProvider()), @@ -576,26 +590,28 @@ class _MainAppState extends State with WidgetsBindingObserver { child: Consumer( builder: (context, themeProvider, child) { return TranslationProvider( - child: Listener( - onPointerDown: (event) { - if ((event.buttons & kBackMouseButton) != 0) { - rootNavigatorKey.currentState?.maybePop(); - } - }, - behavior: HitTestBehavior.translucent, - child: InputModeTracker( - child: MaterialApp( - title: t.app.title, - debugShowCheckedModeBanner: false, - theme: themeProvider.lightTheme, - darkTheme: themeProvider.darkTheme, - themeMode: themeProvider.materialThemeMode, - navigatorKey: rootNavigatorKey, - navigatorObservers: [routeObserver, BackKeySuppressorObserver()], - home: const OrientationAwareSetup(), - builder: (context, child) => ScaffoldMessenger( - key: rootScaffoldMessengerKey, - child: Scaffold(backgroundColor: Colors.transparent, body: child), + child: _TraktProfileBootstrap( + child: Listener( + onPointerDown: (event) { + if ((event.buttons & kBackMouseButton) != 0) { + rootNavigatorKey.currentState?.maybePop(); + } + }, + behavior: HitTestBehavior.translucent, + child: InputModeTracker( + child: MaterialApp( + title: t.app.title, + debugShowCheckedModeBanner: false, + theme: themeProvider.lightTheme, + darkTheme: themeProvider.darkTheme, + themeMode: themeProvider.materialThemeMode, + navigatorKey: rootNavigatorKey, + navigatorObservers: [routeObserver, BackKeySuppressorObserver()], + home: const OrientationAwareSetup(), + builder: (context, child) => ScaffoldMessenger( + key: rootScaffoldMessengerKey, + child: Scaffold(backgroundColor: Colors.transparent, body: child), + ), ), ), ), @@ -607,6 +623,61 @@ class _MainAppState extends State 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(); + final trakt = context.read(); + + 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 { const OrientationAwareSetup({super.key}); diff --git a/lib/models/trakt/trakt_device_code.dart b/lib/models/trakt/trakt_device_code.dart new file mode 100644 index 00000000..e1161323 --- /dev/null +++ b/lib/models/trakt/trakt_device_code.dart @@ -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 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 tokenResponse; + const TraktDevicePollSuccess(this.tokenResponse); +} diff --git a/lib/models/trakt/trakt_ids.dart b/lib/models/trakt/trakt_ids.dart new file mode 100644 index 00000000..1382c649 --- /dev/null +++ b/lib/models/trakt/trakt_ids.dart @@ -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 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 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 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); + } +} diff --git a/lib/models/trakt/trakt_scrobble_request.dart b/lib/models/trakt/trakt_scrobble_request.dart new file mode 100644 index 00000000..9b243f84 --- /dev/null +++ b/lib/models/trakt/trakt_scrobble_request.dart @@ -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 (0–100) 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 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 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 toHistoryRemoveBody() { + if (isMovie) { + return { + 'movies': [ + {'ids': _movieIds!.toJson()}, + ], + }; + } + return { + 'shows': [ + { + 'ids': _showIds!.toJson(), + 'seasons': [ + { + 'number': _season, + 'episodes': [ + {'number': _episode}, + ], + }, + ], + }, + ], + }; + } +} diff --git a/lib/models/trakt/trakt_user.dart b/lib/models/trakt/trakt_user.dart new file mode 100644 index 00000000..bd023b72 --- /dev/null +++ b/lib/models/trakt/trakt_user.dart @@ -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 json) { + final user = json['user'] as Map?; + 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?); + } +} diff --git a/lib/providers/trakt_account_provider.dart b/lib/providers/trakt_account_provider.dart new file mode 100644 index 00000000..612bc8d7 --- /dev/null +++ b/lib/providers/trakt_account_provider.dart @@ -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 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 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 _completeConnect(Map 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 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(); + } +} diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 50a5ceae..de11751d 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -18,6 +19,7 @@ import '../../services/file_picker_service.dart'; import '../../services/saf_storage_service.dart'; import '../../providers/settings_provider.dart'; import '../../providers/theme_provider.dart'; +import '../../providers/trakt_account_provider.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart' as settings; import '../../services/update_service.dart'; @@ -30,6 +32,7 @@ import 'appearance_settings_screen.dart'; import 'keyboard_shortcuts_screen.dart'; import 'logs_screen.dart'; import 'playback_settings_screen.dart'; +import 'trakt_settings_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -45,6 +48,7 @@ class _SettingsScreenState extends State with FocusableTab { // Focus tracking keys static const _kAppearance = 'appearance'; static const _kPlayback = 'playback'; + static const _kTrakt = 'trakt'; static const _kDownloadLocation = 'download_location'; static const _kDownloadOnWifiOnly = 'download_on_wifi_only'; static const _kAutoRemoveWatchedDownloads = 'auto_remove_watched_downloads'; @@ -151,6 +155,9 @@ class _SettingsScreenState extends State with FocusableTab { // --- Playback (navigation tile) --- _buildPlaybackTile(), + // --- Trakt.tv (navigation tile) --- + _buildTraktTile(), + // --- Downloads (inline) --- _buildDownloadsSection(), @@ -215,6 +222,37 @@ class _SettingsScreenState extends State with FocusableTab { ); } + Widget _buildTraktTile() { + return Consumer( + 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() { final storageService = DownloadStorageService.instance; final isCustom = storageService.isUsingCustomPath(); diff --git a/lib/screens/settings/trakt_settings_screen.dart b/lib/screens/settings/trakt_settings_screen.dart new file mode 100644 index 00000000..587d1ff8 --- /dev/null +++ b/lib/screens/settings/trakt_settings_screen.dart @@ -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 startTraktConnection(BuildContext context) async { + final account = context.read(); + 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( + 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 createState() => _TraktSettingsScreenState(); +} + +class _TraktSettingsScreenState extends State { + SettingsService? _settings; + bool _scrobbleEnabled = true; + bool _watchedSyncEnabled = true; + bool _loaded = false; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + final s = await SettingsService.getInstance(); + if (!mounted) return; + setState(() { + _settings = s; + _scrobbleEnabled = s.getEnableTraktScrobble(); + _watchedSyncEnabled = s.getEnableTraktWatchedSync(); + _loaded = true; + }); + } + + Future _disconnect(TraktAccountProvider account) async { + final confirmed = await showDialog( + 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( + 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 _open() async { + final url = widget.code.verificationUrlComplete ?? widget.code.verificationUrl; + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + + Future _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), + ), + ], + ); + } +} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 6a14d458..1acb64ab 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -36,6 +36,7 @@ import '../providers/companion_remote_provider.dart'; import '../services/companion_remote/companion_remote_receiver.dart'; import '../services/fullscreen_state_manager.dart'; import '../services/discord_rpc_service.dart'; +import '../services/trakt/trakt_scrobble_service.dart'; import '../services/episode_navigation_service.dart'; import '../services/media_controls_manager.dart'; import '../services/playback_initialization_service.dart'; @@ -1061,6 +1062,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin speed: player!.state.rate, ); 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 @@ -1075,6 +1080,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Start Discord Rich Presence for current media if (client != null) { DiscordRPCService.instance.startPlayback(_currentMetadata, client); + TraktScrobbleService.instance.startPlayback(_currentMetadata, client, isLive: widget.isLive); } } @@ -2106,8 +2112,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin _mediaControlsManager?.clear(); _mediaControlsManager?.dispose(); - // Clear Discord Rich Presence + // Clear Discord Rich Presence + send Trakt stop scrobble DiscordRPCService.instance.stopPlayback(); + TraktScrobbleService.instance.stopPlayback(); // Clean up Windows display mode service if (Platform.isWindows && _displayModeService != null) { @@ -2195,11 +2202,13 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Update OS media controls playback state _updateMediaControlsPlaybackState(); - // Update Discord Rich Presence + // Update Discord Rich Presence + Trakt scrobble if (isPlaying) { DiscordRPCService.instance.resumePlayback(); + TraktScrobbleService.instance.resumePlayback(); } else { DiscordRPCService.instance.pausePlayback(); + TraktScrobbleService.instance.pausePlayback(); } // Update auto-PiP readiness @@ -2222,6 +2231,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _progressTracker?.sendProgress('paused'); _updateMediaControlsPlaybackState(); DiscordRPCService.instance.pausePlayback(); + TraktScrobbleService.instance.pausePlayback(); if (_autoPipEnabled) { _videoPIPManager?.updateAutoPipState(isPlaying: false); } @@ -2858,8 +2868,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Set flag to skip orientation restoration in dispose() _isReplacingWithVideo = true; - // Clear Discord Rich Presence before switching episodes + // Clear Discord Rich Presence + Trakt scrobble before switching episodes DiscordRPCService.instance.stopPlayback(); + TraktScrobbleService.instance.stopPlayback(); // If player isn't available, navigate without preserving settings if (player == null) { @@ -2939,6 +2950,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _progressTracker?.dispose(); _progressTracker = null; DiscordRPCService.instance.stopPlayback(); + TraktScrobbleService.instance.stopPlayback(); _currentMetadata = episodeMetadata; _activeRatingKey = episodeMetadata.ratingKey; @@ -3051,6 +3063,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (client != null) { DiscordRPCService.instance.startPlayback(episodeMetadata, client); + TraktScrobbleService.instance.startPlayback(episodeMetadata, client, isLive: widget.isLive); } try { diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index 31cee1cd..4f4cc4f5 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -3,9 +3,11 @@ import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import '../database/app_database.dart'; +import '../models/plex_metadata.dart'; import '../providers/offline_mode_provider.dart'; import '../utils/app_logger.dart'; import '../utils/plex_cache_parser.dart'; +import '../utils/watch_state_notifier.dart'; import 'multi_server_manager.dart'; import 'plex_api_cache.dart'; import 'plex_client.dart'; @@ -395,13 +397,30 @@ class OfflineWatchSyncService extends ChangeNotifier { /// Sync a single action to the server. Future _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) { case 'watched': - await client.markAsWatched(action.ratingKey); + await client.markAsWatched(action.ratingKey, metadata: metadata); break; case 'unwatched': - await client.markAsUnwatched(action.ratingKey); + await client.markAsUnwatched(action.ratingKey, metadata: metadata); break; case 'progress': @@ -417,7 +436,7 @@ class OfflineWatchSyncService extends ChangeNotifier { // If progress exceeded threshold, also mark as watched if (action.shouldMarkWatched) { - await client.markAsWatched(action.ratingKey); + await client.markAsWatched(action.ratingKey, metadata: metadata); } break; } @@ -444,6 +463,12 @@ class OfflineWatchSyncService extends ChangeNotifier { final existingMeta = PlexCacheParser.extractFirstMetadata(existing); 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. existingMeta['viewCount'] = episode.viewCount; 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) if (existingMeta['Media'] == null) { try { @@ -544,11 +573,22 @@ class OfflineWatchSyncService extends ChangeNotifier { await _withOnlineClient(serverId, (client) async { for (final ratingKey in ratingKeys) { 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 // (with chapters/markers) via _fetchWithCacheFallback internally final metadata = await client.getMetadataWithImages(ratingKey); if (metadata != null) { syncedCount++; + final isWatched = (metadata.viewCount ?? 0) > 0; + if (wasWatched != isWatched) { + WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: isWatched); + } } } catch (e) { appLogger.d('Failed to sync watch state for $ratingKey: $e'); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index ab048f09..d8643202 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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> 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 /// /// If [metadata] is provided, emits a [WatchStateEvent] for UI updates. diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index df9a60cf..f02c7ca2 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -92,6 +92,8 @@ class SettingsService extends BaseSharedPreferencesService { static const String _keyMpvConfigPresets = 'mpv_config_presets'; static const String _keyMaxVolume = 'max_volume'; 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 _keyAutoPip = 'auto_pip'; static const String _keyMatchContentFrameRate = 'match_content_frame_rate'; @@ -1124,6 +1126,24 @@ class SettingsService extends BaseSharedPreferencesService { return prefs.getBool(_keyEnableDiscordRPC) ?? false; // Default disabled } + // Trakt Scrobbling (sends play/pause/stop during playback to Trakt) + Future 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 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 Future setEnableCompanionRemoteServer(bool enabled) async { await prefs.setBool(_keyEnableCompanionRemoteServer, enabled); @@ -1451,6 +1471,8 @@ class SettingsService extends BaseSharedPreferencesService { prefs.remove(_keyMpvConfigText), prefs.remove(_keyMpvConfigPresets), prefs.remove(_keyEnableDiscordRPC), + prefs.remove(_keyEnableTraktScrobble), + prefs.remove(_keyEnableTraktWatchedSync), prefs.remove(_keyAutoPip), prefs.remove(_keyMatchContentFrameRate), prefs.remove(_keyTunneledPlayback), diff --git a/lib/services/trakt/trakt_account_store.dart b/lib/services/trakt/trakt_account_store.dart new file mode 100644 index 00000000..12a5079e --- /dev/null +++ b/lib/services/trakt/trakt_account_store.dart @@ -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 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 save(String userUuid, TraktSession session) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(traktUserKey(userUuid, _baseKey), session.encode()); + } + + Future clear(String userUuid) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(traktUserKey(userUuid, _baseKey)); + } +} diff --git a/lib/services/trakt/trakt_auth_service.dart b/lib/services/trakt/trakt_auth_service.dart new file mode 100644 index 00000000..de6b1cc1 --- /dev/null +++ b/lib/services/trakt/trakt_auth_service.dart @@ -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 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); + } + + /// 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 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.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; + 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'; +} diff --git a/lib/services/trakt/trakt_client.dart b/lib/services/trakt/trakt_client.dart new file mode 100644 index 00000000..49205d6f --- /dev/null +++ b/lib/services/trakt/trakt_client.dart @@ -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 _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? _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 getUserSettings() async { + final res = await _request('GET', '/users/settings'); + return TraktUser.fromJson(res as Map); + } + + Future scrobbleStart(TraktScrobbleRequest body) => + _request('POST', '/scrobble/start', body: body.toJson(), allowStatuses: _scrobbleAllowedStatuses); + + Future scrobblePause(TraktScrobbleRequest body) => + _request('POST', '/scrobble/pause', body: body.toJson(), allowStatuses: _scrobbleAllowedStatuses); + + Future scrobbleStop(TraktScrobbleRequest body) => + _request('POST', '/scrobble/stop', body: body.toJson(), allowStatuses: _scrobbleAllowedStatuses); + + Future addToHistory(TraktScrobbleRequest item, {String? watchedAt}) => + _request('POST', '/sync/history', body: item.toHistoryAddBody(watchedAt: watchedAt)); + + Future 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 refresh() { + final existing = _refreshLock; + if (existing != null) return existing; + + final lock = _doRefresh(); + _refreshLock = lock; + return lock.whenComplete(() => _refreshLock = null); + } + + Future _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; + _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 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 _request( + String method, + String path, { + Map? body, + Set 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 _send(String method, String path, {Map? 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'; +} diff --git a/lib/services/trakt/trakt_constants.dart b/lib/services/trakt/trakt_constants.dart new file mode 100644 index 00000000..bc8285d9 --- /dev/null +++ b/lib/services/trakt/trakt_constants.dart @@ -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 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')); +} diff --git a/lib/services/trakt/trakt_guid_resolver.dart b/lib/services/trakt/trakt_guid_resolver.dart new file mode 100644 index 00000000..bf9b9fa8 --- /dev/null +++ b/lib/services/trakt/trakt_guid_resolver.dart @@ -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 _cache = {}; + + TraktGuidResolver(this._client); + + /// Resolve external IDs for a movie rating key. + Future 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 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(); +} diff --git a/lib/services/trakt/trakt_scrobble_service.dart b/lib/services/trakt/trakt_scrobble_service.dart new file mode 100644 index 00000000..27868165 --- /dev/null +++ b/lib/services/trakt/trakt_scrobble_service.dart @@ -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 initialize() async { + if (_isInitialized) return; + _isInitialized = true; + final settings = await SettingsService.getInstance(); + _isEnabled = settings.getEnableTraktScrobble(); + } + + Future 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 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 pausePlayback() async { + if (_currentBody == null) return; + await _send(TraktScrobbleState.pause, progress: _progressPercent()); + } + + Future resumePlayback() async { + if (_currentBody == null) return; + await _send(TraktScrobbleState.start, progress: _progressPercent()); + } + + Future stopPlayback() async { + if (_currentBody == null) return; + await _send(TraktScrobbleState.stop, progress: _progressPercent()); + cancelInFlight(); + } + + Future _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 _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 _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); + } + } +} diff --git a/lib/services/trakt/trakt_session.dart b/lib/services/trakt/trakt_session.dart new file mode 100644 index 00000000..8bde107e --- /dev/null +++ b/lib/services/trakt/trakt_session.dart @@ -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 toJson() => { + 'access_token': accessToken, + 'refresh_token': refreshToken, + 'expires_at': expiresAt, + 'username': username, + 'scope': scope, + 'created_at': createdAt, + }; + + factory TraktSession.fromJson(Map 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 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); +} diff --git a/lib/services/trakt/trakt_sync_queue.dart b/lib/services/trakt/trakt_sync_queue.dart new file mode 100644 index 00000000..c893fb8b --- /dev/null +++ b/lib/services/trakt/trakt_sync_queue.dart @@ -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 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 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), + 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> 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; + return list.map((e) => TraktSyncQueueItem.fromJson(e as Map)).toList(); + } catch (_) { + return []; + } + } + + Future save(String userUuid, List 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 add(String userUuid, TraktSyncQueueItem item) async { + final items = await load(userUuid); + items.add(item); + await save(userUuid, items); + } +} diff --git a/lib/services/trakt/trakt_sync_service.dart b/lib/services/trakt/trakt_sync_service.dart new file mode 100644 index 00000000..1911bf37 --- /dev/null +++ b/lib/services/trakt/trakt_sync_service.dart @@ -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? _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 _resolvers = {}; + + bool _isFlushing = false; + + Future 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 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 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 _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 _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 _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 _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 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 = []; + 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.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!, + ), + }; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 247d6c97..732e93bd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -116,6 +116,7 @@ flutter: assets: - assets/plezy.png - assets/plezy_adaptive_foreground.svg + - assets/trakt_circlemark.svg - assets/go-noto-current-regular.ttf - assets/shaders/nvscaler/ - assets/shaders/anime4k/