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/