@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><circle cx="24" cy="24" r="24" fill="transparent"/><path fill="currentColor" d="M45.99 14.39 30.17 30.22l-1.46-1.46 16.31-16.32c-.15-.3-.32-.58-.5-.87l-17.2 17.2 2.16 2.16-1.46 1.46-3.62-3.62L43.34 9.82c-.18-.27-.38-.53-.6-.78L23.01 28.78l4.31 4.31-1.46 1.46-14.4-14.4 1.46-1.46 8.62 8.62L41.37 7.46c-.12-.14-.26-.28-.4-.42C36.62 2.69 30.62 0 24 0S11.38 2.69 7.03 7.03C2.69 11.37 0 17.37 0 24c0 13.25 10.75 24 24 24 6.62 0 12.62-2.69 16.97-7.03C45.31 36.64 48 30.64 48 24c0-3.42-.71-6.67-2.01-9.61ZM21.54 25.9l-7.93-7.92 1.46-1.46L23 24.44l-1.46 1.46Zm2.16-2.16-7.93-7.92 1.46-1.46 7.93 7.92-1.46 1.46Zm.3 19.65c-10.7 0-19.39-8.7-19.39-19.39S13.3 4.61 24 4.61c2.91 0 5.72.63 8.33 1.88l-.89 1.88c-2.33-1.11-4.84-1.68-7.43-1.68-9.55 0-17.33 7.77-17.33 17.32s7.78 17.33 17.33 17.33c6.82 0 13.03-4.03 15.82-10.25l1.89.84C38.6 38.9 31.65 43.41 24.01 43.41Z"/></svg>
|
||||
|
After Width: | Height: | Size: 921 B |
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 に接続できませんでした。もう一度お試しください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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에 연결할 수 없습니다. 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. Попробуйте ещё раз."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsDa with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsDe with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsEs with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsFr with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsIt with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsJa with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsKo with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsNb with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsNl with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsPl with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsPt with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsRu with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsSv with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ class TranslationsZh with BaseTranslations<AppLocale, Translations> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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。请重试。"
|
||||
}
|
||||
}
|
||||
|
||||
+91
-20
@@ -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<void> _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<MainApp> 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<MainApp> 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<MainApp> 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<MainApp> with WidgetsBindingObserver {
|
||||
child: Consumer<ThemeProvider>(
|
||||
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<MainApp> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrates the [TraktAccountProvider] with the active Plex profile's session
|
||||
/// and rebinds the scrobble + sync services whenever the user switches.
|
||||
///
|
||||
/// Lives high in the widget tree (above MaterialApp) so the listener survives
|
||||
/// route changes.
|
||||
class _TraktProfileBootstrap extends StatefulWidget {
|
||||
final Widget child;
|
||||
const _TraktProfileBootstrap({required this.child});
|
||||
|
||||
@override
|
||||
State<_TraktProfileBootstrap> createState() => _TraktProfileBootstrapState();
|
||||
}
|
||||
|
||||
class _TraktProfileBootstrapState extends State<_TraktProfileBootstrap> {
|
||||
UserProfileProvider? _profile;
|
||||
TraktAccountProvider? _trakt;
|
||||
String? _lastUuid;
|
||||
bool _hydrated = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final profile = context.read<UserProfileProvider>();
|
||||
final trakt = context.read<TraktAccountProvider>();
|
||||
|
||||
if (!identical(_profile, profile)) {
|
||||
_profile?.removeListener(_onProfileChanged);
|
||||
_profile = profile;
|
||||
_profile!.addListener(_onProfileChanged);
|
||||
}
|
||||
_trakt = trakt;
|
||||
|
||||
if (!_hydrated) {
|
||||
_hydrated = true;
|
||||
_onProfileChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void _onProfileChanged() {
|
||||
final uuid = _profile?.currentUser?.uuid;
|
||||
if (uuid == _lastUuid) return;
|
||||
_lastUuid = uuid;
|
||||
_trakt?.onActiveProfileChanged(uuid);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_profile?.removeListener(_onProfileChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
|
||||
class OrientationAwareSetup extends StatefulWidget {
|
||||
const OrientationAwareSetup({super.key});
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/// Response from `POST /oauth/device/code`.
|
||||
///
|
||||
/// The user enters [userCode] at [verificationUrl]; the app polls
|
||||
/// `/oauth/device/token` with [deviceCode] every [interval] seconds until
|
||||
/// [expiresIn] seconds elapse.
|
||||
class TraktDeviceCode {
|
||||
final String deviceCode;
|
||||
final String userCode;
|
||||
final String verificationUrl;
|
||||
|
||||
/// URL with the user code prefilled (e.g. `https://trakt.tv/activate/ABC12345`).
|
||||
/// Useful for `url_launcher` so the user doesn't have to type the code.
|
||||
final String? verificationUrlComplete;
|
||||
|
||||
/// Seconds until the device code expires (typically 600).
|
||||
final int expiresIn;
|
||||
|
||||
/// Seconds the app should wait between polls (typically 5).
|
||||
final int interval;
|
||||
|
||||
const TraktDeviceCode({
|
||||
required this.deviceCode,
|
||||
required this.userCode,
|
||||
required this.verificationUrl,
|
||||
required this.expiresIn,
|
||||
required this.interval,
|
||||
this.verificationUrlComplete,
|
||||
});
|
||||
|
||||
factory TraktDeviceCode.fromJson(Map<String, dynamic> json) {
|
||||
final verificationUrl = json['verification_url'] as String;
|
||||
final userCode = json['user_code'] as String;
|
||||
return TraktDeviceCode(
|
||||
deviceCode: json['device_code'] as String,
|
||||
userCode: userCode,
|
||||
verificationUrl: verificationUrl,
|
||||
verificationUrlComplete: '$verificationUrl/$userCode',
|
||||
expiresIn: (json['expires_in'] as num).toInt(),
|
||||
interval: (json['interval'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Discriminated event emitted by `TraktAuthService.pollDeviceCode`.
|
||||
sealed class TraktDevicePollEvent {
|
||||
const TraktDevicePollEvent();
|
||||
}
|
||||
|
||||
class TraktDevicePollPending extends TraktDevicePollEvent {
|
||||
const TraktDevicePollPending();
|
||||
}
|
||||
|
||||
class TraktDevicePollSlowDown extends TraktDevicePollEvent {
|
||||
const TraktDevicePollSlowDown();
|
||||
}
|
||||
|
||||
class TraktDevicePollDenied extends TraktDevicePollEvent {
|
||||
const TraktDevicePollDenied();
|
||||
}
|
||||
|
||||
class TraktDevicePollExpired extends TraktDevicePollEvent {
|
||||
const TraktDevicePollExpired();
|
||||
}
|
||||
|
||||
class TraktDevicePollSuccess extends TraktDevicePollEvent {
|
||||
final Map<String, dynamic> tokenResponse;
|
||||
const TraktDevicePollSuccess(this.tokenResponse);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/// External IDs for matching Plex items against Trakt's catalog.
|
||||
///
|
||||
/// Trakt prefers (in order): trakt > slug > imdb > tmdb > tvdb. Movies use
|
||||
/// imdb/tmdb; episodes use the show's tvdb/tmdb/imdb plus season/episode index.
|
||||
class TraktIds {
|
||||
final int? trakt;
|
||||
final String? slug;
|
||||
final String? imdb;
|
||||
final int? tmdb;
|
||||
final int? tvdb;
|
||||
|
||||
const TraktIds({this.trakt, this.slug, this.imdb, this.tmdb, this.tvdb});
|
||||
|
||||
/// True when at least one external ID is set (i.e. usable for Trakt matching).
|
||||
bool get hasAny => imdb != null || tmdb != null || tvdb != null || trakt != null || slug != null;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (trakt != null) 'trakt': trakt,
|
||||
if (slug != null) 'slug': slug,
|
||||
if (imdb != null) 'imdb': imdb,
|
||||
if (tmdb != null) 'tmdb': tmdb,
|
||||
if (tvdb != null) 'tvdb': tvdb,
|
||||
};
|
||||
|
||||
factory TraktIds.fromJson(Map<String, dynamic> json) => TraktIds(
|
||||
trakt: (json['trakt'] as num?)?.toInt(),
|
||||
slug: json['slug'] as String?,
|
||||
imdb: json['imdb'] as String?,
|
||||
tmdb: (json['tmdb'] as num?)?.toInt(),
|
||||
tvdb: (json['tvdb'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
/// Parse a Plex GUID array (`[{id: 'imdb://tt123'}, {id: 'tmdb://456'}, ...]`)
|
||||
/// into a [TraktIds] struct. The Plex `Guid` field is unparsed in
|
||||
/// `PlexMetadata`; this parser reads the array returned by `?includeGuids=1`.
|
||||
factory TraktIds.fromPlexGuids(List<dynamic> guids) {
|
||||
String? imdb;
|
||||
int? tmdb;
|
||||
int? tvdb;
|
||||
for (final g in guids) {
|
||||
if (g is! Map) continue;
|
||||
final id = g['id'];
|
||||
if (id is! String) continue;
|
||||
if (id.startsWith('imdb://')) {
|
||||
imdb = id.substring(7);
|
||||
} else if (id.startsWith('tmdb://')) {
|
||||
tmdb = int.tryParse(id.substring(7));
|
||||
} else if (id.startsWith('tvdb://')) {
|
||||
tvdb = int.tryParse(id.substring(7));
|
||||
}
|
||||
}
|
||||
return TraktIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'trakt_ids.dart';
|
||||
|
||||
/// Body for `POST /scrobble/{start|pause|stop}` and `POST /sync/history`.
|
||||
///
|
||||
/// Either movie IDs or show IDs + season/episode are set, never both.
|
||||
/// [progress] is the percent (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<String, dynamic> toJson() => {
|
||||
if (_movieIds != null) 'movie': {'ids': _movieIds.toJson()},
|
||||
if (_showIds != null) 'show': {'ids': _showIds.toJson()},
|
||||
if (_season != null && _episode != null) 'episode': {'season': _season, 'number': _episode},
|
||||
'progress': ?progress,
|
||||
};
|
||||
|
||||
/// Build a `POST /sync/history` body that adds this item to history.
|
||||
///
|
||||
/// Optional [watchedAt] (ISO-8601 UTC) lets the server attribute the play
|
||||
/// to a specific point in time; defaults to "now" on Trakt's side.
|
||||
Map<String, dynamic> toHistoryAddBody({String? watchedAt}) {
|
||||
if (isMovie) {
|
||||
return {
|
||||
'movies': [
|
||||
{'watched_at': ?watchedAt, 'ids': _movieIds!.toJson()},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
'shows': [
|
||||
{
|
||||
'ids': _showIds!.toJson(),
|
||||
'seasons': [
|
||||
{
|
||||
'number': _season,
|
||||
'episodes': [
|
||||
{'watched_at': ?watchedAt, 'number': _episode},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/// Build a `POST /sync/history/remove` body that removes this item from history.
|
||||
Map<String, dynamic> toHistoryRemoveBody() {
|
||||
if (isMovie) {
|
||||
return {
|
||||
'movies': [
|
||||
{'ids': _movieIds!.toJson()},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
'shows': [
|
||||
{
|
||||
'ids': _showIds!.toJson(),
|
||||
'seasons': [
|
||||
{
|
||||
'number': _season,
|
||||
'episodes': [
|
||||
{'number': _episode},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/// Minimal Trakt user info parsed from `GET /users/settings`.
|
||||
class TraktUser {
|
||||
final String username;
|
||||
final String? name;
|
||||
|
||||
const TraktUser({required this.username, this.name});
|
||||
|
||||
factory TraktUser.fromJson(Map<String, dynamic> json) {
|
||||
final user = json['user'] as Map<String, dynamic>?;
|
||||
if (user == null) {
|
||||
throw const FormatException('Trakt /users/settings response missing "user" field');
|
||||
}
|
||||
return TraktUser(username: user['username'] as String, name: user['name'] as String?);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/trakt/trakt_device_code.dart';
|
||||
import '../services/trakt/trakt_account_store.dart';
|
||||
import '../services/trakt/trakt_auth_service.dart';
|
||||
import '../services/trakt/trakt_client.dart';
|
||||
import '../services/trakt/trakt_scrobble_service.dart';
|
||||
import '../services/trakt/trakt_session.dart';
|
||||
import '../services/trakt/trakt_sync_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Owns the active Trakt session for the currently-selected Plex profile.
|
||||
///
|
||||
/// Single rebind seam: `onActiveProfileChanged` loads the new profile's
|
||||
/// session from `TraktAccountStore` and pushes it to both
|
||||
/// `TraktScrobbleService` and `TraktSyncService`.
|
||||
class TraktAccountProvider extends ChangeNotifier {
|
||||
final TraktAuthService _auth = TraktAuthService();
|
||||
final TraktAccountStore _store = TraktAccountStore.instance;
|
||||
|
||||
TraktSession? _session;
|
||||
String _activeUserUuid = '';
|
||||
bool _isConnecting = false;
|
||||
bool _cancelRequested = false;
|
||||
|
||||
TraktSession? get session => _session;
|
||||
bool get isConnected => _session != null;
|
||||
String? get username => _session?.username;
|
||||
bool get isConnecting => _isConnecting;
|
||||
|
||||
/// Cancel an in-flight `connect()` (e.g. user dismissed the device-code
|
||||
/// dialog). The poll loop checks this flag between iterations.
|
||||
void cancelConnect() {
|
||||
_cancelRequested = true;
|
||||
}
|
||||
|
||||
/// Called whenever the active Plex profile changes (or on initial load).
|
||||
Future<void> onActiveProfileChanged(String? newUserUuid) async {
|
||||
_activeUserUuid = newUserUuid ?? '';
|
||||
final loaded = await _store.load(_activeUserUuid);
|
||||
_setSessionAndRebind(loaded);
|
||||
}
|
||||
|
||||
/// Run the device-code OAuth flow.
|
||||
///
|
||||
/// [onCodeReady] is invoked once with the user code + verification URL so
|
||||
/// the UI can render the dialog.
|
||||
Future<bool> connect({required void Function(TraktDeviceCode code) onCodeReady}) async {
|
||||
if (_isConnecting) return false;
|
||||
_isConnecting = true;
|
||||
_cancelRequested = false;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final code = await _auth.createDeviceCode();
|
||||
onCodeReady(code);
|
||||
|
||||
await for (final event in _auth.pollDeviceCode(code, shouldCancel: () => _cancelRequested)) {
|
||||
if (event is TraktDevicePollSuccess) {
|
||||
await _completeConnect(event.tokenResponse);
|
||||
return true;
|
||||
}
|
||||
if (event is TraktDevicePollDenied || event is TraktDevicePollExpired) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
appLogger.w('Trakt connect failed', error: e);
|
||||
return false;
|
||||
} finally {
|
||||
_isConnecting = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _completeConnect(Map<String, dynamic> tokenResponse) async {
|
||||
final raw = TraktSession.fromTokenResponse(tokenResponse);
|
||||
// Best-effort fetch of the username for display.
|
||||
TraktSession enriched = raw;
|
||||
try {
|
||||
final tmpClient = TraktClient(raw, onSessionInvalidated: () {});
|
||||
final user = await tmpClient.getUserSettings();
|
||||
enriched = raw.copyWith(username: user.username);
|
||||
tmpClient.dispose();
|
||||
} catch (e) {
|
||||
appLogger.d('Trakt: getUserSettings failed (non-fatal)', error: e);
|
||||
}
|
||||
|
||||
await _store.save(_activeUserUuid, enriched);
|
||||
_setSessionAndRebind(enriched);
|
||||
}
|
||||
|
||||
/// Revoke the access token and clear local state.
|
||||
Future<void> disconnect() async {
|
||||
final session = _session;
|
||||
if (session != null) {
|
||||
final client = TraktClient(session, onSessionInvalidated: () {});
|
||||
await client.revoke();
|
||||
client.dispose();
|
||||
}
|
||||
await _store.clear(_activeUserUuid);
|
||||
_setSessionAndRebind(null);
|
||||
}
|
||||
|
||||
void _setSessionAndRebind(TraktSession? session) {
|
||||
_session = session;
|
||||
TraktScrobbleService.instance.rebindToProfile(session, onSessionInvalidated: _handleSessionInvalidated);
|
||||
TraktSyncService.instance.rebindToProfile(
|
||||
_activeUserUuid,
|
||||
session,
|
||||
onSessionInvalidated: _handleSessionInvalidated,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Called by [TraktClient] when refresh fails permanently. Clears local state
|
||||
/// so the UI shows "not connected" and the user can re-link.
|
||||
void _handleSessionInvalidated() {
|
||||
_store.clear(_activeUserUuid);
|
||||
_setSessionAndRebind(null);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_auth.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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<SettingsScreen> 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<SettingsScreen> with FocusableTab {
|
||||
// --- Playback (navigation tile) ---
|
||||
_buildPlaybackTile(),
|
||||
|
||||
// --- Trakt.tv (navigation tile) ---
|
||||
_buildTraktTile(),
|
||||
|
||||
// --- Downloads (inline) ---
|
||||
_buildDownloadsSection(),
|
||||
|
||||
@@ -215,6 +222,37 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTraktTile() {
|
||||
return Consumer<TraktAccountProvider>(
|
||||
builder: (context, account, _) {
|
||||
final connected = account.isConnected;
|
||||
final username = account.username;
|
||||
final iconColor = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface;
|
||||
return ListTile(
|
||||
focusNode: _focusTracker.get(_kTrakt),
|
||||
leading: SvgPicture.asset(
|
||||
'assets/trakt_circlemark.svg',
|
||||
width: 24,
|
||||
height: 24,
|
||||
colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn),
|
||||
),
|
||||
title: Text(t.settings.trakt),
|
||||
subtitle: Text(
|
||||
connected && username != null ? t.trakt.connectedAs(username: username) : t.settings.traktDescription,
|
||||
),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () {
|
||||
if (account.isConnected) {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const TraktSettingsScreen()));
|
||||
} else {
|
||||
startTraktConnection(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadsSection() {
|
||||
final storageService = DownloadStorageService.instance;
|
||||
final isCustom = storageService.isUsingCustomPath();
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../models/trakt/trakt_device_code.dart';
|
||||
import '../../providers/trakt_account_provider.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../services/trakt/trakt_scrobble_service.dart';
|
||||
import '../../services/trakt/trakt_sync_service.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../../widgets/settings_section.dart';
|
||||
|
||||
/// Start the Trakt device-code OAuth flow from anywhere in the app.
|
||||
///
|
||||
/// Shows the code dialog + polls until the user completes authorization.
|
||||
/// On pointer-driven platforms (desktop + mobile when dpad/keyboard mode
|
||||
/// isn't active) this additionally auto-launches the browser at
|
||||
/// `trakt.tv/activate` with the code prefilled — the dialog still shows
|
||||
/// as a fallback/progress indicator.
|
||||
Future<void> startTraktConnection(BuildContext context) async {
|
||||
final account = context.read<TraktAccountProvider>();
|
||||
if (account.isConnecting || account.isConnected) return;
|
||||
|
||||
final autoLaunchBrowser = !InputModeTracker.isKeyboardMode(context);
|
||||
var dialogOpen = false;
|
||||
|
||||
final ok = await account.connect(
|
||||
onCodeReady: (code) {
|
||||
if (!context.mounted) return;
|
||||
dialogOpen = true;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _DeviceCodeDialog(code: code, account: account),
|
||||
).whenComplete(() => dialogOpen = false);
|
||||
if (autoLaunchBrowser) {
|
||||
final url = code.verificationUrlComplete ?? code.verificationUrl;
|
||||
unawaited(
|
||||
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication).catchError((Object e) {
|
||||
appLogger.d('Trakt: failed to auto-launch browser', error: e);
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Close the dialog iff we showed one and it's still up (not already closed by
|
||||
// the Cancel button). This is the ONLY site that dismisses the dialog —
|
||||
// popping here and having the dialog self-pop would pop the screen behind.
|
||||
if (dialogOpen) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
|
||||
if (!ok && account.session == null) {
|
||||
showAppSnackBar(context, t.trakt.connectFailed);
|
||||
}
|
||||
}
|
||||
|
||||
class TraktSettingsScreen extends StatefulWidget {
|
||||
const TraktSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<TraktSettingsScreen> createState() => _TraktSettingsScreenState();
|
||||
}
|
||||
|
||||
class _TraktSettingsScreenState extends State<TraktSettingsScreen> {
|
||||
SettingsService? _settings;
|
||||
bool _scrobbleEnabled = true;
|
||||
bool _watchedSyncEnabled = true;
|
||||
bool _loaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final s = await SettingsService.getInstance();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_settings = s;
|
||||
_scrobbleEnabled = s.getEnableTraktScrobble();
|
||||
_watchedSyncEnabled = s.getEnableTraktWatchedSync();
|
||||
_loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _disconnect(TraktAccountProvider account) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(t.trakt.disconnectConfirm),
|
||||
content: Text(t.trakt.disconnectConfirmBody),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.common.cancel)),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(t.common.disconnect, style: TextStyle(color: Theme.of(ctx).colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await account.disconnect();
|
||||
// build()'s post-frame handler pops the screen once the provider rebuilds
|
||||
// with isConnected == false — don't pop here too.
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_loaded) {
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(t.trakt.title),
|
||||
slivers: const [SliverFillRemaining(child: Center(child: CircularProgressIndicator()))],
|
||||
);
|
||||
}
|
||||
|
||||
return Consumer<TraktAccountProvider>(
|
||||
builder: (context, account, _) {
|
||||
// Safety net: if we end up here while not connected (e.g. refresh failed
|
||||
// in the background and cleared the session), bail out. The settings
|
||||
// tile is the only supported entry point for the unauthed flow.
|
||||
if (!account.isConnected) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
});
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(t.trakt.title),
|
||||
slivers: const [SliverFillRemaining(child: SizedBox.shrink())],
|
||||
);
|
||||
}
|
||||
|
||||
final username = account.username;
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(t.trakt.title),
|
||||
slivers: [
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.account_circle_rounded, fill: 1),
|
||||
title: Text(username != null ? t.trakt.connectedAs(username: username) : t.trakt.connected),
|
||||
subtitle: Text(t.trakt.connected),
|
||||
),
|
||||
SettingsSectionHeader(t.settings.behavior),
|
||||
SwitchListTile(
|
||||
secondary: const AppIcon(Symbols.auto_timer, fill: 1),
|
||||
title: Text(t.trakt.scrobble),
|
||||
subtitle: Text(t.trakt.scrobbleDescription),
|
||||
value: _scrobbleEnabled,
|
||||
onChanged: (value) async {
|
||||
setState(() => _scrobbleEnabled = value);
|
||||
await _settings!.setEnableTraktScrobble(value);
|
||||
await TraktScrobbleService.instance.setEnabled(value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const AppIcon(Symbols.check_circle_rounded, fill: 1),
|
||||
title: Text(t.trakt.watchedSync),
|
||||
subtitle: Text(t.trakt.watchedSyncDescription),
|
||||
value: _watchedSyncEnabled,
|
||||
onChanged: (value) async {
|
||||
setState(() => _watchedSyncEnabled = value);
|
||||
await _settings!.setEnableTraktWatchedSync(value);
|
||||
await TraktSyncService.instance.setEnabled(value);
|
||||
},
|
||||
),
|
||||
const Divider(height: 32),
|
||||
ListTile(
|
||||
leading: AppIcon(Symbols.link_off_rounded, fill: 1, color: Theme.of(context).colorScheme.error),
|
||||
title: Text(t.common.disconnect, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
onTap: () => _disconnect(account),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceCodeDialog extends StatefulWidget {
|
||||
final TraktDeviceCode code;
|
||||
final TraktAccountProvider account;
|
||||
const _DeviceCodeDialog({required this.code, required this.account});
|
||||
|
||||
@override
|
||||
State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState();
|
||||
}
|
||||
|
||||
class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
|
||||
Future<void> _open() async {
|
||||
final url = widget.code.verificationUrlComplete ?? widget.code.verificationUrl;
|
||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<void> _copy() async {
|
||||
await Clipboard.setData(ClipboardData(text: widget.code.userCode));
|
||||
if (!mounted) return;
|
||||
showAppSnackBar(context, t.trakt.codeCopied);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(t.trakt.deviceCodeTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(t.trakt.deviceCodeBody(url: widget.code.verificationUrl), style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: InkWell(
|
||||
onTap: _copy,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Text(
|
||||
widget.code.userCode,
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
letterSpacing: 4,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(t.trakt.openTraktActivate),
|
||||
onPressed: _open,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(t.trakt.waitingForAuthorization, style: theme.textTheme.bodySmall)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.account.cancelConnect();
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
},
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
if (client != null) {
|
||||
DiscordRPCService.instance.startPlayback(episodeMetadata, client);
|
||||
TraktScrobbleService.instance.startPlayback(episodeMetadata, client, isLive: widget.isLive);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -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<void> _syncAction(PlexClient client, OfflineWatchProgressItem action) async {
|
||||
// Fetch metadata so the WatchStateNotifier emission inside
|
||||
// markAsWatched/markAsUnwatched carries enough context for downstream
|
||||
// listeners (UI invalidation, Trakt sync). Best-effort: a missed metadata
|
||||
// fetch only suppresses the event, not the Plex API call.
|
||||
final emitsEvent =
|
||||
action.actionType == 'watched' ||
|
||||
action.actionType == 'unwatched' ||
|
||||
(action.actionType == 'progress' && action.shouldMarkWatched);
|
||||
PlexMetadata? metadata;
|
||||
if (emitsEvent) {
|
||||
try {
|
||||
metadata = await client.getMetadataWithImages(action.ratingKey);
|
||||
} catch (_) {
|
||||
// Proceed without metadata — Plex still gets the call.
|
||||
}
|
||||
}
|
||||
|
||||
switch (action.actionType) {
|
||||
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');
|
||||
|
||||
@@ -1348,6 +1348,30 @@ class PlexClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the raw `Guid` array for a metadata item (`includeGuids=1`).
|
||||
///
|
||||
/// Returns the list of `{id: 'imdb://tt...'}` maps as Plex returns them, or
|
||||
/// an empty list if the item has no external IDs / can't be fetched.
|
||||
/// Used by the Trakt integration to match Plex items against Trakt's catalog.
|
||||
Future<List<dynamic>> fetchExternalGuids(String ratingKey) async {
|
||||
try {
|
||||
final response = await _getWithFailover('/library/metadata/$ratingKey', queryParameters: {'includeGuids': 1});
|
||||
final data = response.data;
|
||||
if (data is! Map) return const [];
|
||||
final container = data['MediaContainer'] as Map?;
|
||||
final metadata = container?['Metadata'];
|
||||
if (metadata is! List || metadata.isEmpty) return const [];
|
||||
final first = metadata.first;
|
||||
if (first is! Map) return const [];
|
||||
final guids = first['Guid'];
|
||||
if (guids is List) return guids;
|
||||
return const [];
|
||||
} catch (e) {
|
||||
appLogger.d('fetchExternalGuids failed for $ratingKey', error: e);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark media as watched
|
||||
///
|
||||
/// If [metadata] is provided, emits a [WatchStateEvent] for UI updates.
|
||||
|
||||
@@ -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<void> setEnableTraktScrobble(bool enabled) async {
|
||||
await prefs.setBool(_keyEnableTraktScrobble, enabled);
|
||||
}
|
||||
|
||||
bool getEnableTraktScrobble() {
|
||||
return prefs.getBool(_keyEnableTraktScrobble) ?? true; // Default enabled when account is linked
|
||||
}
|
||||
|
||||
// Trakt Watched-Status Sync (Plezy → Trakt only)
|
||||
Future<void> setEnableTraktWatchedSync(bool enabled) async {
|
||||
await prefs.setBool(_keyEnableTraktWatchedSync, enabled);
|
||||
}
|
||||
|
||||
bool getEnableTraktWatchedSync() {
|
||||
return prefs.getBool(_keyEnableTraktWatchedSync) ?? true; // Default enabled when account is linked
|
||||
}
|
||||
|
||||
// Companion Remote Server
|
||||
Future<void> 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),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'trakt_constants.dart';
|
||||
import 'trakt_session.dart';
|
||||
|
||||
/// Per-Plex-profile persistence of Trakt OAuth sessions.
|
||||
///
|
||||
/// Each Plex Home user can link a different Trakt account. Pass an empty
|
||||
/// `userUuid` to fall back to a single global slot (used before the user has
|
||||
/// selected a profile).
|
||||
class TraktAccountStore {
|
||||
static const String _baseKey = 'trakt_session';
|
||||
|
||||
TraktAccountStore._();
|
||||
static final TraktAccountStore instance = TraktAccountStore._();
|
||||
|
||||
Future<TraktSession?> load(String userUuid) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(traktUserKey(userUuid, _baseKey));
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
return TraktSession.decode(raw);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save(String userUuid, TraktSession session) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(traktUserKey(userUuid, _baseKey), session.encode());
|
||||
}
|
||||
|
||||
Future<void> clear(String userUuid) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(traktUserKey(userUuid, _baseKey));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../models/trakt/trakt_device_code.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/platform_http_client_stub.dart'
|
||||
if (dart.library.io) '../../utils/platform_http_client_io.dart'
|
||||
as platform;
|
||||
import 'trakt_constants.dart';
|
||||
|
||||
/// Trakt OAuth Device Authorization Grant flow (RFC 8628).
|
||||
///
|
||||
/// Mirrors the shape of `PlexAuthService.pollPinUntilClaimed`. The user enters
|
||||
/// a short code at `trakt.tv/activate` (in any browser); the app polls
|
||||
/// `/oauth/device/token` until the user completes the flow.
|
||||
class TraktAuthService {
|
||||
final http.Client _http;
|
||||
|
||||
TraktAuthService({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient();
|
||||
|
||||
void dispose() => _http.close();
|
||||
|
||||
/// Request a device code. The returned [TraktDeviceCode.userCode] is what
|
||||
/// the user must enter at [TraktDeviceCode.verificationUrl].
|
||||
Future<TraktDeviceCode> createDeviceCode() async {
|
||||
final uri = Uri.parse(TraktConstants.deviceCodeUrl);
|
||||
final sw = Stopwatch()..start();
|
||||
final res = await _http
|
||||
.post(uri, headers: TraktConstants.headers(), body: json.encode({'client_id': TraktConstants.clientId}))
|
||||
.timeout(const Duration(seconds: 15));
|
||||
sw.stop();
|
||||
appLogger.d('Trakt POST ${uri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)');
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
throw TraktAuthFlowException('Device code request failed: HTTP ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
|
||||
return TraktDeviceCode.fromJson(json.decode(res.body) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// Poll the device-token endpoint until the user authorizes, denies, or the
|
||||
/// code expires. Yields one event per poll attempt; the stream completes
|
||||
/// after the first terminal event ([TraktDevicePollSuccess],
|
||||
/// [TraktDevicePollDenied], [TraktDevicePollExpired]).
|
||||
///
|
||||
/// Pass [shouldCancel] to abort polling early (e.g. user dismissed the
|
||||
/// dialog).
|
||||
Stream<TraktDevicePollEvent> pollDeviceCode(TraktDeviceCode code, {bool Function()? shouldCancel}) async* {
|
||||
var interval = Duration(seconds: code.interval);
|
||||
final deadline = DateTime.now().add(Duration(seconds: code.expiresIn));
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (shouldCancel != null && shouldCancel()) return;
|
||||
|
||||
await Future<void>.delayed(interval);
|
||||
|
||||
if (shouldCancel != null && shouldCancel()) return;
|
||||
|
||||
http.Response res;
|
||||
final tokenUri = Uri.parse(TraktConstants.deviceTokenUrl);
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
res = await _http
|
||||
.post(
|
||||
tokenUri,
|
||||
headers: TraktConstants.headers(),
|
||||
body: json.encode({
|
||||
'code': code.deviceCode,
|
||||
'client_id': TraktConstants.clientId,
|
||||
'client_secret': TraktConstants.clientSecret,
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
sw.stop();
|
||||
appLogger.d('Trakt POST ${tokenUri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)');
|
||||
} catch (e) {
|
||||
appLogger.d('Trakt device-code poll error (transient)', error: e);
|
||||
yield const TraktDevicePollPending();
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (res.statusCode) {
|
||||
case 200:
|
||||
final body = json.decode(res.body) as Map<String, dynamic>;
|
||||
yield TraktDevicePollSuccess(body);
|
||||
return;
|
||||
case 400:
|
||||
// Pending — keep polling
|
||||
yield const TraktDevicePollPending();
|
||||
break;
|
||||
case 404:
|
||||
// Code not found / expired
|
||||
yield const TraktDevicePollExpired();
|
||||
return;
|
||||
case 409:
|
||||
// Already used
|
||||
yield const TraktDevicePollDenied();
|
||||
return;
|
||||
case 410:
|
||||
yield const TraktDevicePollExpired();
|
||||
return;
|
||||
case 418:
|
||||
// Denied
|
||||
yield const TraktDevicePollDenied();
|
||||
return;
|
||||
case 429:
|
||||
// Slow down — increase interval by 5s
|
||||
interval += const Duration(seconds: 5);
|
||||
yield const TraktDevicePollSlowDown();
|
||||
break;
|
||||
default:
|
||||
appLogger.w('Trakt device-code unexpected HTTP ${res.statusCode}: ${res.body}');
|
||||
yield const TraktDevicePollPending();
|
||||
}
|
||||
}
|
||||
|
||||
yield const TraktDevicePollExpired();
|
||||
}
|
||||
}
|
||||
|
||||
class TraktAuthFlowException implements Exception {
|
||||
final String message;
|
||||
const TraktAuthFlowException(this.message);
|
||||
@override
|
||||
String toString() => 'TraktAuthFlowException: $message';
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../models/trakt/trakt_scrobble_request.dart';
|
||||
import '../../models/trakt/trakt_user.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/platform_http_client_stub.dart'
|
||||
if (dart.library.io) '../../utils/platform_http_client_io.dart'
|
||||
as platform;
|
||||
import 'trakt_constants.dart';
|
||||
import 'trakt_session.dart';
|
||||
|
||||
/// HTTP wrapper for the Trakt REST API.
|
||||
///
|
||||
/// Holds a [TraktSession] (refreshed in place on 401). Concurrent 401s are
|
||||
/// coalesced via [_refreshLock] so we only hit `/oauth/token` once per refresh.
|
||||
class TraktClient {
|
||||
static const Duration _requestTimeout = Duration(seconds: 20);
|
||||
static const Duration _refreshTimeout = Duration(seconds: 15);
|
||||
static const Duration _revokeTimeout = Duration(seconds: 10);
|
||||
static const Set<int> _scrobbleAllowedStatuses = {200, 201, 409};
|
||||
|
||||
TraktSession _session;
|
||||
final http.Client _http;
|
||||
|
||||
/// Fired when refresh fails permanently (e.g. `invalid_grant`). The provider
|
||||
/// uses this to clear the stored session and notify the UI.
|
||||
final void Function() onSessionInvalidated;
|
||||
|
||||
Future<TraktSession>? _refreshLock;
|
||||
|
||||
TraktClient(TraktSession session, {required this.onSessionInvalidated, http.Client? httpClient})
|
||||
: _session = session,
|
||||
_http = httpClient ?? platform.createPlatformClient();
|
||||
|
||||
TraktSession get session => _session;
|
||||
|
||||
void dispose() => _http.close();
|
||||
|
||||
Future<TraktUser> getUserSettings() async {
|
||||
final res = await _request('GET', '/users/settings');
|
||||
return TraktUser.fromJson(res as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> scrobbleStart(TraktScrobbleRequest body) =>
|
||||
_request('POST', '/scrobble/start', body: body.toJson(), allowStatuses: _scrobbleAllowedStatuses);
|
||||
|
||||
Future<void> scrobblePause(TraktScrobbleRequest body) =>
|
||||
_request('POST', '/scrobble/pause', body: body.toJson(), allowStatuses: _scrobbleAllowedStatuses);
|
||||
|
||||
Future<void> scrobbleStop(TraktScrobbleRequest body) =>
|
||||
_request('POST', '/scrobble/stop', body: body.toJson(), allowStatuses: _scrobbleAllowedStatuses);
|
||||
|
||||
Future<void> addToHistory(TraktScrobbleRequest item, {String? watchedAt}) =>
|
||||
_request('POST', '/sync/history', body: item.toHistoryAddBody(watchedAt: watchedAt));
|
||||
|
||||
Future<void> removeFromHistory(TraktScrobbleRequest item) =>
|
||||
_request('POST', '/sync/history/remove', body: item.toHistoryRemoveBody());
|
||||
|
||||
/// Refresh the access token. Coalesces concurrent calls via [_refreshLock] so
|
||||
/// duplicate POSTs don't race when multiple in-flight requests hit 401.
|
||||
Future<TraktSession> refresh() {
|
||||
final existing = _refreshLock;
|
||||
if (existing != null) return existing;
|
||||
|
||||
final lock = _doRefresh();
|
||||
_refreshLock = lock;
|
||||
return lock.whenComplete(() => _refreshLock = null);
|
||||
}
|
||||
|
||||
Future<TraktSession> _doRefresh() async {
|
||||
appLogger.d('Trakt: refreshing access token');
|
||||
final res = await _http
|
||||
.post(
|
||||
Uri.parse(TraktConstants.tokenUrl),
|
||||
headers: TraktConstants.headers(),
|
||||
body: json.encode({
|
||||
'refresh_token': _session.refreshToken,
|
||||
'client_id': TraktConstants.clientId,
|
||||
'client_secret': TraktConstants.clientSecret,
|
||||
'grant_type': 'refresh_token',
|
||||
}),
|
||||
)
|
||||
.timeout(_refreshTimeout);
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
final body = json.decode(res.body) as Map<String, dynamic>;
|
||||
_session = TraktSession.fromTokenResponse(body).copyWith(username: _session.username);
|
||||
return _session;
|
||||
}
|
||||
|
||||
appLogger.w('Trakt: refresh failed (${res.statusCode}), session invalidated');
|
||||
onSessionInvalidated();
|
||||
throw TraktAuthException('Refresh failed: HTTP ${res.statusCode}');
|
||||
}
|
||||
|
||||
/// Revoke the access token at Trakt. Best-effort; swallows network errors.
|
||||
Future<void> revoke() async {
|
||||
try {
|
||||
await _http
|
||||
.post(
|
||||
Uri.parse(TraktConstants.revokeUrl),
|
||||
headers: TraktConstants.headers(),
|
||||
body: json.encode({
|
||||
'token': _session.accessToken,
|
||||
'client_id': TraktConstants.clientId,
|
||||
'client_secret': TraktConstants.clientSecret,
|
||||
}),
|
||||
)
|
||||
.timeout(_revokeTimeout);
|
||||
} catch (e) {
|
||||
appLogger.d('Trakt: revoke failed (non-fatal)', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an authenticated request, refreshing on 401 and retrying once.
|
||||
Future<dynamic> _request(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, dynamic>? body,
|
||||
Set<int> allowStatuses = const {200, 201, 204},
|
||||
}) async {
|
||||
if (_session.needsRefresh) {
|
||||
try {
|
||||
await refresh();
|
||||
} catch (_) {
|
||||
// Fall through; the request will hit 401 naturally and retry.
|
||||
}
|
||||
}
|
||||
|
||||
var res = await _send(method, path, body: body);
|
||||
|
||||
if (res.statusCode == 401) {
|
||||
await refresh();
|
||||
res = await _send(method, path, body: body);
|
||||
}
|
||||
|
||||
if (allowStatuses.contains(res.statusCode)) {
|
||||
if (res.body.isEmpty) return null;
|
||||
try {
|
||||
return json.decode(res.body);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (res.statusCode == 429) {
|
||||
throw TraktRateLimitException(retryAfterSeconds: int.tryParse(res.headers['retry-after'] ?? ''));
|
||||
}
|
||||
|
||||
throw TraktApiException(statusCode: res.statusCode, body: res.body);
|
||||
}
|
||||
|
||||
Future<http.Response> _send(String method, String path, {Map<String, dynamic>? body}) async {
|
||||
final uri = Uri.parse('${TraktConstants.apiBase}$path');
|
||||
final headers = TraktConstants.headers(accessToken: _session.accessToken);
|
||||
final encoded = body == null ? null : json.encode(body);
|
||||
|
||||
final sw = Stopwatch()..start();
|
||||
final res = await switch (method) {
|
||||
'GET' => _http.get(uri, headers: headers),
|
||||
'POST' => _http.post(uri, headers: headers, body: encoded),
|
||||
'PUT' => _http.put(uri, headers: headers, body: encoded),
|
||||
'DELETE' => _http.delete(uri, headers: headers),
|
||||
_ => throw ArgumentError('Unsupported HTTP method: $method'),
|
||||
}.timeout(_requestTimeout);
|
||||
sw.stop();
|
||||
|
||||
appLogger.d('Trakt $method ${uri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)');
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
class TraktApiException implements Exception {
|
||||
final int statusCode;
|
||||
final String body;
|
||||
const TraktApiException({required this.statusCode, required this.body});
|
||||
@override
|
||||
String toString() => 'TraktApiException(HTTP $statusCode): $body';
|
||||
}
|
||||
|
||||
class TraktRateLimitException implements Exception {
|
||||
final int? retryAfterSeconds;
|
||||
const TraktRateLimitException({this.retryAfterSeconds});
|
||||
@override
|
||||
String toString() => 'TraktRateLimitException(retry-after: $retryAfterSeconds s)';
|
||||
}
|
||||
|
||||
class TraktAuthException implements Exception {
|
||||
final String message;
|
||||
const TraktAuthException(this.message);
|
||||
@override
|
||||
String toString() => 'TraktAuthException: $message';
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/// Bundled Trakt API credentials and base URLs.
|
||||
///
|
||||
/// The client_id/client_secret are extractable from the binary; this is
|
||||
/// the standard pattern for native Trakt apps and is acceptable given the
|
||||
/// same threat model as the Plex token already in `SharedPreferences`.
|
||||
class TraktConstants {
|
||||
TraktConstants._();
|
||||
|
||||
// Registered Trakt app credentials. Same threat model as the Plex token in
|
||||
// SharedPreferences — extractable from the binary, but acceptable for a
|
||||
// native client app. To rotate, update the registration at
|
||||
// https://trakt.tv/oauth/applications.
|
||||
static const String clientId = '9861e686e95c13409dd321736f903973cb9b8e5c6abd0634bec8962f52ea30f4';
|
||||
static const String clientSecret = 'acfa17b9d77fabd7e51175b7da6631aea69423530a6d49b3b3c38cd107cbd207';
|
||||
|
||||
static const String apiBase = 'https://api.trakt.tv';
|
||||
static const String apiVersion = '2';
|
||||
|
||||
// OAuth endpoints
|
||||
static const String deviceCodeUrl = '$apiBase/oauth/device/code';
|
||||
static const String deviceTokenUrl = '$apiBase/oauth/device/token';
|
||||
static const String tokenUrl = '$apiBase/oauth/token';
|
||||
static const String revokeUrl = '$apiBase/oauth/revoke';
|
||||
|
||||
// Scopes — Trakt's OAuth doesn't use granular scopes; "public" is the only value
|
||||
static const String scope = 'public';
|
||||
|
||||
/// Headers required on every Trakt API call.
|
||||
static Map<String, String> headers({String? accessToken}) {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'trakt-api-version': apiVersion,
|
||||
'trakt-api-key': clientId,
|
||||
if (accessToken != null) 'Authorization': 'Bearer $accessToken',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `SharedPreferences` key scoped to the given Plex profile UUID.
|
||||
/// Mirrors the `_userPrefix` pattern in [StorageService] so each profile gets
|
||||
/// its own Trakt session and sync queue.
|
||||
String traktUserKey(String userUuid, String baseKey) => userUuid.isEmpty ? baseKey : 'user_${userUuid}_$baseKey';
|
||||
|
||||
/// Scrobble lifecycle state sent to Trakt's `/scrobble/{name}` endpoints.
|
||||
enum TraktScrobbleState { start, pause, stop }
|
||||
|
||||
/// Direction of a watched-status sync push.
|
||||
enum TraktSyncOp {
|
||||
add,
|
||||
remove;
|
||||
|
||||
static TraktSyncOp fromName(String name) =>
|
||||
values.firstWhere((v) => v.name == name, orElse: () => throw ArgumentError('Unknown TraktSyncOp: $name'));
|
||||
}
|
||||
|
||||
/// Trakt-relevant Plex media types.
|
||||
enum TraktMediaKind {
|
||||
movie,
|
||||
episode;
|
||||
|
||||
static TraktMediaKind? tryFromPlexType(String type) => switch (type) {
|
||||
'movie' => movie,
|
||||
'episode' => episode,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
static TraktMediaKind fromName(String name) =>
|
||||
values.firstWhere((v) => v.name == name, orElse: () => throw ArgumentError('Unknown TraktMediaKind: $name'));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../models/trakt/trakt_ids.dart';
|
||||
import '../plex_client.dart';
|
||||
|
||||
/// Resolves Plex rating keys to external IDs (imdb/tmdb/tvdb) for Trakt
|
||||
/// matching. Caches per ratingKey for the duration of a play session.
|
||||
///
|
||||
/// Trakt prefers a show-IDs-plus-season/episode shape for episode scrobbles
|
||||
/// rather than episode-IDs alone, so episode resolution requires a separate
|
||||
/// fetch on `grandparentRatingKey` to get the show's GUIDs.
|
||||
class TraktGuidResolver {
|
||||
final PlexClient _client;
|
||||
|
||||
/// Cache of `ratingKey → TraktIds`. Holds both movies and shows.
|
||||
final Map<String, TraktIds> _cache = {};
|
||||
|
||||
TraktGuidResolver(this._client);
|
||||
|
||||
/// Resolve external IDs for a movie rating key.
|
||||
Future<TraktIds> resolveForMovie(String ratingKey) async {
|
||||
final cached = _cache[ratingKey];
|
||||
if (cached != null) return cached;
|
||||
|
||||
final guids = await _client.fetchExternalGuids(ratingKey);
|
||||
final ids = TraktIds.fromPlexGuids(guids);
|
||||
_cache[ratingKey] = ids;
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Resolve external IDs for an episode. Returns the show's GUIDs (Trakt's
|
||||
/// preferred shape); season/episode index live on the [PlexMetadata] itself.
|
||||
///
|
||||
/// Returns null if the episode is missing `grandparentRatingKey` (in which
|
||||
/// case Trakt has no way to match the show).
|
||||
Future<TraktIds?> resolveShowForEpisode(PlexMetadata episode) async {
|
||||
final showRatingKey = episode.grandparentRatingKey;
|
||||
if (showRatingKey == null || showRatingKey.isEmpty) return null;
|
||||
|
||||
final cached = _cache[showRatingKey];
|
||||
if (cached != null) return cached;
|
||||
|
||||
final guids = await _client.fetchExternalGuids(showRatingKey);
|
||||
final ids = TraktIds.fromPlexGuids(guids);
|
||||
_cache[showRatingKey] = ids;
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Drop all cached IDs. Called on stopPlayback / profile switch so a
|
||||
/// re-matched item gets fresh GUIDs the next time around.
|
||||
void clearCache() => _cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../models/trakt/trakt_scrobble_request.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../plex_client.dart';
|
||||
import '../settings_service.dart';
|
||||
import 'trakt_client.dart';
|
||||
import 'trakt_constants.dart';
|
||||
import 'trakt_guid_resolver.dart';
|
||||
import 'trakt_session.dart';
|
||||
|
||||
/// Real-time scrobble service for Trakt.
|
||||
///
|
||||
/// Mirrors the lifecycle shape of `DiscordRPCService`: invoked from
|
||||
/// `video_player_screen.dart` at the same call sites (start/pause/resume/stop,
|
||||
/// position updates).
|
||||
class TraktScrobbleService {
|
||||
/// Drop a duplicate state transition within this window — mpv emits multiple
|
||||
/// playing-state events on seek.
|
||||
static const Duration _duplicateStateDebounce = Duration(seconds: 1);
|
||||
|
||||
/// Drop a `start` re-send within this window of the previous start.
|
||||
/// Trakt enforces "max one scrobble per 15 min per item"; this avoids
|
||||
/// spamming 409s during rapid pause/play cycles.
|
||||
static const Duration _startResendThrottle = Duration(seconds: 30);
|
||||
|
||||
/// Position-jump magnitude that counts as a seek (matches DiscordRPCService).
|
||||
static const Duration _seekDetectionThreshold = Duration(seconds: 5);
|
||||
|
||||
/// Max one seek-checkpoint per this window — slider drag fires many position
|
||||
/// updates per second; we only want to ship one to Trakt.
|
||||
static const Duration _seekCheckpointThrottle = Duration(seconds: 5);
|
||||
|
||||
static TraktScrobbleService? _instance;
|
||||
static TraktScrobbleService get instance => _instance ??= TraktScrobbleService._();
|
||||
|
||||
TraktScrobbleService._();
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool _isEnabled = false;
|
||||
|
||||
TraktClient? _client;
|
||||
TraktGuidResolver? _resolver;
|
||||
TraktScrobbleRequest? _currentBody;
|
||||
Duration _currentPosition = Duration.zero;
|
||||
Duration _currentDuration = Duration.zero;
|
||||
TraktScrobbleState? _lastSentState;
|
||||
DateTime? _lastSentAt;
|
||||
DateTime? _lastSeekCheckpointAt;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
final settings = await SettingsService.getInstance();
|
||||
_isEnabled = settings.getEnableTraktScrobble();
|
||||
}
|
||||
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
_isEnabled = enabled;
|
||||
if (!enabled) cancelInFlight();
|
||||
}
|
||||
|
||||
/// Switch to a different account. Cancels any in-flight scrobble for the
|
||||
/// previous account so we don't send a stop event to the wrong user.
|
||||
void rebindToProfile(TraktSession? session, {required void Function() onSessionInvalidated}) {
|
||||
_client?.dispose();
|
||||
_client = session != null ? TraktClient(session, onSessionInvalidated: onSessionInvalidated) : null;
|
||||
cancelInFlight();
|
||||
}
|
||||
|
||||
/// Drop the current scrobble state without sending a stop. Called on profile
|
||||
/// switch and when the service is disabled mid-playback.
|
||||
void cancelInFlight() {
|
||||
_currentBody = null;
|
||||
_lastSentState = null;
|
||||
_lastSentAt = null;
|
||||
_resolver?.clearCache();
|
||||
_resolver = null;
|
||||
_currentPosition = Duration.zero;
|
||||
_currentDuration = Duration.zero;
|
||||
}
|
||||
|
||||
bool get _canScrobble => _isEnabled && _client != null;
|
||||
|
||||
Future<void> startPlayback(PlexMetadata metadata, PlexClient plexClient, {bool isLive = false}) async {
|
||||
if (!_canScrobble) return;
|
||||
if (isLive) return;
|
||||
|
||||
final type = metadata.mediaType;
|
||||
if (type != PlexMediaType.movie && type != PlexMediaType.episode) return;
|
||||
|
||||
// Seed with the resume offset so the first real position update doesn't
|
||||
// look like a seek when resuming mid-item.
|
||||
_currentPosition = metadata.viewOffset != null ? Duration(milliseconds: metadata.viewOffset!) : Duration.zero;
|
||||
_currentDuration = metadata.duration != null ? Duration(milliseconds: metadata.duration!) : Duration.zero;
|
||||
_lastSeekCheckpointAt = null;
|
||||
_resolver = TraktGuidResolver(plexClient);
|
||||
|
||||
final body = await _buildBody(metadata);
|
||||
if (body == null) {
|
||||
appLogger.d('Trakt: skipping scrobble — no usable IDs for ${metadata.ratingKey}');
|
||||
cancelInFlight();
|
||||
return;
|
||||
}
|
||||
_currentBody = body;
|
||||
await _send(TraktScrobbleState.start, progress: _progressPercent());
|
||||
}
|
||||
|
||||
void updatePosition(Duration position) {
|
||||
final previous = _currentPosition;
|
||||
_currentPosition = position;
|
||||
|
||||
// Trakt has no seek event — instead, official apps send pause+start with
|
||||
// the new progress to checkpoint. Without this, the "resume on another
|
||||
// device" feature is stuck on the pre-seek position until the next
|
||||
// pause/stop.
|
||||
if (_currentBody == null) return;
|
||||
if (_lastSentState != TraktScrobbleState.start) return;
|
||||
if ((position - previous).abs() <= _seekDetectionThreshold) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (_lastSeekCheckpointAt != null && now.difference(_lastSeekCheckpointAt!) < _seekCheckpointThrottle) return;
|
||||
_lastSeekCheckpointAt = now;
|
||||
unawaited(_sendSeekCheckpoint());
|
||||
}
|
||||
|
||||
void updateDuration(Duration duration) {
|
||||
if (duration.inMilliseconds == 0) return;
|
||||
if (duration == _currentDuration) return;
|
||||
_currentDuration = duration;
|
||||
}
|
||||
|
||||
Future<void> pausePlayback() async {
|
||||
if (_currentBody == null) return;
|
||||
await _send(TraktScrobbleState.pause, progress: _progressPercent());
|
||||
}
|
||||
|
||||
Future<void> resumePlayback() async {
|
||||
if (_currentBody == null) return;
|
||||
await _send(TraktScrobbleState.start, progress: _progressPercent());
|
||||
}
|
||||
|
||||
Future<void> stopPlayback() async {
|
||||
if (_currentBody == null) return;
|
||||
await _send(TraktScrobbleState.stop, progress: _progressPercent());
|
||||
cancelInFlight();
|
||||
}
|
||||
|
||||
Future<TraktScrobbleRequest?> _buildBody(PlexMetadata metadata) async {
|
||||
final resolver = _resolver;
|
||||
if (resolver == null) return null;
|
||||
|
||||
if (metadata.mediaType == PlexMediaType.movie) {
|
||||
final ids = await resolver.resolveForMovie(metadata.ratingKey);
|
||||
if (!ids.hasAny) return null;
|
||||
return TraktScrobbleRequest.movie(ids: ids);
|
||||
}
|
||||
|
||||
final season = metadata.parentIndex;
|
||||
final number = metadata.index;
|
||||
if (season == null || number == null) return null;
|
||||
|
||||
final showIds = await resolver.resolveShowForEpisode(metadata);
|
||||
if (showIds == null || !showIds.hasAny) return null;
|
||||
|
||||
return TraktScrobbleRequest.episode(showIds: showIds, season: season, number: number);
|
||||
}
|
||||
|
||||
double _progressPercent() {
|
||||
if (_currentDuration.inMilliseconds == 0) return 0;
|
||||
final pct = (_currentPosition.inMilliseconds / _currentDuration.inMilliseconds) * 100;
|
||||
return pct.clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
/// Send pause→start to Trakt so the playback-progress endpoint reflects the
|
||||
/// new position. Bypasses [_send]'s state throttle (this is a checkpoint,
|
||||
/// not a state change) but updates the throttle bookkeeping so a regular
|
||||
/// `start` immediately after won't double-fire.
|
||||
Future<void> _sendSeekCheckpoint() async {
|
||||
final client = _client;
|
||||
final body = _currentBody;
|
||||
if (client == null || body == null) return;
|
||||
|
||||
final progress = _progressPercent();
|
||||
final scrobble = body.copyWith(progress: progress);
|
||||
try {
|
||||
await client.scrobblePause(scrobble);
|
||||
await client.scrobbleStart(scrobble);
|
||||
_lastSentState = TraktScrobbleState.start;
|
||||
_lastSentAt = DateTime.now();
|
||||
appLogger.d('Trakt: seek checkpoint @ ${progress.toStringAsFixed(1)}%');
|
||||
} catch (e) {
|
||||
appLogger.d('Trakt: seek checkpoint failed', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _send(TraktScrobbleState state, {required double progress}) async {
|
||||
final client = _client;
|
||||
final body = _currentBody;
|
||||
if (client == null || body == null) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (_lastSentState == state && _lastSentAt != null) {
|
||||
final elapsed = now.difference(_lastSentAt!);
|
||||
if (elapsed < _duplicateStateDebounce) return;
|
||||
if (state == TraktScrobbleState.start && elapsed < _startResendThrottle) return;
|
||||
}
|
||||
_lastSentState = state;
|
||||
_lastSentAt = now;
|
||||
|
||||
final scrobble = body.copyWith(progress: progress);
|
||||
try {
|
||||
switch (state) {
|
||||
case TraktScrobbleState.start:
|
||||
await client.scrobbleStart(scrobble);
|
||||
case TraktScrobbleState.pause:
|
||||
await client.scrobblePause(scrobble);
|
||||
case TraktScrobbleState.stop:
|
||||
await client.scrobbleStop(scrobble);
|
||||
}
|
||||
appLogger.d('Trakt: scrobble ${state.name} @ ${progress.toStringAsFixed(1)}%');
|
||||
} catch (e) {
|
||||
// Never let scrobble errors block playback.
|
||||
appLogger.d('Trakt: scrobble ${state.name} failed', error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Immutable Trakt OAuth session.
|
||||
///
|
||||
/// Persisted as a JSON blob under `user_{uuid}_trakt_session` in
|
||||
/// `SharedPreferences`. Tokens are stored in plaintext, matching the security
|
||||
/// model of the existing Plex token.
|
||||
class TraktSession {
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
|
||||
/// Epoch seconds at which the access token expires.
|
||||
final int expiresAt;
|
||||
|
||||
/// Trakt username (`@handle`), populated after `getUserSettings`.
|
||||
final String? username;
|
||||
|
||||
final String scope;
|
||||
|
||||
/// Epoch seconds at which the session was first created.
|
||||
final int createdAt;
|
||||
|
||||
const TraktSession({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.expiresAt,
|
||||
required this.scope,
|
||||
required this.createdAt,
|
||||
this.username,
|
||||
});
|
||||
|
||||
/// Whether the access token has already expired.
|
||||
bool get isExpired => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt;
|
||||
|
||||
/// Whether the access token will expire in the next 5 minutes.
|
||||
bool get needsRefresh => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt - 300;
|
||||
|
||||
TraktSession copyWith({
|
||||
String? accessToken,
|
||||
String? refreshToken,
|
||||
int? expiresAt,
|
||||
String? username,
|
||||
String? scope,
|
||||
int? createdAt,
|
||||
}) {
|
||||
return TraktSession(
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
username: username ?? this.username,
|
||||
scope: scope ?? this.scope,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'access_token': accessToken,
|
||||
'refresh_token': refreshToken,
|
||||
'expires_at': expiresAt,
|
||||
'username': username,
|
||||
'scope': scope,
|
||||
'created_at': createdAt,
|
||||
};
|
||||
|
||||
factory TraktSession.fromJson(Map<String, dynamic> json) {
|
||||
return TraktSession(
|
||||
accessToken: json['access_token'] as String,
|
||||
refreshToken: json['refresh_token'] as String,
|
||||
expiresAt: (json['expires_at'] as num).toInt(),
|
||||
username: json['username'] as String?,
|
||||
scope: json['scope'] as String? ?? 'public',
|
||||
createdAt: (json['created_at'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a session from Trakt's `/oauth/token` or `/oauth/device/token` response,
|
||||
/// which uses `expires_in` (relative seconds) rather than `expires_at`.
|
||||
factory TraktSession.fromTokenResponse(Map<String, dynamic> json) {
|
||||
final createdAt = (json['created_at'] as num?)?.toInt() ?? DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final expiresIn = (json['expires_in'] as num).toInt();
|
||||
return TraktSession(
|
||||
accessToken: json['access_token'] as String,
|
||||
refreshToken: json['refresh_token'] as String,
|
||||
expiresAt: createdAt + expiresIn,
|
||||
scope: json['scope'] as String? ?? 'public',
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
String encode() => json.encode(toJson());
|
||||
static TraktSession decode(String raw) => TraktSession.fromJson(json.decode(raw) as Map<String, dynamic>);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../models/trakt/trakt_ids.dart';
|
||||
import 'trakt_constants.dart';
|
||||
|
||||
/// One pending watched/unwatched push waiting to be drained to Trakt.
|
||||
class TraktSyncQueueItem {
|
||||
final TraktSyncOp op;
|
||||
final String ratingKey;
|
||||
final String serverId;
|
||||
final TraktMediaKind kind;
|
||||
final TraktIds ids;
|
||||
|
||||
/// For episodes only.
|
||||
final int? season;
|
||||
final int? number;
|
||||
|
||||
final String watchedAtIso;
|
||||
final int attempts;
|
||||
|
||||
const TraktSyncQueueItem({
|
||||
required this.op,
|
||||
required this.ratingKey,
|
||||
required this.serverId,
|
||||
required this.kind,
|
||||
required this.ids,
|
||||
required this.watchedAtIso,
|
||||
this.season,
|
||||
this.number,
|
||||
this.attempts = 0,
|
||||
});
|
||||
|
||||
TraktSyncQueueItem incrementAttempts() => TraktSyncQueueItem(
|
||||
op: op,
|
||||
ratingKey: ratingKey,
|
||||
serverId: serverId,
|
||||
kind: kind,
|
||||
ids: ids,
|
||||
watchedAtIso: watchedAtIso,
|
||||
season: season,
|
||||
number: number,
|
||||
attempts: attempts + 1,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'op': op.name,
|
||||
'ratingKey': ratingKey,
|
||||
'serverId': serverId,
|
||||
'kind': kind.name,
|
||||
'ids': ids.toJson(),
|
||||
if (season != null) 'season': season,
|
||||
if (number != null) 'number': number,
|
||||
'watchedAtIso': watchedAtIso,
|
||||
'attempts': attempts,
|
||||
};
|
||||
|
||||
factory TraktSyncQueueItem.fromJson(Map<String, dynamic> json) => TraktSyncQueueItem(
|
||||
op: TraktSyncOp.fromName(json['op'] as String),
|
||||
ratingKey: json['ratingKey'] as String,
|
||||
serverId: json['serverId'] as String,
|
||||
kind: TraktMediaKind.fromName(json['kind'] as String),
|
||||
ids: TraktIds.fromJson(json['ids'] as Map<String, dynamic>),
|
||||
season: (json['season'] as num?)?.toInt(),
|
||||
number: (json['number'] as num?)?.toInt(),
|
||||
watchedAtIso: json['watchedAtIso'] as String,
|
||||
attempts: (json['attempts'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Per-profile persisted retry queue for failed Trakt history pushes.
|
||||
///
|
||||
/// Cap at [maxAttempts] before dropping permanently — matches
|
||||
/// `OfflineWatchSyncService.maxSyncAttempts`.
|
||||
class TraktSyncQueue {
|
||||
static const String _baseKey = 'trakt_sync_queue';
|
||||
static const int maxAttempts = 5;
|
||||
|
||||
Future<List<TraktSyncQueueItem>> load(String userUuid) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(traktUserKey(userUuid, _baseKey));
|
||||
if (raw == null) return [];
|
||||
try {
|
||||
final list = json.decode(raw) as List<dynamic>;
|
||||
return list.map((e) => TraktSyncQueueItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save(String userUuid, List<TraktSyncQueueItem> items) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = traktUserKey(userUuid, _baseKey);
|
||||
if (items.isEmpty) {
|
||||
await prefs.remove(key);
|
||||
} else {
|
||||
await prefs.setString(key, json.encode(items.map((e) => e.toJson()).toList()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> add(String userUuid, TraktSyncQueueItem item) async {
|
||||
final items = await load(userUuid);
|
||||
items.add(item);
|
||||
await save(userUuid, items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../models/trakt/trakt_ids.dart';
|
||||
import '../../models/trakt/trakt_scrobble_request.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/watch_state_notifier.dart';
|
||||
import '../multi_server_manager.dart';
|
||||
import '../settings_service.dart';
|
||||
import 'trakt_client.dart';
|
||||
import 'trakt_constants.dart';
|
||||
import 'trakt_guid_resolver.dart';
|
||||
import 'trakt_session.dart';
|
||||
import 'trakt_sync_queue.dart';
|
||||
|
||||
/// One-way push of watched/unwatched events from Plezy to Trakt.
|
||||
///
|
||||
/// Subscribes to `WatchStateNotifier` and filters to `{watched, unwatched}`
|
||||
/// events on movies/episodes. Failures are queued via `TraktSyncQueue` and
|
||||
/// drained on app foreground, network restore, and at startup.
|
||||
class TraktSyncService {
|
||||
/// Inter-request delay during queue drain to stay under Trakt's
|
||||
/// 1000 req / 5 min rate limit.
|
||||
static const Duration _queueRequestSpacing = Duration(milliseconds: 50);
|
||||
|
||||
static TraktSyncService? _instance;
|
||||
static TraktSyncService get instance => _instance ??= TraktSyncService._();
|
||||
|
||||
TraktSyncService._();
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool _isEnabled = false;
|
||||
String _activeUserUuid = '';
|
||||
|
||||
TraktClient? _client;
|
||||
MultiServerManager? _serverManager;
|
||||
StreamSubscription<WatchStateEvent>? _subscription;
|
||||
final TraktSyncQueue _queue = TraktSyncQueue();
|
||||
|
||||
/// One resolver per Plex server, kept alive across events so the per-rating-
|
||||
/// key GUID cache survives a binge-watch session.
|
||||
final Map<String, TraktGuidResolver> _resolvers = {};
|
||||
|
||||
bool _isFlushing = false;
|
||||
|
||||
Future<void> initialize({required MultiServerManager serverManager}) async {
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
_serverManager = serverManager;
|
||||
|
||||
final settings = await SettingsService.getInstance();
|
||||
_isEnabled = settings.getEnableTraktWatchedSync();
|
||||
|
||||
_subscription = WatchStateNotifier().stream.listen(_onWatchStateEvent);
|
||||
}
|
||||
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
_isEnabled = enabled;
|
||||
}
|
||||
|
||||
/// Switch to a different account. Drops cached resolvers (their PlexClients
|
||||
/// are tied to the previous user's tokens) and rebinds the queue.
|
||||
void rebindToProfile(String userUuid, TraktSession? session, {required void Function() onSessionInvalidated}) {
|
||||
_client?.dispose();
|
||||
_client = session != null ? TraktClient(session, onSessionInvalidated: onSessionInvalidated) : null;
|
||||
_activeUserUuid = userUuid;
|
||||
_resolvers.clear();
|
||||
if (_client != null) {
|
||||
unawaited(flushQueue());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
_client?.dispose();
|
||||
_client = null;
|
||||
_resolvers.clear();
|
||||
}
|
||||
|
||||
bool get _canPush => _isEnabled && _client != null;
|
||||
|
||||
TraktGuidResolver? _resolverFor(String serverId) {
|
||||
final cached = _resolvers[serverId];
|
||||
if (cached != null) return cached;
|
||||
|
||||
final plexClient = _serverManager?.getClient(serverId);
|
||||
if (plexClient == null) return null;
|
||||
|
||||
final resolver = TraktGuidResolver(plexClient);
|
||||
_resolvers[serverId] = resolver;
|
||||
return resolver;
|
||||
}
|
||||
|
||||
Future<void> _onWatchStateEvent(WatchStateEvent event) async {
|
||||
if (!_canPush) return;
|
||||
if (event.changeType == WatchStateChangeType.progressUpdate) return;
|
||||
|
||||
final kind = TraktMediaKind.tryFromPlexType(event.mediaType);
|
||||
if (kind == null) return;
|
||||
|
||||
final op = event.changeType == WatchStateChangeType.watched ? TraktSyncOp.add : TraktSyncOp.remove;
|
||||
await _push(
|
||||
op: op,
|
||||
ratingKey: event.ratingKey,
|
||||
serverId: event.serverId,
|
||||
kind: kind,
|
||||
watchedAtIso: DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _push({
|
||||
required TraktSyncOp op,
|
||||
required String ratingKey,
|
||||
required String serverId,
|
||||
required TraktMediaKind kind,
|
||||
required String watchedAtIso,
|
||||
}) async {
|
||||
final resolver = _resolverFor(serverId);
|
||||
if (resolver == null) {
|
||||
appLogger.d('Trakt sync: no PlexClient for server $serverId, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
TraktIds? ids;
|
||||
int? season;
|
||||
int? number;
|
||||
|
||||
if (kind == TraktMediaKind.movie) {
|
||||
ids = await resolver.resolveForMovie(ratingKey);
|
||||
} else {
|
||||
// Episode — need show IDs + season/episode index. The WatchStateEvent
|
||||
// doesn't carry the index, so fetch episode metadata.
|
||||
final plexClient = _serverManager?.getClient(serverId);
|
||||
if (plexClient == null) return;
|
||||
final episodeMeta = await plexClient.getMetadataWithImages(ratingKey);
|
||||
if (episodeMeta == null) return;
|
||||
season = episodeMeta.parentIndex;
|
||||
number = episodeMeta.index;
|
||||
if (season == null || number == null) return;
|
||||
ids = await resolver.resolveShowForEpisode(episodeMeta);
|
||||
}
|
||||
|
||||
if (ids == null || !ids.hasAny) {
|
||||
appLogger.d('Trakt sync: no IDs for ${kind.name} $ratingKey, dropping');
|
||||
return;
|
||||
}
|
||||
|
||||
final body = kind == TraktMediaKind.movie
|
||||
? TraktScrobbleRequest.movie(ids: ids)
|
||||
: TraktScrobbleRequest.episode(showIds: ids, season: season!, number: number!);
|
||||
|
||||
final item = TraktSyncQueueItem(
|
||||
op: op,
|
||||
ratingKey: ratingKey,
|
||||
serverId: serverId,
|
||||
kind: kind,
|
||||
ids: ids,
|
||||
season: season,
|
||||
number: number,
|
||||
watchedAtIso: watchedAtIso,
|
||||
);
|
||||
|
||||
await _trySendOrQueue(item, body);
|
||||
}
|
||||
|
||||
Future<void> _trySendOrQueue(TraktSyncQueueItem item, TraktScrobbleRequest body) async {
|
||||
final client = _client;
|
||||
if (client == null) {
|
||||
await _queue.add(_activeUserUuid, item);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _dispatch(client, item, body);
|
||||
appLogger.d('Trakt sync: ${item.op.name} ${item.ratingKey} → ok');
|
||||
} catch (e) {
|
||||
appLogger.d('Trakt sync: ${item.op.name} ${item.ratingKey} failed, queuing', error: e);
|
||||
await _queue.add(_activeUserUuid, item);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _dispatch(TraktClient client, TraktSyncQueueItem item, TraktScrobbleRequest body) {
|
||||
return switch (item.op) {
|
||||
TraktSyncOp.add => client.addToHistory(body, watchedAt: item.watchedAtIso),
|
||||
TraktSyncOp.remove => client.removeFromHistory(body),
|
||||
};
|
||||
}
|
||||
|
||||
/// Drain the persisted queue. Called on init, on app foreground, and when
|
||||
/// `OfflineModeProvider.isOffline` flips false.
|
||||
Future<void> flushQueue() async {
|
||||
if (_isFlushing) return;
|
||||
final client = _client;
|
||||
if (client == null) return;
|
||||
_isFlushing = true;
|
||||
try {
|
||||
final items = await _queue.load(_activeUserUuid);
|
||||
if (items.isEmpty) return;
|
||||
|
||||
final remaining = <TraktSyncQueueItem>[];
|
||||
for (final item in items) {
|
||||
if (item.attempts >= TraktSyncQueue.maxAttempts) {
|
||||
appLogger.w('Trakt sync: dropping ${item.op.name} ${item.ratingKey} after ${item.attempts} attempts');
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await _dispatch(client, item, _bodyFor(item));
|
||||
appLogger.d('Trakt sync: drained ${item.op.name} ${item.ratingKey}');
|
||||
} catch (e) {
|
||||
appLogger.d('Trakt sync: drain failed for ${item.ratingKey}, will retry', error: e);
|
||||
remaining.add(item.incrementAttempts());
|
||||
}
|
||||
await Future<void>.delayed(_queueRequestSpacing);
|
||||
}
|
||||
|
||||
await _queue.save(_activeUserUuid, remaining);
|
||||
} finally {
|
||||
_isFlushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
TraktScrobbleRequest _bodyFor(TraktSyncQueueItem item) {
|
||||
return switch (item.kind) {
|
||||
TraktMediaKind.movie => TraktScrobbleRequest.movie(ids: item.ids),
|
||||
TraktMediaKind.episode => TraktScrobbleRequest.episode(
|
||||
showIds: item.ids,
|
||||
season: item.season!,
|
||||
number: item.number!,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
|
||||
Reference in New Issue
Block a user