diff --git a/assets/anilist_mark.svg b/assets/anilist_mark.svg new file mode 100644 index 00000000..1f3c6de5 --- /dev/null +++ b/assets/anilist_mark.svg @@ -0,0 +1 @@ + diff --git a/assets/mal_mark.svg b/assets/mal_mark.svg new file mode 100644 index 00000000..a18e9bf1 --- /dev/null +++ b/assets/mal_mark.svg @@ -0,0 +1 @@ + diff --git a/assets/simkl_mark.svg b/assets/simkl_mark.svg new file mode 100644 index 00000000..419d5fe7 --- /dev/null +++ b/assets/simkl_mark.svg @@ -0,0 +1 @@ + diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 137b6208..d9b844cb 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Vis hvad du ser på Discord", "trakt": "Trakt", "traktDescription": "Synkroniser visningshistorik med Trakt", + "trackers": "Trackere", + "trackersDescription": "Synkroniser fremgang til Trakt, MyAnimeList, AniList og Simkl", "companionRemoteServer": "Companion Remote Server", "companionRemoteServerDescription": "Tillad mobilenheder på dit netværk at styre denne app", "autoPip": "Auto billede-i-billede", @@ -1015,11 +1017,29 @@ "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." + }, + "trackers": { + "title": "Trackere", + "hubSubtitle": "Hold din visningsfremgang synkroniseret med Trakt og andre tjenester.", + "notConnected": "Ikke forbundet", + "connectedAs": "Forbundet som @${username}", + "scrobble": "Registrer fremgang automatisk", + "scrobbleDescription": "Opdater din liste når du er færdig med et afsnit eller en film.", + "disconnectConfirm": "Afbryd ${service}?", + "disconnectConfirmBody": "Plezy stopper med at opdatere din ${service}-liste. Du kan forbinde igen når som helst.", + "connectFailed": "Kunne ikke forbinde til ${service}. Prøv igen.", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "Aktiver Plezy på ${service}", + "body": "Besøg ${url} og indtast denne kode:", + "openToActivate": "Åbn ${service} for at aktivere", + "waitingForAuthorization": "Venter på godkendelse…", + "codeCopied": "Kode kopieret" + } } } diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 4b9a1cbd..1fbee2b8 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Zeige auf Discord, was du gerade schaust", "trakt": "Trakt", "traktDescription": "Wiedergabeverlauf mit Trakt synchronisieren", + "trackers": "Tracker", + "trackersDescription": "Fortschritt mit Trakt, MyAnimeList, AniList und Simkl synchronisieren", "companionRemoteServer": "Companion Remote Server", "companionRemoteServerDescription": "Mobilgeräten in deinem Netzwerk erlauben, diese App zu steuern", "autoPip": "Automatisches Bild-in-Bild", @@ -1015,11 +1017,29 @@ "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." + }, + "trackers": { + "title": "Tracker", + "hubSubtitle": "Halte deinen Wiedergabefortschritt mit Trakt und anderen Diensten synchron.", + "notConnected": "Nicht verbunden", + "connectedAs": "Verbunden als @${username}", + "scrobble": "Fortschritt automatisch verfolgen", + "scrobbleDescription": "Aktualisiere deine Liste, wenn du eine Folge oder einen Film beendest.", + "disconnectConfirm": "${service} trennen?", + "disconnectConfirmBody": "Plezy aktualisiert deine ${service}-Liste nicht mehr. Du kannst jederzeit wieder verbinden.", + "connectFailed": "Verbindung zu ${service} fehlgeschlagen. Versuche es erneut.", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "Plezy auf ${service} aktivieren", + "body": "Gehe zu ${url} und gib diesen Code ein:", + "openToActivate": "${service} zum Aktivieren öffnen", + "waitingForAuthorization": "Warte auf Autorisierung…", + "codeCopied": "Code kopiert" + } } } diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 109d63a2..0f12e7e6 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Show what you're watching on Discord", "trakt": "Trakt", "traktDescription": "Sync watch history with Trakt", + "trackers": "Trackers", + "trackersDescription": "Sync progress to Trakt, MyAnimeList, AniList, and Simkl", "companionRemoteServer": "Companion Remote Server", "companionRemoteServerDescription": "Allow mobile devices on your network to control this app", "autoPip": "Auto Picture-in-Picture", @@ -1015,11 +1017,29 @@ "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." + }, + "trackers": { + "title": "Trackers", + "hubSubtitle": "Keep your watch progress in sync with Trakt and other services.", + "notConnected": "Not connected", + "connectedAs": "Connected as @${username}", + "scrobble": "Track progress automatically", + "scrobbleDescription": "Update your list when you finish an episode or movie.", + "disconnectConfirm": "Disconnect ${service}?", + "disconnectConfirmBody": "Plezy will stop updating your ${service} list. You can reconnect at any time.", + "connectFailed": "Couldn't connect to ${service}. Try again.", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "Activate Plezy on ${service}", + "body": "Visit ${url} and enter this code:", + "openToActivate": "Open ${service} to activate", + "waitingForAuthorization": "Waiting for authorization…", + "codeCopied": "Code copied" + } } } diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 88fc9474..1bdb5f03 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Mostrar lo que estás viendo en Discord", "trakt": "Trakt", "traktDescription": "Sincronizar historial de visualización con Trakt", + "trackers": "Rastreadores", + "trackersDescription": "Sincronizar progreso con Trakt, MyAnimeList, AniList y Simkl", "companionRemoteServer": "Servidor de control remoto", "companionRemoteServerDescription": "Permitir que dispositivos móviles en tu red controlen esta aplicación", "autoPip": "Imagen en imagen automática", @@ -1015,11 +1017,29 @@ "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." + }, + "trackers": { + "title": "Rastreadores", + "hubSubtitle": "Mantén sincronizado tu progreso con Trakt y otros servicios.", + "notConnected": "No conectado", + "connectedAs": "Conectado como @${username}", + "scrobble": "Registrar progreso automáticamente", + "scrobbleDescription": "Actualiza tu lista cuando termines un episodio o película.", + "disconnectConfirm": "¿Desconectar ${service}?", + "disconnectConfirmBody": "Plezy dejará de actualizar tu lista de ${service}. Puedes reconectarte en cualquier momento.", + "connectFailed": "No se pudo conectar a ${service}. Inténtalo de nuevo.", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "Activar Plezy en ${service}", + "body": "Visita ${url} e introduce este código:", + "openToActivate": "Abrir ${service} para activar", + "waitingForAuthorization": "Esperando autorización…", + "codeCopied": "Código copiado" + } } } diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 74bce1b9..a522ee45 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Montrez ce que vous regardez sur Discord", "trakt": "Trakt", "traktDescription": "Synchroniser l'historique de visionnage avec Trakt", + "trackers": "Trackers", + "trackersDescription": "Synchroniser la progression avec Trakt, MyAnimeList, AniList et Simkl", "companionRemoteServer": "Serveur de télécommande", "companionRemoteServerDescription": "Autoriser les appareils mobiles de votre réseau à contrôler cette application", "autoPip": "Image dans l'image automatique", @@ -1015,11 +1017,29 @@ "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." + }, + "trackers": { + "title": "Trackers", + "hubSubtitle": "Gardez votre progression synchronisée avec Trakt et d'autres services.", + "notConnected": "Non connecté", + "connectedAs": "Connecté en tant que @${username}", + "scrobble": "Suivre la progression automatiquement", + "scrobbleDescription": "Mettre à jour votre liste lorsque vous terminez un épisode ou un film.", + "disconnectConfirm": "Déconnecter ${service} ?", + "disconnectConfirmBody": "Plezy ne mettra plus à jour votre liste ${service}. Vous pouvez vous reconnecter à tout moment.", + "connectFailed": "Échec de la connexion à ${service}. Réessayez.", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "Activer Plezy sur ${service}", + "body": "Rendez-vous sur ${url} et entrez ce code :", + "openToActivate": "Ouvrir ${service} pour activer", + "waitingForAuthorization": "En attente d'autorisation…", + "codeCopied": "Code copié" + } } } diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 80a7e2b9..5ed18be5 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Mostra su Discord cosa stai guardando", "trakt": "Trakt", "traktDescription": "Sincronizza la cronologia di visione con Trakt", + "trackers": "Tracker", + "trackersDescription": "Sincronizza i progressi con Trakt, MyAnimeList, AniList e Simkl", "companionRemoteServer": "Server telecomando", "companionRemoteServerDescription": "Consenti ai dispositivi mobili nella tua rete di controllare questa app", "autoPip": "Picture-in-Picture automatico", @@ -1015,11 +1017,29 @@ "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." + }, + "trackers": { + "title": "Tracker", + "hubSubtitle": "Mantieni i tuoi progressi sincronizzati con Trakt e altri servizi.", + "notConnected": "Non connesso", + "connectedAs": "Connesso come @${username}", + "scrobble": "Traccia i progressi automaticamente", + "scrobbleDescription": "Aggiorna la tua lista quando finisci un episodio o un film.", + "disconnectConfirm": "Disconnettere ${service}?", + "disconnectConfirmBody": "Plezy smetterà di aggiornare la tua lista ${service}. Puoi riconnetterti in qualsiasi momento.", + "connectFailed": "Impossibile connettersi a ${service}. Riprova.", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "Attiva Plezy su ${service}", + "body": "Visita ${url} e inserisci questo codice:", + "openToActivate": "Apri ${service} per attivare", + "waitingForAuthorization": "In attesa di autorizzazione…", + "codeCopied": "Codice copiato" + } } } diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 13c9389c..ae54e515 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Discordで視聴中の内容を表示", "trakt": "Trakt", "traktDescription": "視聴履歴を Trakt と同期", + "trackers": "トラッカー", + "trackersDescription": "進捗を Trakt、MyAnimeList、AniList、Simkl と同期", "companionRemoteServer": "コンパニオンリモートサーバー", "companionRemoteServerDescription": "ネットワーク上のモバイルデバイスからこのアプリを操作できるようにする", "autoPip": "自動ピクチャーインピクチャー", @@ -1015,11 +1017,29 @@ "scrobbleDescription": "再生中に再生・一時停止・停止イベントを Trakt に送信します。", "watchedSync": "視聴済みステータスを同期", "watchedSyncDescription": "Plezy で項目を視聴済みにすると、Trakt でも視聴済みになります。", - "deviceCodeTitle": "Trakt で Plezy をアクティベート", - "deviceCodeBody": "${url} を開き、このコードを入力してください:", - "openTraktActivate": "アクティベートするには Trakt を開く", - "waitingForAuthorization": "認証を待機中…", - "codeCopied": "コードをコピーしました", "connectFailed": "Trakt に接続できませんでした。もう一度お試しください。" + }, + "trackers": { + "title": "トラッカー", + "hubSubtitle": "視聴進捗を Trakt や他のサービスで同期します。", + "notConnected": "未接続", + "connectedAs": "@${username} として接続済み", + "scrobble": "進捗を自動で記録", + "scrobbleDescription": "エピソードや映画を見終えたときにリストを更新します。", + "disconnectConfirm": "${service} の接続を解除しますか?", + "disconnectConfirmBody": "Plezy は ${service} のリストを更新しなくなります。いつでも再接続できます。", + "connectFailed": "${service} に接続できませんでした。もう一度お試しください。", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "${service} で Plezy を有効化", + "body": "${url} にアクセスしてこのコードを入力:", + "openToActivate": "${service} を開いて有効化", + "waitingForAuthorization": "認証を待っています…", + "codeCopied": "コードをコピーしました" + } } } diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 61267db7..9319e075 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Discord에서 시청 중인 콘텐츠 표시", "trakt": "Trakt", "traktDescription": "Trakt와 시청 기록 동기화", + "trackers": "트래커", + "trackersDescription": "Trakt, MyAnimeList, AniList 및 Simkl에 진행률 동기화", "companionRemoteServer": "컴패니언 리모트 서버", "companionRemoteServerDescription": "네트워크의 모바일 기기가 이 앱을 제어할 수 있도록 허용", "autoPip": "자동 PIP 모드", @@ -1015,11 +1017,29 @@ "scrobbleDescription": "재생 중 재생, 일시정지, 정지 이벤트를 Trakt로 전송합니다.", "watchedSync": "시청 상태 동기화", "watchedSyncDescription": "Plezy에서 시청 완료로 표시한 항목이 Trakt에도 시청 완료로 표시됩니다.", - "deviceCodeTitle": "Trakt에서 Plezy 활성화", - "deviceCodeBody": "${url}을(를) 방문하여 이 코드를 입력하세요:", - "openTraktActivate": "활성화하려면 Trakt 열기", - "waitingForAuthorization": "인증 대기 중…", - "codeCopied": "코드가 복사됨", "connectFailed": "Trakt에 연결할 수 없습니다. 다시 시도하세요." + }, + "trackers": { + "title": "트래커", + "hubSubtitle": "시청 진행률을 Trakt 및 기타 서비스와 동기화하세요.", + "notConnected": "연결되지 않음", + "connectedAs": "@${username} 로 연결됨", + "scrobble": "진행률 자동 추적", + "scrobbleDescription": "에피소드나 영화를 시청하면 목록을 업데이트합니다.", + "disconnectConfirm": "${service} 연결을 해제하시겠습니까?", + "disconnectConfirmBody": "Plezy가 ${service} 목록 업데이트를 중지합니다. 언제든지 다시 연결할 수 있습니다.", + "connectFailed": "${service}에 연결할 수 없습니다. 다시 시도하세요.", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "${service}에서 Plezy 활성화", + "body": "${url}을 방문하여 이 코드를 입력하세요:", + "openToActivate": "활성화하려면 ${service} 열기", + "waitingForAuthorization": "인증을 기다리는 중…", + "codeCopied": "코드가 복사되었습니다" + } } } diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 612d5f2d..8a4c36f9 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Vis hva du ser på Discord", "trakt": "Trakt", "traktDescription": "Synkroniser visningshistorikk med Trakt", + "trackers": "Trackere", + "trackersDescription": "Synkroniser fremdrift til Trakt, MyAnimeList, AniList og Simkl", "companionRemoteServer": "Companion Remote-server", "companionRemoteServerDescription": "Tillat mobilenheter på nettverket ditt å styre denne appen", "autoPip": "Auto bilde-i-bilde", @@ -1015,11 +1017,29 @@ "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." + }, + "trackers": { + "title": "Trackere", + "hubSubtitle": "Hold visningsfremdriften synkronisert med Trakt og andre tjenester.", + "notConnected": "Ikke tilkoblet", + "connectedAs": "Tilkoblet som @${username}", + "scrobble": "Registrer fremdrift automatisk", + "scrobbleDescription": "Oppdater listen din når du er ferdig med en episode eller film.", + "disconnectConfirm": "Koble fra ${service}?", + "disconnectConfirmBody": "Plezy slutter å oppdatere ${service}-listen din. Du kan koble til igjen når som helst.", + "connectFailed": "Kunne ikke koble til ${service}. Prøv igjen.", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "Aktiver Plezy på ${service}", + "body": "Besøk ${url} og skriv inn denne koden:", + "openToActivate": "Åpne ${service} for å aktivere", + "waitingForAuthorization": "Venter på godkjenning…", + "codeCopied": "Kode kopiert" + } } } diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 36a36204..a3a7ad30 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Toon op Discord wat je aan het kijken bent", "trakt": "Trakt", "traktDescription": "Kijkgeschiedenis synchroniseren met Trakt", + "trackers": "Trackers", + "trackersDescription": "Voortgang synchroniseren met Trakt, MyAnimeList, AniList en Simkl", "companionRemoteServer": "Companion Remote-server", "companionRemoteServerDescription": "Sta mobiele apparaten op je netwerk toe om deze app te bedienen", "autoPip": "Automatische beeld-in-beeld", @@ -1015,11 +1017,29 @@ "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." + }, + "trackers": { + "title": "Trackers", + "hubSubtitle": "Houd je kijkvoortgang gesynchroniseerd met Trakt en andere diensten.", + "notConnected": "Niet verbonden", + "connectedAs": "Verbonden als @${username}", + "scrobble": "Voortgang automatisch volgen", + "scrobbleDescription": "Werk je lijst bij wanneer je een aflevering of film afrondt.", + "disconnectConfirm": "${service} loskoppelen?", + "disconnectConfirmBody": "Plezy werkt je ${service}-lijst niet meer bij. Je kunt op elk moment opnieuw verbinden.", + "connectFailed": "Kan niet verbinden met ${service}. Probeer opnieuw.", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "Plezy activeren op ${service}", + "body": "Ga naar ${url} en voer deze code in:", + "openToActivate": "Open ${service} om te activeren", + "waitingForAuthorization": "Wachten op autorisatie…", + "codeCopied": "Code gekopieerd" + } } } diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 55fd7c94..60692f8a 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Pokaż, co oglądasz na Discordzie", "trakt": "Trakt", "traktDescription": "Synchronizuj historię oglądania z Trakt", + "trackers": "Trackery", + "trackersDescription": "Synchronizuj postęp z Trakt, MyAnimeList, AniList i Simkl", "companionRemoteServer": "Serwer zdalnego sterowania", "companionRemoteServerDescription": "Pozwól urządzeniom mobilnym w sieci sterować tą aplikacją", "autoPip": "Automatyczny obraz w obrazie", @@ -1015,11 +1017,29 @@ "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." + }, + "trackers": { + "title": "Trackery", + "hubSubtitle": "Synchronizuj postęp oglądania z Trakt i innymi usługami.", + "notConnected": "Nie połączono", + "connectedAs": "Połączono jako @${username}", + "scrobble": "Automatycznie śledź postęp", + "scrobbleDescription": "Aktualizuj swoją listę po ukończeniu odcinka lub filmu.", + "disconnectConfirm": "Odłączyć ${service}?", + "disconnectConfirmBody": "Plezy przestanie aktualizować Twoją listę ${service}. Możesz połączyć się ponownie w dowolnej chwili.", + "connectFailed": "Nie udało się połączyć z ${service}. Spróbuj ponownie.", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "Aktywuj Plezy w ${service}", + "body": "Odwiedź ${url} i wpisz ten kod:", + "openToActivate": "Otwórz ${service}, aby aktywować", + "waitingForAuthorization": "Oczekiwanie na autoryzację…", + "codeCopied": "Kod skopiowany" + } } } diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 586bd737..c8616c01 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Mostrar o que você está assistindo no Discord", "trakt": "Trakt", "traktDescription": "Sincronizar histórico de exibição com Trakt", + "trackers": "Rastreadores", + "trackersDescription": "Sincronizar progresso com Trakt, MyAnimeList, AniList e Simkl", "companionRemoteServer": "Servidor de controlo remoto", "companionRemoteServerDescription": "Permitir que dispositivos móveis na sua rede controlem esta aplicação", "autoPip": "Picture-in-Picture Automático", @@ -1015,11 +1017,29 @@ "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." + }, + "trackers": { + "title": "Rastreadores", + "hubSubtitle": "Mantenha seu progresso sincronizado com Trakt e outros serviços.", + "notConnected": "Não conectado", + "connectedAs": "Conectado como @${username}", + "scrobble": "Registrar progresso automaticamente", + "scrobbleDescription": "Atualiza sua lista quando você termina um episódio ou filme.", + "disconnectConfirm": "Desconectar ${service}?", + "disconnectConfirmBody": "Plezy deixará de atualizar sua lista do ${service}. Você pode reconectar a qualquer momento.", + "connectFailed": "Não foi possível conectar ao ${service}. Tente novamente.", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "Ativar Plezy no ${service}", + "body": "Acesse ${url} e insira este código:", + "openToActivate": "Abrir ${service} para ativar", + "waitingForAuthorization": "Aguardando autorização…", + "codeCopied": "Código copiado" + } } } diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 71fae8b1..cc54615d 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Показывать, что вы смотрите, в Discord", "trakt": "Trakt", "traktDescription": "Синхронизировать историю просмотров с Trakt", + "trackers": "Трекеры", + "trackersDescription": "Синхронизировать прогресс с Trakt, MyAnimeList, AniList и Simkl", "companionRemoteServer": "Сервер удалённого управления", "companionRemoteServerDescription": "Разрешить мобильным устройствам в сети управлять этим приложением", "autoPip": "Автоматический «картинка в картинке»", @@ -1015,11 +1017,29 @@ "scrobbleDescription": "Отправлять события воспроизведения, паузы и остановки в Trakt во время просмотра.", "watchedSync": "Синхронизация статуса просмотра", "watchedSyncDescription": "Когда вы отмечаете элементы как просмотренные в Plezy, они отмечаются и в Trakt.", - "deviceCodeTitle": "Активация Plezy в Trakt", - "deviceCodeBody": "Откройте ${url} и введите этот код:", - "openTraktActivate": "Открыть Trakt для активации", - "waitingForAuthorization": "Ожидание авторизации…", - "codeCopied": "Код скопирован", "connectFailed": "Не удалось подключиться к Trakt. Попробуйте ещё раз." + }, + "trackers": { + "title": "Трекеры", + "hubSubtitle": "Синхронизируйте прогресс просмотра с Trakt и другими сервисами.", + "notConnected": "Не подключено", + "connectedAs": "Подключено как @${username}", + "scrobble": "Автоматически отслеживать прогресс", + "scrobbleDescription": "Обновляет список, когда вы заканчиваете эпизод или фильм.", + "disconnectConfirm": "Отключить ${service}?", + "disconnectConfirmBody": "Plezy перестанет обновлять ваш список ${service}. Вы можете подключиться снова в любое время.", + "connectFailed": "Не удалось подключиться к ${service}. Попробуйте ещё раз.", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "Активируйте Plezy в ${service}", + "body": "Перейдите на ${url} и введите этот код:", + "openToActivate": "Открыть ${service} для активации", + "waitingForAuthorization": "Ожидание авторизации…", + "codeCopied": "Код скопирован" + } } } diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 71531e0c..425a1669 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 15 -/// Strings: 13935 (929 per locale) +/// Strings: 14145 (943 per locale) /// -/// Built on 2026-04-23 at 16:38 UTC +/// Built on 2026-04-24 at 06:22 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index bef3a56e..f2fd6abd 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -77,6 +77,7 @@ class TranslationsDa with BaseTranslations implements T @override late final _TranslationsMatchScreenDa matchScreen = _TranslationsMatchScreenDa._(_root); @override late final _TranslationsServerTasksDa serverTasks = _TranslationsServerTasksDa._(_root); @override late final _TranslationsTraktDa trakt = _TranslationsTraktDa._(_root); + @override late final _TranslationsTrackersDa trackers = _TranslationsTrackersDa._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsDa implements TranslationsSettingsEn { @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 trackers => 'Trackere'; + @override String get trackersDescription => 'Synkroniser fremgang til Trakt, MyAnimeList, AniList og Simkl'; @override String get companionRemoteServer => 'Companion Remote Server'; @override String get companionRemoteServerDescription => 'Tillad mobilenheder på dit netværk at styre denne app'; @override String get autoPip => 'Auto billede-i-billede'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktDa implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersDa implements TranslationsTrackersEn { + _TranslationsTrackersDa._(this._root); + + final TranslationsDa _root; // ignore: unused_field + + // Translations + @override String get title => 'Trackere'; + @override String get hubSubtitle => 'Hold din visningsfremgang synkroniseret med Trakt og andre tjenester.'; + @override String get notConnected => 'Ikke forbundet'; + @override String connectedAs({required Object username}) => 'Forbundet som @${username}'; + @override String get scrobble => 'Registrer fremgang automatisk'; + @override String get scrobbleDescription => 'Opdater din liste når du er færdig med et afsnit eller en film.'; + @override String disconnectConfirm({required Object service}) => 'Afbryd ${service}?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy stopper med at opdatere din ${service}-liste. Du kan forbinde igen når som helst.'; + @override String connectFailed({required Object service}) => 'Kunne ikke forbinde til ${service}. Prøv igen.'; + @override late final _TranslationsTrackersServicesDa services = _TranslationsTrackersServicesDa._(_root); + @override late final _TranslationsTrackersDeviceCodeDa deviceCode = _TranslationsTrackersDeviceCodeDa._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsDa implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsDa._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemoteDa implements TranslationsCompanionRemot @override String get searchHint => 'Søg på desktop...'; } +// Path: trackers.services +class _TranslationsTrackersServicesDa implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesDa._(this._root); + + final TranslationsDa _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodeDa implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeDa._(this._root); + + final TranslationsDa _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Aktiver Plezy på ${service}'; + @override String body({required Object url}) => 'Besøg ${url} og indtast denne kode:'; + @override String openToActivate({required Object service}) => 'Åbn ${service} for at aktivere'; + @override String get waitingForAuthorization => 'Venter på godkendelse…'; + @override String get codeCopied => 'Kode kopieret'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsDa { 'settings.discordRichPresenceDescription' => 'Vis hvad du ser på Discord', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => 'Synkroniser visningshistorik med Trakt', + 'settings.trackers' => 'Trackere', + 'settings.trackersDescription' => 'Synkroniser fremgang til Trakt, MyAnimeList, AniList og Simkl', 'settings.companionRemoteServer' => 'Companion Remote Server', 'settings.companionRemoteServerDescription' => 'Tillad mobilenheder på dit netværk at styre denne app', 'settings.autoPip' => 'Auto billede-i-billede', @@ -1956,10 +2002,10 @@ extension on TranslationsDa { 'libraries.scanLibraryConfirm' => ({required Object title}) => 'Er du sikker på, at du vil scanne "${title}"?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => 'Er du sikker på, at du vil analysere "${title}"?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => 'Er du sikker på, at du vil opdatere metadata for "${title}"?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Er du sikker på, at du vil tømme papirkurven for "${title}"?', - 'libraries.manageLibraries' => 'Administrer biblioteker', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Er du sikker på, at du vil tømme papirkurven for "${title}"?', + 'libraries.manageLibraries' => 'Administrer biblioteker', 'libraries.sort' => 'Sortér', 'libraries.sortBy' => 'Sortér efter', 'libraries.filters' => 'Filtre', @@ -2371,12 +2417,24 @@ extension on TranslationsDa { '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.', + 'trackers.title' => 'Trackere', + 'trackers.hubSubtitle' => 'Hold din visningsfremgang synkroniseret med Trakt og andre tjenester.', + 'trackers.notConnected' => 'Ikke forbundet', + 'trackers.connectedAs' => ({required Object username}) => 'Forbundet som @${username}', + 'trackers.scrobble' => 'Registrer fremgang automatisk', + 'trackers.scrobbleDescription' => 'Opdater din liste når du er færdig med et afsnit eller en film.', + 'trackers.disconnectConfirm' => ({required Object service}) => 'Afbryd ${service}?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy stopper med at opdatere din ${service}-liste. Du kan forbinde igen når som helst.', + 'trackers.connectFailed' => ({required Object service}) => 'Kunne ikke forbinde til ${service}. Prøv igen.', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => 'Aktiver Plezy på ${service}', + 'trackers.deviceCode.body' => ({required Object url}) => 'Besøg ${url} og indtast denne kode:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Åbn ${service} for at aktivere', + 'trackers.deviceCode.waitingForAuthorization' => 'Venter på godkendelse…', + 'trackers.deviceCode.codeCopied' => 'Kode kopieret', _ => null, }; } diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index aca6cf0a..5264270d 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -77,6 +77,7 @@ class TranslationsDe with BaseTranslations implements T @override late final _TranslationsMatchScreenDe matchScreen = _TranslationsMatchScreenDe._(_root); @override late final _TranslationsServerTasksDe serverTasks = _TranslationsServerTasksDe._(_root); @override late final _TranslationsTraktDe trakt = _TranslationsTraktDe._(_root); + @override late final _TranslationsTrackersDe trackers = _TranslationsTrackersDe._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn { @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 trackers => 'Tracker'; + @override String get trackersDescription => 'Fortschritt mit Trakt, MyAnimeList, AniList und Simkl synchronisieren'; @override String get companionRemoteServer => 'Companion Remote Server'; @override String get companionRemoteServerDescription => 'Mobilgeräten in deinem Netzwerk erlauben, diese App zu steuern'; @override String get autoPip => 'Automatisches Bild-in-Bild'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktDe implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersDe implements TranslationsTrackersEn { + _TranslationsTrackersDe._(this._root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Tracker'; + @override String get hubSubtitle => 'Halte deinen Wiedergabefortschritt mit Trakt und anderen Diensten synchron.'; + @override String get notConnected => 'Nicht verbunden'; + @override String connectedAs({required Object username}) => 'Verbunden als @${username}'; + @override String get scrobble => 'Fortschritt automatisch verfolgen'; + @override String get scrobbleDescription => 'Aktualisiere deine Liste, wenn du eine Folge oder einen Film beendest.'; + @override String disconnectConfirm({required Object service}) => '${service} trennen?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy aktualisiert deine ${service}-Liste nicht mehr. Du kannst jederzeit wieder verbinden.'; + @override String connectFailed({required Object service}) => 'Verbindung zu ${service} fehlgeschlagen. Versuche es erneut.'; + @override late final _TranslationsTrackersServicesDe services = _TranslationsTrackersServicesDe._(_root); + @override late final _TranslationsTrackersDeviceCodeDe deviceCode = _TranslationsTrackersDeviceCodeDe._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsDe implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsDe._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemoteDe implements TranslationsCompanionRemot @override String get searchHint => 'Auf Desktop suchen...'; } +// Path: trackers.services +class _TranslationsTrackersServicesDe implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesDe._(this._root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodeDe implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeDe._(this._root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Plezy auf ${service} aktivieren'; + @override String body({required Object url}) => 'Gehe zu ${url} und gib diesen Code ein:'; + @override String openToActivate({required Object service}) => '${service} zum Aktivieren öffnen'; + @override String get waitingForAuthorization => 'Warte auf Autorisierung…'; + @override String get codeCopied => 'Code kopiert'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsDe { 'settings.discordRichPresenceDescription' => 'Zeige auf Discord, was du gerade schaust', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => 'Wiedergabeverlauf mit Trakt synchronisieren', + 'settings.trackers' => 'Tracker', + 'settings.trackersDescription' => 'Fortschritt mit Trakt, MyAnimeList, AniList und Simkl synchronisieren', 'settings.companionRemoteServer' => 'Companion Remote Server', 'settings.companionRemoteServerDescription' => 'Mobilgeräten in deinem Netzwerk erlauben, diese App zu steuern', 'settings.autoPip' => 'Automatisches Bild-in-Bild', @@ -1956,10 +2002,10 @@ extension on TranslationsDe { 'libraries.scanLibraryConfirm' => ({required Object title}) => '„${title}“ wirklich scannen?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => '„${title}“ wirklich analysieren?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => 'Metadaten für „${title}“ wirklich aktualisieren?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Papierkorb für „${title}“ wirklich leeren?', - 'libraries.manageLibraries' => 'Mediatheken verwalten', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Papierkorb für „${title}“ wirklich leeren?', + 'libraries.manageLibraries' => 'Mediatheken verwalten', 'libraries.sort' => 'Sortieren', 'libraries.sortBy' => 'Sortieren nach', 'libraries.filters' => 'Filter', @@ -2371,12 +2417,24 @@ extension on TranslationsDe { '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.', + 'trackers.title' => 'Tracker', + 'trackers.hubSubtitle' => 'Halte deinen Wiedergabefortschritt mit Trakt und anderen Diensten synchron.', + 'trackers.notConnected' => 'Nicht verbunden', + 'trackers.connectedAs' => ({required Object username}) => 'Verbunden als @${username}', + 'trackers.scrobble' => 'Fortschritt automatisch verfolgen', + 'trackers.scrobbleDescription' => 'Aktualisiere deine Liste, wenn du eine Folge oder einen Film beendest.', + 'trackers.disconnectConfirm' => ({required Object service}) => '${service} trennen?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy aktualisiert deine ${service}-Liste nicht mehr. Du kannst jederzeit wieder verbinden.', + 'trackers.connectFailed' => ({required Object service}) => 'Verbindung zu ${service} fehlgeschlagen. Versuche es erneut.', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => 'Plezy auf ${service} aktivieren', + 'trackers.deviceCode.body' => ({required Object url}) => 'Gehe zu ${url} und gib diesen Code ein:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => '${service} zum Aktivieren öffnen', + 'trackers.deviceCode.waitingForAuthorization' => 'Warte auf Autorisierung…', + 'trackers.deviceCode.codeCopied' => 'Code kopiert', _ => null, }; } diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index e851ce2c..72575340 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -80,6 +80,7 @@ class Translations with BaseTranslations { late final TranslationsMatchScreenEn matchScreen = TranslationsMatchScreenEn._(_root); late final TranslationsServerTasksEn serverTasks = TranslationsServerTasksEn._(_root); late final TranslationsTraktEn trakt = TranslationsTraktEn._(_root); + late final TranslationsTrackersEn trackers = TranslationsTrackersEn._(_root); } // Path: app @@ -759,6 +760,12 @@ class TranslationsSettingsEn { /// en: 'Sync watch history with Trakt' String get traktDescription => 'Sync watch history with Trakt'; + /// en: 'Trackers' + String get trackers => 'Trackers'; + + /// en: 'Sync progress to Trakt, MyAnimeList, AniList, and Simkl' + String get trackersDescription => 'Sync progress to Trakt, MyAnimeList, AniList, and Simkl'; + /// en: 'Companion Remote Server' String get companionRemoteServer => 'Companion Remote Server'; @@ -2942,25 +2949,49 @@ class TranslationsTraktEn { /// 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: trackers +class TranslationsTrackersEn { + TranslationsTrackersEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Trackers' + String get title => 'Trackers'; + + /// en: 'Keep your watch progress in sync with Trakt and other services.' + String get hubSubtitle => 'Keep your watch progress in sync with Trakt and other services.'; + + /// en: 'Not connected' + String get notConnected => 'Not connected'; + + /// en: 'Connected as @${username}' + String connectedAs({required Object username}) => 'Connected as @${username}'; + + /// en: 'Track progress automatically' + String get scrobble => 'Track progress automatically'; + + /// en: 'Update your list when you finish an episode or movie.' + String get scrobbleDescription => 'Update your list when you finish an episode or movie.'; + + /// en: 'Disconnect ${service}?' + String disconnectConfirm({required Object service}) => 'Disconnect ${service}?'; + + /// en: 'Plezy will stop updating your ${service} list. You can reconnect at any time.' + String disconnectConfirmBody({required Object service}) => 'Plezy will stop updating your ${service} list. You can reconnect at any time.'; + + /// en: 'Couldn't connect to ${service}. Try again.' + String connectFailed({required Object service}) => 'Couldn\'t connect to ${service}. Try again.'; + + late final TranslationsTrackersServicesEn services = TranslationsTrackersServicesEn._(_root); + late final TranslationsTrackersDeviceCodeEn deviceCode = TranslationsTrackersDeviceCodeEn._(_root); +} + // Path: hotkeys.actions class TranslationsHotkeysActionsEn { TranslationsHotkeysActionsEn._(this._root); @@ -3303,6 +3334,48 @@ class TranslationsCompanionRemoteRemoteEn { String get searchHint => 'Search on desktop...'; } +// Path: trackers.services +class TranslationsTrackersServicesEn { + TranslationsTrackersServicesEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'MyAnimeList' + String get mal => 'MyAnimeList'; + + /// en: 'AniList' + String get anilist => 'AniList'; + + /// en: 'Simkl' + String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class TranslationsTrackersDeviceCodeEn { + TranslationsTrackersDeviceCodeEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Activate Plezy on ${service}' + String title({required Object service}) => 'Activate Plezy on ${service}'; + + /// en: 'Visit ${url} and enter this code:' + String body({required Object url}) => 'Visit ${url} and enter this code:'; + + /// en: 'Open ${service} to activate' + String openToActivate({required Object service}) => 'Open ${service} to activate'; + + /// en: 'Waiting for authorization…' + String get waitingForAuthorization => 'Waiting for authorization…'; + + /// en: 'Code copied' + String get codeCopied => 'Code copied'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -3519,6 +3592,8 @@ extension on Translations { 'settings.discordRichPresenceDescription' => 'Show what you\'re watching on Discord', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => 'Sync watch history with Trakt', + 'settings.trackers' => 'Trackers', + 'settings.trackersDescription' => 'Sync progress to Trakt, MyAnimeList, AniList, and Simkl', 'settings.companionRemoteServer' => 'Companion Remote Server', 'settings.companionRemoteServerDescription' => 'Allow mobile devices on your network to control this app', 'settings.autoPip' => 'Auto Picture-in-Picture', @@ -3821,10 +3896,10 @@ extension on Translations { 'libraries.scanLibraryConfirm' => ({required Object title}) => 'Are you sure you want to scan "${title}"?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => 'Are you sure you want to analyze "${title}"?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => 'Are you sure you want to refresh metadata for "${title}"?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Are you sure you want to empty trash for "${title}"?', - 'libraries.manageLibraries' => 'Manage Libraries', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Are you sure you want to empty trash for "${title}"?', + 'libraries.manageLibraries' => 'Manage Libraries', 'libraries.sort' => 'Sort', 'libraries.sortBy' => 'Sort By', 'libraries.filters' => 'Filters', @@ -4236,12 +4311,24 @@ extension on Translations { '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.', + 'trackers.title' => 'Trackers', + 'trackers.hubSubtitle' => 'Keep your watch progress in sync with Trakt and other services.', + 'trackers.notConnected' => 'Not connected', + 'trackers.connectedAs' => ({required Object username}) => 'Connected as @${username}', + 'trackers.scrobble' => 'Track progress automatically', + 'trackers.scrobbleDescription' => 'Update your list when you finish an episode or movie.', + 'trackers.disconnectConfirm' => ({required Object service}) => 'Disconnect ${service}?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy will stop updating your ${service} list. You can reconnect at any time.', + 'trackers.connectFailed' => ({required Object service}) => 'Couldn\'t connect to ${service}. Try again.', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => 'Activate Plezy on ${service}', + 'trackers.deviceCode.body' => ({required Object url}) => 'Visit ${url} and enter this code:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} to activate', + 'trackers.deviceCode.waitingForAuthorization' => 'Waiting for authorization…', + 'trackers.deviceCode.codeCopied' => 'Code copied', _ => null, }; } diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 591cf2f2..e2855f12 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -77,6 +77,7 @@ class TranslationsEs with BaseTranslations implements T @override late final _TranslationsMatchScreenEs matchScreen = _TranslationsMatchScreenEs._(_root); @override late final _TranslationsServerTasksEs serverTasks = _TranslationsServerTasksEs._(_root); @override late final _TranslationsTraktEs trakt = _TranslationsTraktEs._(_root); + @override late final _TranslationsTrackersEs trackers = _TranslationsTrackersEs._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsEs implements TranslationsSettingsEn { @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 trackers => 'Rastreadores'; + @override String get trackersDescription => 'Sincronizar progreso con Trakt, MyAnimeList, AniList y Simkl'; @override String get companionRemoteServer => 'Servidor de control remoto'; @override String get companionRemoteServerDescription => 'Permitir que dispositivos móviles en tu red controlen esta aplicación'; @override String get autoPip => 'Imagen en imagen automática'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktEs implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersEs implements TranslationsTrackersEn { + _TranslationsTrackersEs._(this._root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Rastreadores'; + @override String get hubSubtitle => 'Mantén sincronizado tu progreso con Trakt y otros servicios.'; + @override String get notConnected => 'No conectado'; + @override String connectedAs({required Object username}) => 'Conectado como @${username}'; + @override String get scrobble => 'Registrar progreso automáticamente'; + @override String get scrobbleDescription => 'Actualiza tu lista cuando termines un episodio o película.'; + @override String disconnectConfirm({required Object service}) => '¿Desconectar ${service}?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy dejará de actualizar tu lista de ${service}. Puedes reconectarte en cualquier momento.'; + @override String connectFailed({required Object service}) => 'No se pudo conectar a ${service}. Inténtalo de nuevo.'; + @override late final _TranslationsTrackersServicesEs services = _TranslationsTrackersServicesEs._(_root); + @override late final _TranslationsTrackersDeviceCodeEs deviceCode = _TranslationsTrackersDeviceCodeEs._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsEs implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsEs._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemoteEs implements TranslationsCompanionRemot @override String get searchHint => 'Buscar en escritorio...'; } +// Path: trackers.services +class _TranslationsTrackersServicesEs implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesEs._(this._root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodeEs implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeEs._(this._root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Activar Plezy en ${service}'; + @override String body({required Object url}) => 'Visita ${url} e introduce este código:'; + @override String openToActivate({required Object service}) => 'Abrir ${service} para activar'; + @override String get waitingForAuthorization => 'Esperando autorización…'; + @override String get codeCopied => 'Código copiado'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsEs { 'settings.discordRichPresenceDescription' => 'Mostrar lo que estás viendo en Discord', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => 'Sincronizar historial de visualización con Trakt', + 'settings.trackers' => 'Rastreadores', + 'settings.trackersDescription' => 'Sincronizar progreso con Trakt, MyAnimeList, AniList y Simkl', 'settings.companionRemoteServer' => 'Servidor de control remoto', 'settings.companionRemoteServerDescription' => 'Permitir que dispositivos móviles en tu red controlen esta aplicación', 'settings.autoPip' => 'Imagen en imagen automática', @@ -1956,10 +2002,10 @@ extension on TranslationsEs { 'libraries.scanLibraryConfirm' => ({required Object title}) => '¿Estás seguro de que quieres escanear "${title}"?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => '¿Estás seguro de que quieres analizar "${title}"?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => '¿Estás seguro de que quieres actualizar los metadatos de "${title}"?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => '¿Estás seguro de que quieres vaciar la papelera de "${title}"?', - 'libraries.manageLibraries' => 'Gestionar Bibliotecas', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => '¿Estás seguro de que quieres vaciar la papelera de "${title}"?', + 'libraries.manageLibraries' => 'Gestionar Bibliotecas', 'libraries.sort' => 'Ordenar', 'libraries.sortBy' => 'Ordenar por', 'libraries.filters' => 'Filtros', @@ -2371,12 +2417,24 @@ extension on TranslationsEs { '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.', + 'trackers.title' => 'Rastreadores', + 'trackers.hubSubtitle' => 'Mantén sincronizado tu progreso con Trakt y otros servicios.', + 'trackers.notConnected' => 'No conectado', + 'trackers.connectedAs' => ({required Object username}) => 'Conectado como @${username}', + 'trackers.scrobble' => 'Registrar progreso automáticamente', + 'trackers.scrobbleDescription' => 'Actualiza tu lista cuando termines un episodio o película.', + 'trackers.disconnectConfirm' => ({required Object service}) => '¿Desconectar ${service}?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy dejará de actualizar tu lista de ${service}. Puedes reconectarte en cualquier momento.', + 'trackers.connectFailed' => ({required Object service}) => 'No se pudo conectar a ${service}. Inténtalo de nuevo.', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => 'Activar Plezy en ${service}', + 'trackers.deviceCode.body' => ({required Object url}) => 'Visita ${url} e introduce este código:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Abrir ${service} para activar', + 'trackers.deviceCode.waitingForAuthorization' => 'Esperando autorización…', + 'trackers.deviceCode.codeCopied' => 'Código copiado', _ => null, }; } diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index a5e145fe..a12597bd 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -77,6 +77,7 @@ class TranslationsFr with BaseTranslations implements T @override late final _TranslationsMatchScreenFr matchScreen = _TranslationsMatchScreenFr._(_root); @override late final _TranslationsServerTasksFr serverTasks = _TranslationsServerTasksFr._(_root); @override late final _TranslationsTraktFr trakt = _TranslationsTraktFr._(_root); + @override late final _TranslationsTrackersFr trackers = _TranslationsTrackersFr._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn { @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 trackers => 'Trackers'; + @override String get trackersDescription => 'Synchroniser la progression avec Trakt, MyAnimeList, AniList et Simkl'; @override String get companionRemoteServer => 'Serveur de télécommande'; @override String get companionRemoteServerDescription => 'Autoriser les appareils mobiles de votre réseau à contrôler cette application'; @override String get autoPip => 'Image dans l\'image automatique'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktFr implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersFr implements TranslationsTrackersEn { + _TranslationsTrackersFr._(this._root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Trackers'; + @override String get hubSubtitle => 'Gardez votre progression synchronisée avec Trakt et d\'autres services.'; + @override String get notConnected => 'Non connecté'; + @override String connectedAs({required Object username}) => 'Connecté en tant que @${username}'; + @override String get scrobble => 'Suivre la progression automatiquement'; + @override String get scrobbleDescription => 'Mettre à jour votre liste lorsque vous terminez un épisode ou un film.'; + @override String disconnectConfirm({required Object service}) => 'Déconnecter ${service} ?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy ne mettra plus à jour votre liste ${service}. Vous pouvez vous reconnecter à tout moment.'; + @override String connectFailed({required Object service}) => 'Échec de la connexion à ${service}. Réessayez.'; + @override late final _TranslationsTrackersServicesFr services = _TranslationsTrackersServicesFr._(_root); + @override late final _TranslationsTrackersDeviceCodeFr deviceCode = _TranslationsTrackersDeviceCodeFr._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsFr implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsFr._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemoteFr implements TranslationsCompanionRemot @override String get searchHint => 'Rechercher sur le bureau...'; } +// Path: trackers.services +class _TranslationsTrackersServicesFr implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesFr._(this._root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodeFr implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeFr._(this._root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Activer Plezy sur ${service}'; + @override String body({required Object url}) => 'Rendez-vous sur ${url} et entrez ce code :'; + @override String openToActivate({required Object service}) => 'Ouvrir ${service} pour activer'; + @override String get waitingForAuthorization => 'En attente d\'autorisation…'; + @override String get codeCopied => 'Code copié'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsFr { 'settings.discordRichPresenceDescription' => 'Montrez ce que vous regardez sur Discord', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => 'Synchroniser l\'historique de visionnage avec Trakt', + 'settings.trackers' => 'Trackers', + 'settings.trackersDescription' => 'Synchroniser la progression avec Trakt, MyAnimeList, AniList et Simkl', 'settings.companionRemoteServer' => 'Serveur de télécommande', 'settings.companionRemoteServerDescription' => 'Autoriser les appareils mobiles de votre réseau à contrôler cette application', 'settings.autoPip' => 'Image dans l\'image automatique', @@ -1956,10 +2002,10 @@ extension on TranslationsFr { 'libraries.scanLibraryConfirm' => ({required Object title}) => 'Êtes-vous sûr de vouloir lancer le scan de "${title}"?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => 'Êtes-vous sûr de vouloir analyser "${title}"?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => 'Êtes-vous sûr de vouloir actualiser les métadonnées pour "${title}"?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Êtes-vous sûr de vouloir vider la corbeille pour "${title}"?', - 'libraries.manageLibraries' => 'Gérer les bibliothèques', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Êtes-vous sûr de vouloir vider la corbeille pour "${title}"?', + 'libraries.manageLibraries' => 'Gérer les bibliothèques', 'libraries.sort' => 'Trier', 'libraries.sortBy' => 'Trier par', 'libraries.filters' => 'Filtres', @@ -2371,12 +2417,24 @@ extension on TranslationsFr { '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.', + 'trackers.title' => 'Trackers', + 'trackers.hubSubtitle' => 'Gardez votre progression synchronisée avec Trakt et d\'autres services.', + 'trackers.notConnected' => 'Non connecté', + 'trackers.connectedAs' => ({required Object username}) => 'Connecté en tant que @${username}', + 'trackers.scrobble' => 'Suivre la progression automatiquement', + 'trackers.scrobbleDescription' => 'Mettre à jour votre liste lorsque vous terminez un épisode ou un film.', + 'trackers.disconnectConfirm' => ({required Object service}) => 'Déconnecter ${service} ?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy ne mettra plus à jour votre liste ${service}. Vous pouvez vous reconnecter à tout moment.', + 'trackers.connectFailed' => ({required Object service}) => 'Échec de la connexion à ${service}. Réessayez.', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => 'Activer Plezy sur ${service}', + 'trackers.deviceCode.body' => ({required Object url}) => 'Rendez-vous sur ${url} et entrez ce code :', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Ouvrir ${service} pour activer', + 'trackers.deviceCode.waitingForAuthorization' => 'En attente d\'autorisation…', + 'trackers.deviceCode.codeCopied' => 'Code copié', _ => null, }; } diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 124c5033..ab1bf948 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -77,6 +77,7 @@ class TranslationsIt with BaseTranslations implements T @override late final _TranslationsMatchScreenIt matchScreen = _TranslationsMatchScreenIt._(_root); @override late final _TranslationsServerTasksIt serverTasks = _TranslationsServerTasksIt._(_root); @override late final _TranslationsTraktIt trakt = _TranslationsTraktIt._(_root); + @override late final _TranslationsTrackersIt trackers = _TranslationsTrackersIt._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn { @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 trackers => 'Tracker'; + @override String get trackersDescription => 'Sincronizza i progressi con Trakt, MyAnimeList, AniList e Simkl'; @override String get companionRemoteServer => 'Server telecomando'; @override String get companionRemoteServerDescription => 'Consenti ai dispositivi mobili nella tua rete di controllare questa app'; @override String get autoPip => 'Picture-in-Picture automatico'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktIt implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersIt implements TranslationsTrackersEn { + _TranslationsTrackersIt._(this._root); + + final TranslationsIt _root; // ignore: unused_field + + // Translations + @override String get title => 'Tracker'; + @override String get hubSubtitle => 'Mantieni i tuoi progressi sincronizzati con Trakt e altri servizi.'; + @override String get notConnected => 'Non connesso'; + @override String connectedAs({required Object username}) => 'Connesso come @${username}'; + @override String get scrobble => 'Traccia i progressi automaticamente'; + @override String get scrobbleDescription => 'Aggiorna la tua lista quando finisci un episodio o un film.'; + @override String disconnectConfirm({required Object service}) => 'Disconnettere ${service}?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy smetterà di aggiornare la tua lista ${service}. Puoi riconnetterti in qualsiasi momento.'; + @override String connectFailed({required Object service}) => 'Impossibile connettersi a ${service}. Riprova.'; + @override late final _TranslationsTrackersServicesIt services = _TranslationsTrackersServicesIt._(_root); + @override late final _TranslationsTrackersDeviceCodeIt deviceCode = _TranslationsTrackersDeviceCodeIt._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsIt implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsIt._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemoteIt implements TranslationsCompanionRemot @override String get searchHint => 'Cerca sul desktop...'; } +// Path: trackers.services +class _TranslationsTrackersServicesIt implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesIt._(this._root); + + final TranslationsIt _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodeIt implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeIt._(this._root); + + final TranslationsIt _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Attiva Plezy su ${service}'; + @override String body({required Object url}) => 'Visita ${url} e inserisci questo codice:'; + @override String openToActivate({required Object service}) => 'Apri ${service} per attivare'; + @override String get waitingForAuthorization => 'In attesa di autorizzazione…'; + @override String get codeCopied => 'Codice copiato'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsIt { 'settings.discordRichPresenceDescription' => 'Mostra su Discord cosa stai guardando', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => 'Sincronizza la cronologia di visione con Trakt', + 'settings.trackers' => 'Tracker', + 'settings.trackersDescription' => 'Sincronizza i progressi con Trakt, MyAnimeList, AniList e Simkl', 'settings.companionRemoteServer' => 'Server telecomando', 'settings.companionRemoteServerDescription' => 'Consenti ai dispositivi mobili nella tua rete di controllare questa app', 'settings.autoPip' => 'Picture-in-Picture automatico', @@ -1956,10 +2002,10 @@ extension on TranslationsIt { 'libraries.scanLibraryConfirm' => ({required Object title}) => 'Sei sicuro di voler scansionare "${title}"?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => 'Sei sicuro di voler analizzare "${title}"?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => 'Sei sicuro di voler aggiornare i metadati per "${title}"?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Sei sicuro di voler svuotare il cestino per "${title}"?', - 'libraries.manageLibraries' => 'Gestisci librerie', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Sei sicuro di voler svuotare il cestino per "${title}"?', + 'libraries.manageLibraries' => 'Gestisci librerie', 'libraries.sort' => 'Ordina', 'libraries.sortBy' => 'Ordina per', 'libraries.filters' => 'Filtri', @@ -2371,12 +2417,24 @@ extension on TranslationsIt { '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.', + 'trackers.title' => 'Tracker', + 'trackers.hubSubtitle' => 'Mantieni i tuoi progressi sincronizzati con Trakt e altri servizi.', + 'trackers.notConnected' => 'Non connesso', + 'trackers.connectedAs' => ({required Object username}) => 'Connesso come @${username}', + 'trackers.scrobble' => 'Traccia i progressi automaticamente', + 'trackers.scrobbleDescription' => 'Aggiorna la tua lista quando finisci un episodio o un film.', + 'trackers.disconnectConfirm' => ({required Object service}) => 'Disconnettere ${service}?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy smetterà di aggiornare la tua lista ${service}. Puoi riconnetterti in qualsiasi momento.', + 'trackers.connectFailed' => ({required Object service}) => 'Impossibile connettersi a ${service}. Riprova.', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => 'Attiva Plezy su ${service}', + 'trackers.deviceCode.body' => ({required Object url}) => 'Visita ${url} e inserisci questo codice:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Apri ${service} per attivare', + 'trackers.deviceCode.waitingForAuthorization' => 'In attesa di autorizzazione…', + 'trackers.deviceCode.codeCopied' => 'Codice copiato', _ => null, }; } diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index a86b57e1..4a89e77c 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -77,6 +77,7 @@ class TranslationsJa with BaseTranslations implements T @override late final _TranslationsMatchScreenJa matchScreen = _TranslationsMatchScreenJa._(_root); @override late final _TranslationsServerTasksJa serverTasks = _TranslationsServerTasksJa._(_root); @override late final _TranslationsTraktJa trakt = _TranslationsTraktJa._(_root); + @override late final _TranslationsTrackersJa trackers = _TranslationsTrackersJa._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsJa implements TranslationsSettingsEn { @override String get discordRichPresenceDescription => 'Discordで視聴中の内容を表示'; @override String get trakt => 'Trakt'; @override String get traktDescription => '視聴履歴を Trakt と同期'; + @override String get trackers => 'トラッカー'; + @override String get trackersDescription => '進捗を Trakt、MyAnimeList、AniList、Simkl と同期'; @override String get companionRemoteServer => 'コンパニオンリモートサーバー'; @override String get companionRemoteServerDescription => 'ネットワーク上のモバイルデバイスからこのアプリを操作できるようにする'; @override String get autoPip => '自動ピクチャーインピクチャー'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktJa implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersJa implements TranslationsTrackersEn { + _TranslationsTrackersJa._(this._root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String get title => 'トラッカー'; + @override String get hubSubtitle => '視聴進捗を Trakt や他のサービスで同期します。'; + @override String get notConnected => '未接続'; + @override String connectedAs({required Object username}) => '@${username} として接続済み'; + @override String get scrobble => '進捗を自動で記録'; + @override String get scrobbleDescription => 'エピソードや映画を見終えたときにリストを更新します。'; + @override String disconnectConfirm({required Object service}) => '${service} の接続を解除しますか?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy は ${service} のリストを更新しなくなります。いつでも再接続できます。'; + @override String connectFailed({required Object service}) => '${service} に接続できませんでした。もう一度お試しください。'; + @override late final _TranslationsTrackersServicesJa services = _TranslationsTrackersServicesJa._(_root); + @override late final _TranslationsTrackersDeviceCodeJa deviceCode = _TranslationsTrackersDeviceCodeJa._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsJa implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsJa._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemoteJa implements TranslationsCompanionRemot @override String get searchHint => 'デスクトップで検索...'; } +// Path: trackers.services +class _TranslationsTrackersServicesJa implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesJa._(this._root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodeJa implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeJa._(this._root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => '${service} で Plezy を有効化'; + @override String body({required Object url}) => '${url} にアクセスしてこのコードを入力:'; + @override String openToActivate({required Object service}) => '${service} を開いて有効化'; + @override String get waitingForAuthorization => '認証を待っています…'; + @override String get codeCopied => 'コードをコピーしました'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsJa { 'settings.discordRichPresenceDescription' => 'Discordで視聴中の内容を表示', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => '視聴履歴を Trakt と同期', + 'settings.trackers' => 'トラッカー', + 'settings.trackersDescription' => '進捗を Trakt、MyAnimeList、AniList、Simkl と同期', 'settings.companionRemoteServer' => 'コンパニオンリモートサーバー', 'settings.companionRemoteServerDescription' => 'ネットワーク上のモバイルデバイスからこのアプリを操作できるようにする', 'settings.autoPip' => '自動ピクチャーインピクチャー', @@ -1956,10 +2002,10 @@ extension on TranslationsJa { 'libraries.scanLibraryConfirm' => ({required Object title}) => '"${title}"をスキャンしてもよろしいですか?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => '"${title}"を解析してもよろしいですか?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => '"${title}"のメタデータを更新してもよろしいですか?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => '"${title}"のゴミ箱を空にしてもよろしいですか?', - 'libraries.manageLibraries' => 'ライブラリを管理', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => '"${title}"のゴミ箱を空にしてもよろしいですか?', + 'libraries.manageLibraries' => 'ライブラリを管理', 'libraries.sort' => '並べ替え', 'libraries.sortBy' => '並べ替え順', 'libraries.filters' => 'フィルター', @@ -2371,12 +2417,24 @@ extension on TranslationsJa { '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 に接続できませんでした。もう一度お試しください。', + 'trackers.title' => 'トラッカー', + 'trackers.hubSubtitle' => '視聴進捗を Trakt や他のサービスで同期します。', + 'trackers.notConnected' => '未接続', + 'trackers.connectedAs' => ({required Object username}) => '@${username} として接続済み', + 'trackers.scrobble' => '進捗を自動で記録', + 'trackers.scrobbleDescription' => 'エピソードや映画を見終えたときにリストを更新します。', + 'trackers.disconnectConfirm' => ({required Object service}) => '${service} の接続を解除しますか?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy は ${service} のリストを更新しなくなります。いつでも再接続できます。', + 'trackers.connectFailed' => ({required Object service}) => '${service} に接続できませんでした。もう一度お試しください。', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => '${service} で Plezy を有効化', + 'trackers.deviceCode.body' => ({required Object url}) => '${url} にアクセスしてこのコードを入力:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => '${service} を開いて有効化', + 'trackers.deviceCode.waitingForAuthorization' => '認証を待っています…', + 'trackers.deviceCode.codeCopied' => 'コードをコピーしました', _ => null, }; } diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index ef4d7963..a9555c22 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -77,6 +77,7 @@ class TranslationsKo with BaseTranslations implements T @override late final _TranslationsMatchScreenKo matchScreen = _TranslationsMatchScreenKo._(_root); @override late final _TranslationsServerTasksKo serverTasks = _TranslationsServerTasksKo._(_root); @override late final _TranslationsTraktKo trakt = _TranslationsTraktKo._(_root); + @override late final _TranslationsTrackersKo trackers = _TranslationsTrackersKo._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn { @override String get discordRichPresenceDescription => 'Discord에서 시청 중인 콘텐츠 표시'; @override String get trakt => 'Trakt'; @override String get traktDescription => 'Trakt와 시청 기록 동기화'; + @override String get trackers => '트래커'; + @override String get trackersDescription => 'Trakt, MyAnimeList, AniList 및 Simkl에 진행률 동기화'; @override String get companionRemoteServer => '컴패니언 리모트 서버'; @override String get companionRemoteServerDescription => '네트워크의 모바일 기기가 이 앱을 제어할 수 있도록 허용'; @override String get autoPip => '자동 PIP 모드'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktKo implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersKo implements TranslationsTrackersEn { + _TranslationsTrackersKo._(this._root); + + final TranslationsKo _root; // ignore: unused_field + + // Translations + @override String get title => '트래커'; + @override String get hubSubtitle => '시청 진행률을 Trakt 및 기타 서비스와 동기화하세요.'; + @override String get notConnected => '연결되지 않음'; + @override String connectedAs({required Object username}) => '@${username} 로 연결됨'; + @override String get scrobble => '진행률 자동 추적'; + @override String get scrobbleDescription => '에피소드나 영화를 시청하면 목록을 업데이트합니다.'; + @override String disconnectConfirm({required Object service}) => '${service} 연결을 해제하시겠습니까?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy가 ${service} 목록 업데이트를 중지합니다. 언제든지 다시 연결할 수 있습니다.'; + @override String connectFailed({required Object service}) => '${service}에 연결할 수 없습니다. 다시 시도하세요.'; + @override late final _TranslationsTrackersServicesKo services = _TranslationsTrackersServicesKo._(_root); + @override late final _TranslationsTrackersDeviceCodeKo deviceCode = _TranslationsTrackersDeviceCodeKo._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsKo implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsKo._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemoteKo implements TranslationsCompanionRemot @override String get searchHint => '데스크톱에서 검색...'; } +// Path: trackers.services +class _TranslationsTrackersServicesKo implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesKo._(this._root); + + final TranslationsKo _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodeKo implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeKo._(this._root); + + final TranslationsKo _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => '${service}에서 Plezy 활성화'; + @override String body({required Object url}) => '${url}을 방문하여 이 코드를 입력하세요:'; + @override String openToActivate({required Object service}) => '활성화하려면 ${service} 열기'; + @override String get waitingForAuthorization => '인증을 기다리는 중…'; + @override String get codeCopied => '코드가 복사되었습니다'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsKo { 'settings.discordRichPresenceDescription' => 'Discord에서 시청 중인 콘텐츠 표시', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => 'Trakt와 시청 기록 동기화', + 'settings.trackers' => '트래커', + 'settings.trackersDescription' => 'Trakt, MyAnimeList, AniList 및 Simkl에 진행률 동기화', 'settings.companionRemoteServer' => '컴패니언 리모트 서버', 'settings.companionRemoteServerDescription' => '네트워크의 모바일 기기가 이 앱을 제어할 수 있도록 허용', 'settings.autoPip' => '자동 PIP 모드', @@ -1956,10 +2002,10 @@ extension on TranslationsKo { 'libraries.scanLibraryConfirm' => ({required Object title}) => '「${title}」를 스캔 하시겠습니까?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => '「${title}」를 분석 하시겠습니까?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => '「${title}」의 메타데이터를 새로고침 하시겠습니까?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => '${title}의 휴지통을 비우시겠습니까?', - 'libraries.manageLibraries' => '미디어 라이브러리 관리', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => '${title}의 휴지통을 비우시겠습니까?', + 'libraries.manageLibraries' => '미디어 라이브러리 관리', 'libraries.sort' => '정렬', 'libraries.sortBy' => '정렬 기준', 'libraries.filters' => '필터', @@ -2371,12 +2417,24 @@ extension on TranslationsKo { '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에 연결할 수 없습니다. 다시 시도하세요.', + 'trackers.title' => '트래커', + 'trackers.hubSubtitle' => '시청 진행률을 Trakt 및 기타 서비스와 동기화하세요.', + 'trackers.notConnected' => '연결되지 않음', + 'trackers.connectedAs' => ({required Object username}) => '@${username} 로 연결됨', + 'trackers.scrobble' => '진행률 자동 추적', + 'trackers.scrobbleDescription' => '에피소드나 영화를 시청하면 목록을 업데이트합니다.', + 'trackers.disconnectConfirm' => ({required Object service}) => '${service} 연결을 해제하시겠습니까?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy가 ${service} 목록 업데이트를 중지합니다. 언제든지 다시 연결할 수 있습니다.', + 'trackers.connectFailed' => ({required Object service}) => '${service}에 연결할 수 없습니다. 다시 시도하세요.', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => '${service}에서 Plezy 활성화', + 'trackers.deviceCode.body' => ({required Object url}) => '${url}을 방문하여 이 코드를 입력하세요:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => '활성화하려면 ${service} 열기', + 'trackers.deviceCode.waitingForAuthorization' => '인증을 기다리는 중…', + 'trackers.deviceCode.codeCopied' => '코드가 복사되었습니다', _ => null, }; } diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 01f14b5c..12472ba4 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -77,6 +77,7 @@ class TranslationsNb with BaseTranslations implements T @override late final _TranslationsMatchScreenNb matchScreen = _TranslationsMatchScreenNb._(_root); @override late final _TranslationsServerTasksNb serverTasks = _TranslationsServerTasksNb._(_root); @override late final _TranslationsTraktNb trakt = _TranslationsTraktNb._(_root); + @override late final _TranslationsTrackersNb trackers = _TranslationsTrackersNb._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsNb implements TranslationsSettingsEn { @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 trackers => 'Trackere'; + @override String get trackersDescription => 'Synkroniser fremdrift til Trakt, MyAnimeList, AniList og Simkl'; @override String get companionRemoteServer => 'Companion Remote-server'; @override String get companionRemoteServerDescription => 'Tillat mobilenheter på nettverket ditt å styre denne appen'; @override String get autoPip => 'Auto bilde-i-bilde'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktNb implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersNb implements TranslationsTrackersEn { + _TranslationsTrackersNb._(this._root); + + final TranslationsNb _root; // ignore: unused_field + + // Translations + @override String get title => 'Trackere'; + @override String get hubSubtitle => 'Hold visningsfremdriften synkronisert med Trakt og andre tjenester.'; + @override String get notConnected => 'Ikke tilkoblet'; + @override String connectedAs({required Object username}) => 'Tilkoblet som @${username}'; + @override String get scrobble => 'Registrer fremdrift automatisk'; + @override String get scrobbleDescription => 'Oppdater listen din når du er ferdig med en episode eller film.'; + @override String disconnectConfirm({required Object service}) => 'Koble fra ${service}?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy slutter å oppdatere ${service}-listen din. Du kan koble til igjen når som helst.'; + @override String connectFailed({required Object service}) => 'Kunne ikke koble til ${service}. Prøv igjen.'; + @override late final _TranslationsTrackersServicesNb services = _TranslationsTrackersServicesNb._(_root); + @override late final _TranslationsTrackersDeviceCodeNb deviceCode = _TranslationsTrackersDeviceCodeNb._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsNb implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsNb._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemoteNb implements TranslationsCompanionRemot @override String get searchHint => 'Søk på stasjonær...'; } +// Path: trackers.services +class _TranslationsTrackersServicesNb implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesNb._(this._root); + + final TranslationsNb _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodeNb implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeNb._(this._root); + + final TranslationsNb _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Aktiver Plezy på ${service}'; + @override String body({required Object url}) => 'Besøk ${url} og skriv inn denne koden:'; + @override String openToActivate({required Object service}) => 'Åpne ${service} for å aktivere'; + @override String get waitingForAuthorization => 'Venter på godkjenning…'; + @override String get codeCopied => 'Kode kopiert'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsNb { 'settings.discordRichPresenceDescription' => 'Vis hva du ser på Discord', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => 'Synkroniser visningshistorikk med Trakt', + 'settings.trackers' => 'Trackere', + 'settings.trackersDescription' => 'Synkroniser fremdrift til Trakt, MyAnimeList, AniList og Simkl', 'settings.companionRemoteServer' => 'Companion Remote-server', 'settings.companionRemoteServerDescription' => 'Tillat mobilenheter på nettverket ditt å styre denne appen', 'settings.autoPip' => 'Auto bilde-i-bilde', @@ -1956,10 +2002,10 @@ extension on TranslationsNb { 'libraries.scanLibraryConfirm' => ({required Object title}) => 'Er du sikker på at du vil skanne "${title}"?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => 'Er du sikker på at du vil analysere "${title}"?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => 'Er du sikker på at du vil oppdatere metadata for "${title}"?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Er du sikker på at du vil tømme papirkurven for "${title}"?', - 'libraries.manageLibraries' => 'Administrer biblioteker', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Er du sikker på at du vil tømme papirkurven for "${title}"?', + 'libraries.manageLibraries' => 'Administrer biblioteker', 'libraries.sort' => 'Sorter', 'libraries.sortBy' => 'Sorter etter', 'libraries.filters' => 'Filtre', @@ -2371,12 +2417,24 @@ extension on TranslationsNb { '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.', + 'trackers.title' => 'Trackere', + 'trackers.hubSubtitle' => 'Hold visningsfremdriften synkronisert med Trakt og andre tjenester.', + 'trackers.notConnected' => 'Ikke tilkoblet', + 'trackers.connectedAs' => ({required Object username}) => 'Tilkoblet som @${username}', + 'trackers.scrobble' => 'Registrer fremdrift automatisk', + 'trackers.scrobbleDescription' => 'Oppdater listen din når du er ferdig med en episode eller film.', + 'trackers.disconnectConfirm' => ({required Object service}) => 'Koble fra ${service}?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy slutter å oppdatere ${service}-listen din. Du kan koble til igjen når som helst.', + 'trackers.connectFailed' => ({required Object service}) => 'Kunne ikke koble til ${service}. Prøv igjen.', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => 'Aktiver Plezy på ${service}', + 'trackers.deviceCode.body' => ({required Object url}) => 'Besøk ${url} og skriv inn denne koden:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Åpne ${service} for å aktivere', + 'trackers.deviceCode.waitingForAuthorization' => 'Venter på godkjenning…', + 'trackers.deviceCode.codeCopied' => 'Kode kopiert', _ => null, }; } diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 9ef51f40..7f89b499 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -77,6 +77,7 @@ class TranslationsNl with BaseTranslations implements T @override late final _TranslationsMatchScreenNl matchScreen = _TranslationsMatchScreenNl._(_root); @override late final _TranslationsServerTasksNl serverTasks = _TranslationsServerTasksNl._(_root); @override late final _TranslationsTraktNl trakt = _TranslationsTraktNl._(_root); + @override late final _TranslationsTrackersNl trackers = _TranslationsTrackersNl._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn { @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 trackers => 'Trackers'; + @override String get trackersDescription => 'Voortgang synchroniseren met Trakt, MyAnimeList, AniList en Simkl'; @override String get companionRemoteServer => 'Companion Remote-server'; @override String get companionRemoteServerDescription => 'Sta mobiele apparaten op je netwerk toe om deze app te bedienen'; @override String get autoPip => 'Automatische beeld-in-beeld'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktNl implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersNl implements TranslationsTrackersEn { + _TranslationsTrackersNl._(this._root); + + final TranslationsNl _root; // ignore: unused_field + + // Translations + @override String get title => 'Trackers'; + @override String get hubSubtitle => 'Houd je kijkvoortgang gesynchroniseerd met Trakt en andere diensten.'; + @override String get notConnected => 'Niet verbonden'; + @override String connectedAs({required Object username}) => 'Verbonden als @${username}'; + @override String get scrobble => 'Voortgang automatisch volgen'; + @override String get scrobbleDescription => 'Werk je lijst bij wanneer je een aflevering of film afrondt.'; + @override String disconnectConfirm({required Object service}) => '${service} loskoppelen?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy werkt je ${service}-lijst niet meer bij. Je kunt op elk moment opnieuw verbinden.'; + @override String connectFailed({required Object service}) => 'Kan niet verbinden met ${service}. Probeer opnieuw.'; + @override late final _TranslationsTrackersServicesNl services = _TranslationsTrackersServicesNl._(_root); + @override late final _TranslationsTrackersDeviceCodeNl deviceCode = _TranslationsTrackersDeviceCodeNl._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsNl implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsNl._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemoteNl implements TranslationsCompanionRemot @override String get searchHint => 'Zoeken op desktop...'; } +// Path: trackers.services +class _TranslationsTrackersServicesNl implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesNl._(this._root); + + final TranslationsNl _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodeNl implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeNl._(this._root); + + final TranslationsNl _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Plezy activeren op ${service}'; + @override String body({required Object url}) => 'Ga naar ${url} en voer deze code in:'; + @override String openToActivate({required Object service}) => 'Open ${service} om te activeren'; + @override String get waitingForAuthorization => 'Wachten op autorisatie…'; + @override String get codeCopied => 'Code gekopieerd'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsNl { 'settings.discordRichPresenceDescription' => 'Toon op Discord wat je aan het kijken bent', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => 'Kijkgeschiedenis synchroniseren met Trakt', + 'settings.trackers' => 'Trackers', + 'settings.trackersDescription' => 'Voortgang synchroniseren met Trakt, MyAnimeList, AniList en Simkl', 'settings.companionRemoteServer' => 'Companion Remote-server', 'settings.companionRemoteServerDescription' => 'Sta mobiele apparaten op je netwerk toe om deze app te bedienen', 'settings.autoPip' => 'Automatische beeld-in-beeld', @@ -1956,10 +2002,10 @@ extension on TranslationsNl { 'libraries.scanLibraryConfirm' => ({required Object title}) => 'Weet je zeker dat je "${title}" wilt scannen?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => 'Weet je zeker dat je "${title}" wilt analyseren?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => 'Weet je zeker dat je metadata wilt vernieuwen voor "${title}"?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Weet je zeker dat je de prullenbak wilt legen voor "${title}"?', - 'libraries.manageLibraries' => 'Beheer bibliotheken', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Weet je zeker dat je de prullenbak wilt legen voor "${title}"?', + 'libraries.manageLibraries' => 'Beheer bibliotheken', 'libraries.sort' => 'Sorteren', 'libraries.sortBy' => 'Sorteer op', 'libraries.filters' => 'Filters', @@ -2371,12 +2417,24 @@ extension on TranslationsNl { '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.', + 'trackers.title' => 'Trackers', + 'trackers.hubSubtitle' => 'Houd je kijkvoortgang gesynchroniseerd met Trakt en andere diensten.', + 'trackers.notConnected' => 'Niet verbonden', + 'trackers.connectedAs' => ({required Object username}) => 'Verbonden als @${username}', + 'trackers.scrobble' => 'Voortgang automatisch volgen', + 'trackers.scrobbleDescription' => 'Werk je lijst bij wanneer je een aflevering of film afrondt.', + 'trackers.disconnectConfirm' => ({required Object service}) => '${service} loskoppelen?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy werkt je ${service}-lijst niet meer bij. Je kunt op elk moment opnieuw verbinden.', + 'trackers.connectFailed' => ({required Object service}) => 'Kan niet verbinden met ${service}. Probeer opnieuw.', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => 'Plezy activeren op ${service}', + 'trackers.deviceCode.body' => ({required Object url}) => 'Ga naar ${url} en voer deze code in:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} om te activeren', + 'trackers.deviceCode.waitingForAuthorization' => 'Wachten op autorisatie…', + 'trackers.deviceCode.codeCopied' => 'Code gekopieerd', _ => null, }; } diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index b5dacf65..927f3928 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -77,6 +77,7 @@ class TranslationsPl with BaseTranslations implements T @override late final _TranslationsMatchScreenPl matchScreen = _TranslationsMatchScreenPl._(_root); @override late final _TranslationsServerTasksPl serverTasks = _TranslationsServerTasksPl._(_root); @override late final _TranslationsTraktPl trakt = _TranslationsTraktPl._(_root); + @override late final _TranslationsTrackersPl trackers = _TranslationsTrackersPl._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsPl implements TranslationsSettingsEn { @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 trackers => 'Trackery'; + @override String get trackersDescription => 'Synchronizuj postęp z Trakt, MyAnimeList, AniList i Simkl'; @override String get companionRemoteServer => 'Serwer zdalnego sterowania'; @override String get companionRemoteServerDescription => 'Pozwól urządzeniom mobilnym w sieci sterować tą aplikacją'; @override String get autoPip => 'Automatyczny obraz w obrazie'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktPl implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersPl implements TranslationsTrackersEn { + _TranslationsTrackersPl._(this._root); + + final TranslationsPl _root; // ignore: unused_field + + // Translations + @override String get title => 'Trackery'; + @override String get hubSubtitle => 'Synchronizuj postęp oglądania z Trakt i innymi usługami.'; + @override String get notConnected => 'Nie połączono'; + @override String connectedAs({required Object username}) => 'Połączono jako @${username}'; + @override String get scrobble => 'Automatycznie śledź postęp'; + @override String get scrobbleDescription => 'Aktualizuj swoją listę po ukończeniu odcinka lub filmu.'; + @override String disconnectConfirm({required Object service}) => 'Odłączyć ${service}?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy przestanie aktualizować Twoją listę ${service}. Możesz połączyć się ponownie w dowolnej chwili.'; + @override String connectFailed({required Object service}) => 'Nie udało się połączyć z ${service}. Spróbuj ponownie.'; + @override late final _TranslationsTrackersServicesPl services = _TranslationsTrackersServicesPl._(_root); + @override late final _TranslationsTrackersDeviceCodePl deviceCode = _TranslationsTrackersDeviceCodePl._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsPl implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsPl._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemotePl implements TranslationsCompanionRemot @override String get searchHint => 'Szukaj na komputerze...'; } +// Path: trackers.services +class _TranslationsTrackersServicesPl implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesPl._(this._root); + + final TranslationsPl _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodePl implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodePl._(this._root); + + final TranslationsPl _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Aktywuj Plezy w ${service}'; + @override String body({required Object url}) => 'Odwiedź ${url} i wpisz ten kod:'; + @override String openToActivate({required Object service}) => 'Otwórz ${service}, aby aktywować'; + @override String get waitingForAuthorization => 'Oczekiwanie na autoryzację…'; + @override String get codeCopied => 'Kod skopiowany'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsPl { 'settings.discordRichPresenceDescription' => 'Pokaż, co oglądasz na Discordzie', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => 'Synchronizuj historię oglądania z Trakt', + 'settings.trackers' => 'Trackery', + 'settings.trackersDescription' => 'Synchronizuj postęp z Trakt, MyAnimeList, AniList i Simkl', 'settings.companionRemoteServer' => 'Serwer zdalnego sterowania', 'settings.companionRemoteServerDescription' => 'Pozwól urządzeniom mobilnym w sieci sterować tą aplikacją', 'settings.autoPip' => 'Automatyczny obraz w obrazie', @@ -1956,10 +2002,10 @@ extension on TranslationsPl { 'libraries.scanLibraryConfirm' => ({required Object title}) => 'Czy na pewno chcesz zeskanować "${title}"?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => 'Czy na pewno chcesz przeanalizować "${title}"?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => 'Czy na pewno chcesz odświeżyć metadane dla "${title}"?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Czy na pewno chcesz opróżnić kosz dla "${title}"?', - 'libraries.manageLibraries' => 'Zarządzaj bibliotekami', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Czy na pewno chcesz opróżnić kosz dla "${title}"?', + 'libraries.manageLibraries' => 'Zarządzaj bibliotekami', 'libraries.sort' => 'Sortuj', 'libraries.sortBy' => 'Sortuj wg', 'libraries.filters' => 'Filtry', @@ -2371,12 +2417,24 @@ extension on TranslationsPl { '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.', + 'trackers.title' => 'Trackery', + 'trackers.hubSubtitle' => 'Synchronizuj postęp oglądania z Trakt i innymi usługami.', + 'trackers.notConnected' => 'Nie połączono', + 'trackers.connectedAs' => ({required Object username}) => 'Połączono jako @${username}', + 'trackers.scrobble' => 'Automatycznie śledź postęp', + 'trackers.scrobbleDescription' => 'Aktualizuj swoją listę po ukończeniu odcinka lub filmu.', + 'trackers.disconnectConfirm' => ({required Object service}) => 'Odłączyć ${service}?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy przestanie aktualizować Twoją listę ${service}. Możesz połączyć się ponownie w dowolnej chwili.', + 'trackers.connectFailed' => ({required Object service}) => 'Nie udało się połączyć z ${service}. Spróbuj ponownie.', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => 'Aktywuj Plezy w ${service}', + 'trackers.deviceCode.body' => ({required Object url}) => 'Odwiedź ${url} i wpisz ten kod:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Otwórz ${service}, aby aktywować', + 'trackers.deviceCode.waitingForAuthorization' => 'Oczekiwanie na autoryzację…', + 'trackers.deviceCode.codeCopied' => 'Kod skopiowany', _ => null, }; } diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index d0c2d0e3..5be7156c 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -77,6 +77,7 @@ class TranslationsPt with BaseTranslations implements T @override late final _TranslationsMatchScreenPt matchScreen = _TranslationsMatchScreenPt._(_root); @override late final _TranslationsServerTasksPt serverTasks = _TranslationsServerTasksPt._(_root); @override late final _TranslationsTraktPt trakt = _TranslationsTraktPt._(_root); + @override late final _TranslationsTrackersPt trackers = _TranslationsTrackersPt._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsPt implements TranslationsSettingsEn { @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 trackers => 'Rastreadores'; + @override String get trackersDescription => 'Sincronizar progresso com Trakt, MyAnimeList, AniList e Simkl'; @override String get companionRemoteServer => 'Servidor de controlo remoto'; @override String get companionRemoteServerDescription => 'Permitir que dispositivos móveis na sua rede controlem esta aplicação'; @override String get autoPip => 'Picture-in-Picture Automático'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktPt implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersPt implements TranslationsTrackersEn { + _TranslationsTrackersPt._(this._root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Rastreadores'; + @override String get hubSubtitle => 'Mantenha seu progresso sincronizado com Trakt e outros serviços.'; + @override String get notConnected => 'Não conectado'; + @override String connectedAs({required Object username}) => 'Conectado como @${username}'; + @override String get scrobble => 'Registrar progresso automaticamente'; + @override String get scrobbleDescription => 'Atualiza sua lista quando você termina um episódio ou filme.'; + @override String disconnectConfirm({required Object service}) => 'Desconectar ${service}?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy deixará de atualizar sua lista do ${service}. Você pode reconectar a qualquer momento.'; + @override String connectFailed({required Object service}) => 'Não foi possível conectar ao ${service}. Tente novamente.'; + @override late final _TranslationsTrackersServicesPt services = _TranslationsTrackersServicesPt._(_root); + @override late final _TranslationsTrackersDeviceCodePt deviceCode = _TranslationsTrackersDeviceCodePt._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsPt implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsPt._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemotePt implements TranslationsCompanionRemot @override String get searchHint => 'Buscar no desktop...'; } +// Path: trackers.services +class _TranslationsTrackersServicesPt implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesPt._(this._root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodePt implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodePt._(this._root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Ativar Plezy no ${service}'; + @override String body({required Object url}) => 'Acesse ${url} e insira este código:'; + @override String openToActivate({required Object service}) => 'Abrir ${service} para ativar'; + @override String get waitingForAuthorization => 'Aguardando autorização…'; + @override String get codeCopied => 'Código copiado'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsPt { 'settings.discordRichPresenceDescription' => 'Mostrar o que você está assistindo no Discord', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => 'Sincronizar histórico de exibição com Trakt', + 'settings.trackers' => 'Rastreadores', + 'settings.trackersDescription' => 'Sincronizar progresso com Trakt, MyAnimeList, AniList e Simkl', 'settings.companionRemoteServer' => 'Servidor de controlo remoto', 'settings.companionRemoteServerDescription' => 'Permitir que dispositivos móveis na sua rede controlem esta aplicação', 'settings.autoPip' => 'Picture-in-Picture Automático', @@ -1956,10 +2002,10 @@ extension on TranslationsPt { 'libraries.scanLibraryConfirm' => ({required Object title}) => 'Tem certeza que deseja escanear "${title}"?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => 'Tem certeza que deseja analisar "${title}"?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => 'Tem certeza que deseja atualizar os metadados de "${title}"?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Tem certeza que deseja esvaziar a lixeira de "${title}"?', - 'libraries.manageLibraries' => 'Gerenciar Bibliotecas', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Tem certeza que deseja esvaziar a lixeira de "${title}"?', + 'libraries.manageLibraries' => 'Gerenciar Bibliotecas', 'libraries.sort' => 'Ordenar', 'libraries.sortBy' => 'Ordenar Por', 'libraries.filters' => 'Filtros', @@ -2371,12 +2417,24 @@ extension on TranslationsPt { '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.', + 'trackers.title' => 'Rastreadores', + 'trackers.hubSubtitle' => 'Mantenha seu progresso sincronizado com Trakt e outros serviços.', + 'trackers.notConnected' => 'Não conectado', + 'trackers.connectedAs' => ({required Object username}) => 'Conectado como @${username}', + 'trackers.scrobble' => 'Registrar progresso automaticamente', + 'trackers.scrobbleDescription' => 'Atualiza sua lista quando você termina um episódio ou filme.', + 'trackers.disconnectConfirm' => ({required Object service}) => 'Desconectar ${service}?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy deixará de atualizar sua lista do ${service}. Você pode reconectar a qualquer momento.', + 'trackers.connectFailed' => ({required Object service}) => 'Não foi possível conectar ao ${service}. Tente novamente.', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => 'Ativar Plezy no ${service}', + 'trackers.deviceCode.body' => ({required Object url}) => 'Acesse ${url} e insira este código:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Abrir ${service} para ativar', + 'trackers.deviceCode.waitingForAuthorization' => 'Aguardando autorização…', + 'trackers.deviceCode.codeCopied' => 'Código copiado', _ => null, }; } diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 72b32500..b727ae81 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -77,6 +77,7 @@ class TranslationsRu with BaseTranslations implements T @override late final _TranslationsMatchScreenRu matchScreen = _TranslationsMatchScreenRu._(_root); @override late final _TranslationsServerTasksRu serverTasks = _TranslationsServerTasksRu._(_root); @override late final _TranslationsTraktRu trakt = _TranslationsTraktRu._(_root); + @override late final _TranslationsTrackersRu trackers = _TranslationsTrackersRu._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsRu implements TranslationsSettingsEn { @override String get discordRichPresenceDescription => 'Показывать, что вы смотрите, в Discord'; @override String get trakt => 'Trakt'; @override String get traktDescription => 'Синхронизировать историю просмотров с Trakt'; + @override String get trackers => 'Трекеры'; + @override String get trackersDescription => 'Синхронизировать прогресс с Trakt, MyAnimeList, AniList и Simkl'; @override String get companionRemoteServer => 'Сервер удалённого управления'; @override String get companionRemoteServerDescription => 'Разрешить мобильным устройствам в сети управлять этим приложением'; @override String get autoPip => 'Автоматический «картинка в картинке»'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktRu implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersRu implements TranslationsTrackersEn { + _TranslationsTrackersRu._(this._root); + + final TranslationsRu _root; // ignore: unused_field + + // Translations + @override String get title => 'Трекеры'; + @override String get hubSubtitle => 'Синхронизируйте прогресс просмотра с Trakt и другими сервисами.'; + @override String get notConnected => 'Не подключено'; + @override String connectedAs({required Object username}) => 'Подключено как @${username}'; + @override String get scrobble => 'Автоматически отслеживать прогресс'; + @override String get scrobbleDescription => 'Обновляет список, когда вы заканчиваете эпизод или фильм.'; + @override String disconnectConfirm({required Object service}) => 'Отключить ${service}?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy перестанет обновлять ваш список ${service}. Вы можете подключиться снова в любое время.'; + @override String connectFailed({required Object service}) => 'Не удалось подключиться к ${service}. Попробуйте ещё раз.'; + @override late final _TranslationsTrackersServicesRu services = _TranslationsTrackersServicesRu._(_root); + @override late final _TranslationsTrackersDeviceCodeRu deviceCode = _TranslationsTrackersDeviceCodeRu._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsRu implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsRu._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemoteRu implements TranslationsCompanionRemot @override String get searchHint => 'Поиск на десктопе...'; } +// Path: trackers.services +class _TranslationsTrackersServicesRu implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesRu._(this._root); + + final TranslationsRu _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodeRu implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeRu._(this._root); + + final TranslationsRu _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Активируйте Plezy в ${service}'; + @override String body({required Object url}) => 'Перейдите на ${url} и введите этот код:'; + @override String openToActivate({required Object service}) => 'Открыть ${service} для активации'; + @override String get waitingForAuthorization => 'Ожидание авторизации…'; + @override String get codeCopied => 'Код скопирован'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsRu { 'settings.discordRichPresenceDescription' => 'Показывать, что вы смотрите, в Discord', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => 'Синхронизировать историю просмотров с Trakt', + 'settings.trackers' => 'Трекеры', + 'settings.trackersDescription' => 'Синхронизировать прогресс с Trakt, MyAnimeList, AniList и Simkl', 'settings.companionRemoteServer' => 'Сервер удалённого управления', 'settings.companionRemoteServerDescription' => 'Разрешить мобильным устройствам в сети управлять этим приложением', 'settings.autoPip' => 'Автоматический «картинка в картинке»', @@ -1956,10 +2002,10 @@ extension on TranslationsRu { 'libraries.scanLibraryConfirm' => ({required Object title}) => 'Вы уверены, что хотите сканировать "${title}"?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => 'Вы уверены, что хотите проанализировать "${title}"?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => 'Вы уверены, что хотите обновить метаданные для "${title}"?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Вы уверены, что хотите очистить корзину для "${title}"?', - 'libraries.manageLibraries' => 'Управление библиотеками', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Вы уверены, что хотите очистить корзину для "${title}"?', + 'libraries.manageLibraries' => 'Управление библиотеками', 'libraries.sort' => 'Сортировка', 'libraries.sortBy' => 'Сортировать по', 'libraries.filters' => 'Фильтры', @@ -2371,12 +2417,24 @@ extension on TranslationsRu { '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. Попробуйте ещё раз.', + 'trackers.title' => 'Трекеры', + 'trackers.hubSubtitle' => 'Синхронизируйте прогресс просмотра с Trakt и другими сервисами.', + 'trackers.notConnected' => 'Не подключено', + 'trackers.connectedAs' => ({required Object username}) => 'Подключено как @${username}', + 'trackers.scrobble' => 'Автоматически отслеживать прогресс', + 'trackers.scrobbleDescription' => 'Обновляет список, когда вы заканчиваете эпизод или фильм.', + 'trackers.disconnectConfirm' => ({required Object service}) => 'Отключить ${service}?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy перестанет обновлять ваш список ${service}. Вы можете подключиться снова в любое время.', + 'trackers.connectFailed' => ({required Object service}) => 'Не удалось подключиться к ${service}. Попробуйте ещё раз.', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => 'Активируйте Plezy в ${service}', + 'trackers.deviceCode.body' => ({required Object url}) => 'Перейдите на ${url} и введите этот код:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Открыть ${service} для активации', + 'trackers.deviceCode.waitingForAuthorization' => 'Ожидание авторизации…', + 'trackers.deviceCode.codeCopied' => 'Код скопирован', _ => null, }; } diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index db4e9dac..e115bd50 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -77,6 +77,7 @@ class TranslationsSv with BaseTranslations implements T @override late final _TranslationsMatchScreenSv matchScreen = _TranslationsMatchScreenSv._(_root); @override late final _TranslationsServerTasksSv serverTasks = _TranslationsServerTasksSv._(_root); @override late final _TranslationsTraktSv trakt = _TranslationsTraktSv._(_root); + @override late final _TranslationsTrackersSv trackers = _TranslationsTrackersSv._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn { @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 trackers => 'Spårare'; + @override String get trackersDescription => 'Synkronisera framsteg till Trakt, MyAnimeList, AniList och Simkl'; @override String get companionRemoteServer => 'Companion Remote-server'; @override String get companionRemoteServerDescription => 'Tillåt mobila enheter i ditt nätverk att styra denna app'; @override String get autoPip => 'Automatisk bild-i-bild'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktSv implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersSv implements TranslationsTrackersEn { + _TranslationsTrackersSv._(this._root); + + final TranslationsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Spårare'; + @override String get hubSubtitle => 'Håll ditt tittarprogress synkroniserat med Trakt och andra tjänster.'; + @override String get notConnected => 'Ej ansluten'; + @override String connectedAs({required Object username}) => 'Ansluten som @${username}'; + @override String get scrobble => 'Spåra framsteg automatiskt'; + @override String get scrobbleDescription => 'Uppdaterar din lista när du är klar med ett avsnitt eller en film.'; + @override String disconnectConfirm({required Object service}) => 'Koppla från ${service}?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy slutar uppdatera din ${service}-lista. Du kan ansluta igen när som helst.'; + @override String connectFailed({required Object service}) => 'Kunde inte ansluta till ${service}. Försök igen.'; + @override late final _TranslationsTrackersServicesSv services = _TranslationsTrackersServicesSv._(_root); + @override late final _TranslationsTrackersDeviceCodeSv deviceCode = _TranslationsTrackersDeviceCodeSv._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsSv implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsSv._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemoteSv implements TranslationsCompanionRemot @override String get searchHint => 'Sök på datorn...'; } +// Path: trackers.services +class _TranslationsTrackersServicesSv implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesSv._(this._root); + + final TranslationsSv _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodeSv implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeSv._(this._root); + + final TranslationsSv _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Aktivera Plezy på ${service}'; + @override String body({required Object url}) => 'Besök ${url} och ange den här koden:'; + @override String openToActivate({required Object service}) => 'Öppna ${service} för att aktivera'; + @override String get waitingForAuthorization => 'Väntar på auktorisering…'; + @override String get codeCopied => 'Kod kopierad'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsSv { 'settings.discordRichPresenceDescription' => 'Visa vad du tittar på i Discord', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => 'Synkronisera visningshistorik med Trakt', + 'settings.trackers' => 'Spårare', + 'settings.trackersDescription' => 'Synkronisera framsteg till Trakt, MyAnimeList, AniList och Simkl', 'settings.companionRemoteServer' => 'Companion Remote-server', 'settings.companionRemoteServerDescription' => 'Tillåt mobila enheter i ditt nätverk att styra denna app', 'settings.autoPip' => 'Automatisk bild-i-bild', @@ -1956,10 +2002,10 @@ extension on TranslationsSv { 'libraries.scanLibraryConfirm' => ({required Object title}) => 'Är du säker på att du vill skanna "${title}"?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => 'Är du säker på att du vill analysera "${title}"?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => 'Är du säker på att du vill uppdatera metadata för "${title}"?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Är du säker på att du vill tömma papperskorgen för "${title}"?', - 'libraries.manageLibraries' => 'Hantera bibliotek', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => 'Är du säker på att du vill tömma papperskorgen för "${title}"?', + 'libraries.manageLibraries' => 'Hantera bibliotek', 'libraries.sort' => 'Sortera', 'libraries.sortBy' => 'Sortera efter', 'libraries.filters' => 'Filter', @@ -2371,12 +2417,24 @@ extension on TranslationsSv { '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.', + 'trackers.title' => 'Spårare', + 'trackers.hubSubtitle' => 'Håll ditt tittarprogress synkroniserat med Trakt och andra tjänster.', + 'trackers.notConnected' => 'Ej ansluten', + 'trackers.connectedAs' => ({required Object username}) => 'Ansluten som @${username}', + 'trackers.scrobble' => 'Spåra framsteg automatiskt', + 'trackers.scrobbleDescription' => 'Uppdaterar din lista när du är klar med ett avsnitt eller en film.', + 'trackers.disconnectConfirm' => ({required Object service}) => 'Koppla från ${service}?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy slutar uppdatera din ${service}-lista. Du kan ansluta igen när som helst.', + 'trackers.connectFailed' => ({required Object service}) => 'Kunde inte ansluta till ${service}. Försök igen.', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => 'Aktivera Plezy på ${service}', + 'trackers.deviceCode.body' => ({required Object url}) => 'Besök ${url} och ange den här koden:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Öppna ${service} för att aktivera', + 'trackers.deviceCode.waitingForAuthorization' => 'Väntar på auktorisering…', + 'trackers.deviceCode.codeCopied' => 'Kod kopierad', _ => null, }; } diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 28216c1e..de7c1380 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -77,6 +77,7 @@ class TranslationsZh with BaseTranslations implements T @override late final _TranslationsMatchScreenZh matchScreen = _TranslationsMatchScreenZh._(_root); @override late final _TranslationsServerTasksZh serverTasks = _TranslationsServerTasksZh._(_root); @override late final _TranslationsTraktZh trakt = _TranslationsTraktZh._(_root); + @override late final _TranslationsTrackersZh trackers = _TranslationsTrackersZh._(_root); } // Path: app @@ -339,6 +340,8 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn { @override String get discordRichPresenceDescription => '在 Discord 上显示您正在观看的内容'; @override String get trakt => 'Trakt'; @override String get traktDescription => '将观看历史与 Trakt 同步'; + @override String get trackers => '追踪器'; + @override String get trackersDescription => '将进度同步到 Trakt、MyAnimeList、AniList 和 Simkl'; @override String get companionRemoteServer => '配套遥控服务器'; @override String get companionRemoteServerDescription => '允许网络上的移动设备控制此应用'; @override String get autoPip => '自动画中画'; @@ -1274,14 +1277,29 @@ class _TranslationsTraktZh implements TranslationsTraktEn { @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: trackers +class _TranslationsTrackersZh implements TranslationsTrackersEn { + _TranslationsTrackersZh._(this._root); + + final TranslationsZh _root; // ignore: unused_field + + // Translations + @override String get title => '追踪器'; + @override String get hubSubtitle => '让你的观看进度与 Trakt 及其他服务保持同步。'; + @override String get notConnected => '未连接'; + @override String connectedAs({required Object username}) => '已以 @${username} 身份连接'; + @override String get scrobble => '自动记录进度'; + @override String get scrobbleDescription => '观看完一集或一部电影后更新你的列表。'; + @override String disconnectConfirm({required Object service}) => '断开 ${service} 连接?'; + @override String disconnectConfirmBody({required Object service}) => 'Plezy 将停止更新你的 ${service} 列表。你可以随时重新连接。'; + @override String connectFailed({required Object service}) => '无法连接到 ${service}。请重试。'; + @override late final _TranslationsTrackersServicesZh services = _TranslationsTrackersServicesZh._(_root); + @override late final _TranslationsTrackersDeviceCodeZh deviceCode = _TranslationsTrackersDeviceCodeZh._(_root); +} + // Path: hotkeys.actions class _TranslationsHotkeysActionsZh implements TranslationsHotkeysActionsEn { _TranslationsHotkeysActionsZh._(this._root); @@ -1438,6 +1456,32 @@ class _TranslationsCompanionRemoteRemoteZh implements TranslationsCompanionRemot @override String get searchHint => '在桌面上搜索...'; } +// Path: trackers.services +class _TranslationsTrackersServicesZh implements TranslationsTrackersServicesEn { + _TranslationsTrackersServicesZh._(this._root); + + final TranslationsZh _root; // ignore: unused_field + + // Translations + @override String get mal => 'MyAnimeList'; + @override String get anilist => 'AniList'; + @override String get simkl => 'Simkl'; +} + +// Path: trackers.deviceCode +class _TranslationsTrackersDeviceCodeZh implements TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeZh._(this._root); + + final TranslationsZh _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => '在 ${service} 上激活 Plezy'; + @override String body({required Object url}) => '访问 ${url} 并输入此代码:'; + @override String openToActivate({required Object service}) => '打开 ${service} 以激活'; + @override String get waitingForAuthorization => '等待授权…'; + @override String get codeCopied => '代码已复制'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1654,6 +1698,8 @@ extension on TranslationsZh { 'settings.discordRichPresenceDescription' => '在 Discord 上显示您正在观看的内容', 'settings.trakt' => 'Trakt', 'settings.traktDescription' => '将观看历史与 Trakt 同步', + 'settings.trackers' => '追踪器', + 'settings.trackersDescription' => '将进度同步到 Trakt、MyAnimeList、AniList 和 Simkl', 'settings.companionRemoteServer' => '配套遥控服务器', 'settings.companionRemoteServerDescription' => '允许网络上的移动设备控制此应用', 'settings.autoPip' => '自动画中画', @@ -1956,10 +2002,10 @@ extension on TranslationsZh { 'libraries.scanLibraryConfirm' => ({required Object title}) => '确定要扫描 “${title}” 吗?', 'libraries.analyzeLibraryConfirm' => ({required Object title}) => '确定要分析 “${title}” 吗?', 'libraries.refreshMetadataConfirm' => ({required Object title}) => '确定要刷新 “${title}” 的元数据吗?', - 'libraries.emptyTrashConfirm' => ({required Object title}) => '确定要清空 “${title}” 的回收站吗?', - 'libraries.manageLibraries' => '管理媒体库', _ => null, } ?? switch (path) { + 'libraries.emptyTrashConfirm' => ({required Object title}) => '确定要清空 “${title}” 的回收站吗?', + 'libraries.manageLibraries' => '管理媒体库', 'libraries.sort' => '排序', 'libraries.sortBy' => '排序依据', 'libraries.filters' => '筛选器', @@ -2371,12 +2417,24 @@ extension on TranslationsZh { '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。请重试。', + 'trackers.title' => '追踪器', + 'trackers.hubSubtitle' => '让你的观看进度与 Trakt 及其他服务保持同步。', + 'trackers.notConnected' => '未连接', + 'trackers.connectedAs' => ({required Object username}) => '已以 @${username} 身份连接', + 'trackers.scrobble' => '自动记录进度', + 'trackers.scrobbleDescription' => '观看完一集或一部电影后更新你的列表。', + 'trackers.disconnectConfirm' => ({required Object service}) => '断开 ${service} 连接?', + 'trackers.disconnectConfirmBody' => ({required Object service}) => 'Plezy 将停止更新你的 ${service} 列表。你可以随时重新连接。', + 'trackers.connectFailed' => ({required Object service}) => '无法连接到 ${service}。请重试。', + 'trackers.services.mal' => 'MyAnimeList', + 'trackers.services.anilist' => 'AniList', + 'trackers.services.simkl' => 'Simkl', + 'trackers.deviceCode.title' => ({required Object service}) => '在 ${service} 上激活 Plezy', + 'trackers.deviceCode.body' => ({required Object url}) => '访问 ${url} 并输入此代码:', + 'trackers.deviceCode.openToActivate' => ({required Object service}) => '打开 ${service} 以激活', + 'trackers.deviceCode.waitingForAuthorization' => '等待授权…', + 'trackers.deviceCode.codeCopied' => '代码已复制', _ => null, }; } diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 56963329..405c414e 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "Visa vad du tittar på i Discord", "trakt": "Trakt", "traktDescription": "Synkronisera visningshistorik med Trakt", + "trackers": "Spårare", + "trackersDescription": "Synkronisera framsteg till Trakt, MyAnimeList, AniList och Simkl", "companionRemoteServer": "Companion Remote-server", "companionRemoteServerDescription": "Tillåt mobila enheter i ditt nätverk att styra denna app", "autoPip": "Automatisk bild-i-bild", @@ -1015,11 +1017,29 @@ "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." + }, + "trackers": { + "title": "Spårare", + "hubSubtitle": "Håll ditt tittarprogress synkroniserat med Trakt och andra tjänster.", + "notConnected": "Ej ansluten", + "connectedAs": "Ansluten som @${username}", + "scrobble": "Spåra framsteg automatiskt", + "scrobbleDescription": "Uppdaterar din lista när du är klar med ett avsnitt eller en film.", + "disconnectConfirm": "Koppla från ${service}?", + "disconnectConfirmBody": "Plezy slutar uppdatera din ${service}-lista. Du kan ansluta igen när som helst.", + "connectFailed": "Kunde inte ansluta till ${service}. Försök igen.", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "Aktivera Plezy på ${service}", + "body": "Besök ${url} och ange den här koden:", + "openToActivate": "Öppna ${service} för att aktivera", + "waitingForAuthorization": "Väntar på auktorisering…", + "codeCopied": "Kod kopierad" + } } } diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index f23d19e5..acfe76a5 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -218,6 +218,8 @@ "discordRichPresenceDescription": "在 Discord 上显示您正在观看的内容", "trakt": "Trakt", "traktDescription": "将观看历史与 Trakt 同步", + "trackers": "追踪器", + "trackersDescription": "将进度同步到 Trakt、MyAnimeList、AniList 和 Simkl", "companionRemoteServer": "配套遥控服务器", "companionRemoteServerDescription": "允许网络上的移动设备控制此应用", "autoPip": "自动画中画", @@ -1015,11 +1017,29 @@ "scrobbleDescription": "在播放时向 Trakt 发送播放、暂停和停止事件。", "watchedSync": "同步已观看状态", "watchedSyncDescription": "在 Plezy 中将内容标记为已观看时,也会在 Trakt 上标记为已观看。", - "deviceCodeTitle": "在 Trakt 上激活 Plezy", - "deviceCodeBody": "请访问 ${url} 并输入此代码:", - "openTraktActivate": "打开 Trakt 进行激活", - "waitingForAuthorization": "等待授权…", - "codeCopied": "代码已复制", "connectFailed": "无法连接到 Trakt。请重试。" + }, + "trackers": { + "title": "追踪器", + "hubSubtitle": "让你的观看进度与 Trakt 及其他服务保持同步。", + "notConnected": "未连接", + "connectedAs": "已以 @${username} 身份连接", + "scrobble": "自动记录进度", + "scrobbleDescription": "观看完一集或一部电影后更新你的列表。", + "disconnectConfirm": "断开 ${service} 连接?", + "disconnectConfirmBody": "Plezy 将停止更新你的 ${service} 列表。你可以随时重新连接。", + "connectFailed": "无法连接到 ${service}。请重试。", + "services": { + "mal": "MyAnimeList", + "anilist": "AniList", + "simkl": "Simkl" + }, + "deviceCode": { + "title": "在 ${service} 上激活 Plezy", + "body": "访问 ${url} 并输入此代码:", + "openToActivate": "打开 ${service} 以激活", + "waitingForAuthorization": "等待授权…", + "codeCopied": "代码已复制" + } } } diff --git a/lib/main.dart b/lib/main.dart index de2d17d5..bb287144 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,7 +21,9 @@ 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 'services/trackers/tracker_coordinator.dart'; import 'providers/trakt_account_provider.dart'; +import 'providers/trackers_provider.dart'; import 'providers/user_profile_provider.dart'; import 'providers/multi_server_provider.dart'; import 'providers/theme_provider.dart'; @@ -661,9 +663,10 @@ class _MainAppState extends State with WidgetsBindingObserver { // Existing providers ChangeNotifierProvider(create: (context) => UserProfileProvider()), ChangeNotifierProvider(create: (context) => ThemeProvider()), - // Trakt account — depends on UserProfileProvider for per-profile token scoping. - // Hydrated and rebound in `_TraktBootstrap` below. + // Tracker accounts — depend on UserProfileProvider for per-profile + // session scoping. Hydrated and rebound by `_TrackerProfileBootstrap`. ChangeNotifierProvider(create: (context) => TraktAccountProvider()), + ChangeNotifierProvider(create: (context) => TrackersProvider()), ChangeNotifierProvider(create: (context) => SettingsProvider(), lazy: true), ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true), ChangeNotifierProvider(create: (context) => LibrariesProvider()), @@ -675,31 +678,39 @@ class _MainAppState extends State with WidgetsBindingObserver { child: Consumer( builder: (context, themeProvider, child) { return TranslationProvider( - 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), + child: Builder( + builder: (context) { + final trakt = context.read(); + final trackers = context.read(); + return _TrackerProfileBootstrap( + onProfileChanged: [trakt.onActiveProfileChanged, trackers.onActiveProfileChanged], + onFirstMount: TrackerCoordinator.instance.initialize, + 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), + ), + ), ), ), - ), - ), + ); + }, ), ); }, @@ -708,40 +719,46 @@ class _MainAppState extends State with WidgetsBindingObserver { } } -/// Hydrates the [TraktAccountProvider] with the active Plex profile's session -/// and rebinds the scrobble + sync services whenever the user switches. +/// Hydrates Trakt and MAL/AniList/Simkl providers with the active Plex +/// profile's sessions and rebinds their services whenever the user switches. /// /// Lives high in the widget tree (above MaterialApp) so the listener survives -/// route changes. -class _TraktProfileBootstrap extends StatefulWidget { +/// route changes. [onFirstMount] runs exactly once after the first +/// `didChangeDependencies`. +class _TrackerProfileBootstrap extends StatefulWidget { final Widget child; - const _TraktProfileBootstrap({required this.child}); + final List Function(String? uuid)> onProfileChanged; + final VoidCallback? onFirstMount; + + const _TrackerProfileBootstrap({ + required this.child, + required this.onProfileChanged, + this.onFirstMount, + }); @override - State<_TraktProfileBootstrap> createState() => _TraktProfileBootstrapState(); + State<_TrackerProfileBootstrap> createState() => _TrackerProfileBootstrapState(); } -class _TraktProfileBootstrapState extends State<_TraktProfileBootstrap> { +class _TrackerProfileBootstrapState extends State<_TrackerProfileBootstrap> { UserProfileProvider? _profile; - TraktAccountProvider? _trakt; String? _lastUuid; - bool _hydrated = false; + bool _initialized = false; @override void didChangeDependencies() { super.didChangeDependencies(); final profile = context.read(); - final trakt = context.read(); if (!identical(_profile, profile)) { _profile?.removeListener(_onProfileChanged); _profile = profile; _profile!.addListener(_onProfileChanged); } - _trakt = trakt; - if (!_hydrated) { - _hydrated = true; + if (!_initialized) { + _initialized = true; + widget.onFirstMount?.call(); _onProfileChanged(); } } @@ -750,7 +767,11 @@ class _TraktProfileBootstrapState extends State<_TraktProfileBootstrap> { final uuid = _profile?.currentUser?.uuid; if (uuid == _lastUuid) return; _lastUuid = uuid; - _trakt?.onActiveProfileChanged(uuid); + for (final fn in widget.onProfileChanged) { + unawaited(fn(uuid).catchError((Object e, StackTrace s) { + appLogger.w('Tracker profile bootstrap failed', error: e, stackTrace: s); + })); + } } @override diff --git a/lib/models/trackers/anime_ids.dart b/lib/models/trackers/anime_ids.dart new file mode 100644 index 00000000..3174a712 --- /dev/null +++ b/lib/models/trackers/anime_ids.dart @@ -0,0 +1,20 @@ +import 'fribb_mapping_row.dart'; + +/// External IDs for matching a Plex item to MAL / AniList / Simkl catalogs. +/// +/// Populated from a [FribbMappingRow] via the resolver. Each service's tracker +/// picks the ID it needs (MAL uses [mal], AniList uses [anilist], etc.) and +/// no-ops when its ID is missing. +class AnimeIds { + final int? mal; + final int? anilist; + final int? simkl; + + const AnimeIds({this.mal, this.anilist, this.simkl}); + + factory AnimeIds.fromFribb(FribbMappingRow row) => AnimeIds( + mal: row.malId, + anilist: row.anilistId, + simkl: row.simklId, + ); +} diff --git a/lib/models/trackers/device_code.dart b/lib/models/trackers/device_code.dart new file mode 100644 index 00000000..f3798678 --- /dev/null +++ b/lib/models/trackers/device_code.dart @@ -0,0 +1,52 @@ +/// Result of requesting a device code from an RFC 8628 authorization server. +/// +/// The user enters [userCode] at [verificationUrl]; the app polls the token +/// endpoint with [deviceCode] every [interval] seconds until [expiresIn] +/// seconds elapse. +class DeviceCode { + final String deviceCode; + final String userCode; + final String verificationUrl; + + /// URL with the code pre-filled (e.g. `https://trakt.tv/activate/ABC12345`) + /// when the provider supports it. Nullable — Simkl doesn't. + final String? verificationUrlComplete; + + final int expiresIn; + final int interval; + + const DeviceCode({ + required this.deviceCode, + required this.userCode, + required this.verificationUrl, + required this.expiresIn, + required this.interval, + this.verificationUrlComplete, + }); +} + +/// Discriminated event emitted by a device-code poll loop. +sealed class DevicePollEvent { + const DevicePollEvent(); +} + +class DevicePollPending extends DevicePollEvent { + const DevicePollPending(); +} + +class DevicePollSlowDown extends DevicePollEvent { + const DevicePollSlowDown(); +} + +class DevicePollDenied extends DevicePollEvent { + const DevicePollDenied(); +} + +class DevicePollExpired extends DevicePollEvent { + const DevicePollExpired(); +} + +class DevicePollSuccess extends DevicePollEvent { + final Map tokenResponse; + const DevicePollSuccess(this.tokenResponse); +} diff --git a/lib/models/trackers/fribb_mapping_row.dart b/lib/models/trackers/fribb_mapping_row.dart new file mode 100644 index 00000000..01cb87df --- /dev/null +++ b/lib/models/trackers/fribb_mapping_row.dart @@ -0,0 +1,53 @@ +/// One row from `anime-list-mini.json` (Fribb/anime-lists). +class FribbMappingRow { + final int? anilistId; + final String? imdbId; + final int? malId; + final int? simklId; + final int? tmdbId; + final int? tvdbId; + + /// Plex season number this mapping corresponds to. A single show-level + /// external ID can resolve to multiple rows for split-cour anime; the + /// resolver picks by matching the episode's `parentIndex` against these. + final int? tvdbSeason; + final int? tmdbSeason; + + /// `TV` / `MOVIE` / `OVA` / `ONA` / `SPECIAL` / `UNKNOWN` / `null`. + final String? type; + + const FribbMappingRow({ + this.anilistId, + this.imdbId, + this.malId, + this.simklId, + this.tmdbId, + this.tvdbId, + this.tvdbSeason, + this.tmdbSeason, + this.type, + }); + + bool get isMovie => type == 'MOVIE'; + + factory FribbMappingRow.fromJson(Map json) { + final season = json['season']; + int? tvdbSeason; + int? tmdbSeason; + if (season is Map) { + tvdbSeason = (season['tvdb'] as num?)?.toInt(); + tmdbSeason = (season['tmdb'] as num?)?.toInt(); + } + return FribbMappingRow( + anilistId: (json['anilist_id'] as num?)?.toInt(), + imdbId: json['imdb_id'] as String?, + malId: (json['mal_id'] as num?)?.toInt(), + simklId: (json['simkl_id'] as num?)?.toInt(), + tmdbId: (json['themoviedb_id'] as num?)?.toInt(), + tvdbId: (json['tvdb_id'] as num?)?.toInt(), + tvdbSeason: tvdbSeason, + tmdbSeason: tmdbSeason, + type: json['type'] as String?, + ); + } +} diff --git a/lib/models/trackers/tracker_context.dart b/lib/models/trackers/tracker_context.dart new file mode 100644 index 00000000..74cc348a --- /dev/null +++ b/lib/models/trackers/tracker_context.dart @@ -0,0 +1,56 @@ +import '../../utils/plex_external_ids.dart'; +import 'anime_ids.dart'; + +/// Immutable per-playback context passed from the coordinator to each +/// tracker. Built once at `startPlayback`. +/// +/// Carries both Plex external IDs (tvdb/tmdb/imdb, always present when the +/// item has any GUIDs) and Fribb-derived anime IDs (null when the item isn't +/// in the Fribb mapping). General-purpose trackers (Simkl) prefer Plex IDs; +/// anime-only trackers (MAL, AniList) no-op when [anime] is null. +class TrackerContext { + final PlexExternalIds external; + final AnimeIds? anime; + + final bool isMovie; + final int? season; + final int? episodeNumber; + + /// Plex ratingKey of the item being played. Used only for logging — not + /// sent to any tracker. + final String ratingKey; + + const TrackerContext._({ + required this.external, + required this.anime, + required this.isMovie, + required this.ratingKey, + this.season, + this.episodeNumber, + }); + + factory TrackerContext.movie({ + required PlexExternalIds external, + required AnimeIds? anime, + required String ratingKey, + }) { + return TrackerContext._(external: external, anime: anime, isMovie: true, ratingKey: ratingKey); + } + + factory TrackerContext.episode({ + required PlexExternalIds external, + required AnimeIds? anime, + required String ratingKey, + required int season, + required int episodeNumber, + }) { + return TrackerContext._( + external: external, + anime: anime, + isMovie: false, + ratingKey: ratingKey, + season: season, + episodeNumber: episodeNumber, + ); + } +} diff --git a/lib/models/trakt/trakt_device_code.dart b/lib/models/trakt/trakt_device_code.dart deleted file mode 100644 index e1161323..00000000 --- a/lib/models/trakt/trakt_device_code.dart +++ /dev/null @@ -1,68 +0,0 @@ -/// Response from `POST /oauth/device/code`. -/// -/// The user enters [userCode] at [verificationUrl]; the app polls -/// `/oauth/device/token` with [deviceCode] every [interval] seconds until -/// [expiresIn] seconds elapse. -class TraktDeviceCode { - final String deviceCode; - final String userCode; - final String verificationUrl; - - /// URL with the user code prefilled (e.g. `https://trakt.tv/activate/ABC12345`). - /// Useful for `url_launcher` so the user doesn't have to type the code. - final String? verificationUrlComplete; - - /// Seconds until the device code expires (typically 600). - final int expiresIn; - - /// Seconds the app should wait between polls (typically 5). - final int interval; - - const TraktDeviceCode({ - required this.deviceCode, - required this.userCode, - required this.verificationUrl, - required this.expiresIn, - required this.interval, - this.verificationUrlComplete, - }); - - factory TraktDeviceCode.fromJson(Map json) { - final verificationUrl = json['verification_url'] as String; - final userCode = json['user_code'] as String; - return TraktDeviceCode( - deviceCode: json['device_code'] as String, - userCode: userCode, - verificationUrl: verificationUrl, - verificationUrlComplete: '$verificationUrl/$userCode', - expiresIn: (json['expires_in'] as num).toInt(), - interval: (json['interval'] as num).toInt(), - ); - } -} - -/// Discriminated event emitted by `TraktAuthService.pollDeviceCode`. -sealed class TraktDevicePollEvent { - const TraktDevicePollEvent(); -} - -class TraktDevicePollPending extends TraktDevicePollEvent { - const TraktDevicePollPending(); -} - -class TraktDevicePollSlowDown extends TraktDevicePollEvent { - const TraktDevicePollSlowDown(); -} - -class TraktDevicePollDenied extends TraktDevicePollEvent { - const TraktDevicePollDenied(); -} - -class TraktDevicePollExpired extends TraktDevicePollEvent { - const TraktDevicePollExpired(); -} - -class TraktDevicePollSuccess extends TraktDevicePollEvent { - final Map tokenResponse; - const TraktDevicePollSuccess(this.tokenResponse); -} diff --git a/lib/models/trakt/trakt_ids.dart b/lib/models/trakt/trakt_ids.dart index 1382c649..068d03d6 100644 --- a/lib/models/trakt/trakt_ids.dart +++ b/lib/models/trakt/trakt_ids.dart @@ -1,3 +1,5 @@ +import '../../utils/plex_external_ids.dart'; + /// External IDs for matching Plex items against Trakt's catalog. /// /// Trakt prefers (in order): trakt > slug > imdb > tmdb > tvdb. Movies use @@ -30,25 +32,9 @@ class TraktIds { tvdb: (json['tvdb'] as num?)?.toInt(), ); - /// Parse a Plex GUID array (`[{id: 'imdb://tt123'}, {id: 'tmdb://456'}, ...]`) - /// into a [TraktIds] struct. The Plex `Guid` field is unparsed in - /// `PlexMetadata`; this parser reads the array returned by `?includeGuids=1`. - factory TraktIds.fromPlexGuids(List guids) { - String? imdb; - int? tmdb; - int? tvdb; - for (final g in guids) { - if (g is! Map) continue; - final id = g['id']; - if (id is! String) continue; - if (id.startsWith('imdb://')) { - imdb = id.substring(7); - } else if (id.startsWith('tmdb://')) { - tmdb = int.tryParse(id.substring(7)); - } else if (id.startsWith('tvdb://')) { - tvdb = int.tryParse(id.substring(7)); - } - } - return TraktIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb); - } + factory TraktIds.fromExternal(PlexExternalIds ids) => TraktIds( + imdb: ids.imdb, + tmdb: ids.tmdb, + tvdb: ids.tvdb, + ); } diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart new file mode 100644 index 00000000..b602dd03 --- /dev/null +++ b/lib/providers/trackers_provider.dart @@ -0,0 +1,277 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../models/trackers/device_code.dart'; +import '../services/trackers/anilist/anilist_account_store.dart'; +import '../services/trackers/anilist/anilist_auth_service.dart'; +import '../services/trackers/anilist/anilist_client.dart'; +import '../services/trackers/anilist/anilist_session.dart'; +import '../services/trackers/anilist/anilist_tracker.dart'; +import '../services/trackers/mal/mal_account_store.dart'; +import '../services/trackers/mal/mal_auth_service.dart'; +import '../services/trackers/mal/mal_client.dart'; +import '../services/trackers/mal/mal_session.dart'; +import '../services/trackers/mal/mal_tracker.dart'; +import '../services/trackers/simkl/simkl_account_store.dart'; +import '../services/trackers/simkl/simkl_auth_service.dart'; +import '../services/trackers/simkl/simkl_client.dart'; +import '../services/trackers/simkl/simkl_session.dart'; +import '../services/trackers/simkl/simkl_tracker.dart'; +import '../services/trackers/tracker_account_store.dart'; +import '../services/trackers/tracker_coordinator.dart'; +import '../utils/app_logger.dart'; + +/// Identifier used in the settings UI to disambiguate disconnect/connect +/// actions without importing the per-service session classes into widgets. +enum TrackerService { mal, anilist, simkl } + +/// Owns the active MAL / AniList / Simkl sessions for the currently-selected +/// Plex profile. Single rebind seam: [onActiveProfileChanged] loads all three +/// sessions from their stores and pushes them to their trackers. +class TrackersProvider extends ChangeNotifier { + final MalAuthService _malAuth = MalAuthService(); + final SimklAuthService _simklAuth = SimklAuthService(); + + MalSession? _mal; + AnilistSession? _anilist; + SimklSession? _simkl; + + String _activeUserUuid = ''; + TrackerService? _connecting; + bool _cancelRequested = false; + + MalSession? get mal => _mal; + AnilistSession? get anilist => _anilist; + SimklSession? get simkl => _simkl; + + bool get isMalConnected => _mal != null; + bool get isAnilistConnected => _anilist != null; + bool get isSimklConnected => _simkl != null; + + String? get malUsername => _mal?.username; + String? get anilistUsername => _anilist?.username; + String? get simklUsername => _simkl?.username; + + bool isConnecting(TrackerService service) => _connecting == service; + + /// Cancel an in-flight device-code poll. Currently only Simkl is + /// cancellable — MAL/AniList are OS-browser driven, the user dismisses the + /// browser to abort. + void cancelConnect() { + _cancelRequested = true; + } + + Future onActiveProfileChanged(String? newUserUuid) async { + // Drop any in-flight scrobble state and release the resolver (which + // holds a PlexClient + session cache) before binding to the new profile. + TrackerCoordinator.instance.cancelInFlight(); + + _activeUserUuid = newUserUuid ?? ''; + final results = await Future.wait([ + malAccountStore.load(_activeUserUuid), + anilistAccountStore.load(_activeUserUuid), + simklAccountStore.load(_activeUserUuid), + ]); + _mal = results[0] as MalSession?; + _anilist = results[1] as AnilistSession?; + _simkl = results[2] as SimklSession?; + _rebindAll(); + notifyListeners(); + } + + // ───── Connect / disconnect ───── + + Future connectMal() => _runConnect( + service: TrackerService.mal, + alreadyConnected: isMalConnected, + authorize: _malAuth.authorize, + enrich: _enrichMal, + store: malAccountStore, + assign: (s) { + _mal = s; + _rebindMal(); + }, + ); + + Future disconnectMal() => _clearAndRebind(malAccountStore, () { + _mal = null; + _rebindMal(); + }); + + Future connectAnilist() => _runConnect( + service: TrackerService.anilist, + alreadyConnected: isAnilistConnected, + authorize: AnilistAuthService().authorize, + enrich: _enrichAnilist, + store: anilistAccountStore, + assign: (s) { + _anilist = s; + _rebindAnilist(); + }, + ); + + Future disconnectAnilist() => _clearAndRebind(anilistAccountStore, () { + _anilist = null; + _rebindAnilist(); + }); + + Future connectSimkl({required void Function(DeviceCode code) onCodeReady}) => _runConnect( + service: TrackerService.simkl, + alreadyConnected: isSimklConnected, + authorize: () => _simklAuthorize(onCodeReady), + enrich: _enrichSimkl, + store: simklAccountStore, + assign: (s) { + _simkl = s; + _rebindSimkl(); + }, + ); + + Future disconnectSimkl() => _clearAndRebind(simklAccountStore, () { + _simkl = null; + _rebindSimkl(); + }); + + // ───── Connect machinery ───── + + /// Shared connect shell: guard-if-busy, set in-flight flag, authorize, + /// enrich, save, assign, clear flag. Errors are logged and surface as + /// `false` so the UI can show a snack. + Future _runConnect({ + required TrackerService service, + required bool alreadyConnected, + required Future Function() authorize, + required Future Function(T raw) enrich, + required TrackerAccountStore store, + required void Function(T session) assign, + }) async { + if (_connecting != null || alreadyConnected) return false; + _connecting = service; + _cancelRequested = false; + notifyListeners(); + try { + final raw = await authorize(); + if (raw == null) return false; + final enriched = await enrich(raw); + await store.save(_activeUserUuid, enriched); + assign(enriched); + return true; + } catch (e) { + appLogger.w('${service.name} connect failed', error: e); + return false; + } finally { + _connecting = null; + notifyListeners(); + } + } + + Future _clearAndRebind(TrackerAccountStore store, void Function() clearAndRebind) async { + await store.clear(_activeUserUuid); + clearAndRebind(); + notifyListeners(); + } + + Future _simklAuthorize(void Function(DeviceCode) onCodeReady) async { + final code = await _simklAuth.createDeviceCode(); + onCodeReady(code); + await for (final event in _simklAuth.pollDeviceCode(code, shouldCancel: () => _cancelRequested)) { + if (event is DevicePollSuccess) { + final token = event.tokenResponse['access_token'] as String; + return SimklSession(accessToken: token, createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000); + } + if (event is DevicePollDenied || event is DevicePollExpired) return null; + } + return null; + } + + Future _enrichMal(MalSession raw) async { + try { + final tmp = MalClient(raw, onSessionInvalidated: () {}); + final user = await tmp.getMyUser(); + tmp.dispose(); + final name = user?['name'] as String?; + return name != null ? raw.copyWith(username: name) : raw; + } catch (e) { + appLogger.d('MAL: getMyUser failed (non-fatal)', error: e); + return raw; + } + } + + Future _enrichAnilist(AnilistSession raw) async { + try { + final tmp = AnilistClient(raw, onSessionInvalidated: () {}); + final name = await tmp.getViewerName(); + tmp.dispose(); + return name != null ? raw.copyWith(username: name) : raw; + } catch (e) { + appLogger.d('AniList: getViewerName failed (non-fatal)', error: e); + return raw; + } + } + + Future _enrichSimkl(SimklSession raw) async { + try { + final tmp = SimklClient(raw, onSessionInvalidated: () {}); + final user = await tmp.getUserSettings(); + tmp.dispose(); + final userObj = user?['user']; + final name = userObj is Map ? userObj['name'] as String? : null; + return name != null ? raw.copyWith(username: name) : raw; + } catch (e) { + appLogger.d('Simkl: getUserSettings failed (non-fatal)', error: e); + return raw; + } + } + + // ───── Tracker rebinding ───── + + void _rebindAll() { + _rebindMal(); + _rebindAnilist(); + _rebindSimkl(); + // Connect/disconnect may flip `needsFribb` — drop cached resolver IDs so + // the next lookup re-evaluates whether to consult Fribb. + TrackerCoordinator.instance.invalidateResolverCache(); + } + + void _rebindMal() { + MalTracker.instance.rebindSession( + _mal, + onSessionInvalidated: () => _handleInvalidated(malAccountStore, () => _mal = null, _rebindMal), + onSessionUpdated: (next) { + _mal = next; + malAccountStore.save(_activeUserUuid, next); + notifyListeners(); + }, + ); + } + + void _rebindAnilist() { + AnilistTracker.instance.rebindSession( + _anilist, + onSessionInvalidated: () => _handleInvalidated(anilistAccountStore, () => _anilist = null, _rebindAnilist), + ); + } + + void _rebindSimkl() { + SimklTracker.instance.rebindSession( + _simkl, + onSessionInvalidated: () => _handleInvalidated(simklAccountStore, () => _simkl = null, _rebindSimkl), + ); + } + + void _handleInvalidated(TrackerAccountStore store, void Function() clearSession, void Function() rebind) { + store.clear(_activeUserUuid); + clearSession(); + rebind(); + notifyListeners(); + } + + @override + void dispose() { + _malAuth.dispose(); + _simklAuth.dispose(); + super.dispose(); + } +} diff --git a/lib/providers/trakt_account_provider.dart b/lib/providers/trakt_account_provider.dart index 612bc8d7..df7a5d0a 100644 --- a/lib/providers/trakt_account_provider.dart +++ b/lib/providers/trakt_account_provider.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import '../models/trakt/trakt_device_code.dart'; +import '../models/trackers/device_code.dart'; import '../services/trakt/trakt_account_store.dart'; import '../services/trakt/trakt_auth_service.dart'; import '../services/trakt/trakt_client.dart'; @@ -14,11 +14,10 @@ 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`. +/// session and pushes it to both `TraktScrobbleService` and `TraktSyncService`. class TraktAccountProvider extends ChangeNotifier { final TraktAuthService _auth = TraktAuthService(); - final TraktAccountStore _store = TraktAccountStore.instance; + final _store = traktAccountStore; TraktSession? _session; String _activeUserUuid = ''; @@ -47,7 +46,7 @@ class TraktAccountProvider extends ChangeNotifier { /// /// [onCodeReady] is invoked once with the user code + verification URL so /// the UI can render the dialog. - Future connect({required void Function(TraktDeviceCode code) onCodeReady}) async { + Future connect({required void Function(DeviceCode code) onCodeReady}) async { if (_isConnecting) return false; _isConnecting = true; _cancelRequested = false; @@ -58,11 +57,11 @@ class TraktAccountProvider extends ChangeNotifier { onCodeReady(code); await for (final event in _auth.pollDeviceCode(code, shouldCancel: () => _cancelRequested)) { - if (event is TraktDevicePollSuccess) { + if (event is DevicePollSuccess) { await _completeConnect(event.tokenResponse); return true; } - if (event is TraktDevicePollDenied || event is TraktDevicePollExpired) { + if (event is DevicePollDenied || event is DevicePollExpired) { return false; } } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 25276f18..873b6253 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -4,7 +4,6 @@ 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'; @@ -24,6 +23,7 @@ import '../../services/saf_storage_service.dart'; import '../../services/settings_export_service.dart'; import '../../providers/settings_provider.dart'; import '../../providers/theme_provider.dart'; +import '../../providers/trackers_provider.dart'; import '../../providers/trakt_account_provider.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart' as settings; @@ -39,7 +39,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'; +import 'trackers_settings_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -56,7 +56,7 @@ class _SettingsScreenState extends State with FocusableTab { static const _kDonate = 'donate'; static const _kAppearance = 'appearance'; static const _kPlayback = 'playback'; - static const _kTrakt = 'trakt'; + static const _kTrackers = 'trackers'; static const _kDownloadLocation = 'download_location'; static const _kDownloadOnWifiOnly = 'download_on_wifi_only'; static const _kAutoRemoveWatchedDownloads = 'auto_remove_watched_downloads'; @@ -171,8 +171,8 @@ class _SettingsScreenState extends State with FocusableTab { // --- Playback (navigation tile) --- _buildPlaybackTile(), - // --- Trakt.tv (navigation tile) --- - _buildTraktTile(), + // --- Trackers (unified hub: Trakt + MAL + AniList + Simkl) --- + _buildTrackersTile(), // --- Downloads (inline) --- _buildDownloadsSection(), @@ -257,31 +257,24 @@ class _SettingsScreenState extends State with FocusableTab { ); } - Widget _buildTraktTile() { - return Consumer( - builder: (context, account, _) { - final connected = account.isConnected; - final username = account.username; - final iconColor = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface; + Widget _buildTrackersTile() { + return Consumer2( + builder: (context, trakt, trackers, _) { + final connectedNames = [ + if (trakt.isConnected) t.trakt.title, + if (trackers.isMalConnected) t.trackers.services.mal, + if (trackers.isAnilistConnected) t.trackers.services.anilist, + if (trackers.isSimklConnected) t.trackers.services.simkl, + ]; + final subtitle = connectedNames.isEmpty ? t.settings.trackersDescription : connectedNames.join(' · '); 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, - ), + focusNode: _focusTracker.get(_kTrackers), + leading: const AppIcon(Symbols.sync_rounded, fill: 1), + title: Text(t.settings.trackers), + subtitle: Text(subtitle), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () { - if (account.isConnected) { - Navigator.push(context, MaterialPageRoute(builder: (context) => const TraktSettingsScreen())); - } else { - startTraktConnection(context); - } + Navigator.push(context, MaterialPageRoute(builder: (_) => const TrackersSettingsScreen())); }, ); }, diff --git a/lib/screens/settings/tracker_settings_screen.dart b/lib/screens/settings/tracker_settings_screen.dart new file mode 100644 index 00000000..136d0ca0 --- /dev/null +++ b/lib/screens/settings/tracker_settings_screen.dart @@ -0,0 +1,252 @@ +import 'dart:async'; + +import 'package:flutter/material.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 '../../providers/trackers_provider.dart'; +import '../../services/trackers/anilist/anilist_tracker.dart'; +import '../../services/trackers/mal/mal_tracker.dart'; +import '../../services/trackers/simkl/simkl_tracker.dart'; +import '../../services/settings_service.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/dialogs.dart'; +import '../../utils/snackbar_helper.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/device_code_dialog.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/settings_section.dart'; + +/// Start MAL's loopback-OAuth flow from anywhere. The user's browser opens to +/// complete the flow; no in-app dialog is needed. Shows a snackbar on failure. +Future startMalConnection(BuildContext context) async { + final account = context.read(); + if (account.isConnecting(TrackerService.mal) || account.isMalConnected) return; + final ok = await account.connectMal(); + if (!context.mounted) return; + if (!ok && !account.isMalConnected) { + showAppSnackBar(context, t.trackers.connectFailed(service: t.trackers.services.mal)); + } +} + +/// Start AniList's loopback-OAuth flow from anywhere. +Future startAnilistConnection(BuildContext context) async { + final account = context.read(); + if (account.isConnecting(TrackerService.anilist) || account.isAnilistConnected) return; + final ok = await account.connectAnilist(); + if (!context.mounted) return; + if (!ok && !account.isAnilistConnected) { + showAppSnackBar(context, t.trackers.connectFailed(service: t.trackers.services.anilist)); + } +} + +/// Start Simkl's device-code flow. Mirrors `startTraktConnection` — shows the +/// PIN dialog, polls, auto-launches the browser on pointer-driven platforms. +Future startSimklConnection(BuildContext context) async { + final account = context.read(); + if (account.isConnecting(TrackerService.simkl) || account.isSimklConnected) return; + + final autoLaunchBrowser = !InputModeTracker.isKeyboardMode(context); + var dialogOpen = false; + + final ok = await account.connectSimkl( + onCodeReady: (code) { + if (!context.mounted) return; + dialogOpen = true; + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => DeviceCodeDialog( + code: code, + serviceName: t.trackers.services.simkl, + onCancel: account.cancelConnect, + ), + ).whenComplete(() => dialogOpen = false); + if (autoLaunchBrowser) { + unawaited( + launchUrl(Uri.parse(code.verificationUrl), mode: LaunchMode.externalApplication).catchError((Object e) { + appLogger.d('Simkl: failed to auto-launch browser', error: e); + return false; + }), + ); + } + }, + ); + + if (!context.mounted) return; + if (dialogOpen) { + Navigator.of(context, rootNavigator: true).pop(); + } + if (!ok && !account.isSimklConnected) { + showAppSnackBar(context, t.trackers.connectFailed(service: t.trackers.services.simkl)); + } +} + +/// Per-service wiring for [TrackerSettingsScreen]. Keeps tracker-specific +/// method names out of the shared screen body. +class TrackerConfig { + final String displayName; + final bool Function(TrackersProvider) isConnected; + final String? Function(TrackersProvider) username; + final bool Function(SettingsService) readScrobbleEnabled; + final Future Function(SettingsService, bool) setScrobbleEnabled; + final Future Function(TrackersProvider) disconnect; + + const TrackerConfig({ + required this.displayName, + required this.isConnected, + required this.username, + required this.readScrobbleEnabled, + required this.setScrobbleEnabled, + required this.disconnect, + }); + + static TrackerConfig mal() => TrackerConfig( + displayName: t.trackers.services.mal, + isConnected: (a) => a.isMalConnected, + username: (a) => a.malUsername, + readScrobbleEnabled: (s) => s.getEnableMalScrobble(), + setScrobbleEnabled: (s, v) async { + await s.setEnableMalScrobble(v); + await MalTracker.instance.setEnabled(v); + }, + disconnect: (a) => a.disconnectMal(), + ); + + static TrackerConfig anilist() => TrackerConfig( + displayName: t.trackers.services.anilist, + isConnected: (a) => a.isAnilistConnected, + username: (a) => a.anilistUsername, + readScrobbleEnabled: (s) => s.getEnableAnilistScrobble(), + setScrobbleEnabled: (s, v) async { + await s.setEnableAnilistScrobble(v); + await AnilistTracker.instance.setEnabled(v); + }, + disconnect: (a) => a.disconnectAnilist(), + ); + + static TrackerConfig simkl() => TrackerConfig( + displayName: t.trackers.services.simkl, + isConnected: (a) => a.isSimklConnected, + username: (a) => a.simklUsername, + readScrobbleEnabled: (s) => s.getEnableSimklScrobble(), + setScrobbleEnabled: (s, v) async { + await s.setEnableSimklScrobble(v); + await SimklTracker.instance.setEnabled(v); + }, + disconnect: (a) => a.disconnectSimkl(), + ); +} + +/// Shared settings screen for MAL, AniList, and Simkl. Only reachable while +/// connected — if the session drops (refresh failure, back-nav race) we pop +/// back to the hub. +class TrackerSettingsScreen extends StatefulWidget { + final TrackerConfig config; + const TrackerSettingsScreen({super.key, required this.config}); + + @override + State createState() => _TrackerSettingsScreenState(); +} + +class _TrackerSettingsScreenState extends State { + SettingsService? _settings; + bool _scrobbleEnabled = true; + bool _loaded = false; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + final s = await SettingsService.getInstance(); + if (!mounted) return; + setState(() { + _settings = s; + _scrobbleEnabled = widget.config.readScrobbleEnabled(s); + _loaded = true; + }); + } + + Future _disconnect(TrackersProvider account) async { + final confirmed = await showConfirmDialog( + context, + title: t.trackers.disconnectConfirm(service: widget.config.displayName), + message: t.trackers.disconnectConfirmBody(service: widget.config.displayName), + confirmText: t.common.disconnect, + isDestructive: true, + ); + if (!confirmed) return; + await widget.config.disconnect(account); + } + + @override + Widget build(BuildContext context) { + final title = Text(widget.config.displayName); + if (!_loaded) { + return FocusedScrollScaffold( + title: title, + slivers: const [SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], + ); + } + + return Consumer( + builder: (context, account, _) { + if (!widget.config.isConnected(account)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.of(context).pop(); + }); + return FocusedScrollScaffold( + title: title, + slivers: const [SliverFillRemaining(child: SizedBox.shrink())], + ); + } + + final username = widget.config.username(account); + return FocusedScrollScaffold( + title: title, + slivers: [ + SliverList( + delegate: SliverChildListDelegate([ + ListTile( + leading: const AppIcon(Symbols.account_circle_rounded, fill: 1), + title: Text( + username != null + ? t.trackers.connectedAs(username: username) + : widget.config.displayName, + ), + ), + SettingsSectionHeader(t.settings.behavior), + SwitchListTile( + secondary: const AppIcon(Symbols.auto_timer, fill: 1), + title: Text(t.trackers.scrobble), + subtitle: Text(t.trackers.scrobbleDescription), + value: _scrobbleEnabled, + onChanged: (value) async { + setState(() => _scrobbleEnabled = value); + await widget.config.setScrobbleEnabled(_settings!, 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), + ]), + ), + ], + ); + }, + ); + } +} diff --git a/lib/screens/settings/trackers_settings_screen.dart b/lib/screens/settings/trackers_settings_screen.dart new file mode 100644 index 00000000..65b309b3 --- /dev/null +++ b/lib/screens/settings/trackers_settings_screen.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../i18n/strings.g.dart'; +import '../../providers/trackers_provider.dart'; +import '../../providers/trakt_account_provider.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; +import 'tracker_settings_screen.dart'; +import 'trakt_settings_screen.dart'; + +/// Unified hub for all watch-progress trackers: Trakt, MyAnimeList, AniList, +/// Simkl. Each row opens its service-specific settings screen. +class TrackersSettingsScreen extends StatelessWidget { + const TrackersSettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return FocusedScrollScaffold( + title: Text(t.trackers.title), + slivers: [ + SliverList( + delegate: SliverChildListDelegate([ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Text( + t.trackers.hubSubtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + _trakt(), + _mal(), + _anilist(), + _simkl(), + const SizedBox(height: 24), + ]), + ), + ], + ); + } + + Widget _trakt() => Consumer( + builder: (context, account, _) => _TrackerHubRow( + leading: const _TrackerLogo('assets/trakt_circlemark.svg'), + title: t.trakt.title, + username: account.isConnected ? account.username : null, + onTap: () { + if (account.isConnected) { + Navigator.push(context, MaterialPageRoute(builder: (_) => const TraktSettingsScreen())); + } else { + startTraktConnection(context); + } + }, + ), + ); + + Widget _mal() => Consumer( + builder: (context, account, _) => _TrackerHubRow( + leading: const _TrackerLogo('assets/mal_mark.svg'), + title: t.trackers.services.mal, + username: account.isMalConnected ? account.malUsername : null, + onTap: () { + if (account.isMalConnected) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.mal())), + ); + } else { + startMalConnection(context); + } + }, + ), + ); + + Widget _anilist() => Consumer( + builder: (context, account, _) => _TrackerHubRow( + leading: const _TrackerLogo('assets/anilist_mark.svg'), + title: t.trackers.services.anilist, + username: account.isAnilistConnected ? account.anilistUsername : null, + onTap: () { + if (account.isAnilistConnected) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.anilist())), + ); + } else { + startAnilistConnection(context); + } + }, + ), + ); + + Widget _simkl() => Consumer( + builder: (context, account, _) => _TrackerHubRow( + leading: const _TrackerLogo('assets/simkl_mark.svg'), + title: t.trackers.services.simkl, + username: account.isSimklConnected ? account.simklUsername : null, + onTap: () { + if (account.isSimklConnected) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.simkl())), + ); + } else { + startSimklConnection(context); + } + }, + ), + ); +} + +class _TrackerHubRow extends StatelessWidget { + final Widget leading; + final String title; + + /// Non-null when connected. When null, the subtitle shows "Not connected". + final String? username; + + final VoidCallback onTap; + + const _TrackerHubRow({required this.leading, required this.title, required this.username, required this.onTap}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: leading, + title: Text(title), + subtitle: Text(username != null ? t.trackers.connectedAs(username: username!) : t.trackers.notConnected), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: onTap, + ); + } +} + +/// 24x24 SVG logo driven by [IconTheme]. Uses [SvgTheme.currentColor] so SVGs +/// with multiple explicit fills (AniList keeps its brand-blue L while the A +/// follows the theme) render correctly alongside single-color wordmarks. +class _TrackerLogo extends StatelessWidget { + final String asset; + const _TrackerLogo(this.asset); + + @override + Widget build(BuildContext context) { + final color = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface; + return SvgPicture.asset(asset, width: 24, height: 24, theme: SvgTheme(currentColor: color)); + } +} diff --git a/lib/screens/settings/trakt_settings_screen.dart b/lib/screens/settings/trakt_settings_screen.dart index 5c04fb31..6341b87c 100644 --- a/lib/screens/settings/trakt_settings_screen.dart +++ b/lib/screens/settings/trakt_settings_screen.dart @@ -1,14 +1,12 @@ 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'; @@ -16,8 +14,8 @@ import '../../services/trakt/trakt_sync_service.dart'; import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; -import '../../widgets/dialog_action_button.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/device_code_dialog.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/settings_section.dart'; @@ -42,7 +40,11 @@ Future startTraktConnection(BuildContext context) async { showDialog( context: context, barrierDismissible: false, - builder: (_) => _DeviceCodeDialog(code: code, account: account), + builder: (_) => DeviceCodeDialog( + code: code, + serviceName: t.trakt.title, + onCancel: account.cancelConnect, + ), ).whenComplete(() => dialogOpen = false); if (autoLaunchBrowser) { final url = code.verificationUrlComplete ?? code.verificationUrl; @@ -188,83 +190,3 @@ class _TraktSettingsScreenState extends State { } } -class _DeviceCodeDialog extends StatefulWidget { - final TraktDeviceCode code; - final TraktAccountProvider account; - const _DeviceCodeDialog({required this.code, required this.account}); - - @override - State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState(); -} - -class _DeviceCodeDialogState extends State<_DeviceCodeDialog> { - Future _open() async { - final url = widget.code.verificationUrlComplete ?? widget.code.verificationUrl; - await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); - } - - Future _copy() async { - await Clipboard.setData(ClipboardData(text: widget.code.userCode)); - if (!mounted) return; - showAppSnackBar(context, t.trakt.codeCopied); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return AlertDialog( - title: Text(t.trakt.deviceCodeTitle), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(t.trakt.deviceCodeBody(url: widget.code.verificationUrl), style: theme.textTheme.bodyMedium), - const SizedBox(height: 16), - Center( - child: InkWell( - onTap: _copy, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - child: Text( - widget.code.userCode, - style: theme.textTheme.displaySmall?.copyWith( - fontFeatures: const [FontFeature.tabularFigures()], - letterSpacing: 4, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - icon: const Icon(Icons.open_in_new), - label: Text(t.trakt.openTraktActivate), - onPressed: _open, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), - const SizedBox(width: 12), - Expanded(child: Text(t.trakt.waitingForAuthorization, style: theme.textTheme.bodySmall)), - ], - ), - ], - ), - actions: [ - DialogActionButton( - onPressed: () { - widget.account.cancelConnect(); - Navigator.of(context, rootNavigator: true).pop(); - }, - label: t.common.cancel, - ), - ], - ); - } -} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 4684a987..a889fd14 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -36,6 +36,7 @@ import '../providers/companion_remote_provider.dart'; import '../services/companion_remote/companion_remote_receiver.dart'; import '../services/fullscreen_state_manager.dart'; import '../services/discord_rpc_service.dart'; +import '../services/trackers/tracker_coordinator.dart'; import '../services/trakt/trakt_scrobble_service.dart'; import '../services/episode_navigation_service.dart'; import '../services/media_controls_manager.dart'; @@ -1150,9 +1151,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin ); DiscordRPCService.instance.updatePosition(position); TraktScrobbleService.instance.updatePosition(position); + TrackerCoordinator.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); + TrackerCoordinator.instance.updateDuration(player!.state.duration); }); // Listen to playback rate changes for Discord Rich Presence @@ -1168,6 +1171,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (client != null) { DiscordRPCService.instance.startPlayback(_currentMetadata, client); TraktScrobbleService.instance.startPlayback(_currentMetadata, client, isLive: widget.isLive); + TrackerCoordinator.instance.startPlayback(_currentMetadata, client, isLive: widget.isLive); } } @@ -2285,6 +2289,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Clear Discord Rich Presence + send Trakt stop scrobble DiscordRPCService.instance.stopPlayback(); TraktScrobbleService.instance.stopPlayback(); + TrackerCoordinator.instance.stopPlayback(); // Clean up Windows display mode service if (Platform.isWindows && _displayModeService != null) { @@ -3061,6 +3066,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Clear Discord Rich Presence + Trakt scrobble before switching episodes DiscordRPCService.instance.stopPlayback(); TraktScrobbleService.instance.stopPlayback(); + TrackerCoordinator.instance.stopPlayback(); // If player isn't available, navigate without preserving settings if (player == null) { @@ -3141,6 +3147,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _progressTracker = null; DiscordRPCService.instance.stopPlayback(); TraktScrobbleService.instance.stopPlayback(); + TrackerCoordinator.instance.stopPlayback(); _currentMetadata = episodeMetadata; _activeRatingKey = episodeMetadata.ratingKey; @@ -3254,6 +3261,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (client != null) { DiscordRPCService.instance.startPlayback(episodeMetadata, client); TraktScrobbleService.instance.startPlayback(episodeMetadata, client, isLive: widget.isLive); + TrackerCoordinator.instance.startPlayback(episodeMetadata, client, isLive: widget.isLive); } try { diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 6f8d7c63..0292ffd1 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -95,6 +95,9 @@ class SettingsService extends BaseSharedPreferencesService { 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 _keyEnableMalScrobble = 'enable_mal_scrobble'; + static const String _keyEnableAnilistScrobble = 'enable_anilist_scrobble'; + static const String _keyEnableSimklScrobble = 'enable_simkl_scrobble'; static const String _keyEnableCompanionRemoteServer = 'enable_companion_remote_server'; static const String _keyAutoPip = 'auto_pip'; static const String _keyMatchContentFrameRate = 'match_content_frame_rate'; @@ -1166,6 +1169,31 @@ class SettingsService extends BaseSharedPreferencesService { return prefs.getBool(_keyEnableTraktWatchedSync) ?? true; // Default enabled when account is linked } + // Tracker scrobbling (threshold-based, fires at 80% progress) + Future setEnableMalScrobble(bool enabled) async { + await prefs.setBool(_keyEnableMalScrobble, enabled); + } + + bool getEnableMalScrobble() { + return prefs.getBool(_keyEnableMalScrobble) ?? true; + } + + Future setEnableAnilistScrobble(bool enabled) async { + await prefs.setBool(_keyEnableAnilistScrobble, enabled); + } + + bool getEnableAnilistScrobble() { + return prefs.getBool(_keyEnableAnilistScrobble) ?? true; + } + + Future setEnableSimklScrobble(bool enabled) async { + await prefs.setBool(_keyEnableSimklScrobble, enabled); + } + + bool getEnableSimklScrobble() { + return prefs.getBool(_keyEnableSimklScrobble) ?? true; + } + // Companion Remote Server Future setEnableCompanionRemoteServer(bool enabled) async { await prefs.setBool(_keyEnableCompanionRemoteServer, enabled); @@ -1505,6 +1533,9 @@ class SettingsService extends BaseSharedPreferencesService { prefs.remove(_keyEnableDiscordRPC), prefs.remove(_keyEnableTraktScrobble), prefs.remove(_keyEnableTraktWatchedSync), + prefs.remove(_keyEnableMalScrobble), + prefs.remove(_keyEnableAnilistScrobble), + prefs.remove(_keyEnableSimklScrobble), prefs.remove(_keyAutoPip), prefs.remove(_keyMatchContentFrameRate), prefs.remove(_keyTunneledPlayback), diff --git a/lib/services/trackers/anilist/anilist_account_store.dart b/lib/services/trackers/anilist/anilist_account_store.dart new file mode 100644 index 00000000..72b22af1 --- /dev/null +++ b/lib/services/trackers/anilist/anilist_account_store.dart @@ -0,0 +1,8 @@ +import '../tracker_account_store.dart'; +import 'anilist_session.dart'; + +final TrackerAccountStore anilistAccountStore = TrackerAccountStore( + baseKey: 'anilist_session', + decode: AnilistSession.decode, + encode: (s) => s.encode(), +); diff --git a/lib/services/trackers/anilist/anilist_auth_service.dart b/lib/services/trackers/anilist/anilist_auth_service.dart new file mode 100644 index 00000000..6515e068 --- /dev/null +++ b/lib/services/trackers/anilist/anilist_auth_service.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import '../loopback_auth_server.dart'; +import 'anilist_constants.dart'; +import 'anilist_session.dart'; + +/// AniList OAuth 2.0 implicit grant via RFC 8252 loopback redirect. +/// +/// AniList returns the access token in the URL fragment, which browsers +/// don't send to servers. [LoopbackAuthServer] serves a tiny HTML page that +/// rewrites `location.hash` into a query string and reloads — the second +/// request is then captured normally. +class AnilistAuthService { + static const String _callbackPath = '/anilist-oauth'; + + /// Drive the full flow. Returns `null` if the user closes the browser + /// before completing. + Future authorize() async { + // AniList's implicit grant rejects the authorize request when + // `redirect_uri` is present — MAL-Sync omits it too. The redirect URL + // registered for the client at anilist.co is used automatically. + final authorizeUri = Uri.parse(AnilistConstants.oauthAuthorizeUrl).replace( + queryParameters: {'client_id': AnilistConstants.clientId, 'response_type': 'token'}, + ); + + final callback = await LoopbackAuthServer.launchAndWait(authorizeUri, path: _callbackPath); + if (callback == null) return null; + + final params = callback.queryParameters; + final token = params['access_token']; + if (token == null) { + throw AnilistAuthFlowException('AniList redirect missing access_token: $callback'); + } + + final expiresIn = int.tryParse(params['expires_in'] ?? '') ?? (365 * 24 * 60 * 60); + final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return AnilistSession(accessToken: token, expiresAt: createdAt + expiresIn, createdAt: createdAt); + } +} + +class AnilistAuthFlowException implements Exception { + final String message; + const AnilistAuthFlowException(this.message); + @override + String toString() => 'AnilistAuthFlowException: $message'; +} diff --git a/lib/services/trackers/anilist/anilist_client.dart b/lib/services/trackers/anilist/anilist_client.dart new file mode 100644 index 00000000..feac72ec --- /dev/null +++ b/lib/services/trackers/anilist/anilist_client.dart @@ -0,0 +1,92 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +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 'anilist_constants.dart'; +import 'anilist_session.dart'; + +/// GraphQL client for AniList. +/// +/// No refresh endpoint — on 401 the session is terminal and +/// [onSessionInvalidated] clears it so the user re-auths. +class AnilistClient { + static const Duration _requestTimeout = Duration(seconds: 20); + + final AnilistSession _session; + final http.Client _http; + final void Function() onSessionInvalidated; + + AnilistClient(AnilistSession session, {required this.onSessionInvalidated, http.Client? httpClient}) + : _session = session, + _http = httpClient ?? platform.createPlatformClient(); + + AnilistSession get session => _session; + + void dispose() => _http.close(); + + /// Fetch the current viewer's username for the settings UI. + Future getViewerName() async { + final data = await query('query { Viewer { name } }'); + final viewer = data['Viewer']; + if (viewer is Map) return viewer['name'] as String?; + return null; + } + + /// Update the viewer's media-list entry for an AniList media ID. + Future saveMediaListEntry({required int mediaId, required int progress, required String status}) async { + const mutation = ''' + mutation(\$mediaId: Int, \$progress: Int, \$status: MediaListStatus) { + SaveMediaListEntry(mediaId: \$mediaId, progress: \$progress, status: \$status) { + id + } + } + '''; + await query(mutation, variables: {'mediaId': mediaId, 'progress': progress, 'status': status}); + } + + Future> query(String query, {Map? variables}) async { + final uri = Uri.parse(AnilistConstants.apiBase); + final headers = AnilistConstants.headers(accessToken: _session.accessToken); + final body = json.encode({'query': query, 'variables': ?variables}); + + final sw = Stopwatch()..start(); + final res = await _http.post(uri, headers: headers, body: body).timeout(_requestTimeout); + sw.stop(); + appLogger.d('AniList POST ${uri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)'); + + if (res.statusCode == 401) { + onSessionInvalidated(); + throw const AnilistAuthException('Session invalidated (401)'); + } + if (res.statusCode != 200) { + throw AnilistApiException(statusCode: res.statusCode, body: res.body); + } + final decoded = json.decode(res.body) as Map; + final errors = decoded['errors']; + if (errors is List && errors.isNotEmpty) { + throw AnilistApiException(statusCode: res.statusCode, body: json.encode(errors)); + } + final data = decoded['data']; + return data is Map ? data.cast() : {}; + } +} + +class AnilistApiException implements Exception { + final int statusCode; + final String body; + const AnilistApiException({required this.statusCode, required this.body}); + @override + String toString() => 'AnilistApiException(HTTP $statusCode): $body'; +} + +class AnilistAuthException implements Exception { + final String message; + const AnilistAuthException(this.message); + @override + String toString() => 'AnilistAuthException: $message'; +} diff --git a/lib/services/trackers/anilist/anilist_constants.dart b/lib/services/trackers/anilist/anilist_constants.dart new file mode 100644 index 00000000..829f16a1 --- /dev/null +++ b/lib/services/trackers/anilist/anilist_constants.dart @@ -0,0 +1,20 @@ +/// Bundled AniList API credentials and endpoints. +/// +/// Register at https://anilist.co/settings/developer — redirect URL must be +/// `http://127.0.0.1:53682/anilist-oauth` (RFC 8252 loopback). AniList uses +/// OAuth 2.0 Implicit Grant; access tokens are valid for 1 year and have no +/// refresh — the user must re-auth on expiry. +class AnilistConstants { + AnilistConstants._(); + + static const String clientId = '39867'; + + static const String apiBase = 'https://graphql.anilist.co'; + static const String oauthAuthorizeUrl = 'https://anilist.co/api/v2/oauth/authorize'; + + static Map headers({String? accessToken}) => { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + if (accessToken != null) 'Authorization': 'Bearer $accessToken', + }; +} diff --git a/lib/services/trackers/anilist/anilist_session.dart b/lib/services/trackers/anilist/anilist_session.dart new file mode 100644 index 00000000..e17995e8 --- /dev/null +++ b/lib/services/trackers/anilist/anilist_session.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +/// Immutable AniList OAuth session. +/// +/// Implicit grant — no refresh token. Tokens are valid for 1 year; on expiry +/// the user must re-auth. +class AnilistSession { + final String accessToken; + final int expiresAt; + final String? username; + final int createdAt; + + const AnilistSession({ + required this.accessToken, + required this.expiresAt, + required this.createdAt, + this.username, + }); + + bool get isExpired => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt; + + AnilistSession copyWith({String? accessToken, int? expiresAt, String? username, int? createdAt}) { + return AnilistSession( + accessToken: accessToken ?? this.accessToken, + expiresAt: expiresAt ?? this.expiresAt, + username: username ?? this.username, + createdAt: createdAt ?? this.createdAt, + ); + } + + Map toJson() => { + 'access_token': accessToken, + 'expires_at': expiresAt, + 'username': username, + 'created_at': createdAt, + }; + + factory AnilistSession.fromJson(Map json) => AnilistSession( + accessToken: json['access_token'] as String, + expiresAt: (json['expires_at'] as num).toInt(), + username: json['username'] as String?, + createdAt: (json['created_at'] as num).toInt(), + ); + + String encode() => json.encode(toJson()); + static AnilistSession decode(String raw) => AnilistSession.fromJson(json.decode(raw) as Map); +} diff --git a/lib/services/trackers/anilist/anilist_tracker.dart b/lib/services/trackers/anilist/anilist_tracker.dart new file mode 100644 index 00000000..61313470 --- /dev/null +++ b/lib/services/trackers/anilist/anilist_tracker.dart @@ -0,0 +1,51 @@ +import '../../../models/trackers/tracker_context.dart'; +import '../../../utils/app_logger.dart'; +import '../../settings_service.dart'; +import '../tracker.dart'; +import 'anilist_client.dart'; +import 'anilist_session.dart'; + +/// AniList scrobble tracker. Saves `SaveMediaListEntry(progress, status)` +/// once playback crosses the watched threshold. +/// +/// AniList is anime-only: no-op when [TrackerContext.anime] is null. +class AnilistTracker extends TrackerBase { + static AnilistTracker? _instance; + static AnilistTracker get instance => _instance ??= AnilistTracker._(); + AnilistTracker._(); + + @override + String get name => 'anilist'; + + @override + bool get needsFribb => true; + + AnilistClient? _client; + + @override + bool get hasActiveClient => _client != null; + + @override + bool readEnabledSetting(SettingsService settings) => settings.getEnableAnilistScrobble(); + + void rebindSession(AnilistSession? session, {required void Function() onSessionInvalidated}) { + _client?.dispose(); + _client = session == null ? null : AnilistClient(session, onSessionInvalidated: onSessionInvalidated); + } + + @override + Future markWatched(TrackerContext ctx) async { + final client = _client; + final anilistId = ctx.anime?.anilist; + if (client == null || anilistId == null) return; + + // AniList auto-promotes CURRENT → COMPLETED when progress == total, so we + // only need to send CURRENT for episodes. Movies are always a single-unit + // completion. + final progress = ctx.isMovie ? 1 : (ctx.episodeNumber ?? 0); + final status = ctx.isMovie ? 'COMPLETED' : 'CURRENT'; + + await client.saveMediaListEntry(mediaId: anilistId, progress: progress, status: status); + appLogger.d('AniList: saved entry (anilist=$anilistId, progress=$progress, status=$status)'); + } +} diff --git a/lib/services/trackers/device_code_poller.dart b/lib/services/trackers/device_code_poller.dart new file mode 100644 index 00000000..3ae26ea8 --- /dev/null +++ b/lib/services/trackers/device_code_poller.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import '../../models/trackers/device_code.dart'; + +/// Runs the RFC 8628 poll loop. Service-specific HTTP is delegated to [probe]; +/// this helper owns interval decay (`slow_down`), the deadline window, and the +/// cancel-check cadence so Trakt and Simkl can share it. +/// +/// [probe] must perform one poll attempt and return a single event. +/// [DevicePollSlowDown] triggers a 5-second interval bump; terminal events +/// ([DevicePollSuccess], [DevicePollDenied], [DevicePollExpired]) end the +/// stream. +Stream pollDeviceCode( + DeviceCode code, { + required Future Function() probe, + bool Function()? shouldCancel, +}) async* { + var interval = Duration(seconds: code.interval); + final deadline = DateTime.now().add(Duration(seconds: code.expiresIn)); + + while (DateTime.now().isBefore(deadline)) { + if (shouldCancel != null && shouldCancel()) return; + await Future.delayed(interval); + if (shouldCancel != null && shouldCancel()) return; + + final event = await probe(); + yield event; + switch (event) { + case DevicePollSuccess() || DevicePollDenied() || DevicePollExpired(): + return; + case DevicePollSlowDown(): + interval += const Duration(seconds: 5); + case DevicePollPending(): + break; + } + } + + yield const DevicePollExpired(); +} diff --git a/lib/services/trackers/fribb_mapping_store.dart b/lib/services/trackers/fribb_mapping_store.dart new file mode 100644 index 00000000..0d2edd9f --- /dev/null +++ b/lib/services/trackers/fribb_mapping_store.dart @@ -0,0 +1,252 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../models/trackers/fribb_mapping_row.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; + +/// Indexed view of the Fribb mapping database, queried by external ID. +/// +/// Three lookup tables (tvdb / tmdb / imdb) into a shared list of rows. +/// A single tvdb_id may map to multiple rows (split-cour anime → one per +/// season); callers that have a Plex season number should filter by +/// [FribbMappingRow.tvdbSeason] or [FribbMappingRow.tmdbSeason]. +class FribbIndex { + final Map> byTvdb; + final Map> byTmdb; + final Map> byImdb; + + const FribbIndex({required this.byTvdb, required this.byTmdb, required this.byImdb}); + + bool get isEmpty => byTvdb.isEmpty && byTmdb.isEmpty && byImdb.isEmpty; +} + +/// Loads and refreshes the Fribb anime-lists mapping on demand. +/// +/// On first lookup the ~5 MB JSON is downloaded from jsDelivr and cached to +/// the app-support directory. Subsequent lookups read from the cache. Parsing +/// runs in a background isolate. [maybeRefresh] does a weekly conditional-GET +/// (If-None-Match) to pick up upstream changes. +class FribbMappingStore { + static const String _diskFileName = 'anime-list-mini.json'; + static const String _prefsEtagKey = 'fribb_anime_list_etag'; + static const String _prefsLastCheckKey = 'fribb_anime_list_last_check'; + + /// jsDelivr (CDN-backed). `raw.githubusercontent.com` rate-limits + /// aggressively on shared IPs and returns 429 mid-refresh. + static const String _sourceUrl = 'https://cdn.jsdelivr.net/gh/Fribb/anime-lists@master/anime-list-mini.json'; + + static const Duration _refreshInterval = Duration(days: 7); + static const Duration _requestTimeout = Duration(seconds: 60); + + FribbMappingStore._(); + static final FribbMappingStore instance = FribbMappingStore._(); + + FribbIndex? _index; + Future? _loading; + bool _refreshRunning = false; + + /// Lazily load, downloading on first use. Subsequent calls return the + /// cached index in O(1). Concurrent callers share the same Future. + /// Schedules a background refresh after the first successful load. + Future _ensureLoaded() async { + final existing = _index; + if (existing != null) return existing; + final loading = _loading; + if (loading != null) return loading; + + final fresh = _loadOrFetch(); + _loading = fresh; + try { + final idx = await fresh; + // Don't cache an empty index (network failure, no disk copy) — let the + // next lookup retry so transient offline periods self-heal. + if (!idx.isEmpty) { + _index = idx; + unawaited(maybeRefresh()); + } + return idx; + } finally { + _loading = null; + } + } + + Future _loadOrFetch() async { + final path = await _diskPath(); + try { + return await compute(_readAndParse, path); + } on FileSystemException { + appLogger.d('Fribb: no disk cache, downloading from jsDelivr'); + final raw = await _download(); + if (raw == null) return const FribbIndex(byTvdb: {}, byTmdb: {}, byImdb: {}); + return await compute(_parseAndIndex, raw); + } catch (e) { + appLogger.w('Fribb: parse failed — deleting disk copy so next lookup re-downloads', error: e); + await _deleteDiskCopy(); + return const FribbIndex(byTvdb: {}, byTmdb: {}, byImdb: {}); + } + } + + /// GET the mapping, save it to disk, and return the body. Returns `null` + /// on any failure (offline, 4xx/5xx, timeout). + Future _download() async { + final client = platform.createPlatformClient(); + try { + final res = await client + .get(Uri.parse(_sourceUrl), headers: const {'Accept': 'application/json'}) + .timeout(_requestTimeout); + if (res.statusCode != 200) { + appLogger.d('Fribb: download returned HTTP ${res.statusCode}'); + return null; + } + await _writeDiskCopy(res.body, etag: res.headers['etag']); + // Seed the weekly throttle so a same-week relaunch skips the refresh. + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_prefsLastCheckKey, DateTime.now().millisecondsSinceEpoch); + return res.body; + } catch (e) { + appLogger.w('Fribb: download failed', error: e); + return null; + } finally { + client.close(); + } + } + + /// Look up rows by Plex external IDs. Returns the first non-empty candidate + /// list in preference order: tvdb → tmdb → imdb. + Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async { + final idx = await _ensureLoaded(); + if (tvdbId != null) { + final hits = idx.byTvdb[tvdbId]; + if (hits != null && hits.isNotEmpty) return hits; + } + if (tmdbId != null) { + final hits = idx.byTmdb[tmdbId]; + if (hits != null && hits.isNotEmpty) return hits; + } + if (imdbId != null) { + final hits = idx.byImdb[imdbId]; + if (hits != null && hits.isNotEmpty) return hits; + } + return const []; + } + + /// Conditional-GET the mapping if the last check was >[_refreshInterval] ago + /// and we already have an index loaded. No-op when nothing is loaded — the + /// first lookup handles the initial download. + Future maybeRefresh() async { + if (_refreshRunning) return; + if (_index == null) return; + _refreshRunning = true; + try { + final prefs = await SharedPreferences.getInstance(); + final lastCheck = prefs.getInt(_prefsLastCheckKey) ?? 0; + final now = DateTime.now().millisecondsSinceEpoch; + if (now - lastCheck < _refreshInterval.inMilliseconds) return; + + final etag = prefs.getString(_prefsEtagKey); + final client = platform.createPlatformClient(); + try { + final res = await client + .get( + Uri.parse(_sourceUrl), + headers: {'If-None-Match': ?etag, 'Accept': 'application/json'}, + ) + .timeout(_requestTimeout); + await prefs.setInt(_prefsLastCheckKey, now); + + if (res.statusCode == 304) { + appLogger.d('Fribb: mapping unchanged (304)'); + return; + } + if (res.statusCode != 200) { + appLogger.d('Fribb: refresh returned HTTP ${res.statusCode}'); + return; + } + + await _writeDiskCopy(res.body, etag: res.headers['etag']); + final fresh = await compute(_parseAndIndex, res.body); + _index = fresh; + appLogger.d('Fribb: mapping refreshed (${fresh.byTvdb.length} tvdb entries)'); + } finally { + client.close(); + } + } catch (e) { + appLogger.d('Fribb: refresh failed (non-fatal)', error: e); + } finally { + _refreshRunning = false; + } + } + + Future _writeDiskCopy(String body, {String? etag}) async { + await File(await _diskPath()).writeAsString(body, flush: true); + if (etag != null) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsEtagKey, etag); + } + } + + Future _deleteDiskCopy() async { + try { + await File(await _diskPath()).delete(); + } on FileSystemException { + // Already gone. + } + } + + Future _diskPath() async { + final dir = await getApplicationSupportDirectory(); + return p.join(dir.path, _diskFileName); + } + + @visibleForTesting + void resetForTesting() { + _index = null; + _loading = null; + } +} + +/// Read the JSON from disk and parse it inside the isolate. Halves peak +/// memory vs. reading the string on the main isolate and shipping it across. +FribbIndex _readAndParse(String path) { + final raw = File(path).readAsStringSync(); + return _parseAndIndex(raw); +} + +/// Top-level so it can run in a `compute` isolate (which can't capture +/// instance state). +FribbIndex _parseAndIndex(String raw) { + final decoded = json.decode(raw); + if (decoded is! List) return const FribbIndex(byTvdb: {}, byTmdb: {}, byImdb: {}); + + final byTvdb = >{}; + final byTmdb = >{}; + final byImdb = >{}; + + for (final raw in decoded) { + if (raw is! Map) continue; + final row = FribbMappingRow.fromJson(raw.cast()); + final tvdb = row.tvdbId; + if (tvdb != null) { + (byTvdb[tvdb] ??= []).add(row); + } + final tmdb = row.tmdbId; + if (tmdb != null) { + (byTmdb[tmdb] ??= []).add(row); + } + final imdb = row.imdbId; + if (imdb != null && imdb.isNotEmpty) { + (byImdb[imdb] ??= []).add(row); + } + } + + return FribbIndex(byTvdb: byTvdb, byTmdb: byTmdb, byImdb: byImdb); +} diff --git a/lib/services/trackers/loopback_auth_server.dart b/lib/services/trackers/loopback_auth_server.dart new file mode 100644 index 00000000..1a3e08a3 --- /dev/null +++ b/lib/services/trackers/loopback_auth_server.dart @@ -0,0 +1,144 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:url_launcher/url_launcher.dart'; + +import '../../utils/app_logger.dart'; + +/// One-shot HTTP server on `127.0.0.1:[port]` that captures an OAuth redirect +/// (RFC 8252 Loopback Interface Redirection). +/// +/// Replaces the custom `plezy://` URL scheme used by `flutter_web_auth_2` — +/// no platform manifest registration needed. +/// +/// **iOS caveat**: when the app opens an external browser, iOS may suspend +/// the app after ~30 seconds, which silently kills this server. Users who +/// linger in 2FA / password managers may see "localhost refused to connect" +/// on the redirect. If this turns out to be common, fall back to the +/// custom-scheme approach on mobile only. +class LoopbackAuthServer { + /// Fixed port — must be registered as the redirect URI with each OAuth + /// provider. AniList requires exact URI match, so we can't pick dynamically. + static const int port = 53682; + static const String host = '127.0.0.1'; + + /// Listen for one request at `http://$host:$port$path` and return the + /// captured URI. + /// + /// Handles both query-param redirects (RFC 6749 authorization_code grant) + /// and fragment redirects (implicit grant): fragments stay client-side, so + /// we serve a tiny HTML page that rewrites `location.hash` into query + /// params and reloads — the second request is then captured normally. + static Future listenOnce({ + required String path, + Duration timeout = const Duration(minutes: 5), + }) async { + final HttpServer server; + try { + server = await HttpServer.bind(host, port); + } on SocketException catch (e) { + throw LoopbackBindException('Could not bind $host:$port: ${e.message}'); + } + + final completer = Completer(); + + late StreamSubscription sub; + sub = server.listen((req) async { + if (req.uri.path != path) { + req.response.statusCode = HttpStatus.notFound; + await req.response.close(); + return; + } + // Flush the response BEFORE completing the completer — the caller's + // `finally` block forcibly tears down the socket, which would + // otherwise truncate the response body and leave the browser showing + // a broken page despite auth succeeding. + await _respondHtml(req.response, _pageHtml); + if (req.uri.queryParameters.isNotEmpty && !completer.isCompleted) { + completer.complete(req.uri); + } + }); + + try { + return await completer.future.timeout(timeout); + } finally { + await sub.cancel(); + await server.close(force: true); + } + } + + /// Build the redirect URI callers should register with the OAuth provider. + static String redirectUri(String path) => 'http://$host:$port$path'; + + /// Open [authorizeUri] in the external browser and wait for the OAuth + /// provider's redirect to hit the loopback server. Returns `null` if the + /// redirect doesn't arrive within [timeout] (usually because the user + /// closed the browser). + /// + /// Starts the listener BEFORE launching the browser so a fast redirect + /// can't race the bind. + static Future launchAndWait( + Uri authorizeUri, { + required String path, + Duration timeout = const Duration(minutes: 5), + }) async { + final callbackFuture = listenOnce(path: path, timeout: timeout); + await launchUrl(authorizeUri, mode: LaunchMode.externalApplication); + try { + return await callbackFuture; + } on TimeoutException { + return null; + } + } + + static Future _respondHtml(HttpResponse res, String html) async { + try { + res + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.html + ..write(html); + await res.close(); + } catch (e) { + appLogger.d('LoopbackAuthServer: response write failed', error: e); + } + } + + /// One page for all three flows. Code-grant and polling flows (MAL, Simkl) + /// render it as a plain success page. Implicit-grant flows (AniList) put + /// the access token in the URL fragment — the inline script in `` + /// rewrites that into a query string and redirects before the body paints, + /// so users never see a success page flash before the real capture. + static const String _pageHtml = '' + '' + '' + 'Signed in' + '' + '' + '' + '' + '' + '' + '' + '

Signed in to Plezy

' + '

You can close this tab and return to the app.

' + ''; +} + +class LoopbackBindException implements Exception { + final String message; + const LoopbackBindException(this.message); + @override + String toString() => 'LoopbackBindException: $message'; +} diff --git a/lib/services/trackers/mal/mal_account_store.dart b/lib/services/trackers/mal/mal_account_store.dart new file mode 100644 index 00000000..4d409cfc --- /dev/null +++ b/lib/services/trackers/mal/mal_account_store.dart @@ -0,0 +1,8 @@ +import '../tracker_account_store.dart'; +import 'mal_session.dart'; + +final TrackerAccountStore malAccountStore = TrackerAccountStore( + baseKey: 'mal_session', + decode: MalSession.decode, + encode: (s) => s.encode(), +); diff --git a/lib/services/trackers/mal/mal_auth_service.dart b/lib/services/trackers/mal/mal_auth_service.dart new file mode 100644 index 00000000..04223ae4 --- /dev/null +++ b/lib/services/trackers/mal/mal_auth_service.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:http/http.dart' as http; + +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 '../loopback_auth_server.dart'; +import 'mal_constants.dart'; +import 'mal_session.dart'; + +/// MyAnimeList OAuth 2.0 PKCE flow via RFC 8252 loopback redirect. +/// +/// **Quirk**: MAL requires `code_challenge_method=plain` — it rejects `S256` +/// despite RFC 7636. +class MalAuthService { + static const String _callbackPath = '/mal-oauth'; + + final http.Client _http; + + MalAuthService({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient(); + + void dispose() => _http.close(); + + /// Drive the full flow: build the authorize URL, start a loopback server + /// for the redirect, launch the browser, exchange the code for tokens. + /// Returns `null` if the user closes the browser before completing. + Future authorize() async { + final verifier = _randomVerifier(); + final state = _randomVerifier(length: 16); + final redirectUri = LoopbackAuthServer.redirectUri(_callbackPath); + + final authorizeUri = Uri.parse(MalConstants.authorizeUrl).replace( + queryParameters: { + 'response_type': 'code', + 'client_id': MalConstants.clientId, + 'code_challenge': verifier, // plain method → challenge == verifier + 'code_challenge_method': 'plain', + 'redirect_uri': redirectUri, + 'state': state, + }, + ); + + final callback = await LoopbackAuthServer.launchAndWait(authorizeUri, path: _callbackPath); + if (callback == null) return null; + + final code = callback.queryParameters['code']; + final returnedState = callback.queryParameters['state']; + if (code == null) { + throw MalAuthFlowException('MAL redirect missing code: $callback'); + } + if (returnedState != state) { + throw const MalAuthFlowException('MAL state mismatch (possible CSRF)'); + } + + final res = await _http + .post( + Uri.parse(MalConstants.tokenUrl), + body: { + 'client_id': MalConstants.clientId, + 'code': code, + 'code_verifier': verifier, + 'grant_type': 'authorization_code', + 'redirect_uri': redirectUri, + }, + ) + .timeout(const Duration(seconds: 20)); + + if (res.statusCode != 200) { + throw MalAuthFlowException('Token exchange failed: HTTP ${res.statusCode}: ${res.body}'); + } + return MalSession.fromTokenResponse(json.decode(res.body) as Map); + } + + Future refresh(MalSession current) async { + final res = await _http + .post( + Uri.parse(MalConstants.tokenUrl), + body: { + 'client_id': MalConstants.clientId, + 'grant_type': 'refresh_token', + 'refresh_token': current.refreshToken, + }, + ) + .timeout(const Duration(seconds: 20)); + + if (res.statusCode != 200) { + appLogger.w('MAL: refresh failed (${res.statusCode}): ${res.body}'); + throw MalAuthFlowException('Refresh failed: HTTP ${res.statusCode}'); + } + final fresh = MalSession.fromTokenResponse(json.decode(res.body) as Map); + return fresh.copyWith(username: current.username); + } + + /// MAL requires 43–128 chars from the unreserved URL-safe set. + String _randomVerifier({int length = 64}) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + final rand = Random.secure(); + return List.generate(length, (_) => alphabet[rand.nextInt(alphabet.length)]).join(); + } +} + +class MalAuthFlowException implements Exception { + final String message; + const MalAuthFlowException(this.message); + @override + String toString() => 'MalAuthFlowException: $message'; +} diff --git a/lib/services/trackers/mal/mal_client.dart b/lib/services/trackers/mal/mal_client.dart new file mode 100644 index 00000000..efa96ab1 --- /dev/null +++ b/lib/services/trackers/mal/mal_client.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +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 'mal_auth_service.dart'; +import 'mal_constants.dart'; +import 'mal_session.dart'; + +/// HTTP wrapper for the MAL REST API. +/// +/// Refreshes the access token 5 minutes before expiry or on 401. Concurrent +/// 401s are coalesced via [_refreshLock] (same pattern as `TraktClient`). +class MalClient { + static const Duration _requestTimeout = Duration(seconds: 20); + + MalSession _session; + final http.Client _http; + final MalAuthService _auth; + final void Function() onSessionInvalidated; + final void Function(MalSession)? onSessionUpdated; + + Future? _refreshLock; + + MalClient( + MalSession session, { + required this.onSessionInvalidated, + this.onSessionUpdated, + http.Client? httpClient, + MalAuthService? authService, + }) : _session = session, + _http = httpClient ?? platform.createPlatformClient(), + _auth = authService ?? MalAuthService(); + + MalSession get session => _session; + + void dispose() { + _http.close(); + _auth.dispose(); + } + + /// Fetch basic user info to get the display name. + Future?> getMyUser() async { + final res = await _request('GET', '/users/@me'); + return res is Map ? res.cast() : null; + } + + /// Update the user's list entry for an anime. Body shape: + /// ``` + /// {"status": "watching", "num_watched_episodes": 5} + /// ``` + Future updateMyListStatus(int animeId, Map fields) async { + // MAL's list-status endpoint is form-encoded (not JSON). + await _request('PATCH', '/anime/$animeId/my_list_status', formBody: fields); + } + + Future _refresh() { + final existing = _refreshLock; + if (existing != null) return existing; + final lock = _doRefresh(); + _refreshLock = lock; + return lock.whenComplete(() => _refreshLock = null); + } + + Future _doRefresh() async { + try { + final fresh = await _auth.refresh(_session); + _session = fresh; + onSessionUpdated?.call(fresh); + return fresh; + } catch (e) { + appLogger.w('MAL: refresh failed', error: e); + onSessionInvalidated(); + rethrow; + } + } + + Future _request( + String method, + String path, { + Map? body, + Map? formBody, + }) async { + if (_session.needsRefresh) { + try { + await _refresh(); + } catch (_) { + // Fall through; the request will hit 401 naturally and re-try. + } + } + + var res = await _send(method, path, body: body, formBody: formBody); + + if (res.statusCode == 401) { + try { + await _refresh(); + } catch (_) { + throw MalApiException(statusCode: 401, body: res.body); + } + res = await _send(method, path, body: body, formBody: formBody); + } + + if (res.statusCode >= 200 && res.statusCode < 300) { + if (res.body.isEmpty) return null; + try { + return json.decode(res.body); + } catch (_) { + return null; + } + } + throw MalApiException(statusCode: res.statusCode, body: res.body); + } + + Future _send( + String method, + String path, { + Map? body, + Map? formBody, + }) async { + final uri = Uri.parse('${MalConstants.apiBase}$path'); + final headers = MalConstants.headers(accessToken: _session.accessToken); + + String? encoded; + if (formBody != null) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + encoded = formBody.entries.map((e) => '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}').join('&'); + } else if (body != null) { + headers['Content-Type'] = 'application/json'; + encoded = 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), + 'PATCH' => _http.patch(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('MAL $method ${uri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)'); + return res; + } +} + +class MalApiException implements Exception { + final int statusCode; + final String body; + const MalApiException({required this.statusCode, required this.body}); + @override + String toString() => 'MalApiException(HTTP $statusCode): $body'; +} diff --git a/lib/services/trackers/mal/mal_constants.dart b/lib/services/trackers/mal/mal_constants.dart new file mode 100644 index 00000000..19d5df61 --- /dev/null +++ b/lib/services/trackers/mal/mal_constants.dart @@ -0,0 +1,24 @@ +/// Bundled MyAnimeList API credentials and endpoints. +/// +/// Register at https://myanimelist.net/apiconfig — "App type: Other", redirect +/// URI `http://127.0.0.1:53682/mal-oauth` (RFC 8252 loopback). PKCE-only +/// (no client secret). +/// +/// **MAL quirk**: `code_challenge_method` must be `plain` — MAL rejects `S256` +/// despite RFC 7636. See [MalAuthService.authorize]. +class MalConstants { + MalConstants._(); + + static const String clientId = '463b1c92992505e4bdfcef6aab3aedbe'; + + static const String apiBase = 'https://api.myanimelist.net/v2'; + static const String oauthBase = 'https://myanimelist.net/v1/oauth2'; + + static const String authorizeUrl = '$oauthBase/authorize'; + static const String tokenUrl = '$oauthBase/token'; + + static Map headers({String? accessToken}) => { + 'Accept': 'application/json', + if (accessToken != null) 'Authorization': 'Bearer $accessToken', + }; +} diff --git a/lib/services/trackers/mal/mal_session.dart b/lib/services/trackers/mal/mal_session.dart new file mode 100644 index 00000000..d32f98b0 --- /dev/null +++ b/lib/services/trackers/mal/mal_session.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +/// Immutable MyAnimeList OAuth session. +/// +/// Access tokens expire in ~31 days. Refresh token rotates with each refresh +/// (rare but documented in MAL's API contract). +class MalSession { + final String accessToken; + final String refreshToken; + final int expiresAt; + final String? username; + final int createdAt; + + const MalSession({ + required this.accessToken, + required this.refreshToken, + required this.expiresAt, + required this.createdAt, + this.username, + }); + + bool get isExpired => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt; + bool get needsRefresh => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt - 300; + + MalSession copyWith({String? accessToken, String? refreshToken, int? expiresAt, String? username, int? createdAt}) { + return MalSession( + accessToken: accessToken ?? this.accessToken, + refreshToken: refreshToken ?? this.refreshToken, + expiresAt: expiresAt ?? this.expiresAt, + username: username ?? this.username, + createdAt: createdAt ?? this.createdAt, + ); + } + + Map toJson() => { + 'access_token': accessToken, + 'refresh_token': refreshToken, + 'expires_at': expiresAt, + 'username': username, + 'created_at': createdAt, + }; + + factory MalSession.fromJson(Map json) => MalSession( + accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String, + expiresAt: (json['expires_at'] as num).toInt(), + username: json['username'] as String?, + createdAt: (json['created_at'] as num).toInt(), + ); + + /// Build a session from MAL's `/oauth2/token` response. + factory MalSession.fromTokenResponse(Map json) { + final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final expiresIn = (json['expires_in'] as num).toInt(); + return MalSession( + accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String, + expiresAt: createdAt + expiresIn, + createdAt: createdAt, + ); + } + + String encode() => json.encode(toJson()); + static MalSession decode(String raw) => MalSession.fromJson(json.decode(raw) as Map); +} diff --git a/lib/services/trackers/mal/mal_tracker.dart b/lib/services/trackers/mal/mal_tracker.dart new file mode 100644 index 00000000..b8cf39c5 --- /dev/null +++ b/lib/services/trackers/mal/mal_tracker.dart @@ -0,0 +1,60 @@ +import '../../../models/trackers/tracker_context.dart'; +import '../../../utils/app_logger.dart'; +import '../../settings_service.dart'; +import '../tracker.dart'; +import 'mal_client.dart'; +import 'mal_session.dart'; + +/// MyAnimeList scrobble tracker. Marks `num_watched_episodes` on the user's +/// list entry once playback crosses the watched threshold. +/// +/// MAL is anime-only: no-op when [TrackerContext.anime] is null. +/// +/// For split-cour shows Fribb maps each cour to a distinct MAL ID; the +/// episode number sent here is the Plex episode index within the current +/// season, which is usually what MAL expects for the mapped entry. Episode +/// offsets for irregular cuts aren't in the mini mapping — known v1 gap. +class MalTracker extends TrackerBase { + static MalTracker? _instance; + static MalTracker get instance => _instance ??= MalTracker._(); + MalTracker._(); + + @override + String get name => 'mal'; + + @override + bool get needsFribb => true; + + MalClient? _client; + + @override + bool get hasActiveClient => _client != null; + + @override + bool readEnabledSetting(SettingsService settings) => settings.getEnableMalScrobble(); + + void rebindSession( + MalSession? session, { + required void Function() onSessionInvalidated, + void Function(MalSession)? onSessionUpdated, + }) { + _client?.dispose(); + _client = session == null + ? null + : MalClient(session, onSessionInvalidated: onSessionInvalidated, onSessionUpdated: onSessionUpdated); + } + + @override + Future markWatched(TrackerContext ctx) async { + final client = _client; + final malId = ctx.anime?.mal; + if (client == null || malId == null) return; + + final fields = ctx.isMovie + ? {'status': 'completed', 'num_watched_episodes': '1'} + : {'status': 'watching', 'num_watched_episodes': '${ctx.episodeNumber}'}; + + await client.updateMyListStatus(malId, fields); + appLogger.d('MAL: updated list status (mal=$malId, fields=$fields)'); + } +} diff --git a/lib/services/trackers/simkl/simkl_account_store.dart b/lib/services/trackers/simkl/simkl_account_store.dart new file mode 100644 index 00000000..4fd1f8cb --- /dev/null +++ b/lib/services/trackers/simkl/simkl_account_store.dart @@ -0,0 +1,8 @@ +import '../tracker_account_store.dart'; +import 'simkl_session.dart'; + +final TrackerAccountStore simklAccountStore = TrackerAccountStore( + baseKey: 'simkl_session', + decode: SimklSession.decode, + encode: (s) => s.encode(), +); diff --git a/lib/services/trackers/simkl/simkl_auth_service.dart b/lib/services/trackers/simkl/simkl_auth_service.dart new file mode 100644 index 00000000..ca7fa7fe --- /dev/null +++ b/lib/services/trackers/simkl/simkl_auth_service.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../../models/trackers/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 '../device_code_poller.dart' as poller; +import '../loopback_auth_server.dart'; +import 'simkl_constants.dart'; + +/// Simkl OAuth PIN (device-code) flow. +/// +/// `GET /oauth/pin?client_id=...&redirect=http://127.0.0.1:53682/simkl-oauth` +/// returns a PIN the user enters at https://simkl.com/pin. After entry Simkl +/// redirects the browser to our loopback callback (where we show a friendly +/// "close this tab" page). The app polls `/oauth/pin/?client_id=...` +/// until `result == "OK"`. +class SimklAuthService { + /// Redirect path Simkl bounces the browser to after PIN entry. Must match + /// the URL registered at simkl.com/settings/developer for this client. + static const String _callbackPath = '/simkl-oauth'; + + final http.Client _http; + + SimklAuthService({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient(); + + void dispose() => _http.close(); + + Future createDeviceCode() async { + final uri = Uri.parse(SimklConstants.pinUrl).replace( + queryParameters: { + 'client_id': SimklConstants.clientId, + 'redirect': LoopbackAuthServer.redirectUri(_callbackPath), + }, + ); + final res = await _http.get(uri, headers: SimklConstants.headers()).timeout(const Duration(seconds: 15)); + if (res.statusCode != 200) { + throw SimklAuthFlowException('PIN request failed: HTTP ${res.statusCode}: ${res.body}'); + } + final body = json.decode(res.body) as Map; + final expiresIn = (body['expires_in'] as num?)?.toInt() ?? 900; + + // Serve the "close this tab" success page when Simkl redirects the + // browser back after PIN entry. Fire-and-forget — the PIN poll captures + // the token; this listener is purely cosmetic. + unawaited( + LoopbackAuthServer.listenOnce( + path: _callbackPath, + timeout: Duration(seconds: expiresIn), + ).catchError((Object _) => Uri()), + ); + + return DeviceCode( + deviceCode: body['device_code'] as String, + userCode: body['user_code'] as String, + verificationUrl: body['verification_url'] as String? ?? SimklConstants.verificationUrl, + // Simkl doesn't expose a prefilled URL; the user manually enters the code. + verificationUrlComplete: null, + expiresIn: expiresIn, + interval: (body['interval'] as num?)?.toInt() ?? 5, + ); + } + + Stream pollDeviceCode(DeviceCode code, {bool Function()? shouldCancel}) { + return poller.pollDeviceCode(code, shouldCancel: shouldCancel, probe: () => _probe(code)); + } + + Future _probe(DeviceCode code) async { + final pollUri = Uri.parse( + SimklConstants.pinPollUrl(code.userCode), + ).replace(queryParameters: {'client_id': SimklConstants.clientId}); + final http.Response res; + try { + res = await _http.get(pollUri, headers: SimklConstants.headers()).timeout(const Duration(seconds: 15)); + } catch (e) { + appLogger.d('Simkl device-code poll error (transient)', error: e); + return const DevicePollPending(); + } + + // Simkl returns 200 for both pending and success; anything else is + // effectively expired/denied. + if (res.statusCode != 200) return const DevicePollExpired(); + + final body = json.decode(res.body) as Map; + if (body['result'] == 'OK' && body['access_token'] != null) { + return DevicePollSuccess(body); + } + return const DevicePollPending(); + } +} + +class SimklAuthFlowException implements Exception { + final String message; + const SimklAuthFlowException(this.message); + @override + String toString() => 'SimklAuthFlowException: $message'; +} diff --git a/lib/services/trackers/simkl/simkl_client.dart b/lib/services/trackers/simkl/simkl_client.dart new file mode 100644 index 00000000..157787ce --- /dev/null +++ b/lib/services/trackers/simkl/simkl_client.dart @@ -0,0 +1,85 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +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 'simkl_constants.dart'; +import 'simkl_session.dart'; + +/// HTTP wrapper for the Simkl REST API. +/// +/// Simkl tokens don't expire; a 401 is terminal (user revoked access at +/// simkl.com/settings/apps). [onSessionInvalidated] clears the local session +/// in that case. +class SimklClient { + static const Duration _requestTimeout = Duration(seconds: 20); + + final SimklSession session; + final http.Client _http; + final void Function() onSessionInvalidated; + + SimklClient(this.session, {required this.onSessionInvalidated, http.Client? httpClient}) + : _http = httpClient ?? platform.createPlatformClient(); + + void dispose() => _http.close(); + + /// Fetch current user info. Used to populate the display name. + Future?> getUserSettings() async { + final res = await _request('GET', '/users/settings'); + return res is Map ? res.cast() : null; + } + + /// Mark one or more items as watched. Body shape: + /// ``` + /// {"movies": [{"ids": {"simkl": 123}}], "shows": [...]} + /// ``` + Future addToHistory(Map body) => _request('POST', '/sync/history', body: body); + + Future _request(String method, String path, {Map? body}) async { + final uri = Uri.parse('${SimklConstants.apiBase}$path'); + final headers = SimklConstants.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), + _ => throw ArgumentError('Unsupported HTTP method: $method'), + }.timeout(_requestTimeout); + sw.stop(); + appLogger.d('Simkl $method ${uri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)'); + + if (res.statusCode == 401) { + onSessionInvalidated(); + throw SimklAuthException('Session invalidated (401)'); + } + if (res.statusCode < 200 || res.statusCode >= 300) { + throw SimklApiException(statusCode: res.statusCode, body: res.body); + } + if (res.body.isEmpty) return null; + try { + return json.decode(res.body); + } catch (_) { + return null; + } + } +} + +class SimklApiException implements Exception { + final int statusCode; + final String body; + const SimklApiException({required this.statusCode, required this.body}); + @override + String toString() => 'SimklApiException(HTTP $statusCode): $body'; +} + +class SimklAuthException implements Exception { + final String message; + const SimklAuthException(this.message); + @override + String toString() => 'SimklAuthException: $message'; +} diff --git a/lib/services/trackers/simkl/simkl_constants.dart b/lib/services/trackers/simkl/simkl_constants.dart new file mode 100644 index 00000000..22ebd7ce --- /dev/null +++ b/lib/services/trackers/simkl/simkl_constants.dart @@ -0,0 +1,32 @@ +/// Bundled Simkl API credentials and endpoints. +/// +/// Register at https://simkl.com/settings/developer — app type should be +/// "Commandline / Console / Device code" (the same flow Trakt uses). +/// Replace [clientId] with the registered client ID before shipping. +class SimklConstants { + SimklConstants._(); + + /// Registered Simkl app client ID. Extractable from the binary; same threat + /// model as the Plex token already in SharedPreferences. + static const String clientId = 'ac97718a469c33eab948b63f92226106157e58fdcdd70c1b5857f1779b1d3a6a'; + + static const String apiBase = 'https://api.simkl.com'; + + // OAuth (device-code / PIN) endpoints + static const String pinUrl = '$apiBase/oauth/pin'; + + /// Poll URL for a given user code. Append `/?client_id=...`. + static String pinPollUrl(String userCode) => '$apiBase/oauth/pin/$userCode'; + + /// Web page the user visits to enter the code. + static const String verificationUrl = 'https://simkl.com/pin'; + + /// Headers on every Simkl request. `simkl-api-key` is required on all + /// endpoints, authed or not. + static Map headers({String? accessToken}) => { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'simkl-api-key': clientId, + if (accessToken != null) 'Authorization': 'Bearer $accessToken', + }; +} diff --git a/lib/services/trackers/simkl/simkl_session.dart b/lib/services/trackers/simkl/simkl_session.dart new file mode 100644 index 00000000..fbf424ca --- /dev/null +++ b/lib/services/trackers/simkl/simkl_session.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; + +/// Immutable Simkl OAuth session. +/// +/// Simkl access tokens don't expire (per their docs), so there's no +/// refresh_token — just the bearer and a display name for the settings UI. +class SimklSession { + final String accessToken; + final String? username; + final int createdAt; + + const SimklSession({required this.accessToken, required this.createdAt, this.username}); + + SimklSession copyWith({String? accessToken, String? username, int? createdAt}) => SimklSession( + accessToken: accessToken ?? this.accessToken, + username: username ?? this.username, + createdAt: createdAt ?? this.createdAt, + ); + + Map toJson() => { + 'access_token': accessToken, + 'username': username, + 'created_at': createdAt, + }; + + factory SimklSession.fromJson(Map json) => SimklSession( + accessToken: json['access_token'] as String, + username: json['username'] as String?, + createdAt: (json['created_at'] as num).toInt(), + ); + + String encode() => json.encode(toJson()); + static SimklSession decode(String raw) => SimklSession.fromJson(json.decode(raw) as Map); +} diff --git a/lib/services/trackers/simkl/simkl_tracker.dart b/lib/services/trackers/simkl/simkl_tracker.dart new file mode 100644 index 00000000..275cd714 --- /dev/null +++ b/lib/services/trackers/simkl/simkl_tracker.dart @@ -0,0 +1,87 @@ +import '../../../models/trackers/tracker_context.dart'; +import '../../../utils/app_logger.dart'; +import '../../settings_service.dart'; +import '../tracker.dart'; +import 'simkl_client.dart'; +import 'simkl_session.dart'; + +/// Simkl scrobble tracker. Fires `POST /sync/history` once playback crosses +/// the watched threshold (Simkl has no real-time `/scrobble/*` endpoints). +/// +/// General-purpose: accepts any Plex external ID (tvdb/imdb/tmdb) directly, +/// so it fires for non-anime TV and movies too. Prefers Fribb's simkl_id +/// when present for stricter anime match, otherwise falls back to whatever +/// Plex exposes. +class SimklTracker extends TrackerBase { + static SimklTracker? _instance; + static SimklTracker get instance => _instance ??= SimklTracker._(); + SimklTracker._(); + + @override + String get name => 'simkl'; + + @override + bool get needsFribb => false; + + SimklClient? _client; + + @override + bool get hasActiveClient => _client != null; + + @override + bool readEnabledSetting(SettingsService settings) => settings.getEnableSimklScrobble(); + + void rebindSession(SimklSession? session, {required void Function() onSessionInvalidated}) { + _client?.dispose(); + _client = session != null ? SimklClient(session, onSessionInvalidated: onSessionInvalidated) : null; + } + + @override + Future markWatched(TrackerContext ctx) async { + final client = _client; + if (client == null) return; + + final ids = _buildIds(ctx); + if (ids.isEmpty) return; + + final body = ctx.isMovie + ? { + 'movies': [ + {'ids': ids}, + ], + } + : { + 'shows': [ + { + 'ids': ids, + 'seasons': [ + { + 'number': ctx.season, + 'episodes': [ + {'number': ctx.episodeNumber}, + ], + }, + ], + }, + ], + }; + + await client.addToHistory(body); + appLogger.d('Simkl: marked watched (ids=$ids, isMovie=${ctx.isMovie})'); + } + + /// Prefer Fribb's simkl_id for precision; otherwise send whatever Plex + /// exposes. Simkl accepts tvdb/imdb/tmdb in both movie and show shapes. + Map _buildIds(TrackerContext ctx) { + final ids = {}; + final simklId = ctx.anime?.simkl; + if (simklId != null) ids['simkl'] = simklId; + final tvdb = ctx.external.tvdb; + if (tvdb != null) ids['tvdb'] = tvdb; + final tmdb = ctx.external.tmdb; + if (tmdb != null) ids['tmdb'] = tmdb; + final imdb = ctx.external.imdb; + if (imdb != null) ids['imdb'] = imdb; + return ids; + } +} diff --git a/lib/services/trackers/tracker.dart b/lib/services/trackers/tracker.dart new file mode 100644 index 00000000..9f8b510d --- /dev/null +++ b/lib/services/trackers/tracker.dart @@ -0,0 +1,48 @@ +import '../../models/trackers/tracker_context.dart'; +import '../settings_service.dart'; + +/// Abstract tracker contract: the coordinator calls [markWatched] once per +/// playback when progress crosses the watched threshold. Enabled/auth gating +/// lives in [TrackerBase]. +abstract class Tracker { + String get name; + + bool get canScrobble; + + /// True if this tracker's IDs only come from the Fribb anime mapping + /// (MAL, AniList). Simkl returns false because it accepts Plex tvdb/imdb/ + /// tmdb directly; when Simkl is the only active tracker we skip the 5.6 MB + /// Fribb download entirely. + bool get needsFribb; + + Future initialize(); + Future setEnabled(bool enabled); + + Future markWatched(TrackerContext ctx); +} + +/// Shared enabled-state bookkeeping. Subclasses override [hasActiveClient], +/// [readEnabledSetting], and [markWatched]. +abstract class TrackerBase implements Tracker { + bool _isInitialized = false; + bool _isEnabled = false; + + bool get hasActiveClient; + + bool readEnabledSetting(SettingsService settings); + + @override + bool get canScrobble => _isEnabled && hasActiveClient; + + @override + Future initialize() async { + if (_isInitialized) return; + _isInitialized = true; + _isEnabled = readEnabledSetting(await SettingsService.getInstance()); + } + + @override + Future setEnabled(bool enabled) async { + _isEnabled = enabled; + } +} diff --git a/lib/services/trackers/tracker_account_store.dart b/lib/services/trackers/tracker_account_store.dart new file mode 100644 index 00000000..61c24dda --- /dev/null +++ b/lib/services/trackers/tracker_account_store.dart @@ -0,0 +1,46 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Per-Plex-profile session persistence for any tracker service. +/// +/// Keyed by `user_{uuid}_{baseKey}` so each Plex Home profile gets its own +/// stored session. Generic over the session type [T] — callers supply +/// encode/decode functions. +/// +/// Pass an empty `userUuid` to fall back to a single global slot (used +/// before a profile has been selected). +class TrackerAccountStore { + final String _baseKey; + final T Function(String raw) _decode; + final String Function(T session) _encode; + + const TrackerAccountStore({ + required String baseKey, + required T Function(String raw) decode, + required String Function(T session) encode, + }) : _baseKey = baseKey, + _decode = decode, + _encode = encode; + + String _scopedKey(String userUuid) => userUuid.isEmpty ? _baseKey : 'user_${userUuid}_$_baseKey'; + + Future load(String userUuid) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_scopedKey(userUuid)); + if (raw == null) return null; + try { + return _decode(raw); + } catch (_) { + return null; + } + } + + Future save(String userUuid, T session) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_scopedKey(userUuid), _encode(session)); + } + + Future clear(String userUuid) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_scopedKey(userUuid)); + } +} diff --git a/lib/services/trackers/tracker_constants.dart b/lib/services/trackers/tracker_constants.dart new file mode 100644 index 00000000..4c39bcc9 --- /dev/null +++ b/lib/services/trackers/tracker_constants.dart @@ -0,0 +1,8 @@ +/// Shared constants for non-Trakt tracker integrations (MAL, AniList, Simkl). +class TrackerConstants { + TrackerConstants._(); + + /// Progress percent at which an episode/movie counts as watched and is + /// pushed to each tracker. + static const double watchedThresholdPercent = 80.0; +} diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart new file mode 100644 index 00000000..487239b0 --- /dev/null +++ b/lib/services/trackers/tracker_coordinator.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import '../../models/plex_metadata.dart'; +import '../../models/trackers/tracker_context.dart'; +import '../../utils/app_logger.dart'; +import '../plex_client.dart'; +import 'anilist/anilist_tracker.dart'; +import 'mal/mal_tracker.dart'; +import 'simkl/simkl_tracker.dart'; +import 'tracker.dart'; +import 'tracker_constants.dart'; +import 'tracker_id_resolver.dart'; + +/// Fan-out for non-Trakt trackers (MAL, AniList, Simkl). Owns the per-playback +/// threshold state: each connected tracker is notified exactly once when +/// progress crosses the watched threshold, with a safety-net fire on stop if +/// the crossing was missed (e.g. user stopped between ticks). +class TrackerCoordinator { + static TrackerCoordinator? _instance; + static TrackerCoordinator get instance => _instance ??= TrackerCoordinator._(); + + TrackerCoordinator._(); + + late final List _trackers = [ + MalTracker.instance, + AnilistTracker.instance, + SimklTracker.instance, + ]; + + /// Resolver persists across episode swaps so back-to-back episodes of the + /// same show reuse the cached IDs. Cleared only on profile switch. + TrackerIdResolver? _resolver; + + TrackerContext? _ctx; + Duration _duration = Duration.zero; + Duration _lastPosition = Duration.zero; + bool _thresholdCrossed = false; + + Future initialize() async { + await Future.wait(_trackers.map((t) => t.initialize())); + } + + Future startPlayback(PlexMetadata metadata, PlexClient plexClient, {bool isLive = false}) async { + if (isLive) return; + final mediaType = metadata.mediaType; + if (mediaType != PlexMediaType.movie && mediaType != PlexMediaType.episode) return; + if (!_trackers.any((t) => t.canScrobble)) return; + + _resolver ??= TrackerIdResolver(plexClient, needsFribb: _anyTrackerNeedsFribb); + final ctx = await _buildContext(metadata); + if (ctx == null) { + appLogger.d('Trackers: no external IDs for ${metadata.ratingKey}'); + _reset(); + return; + } + _reset(); + _ctx = ctx; + } + + bool _anyTrackerNeedsFribb() => _trackers.any((t) => t.canScrobble && t.needsFribb); + + Future stopPlayback() async { + final ctx = _ctx; + if (ctx == null) { + _reset(); + return; + } + // Safety net: fire if we passed the threshold but missed the tick. + if (!_thresholdCrossed && _crossed(_duration, _lastPosition)) { + await _dispatchMarkWatched(ctx); + } + _reset(); + } + + void updatePosition(Duration position) { + _lastPosition = position; + final ctx = _ctx; + if (ctx == null || _thresholdCrossed) return; + if (!_crossed(_duration, position)) return; + _thresholdCrossed = true; + unawaited(_dispatchMarkWatched(ctx)); + } + + void updateDuration(Duration duration) { + if (duration == _duration) return; + _duration = duration; + } + + /// Called on Plex profile switch — drops in-flight state across all + /// trackers and invalidates the resolver so a fresh Plex client is used. + void cancelInFlight() { + _reset(); + _resolver?.clearCache(); + _resolver = null; + } + + /// Drop the resolver's ID cache without touching in-flight playback state. + /// Called after a tracker is connected/disconnected so cached lookups + /// re-evaluate the `needsFribb` predicate. + void invalidateResolverCache() => _resolver?.clearCache(); + + void _reset() { + _ctx = null; + _duration = Duration.zero; + _lastPosition = Duration.zero; + _thresholdCrossed = false; + } + + static bool _crossed(Duration duration, Duration position) { + final dMs = duration.inMilliseconds; + if (dMs == 0) return false; + return position.inMilliseconds * 100 >= dMs * TrackerConstants.watchedThresholdPercent; + } + + Future _dispatchMarkWatched(TrackerContext ctx) async { + final active = _trackers.where((t) => t.canScrobble); + await Future.wait(active.map((t) async { + try { + await t.markWatched(ctx); + } catch (e) { + appLogger.d('${t.name}: markWatched failed', error: e); + } + })); + } + + Future _buildContext(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 == null) return null; + return TrackerContext.movie( + external: ids.external, + anime: ids.anime, + ratingKey: metadata.ratingKey, + ); + } + + final season = metadata.parentIndex; + final number = metadata.index; + if (season == null || number == null) return null; + + final ids = await resolver.resolveShowForEpisode(metadata); + if (ids == null) return null; + return TrackerContext.episode( + external: ids.external, + anime: ids.anime, + ratingKey: metadata.ratingKey, + season: season, + episodeNumber: number, + ); + } +} diff --git a/lib/services/trackers/tracker_id_resolver.dart b/lib/services/trackers/tracker_id_resolver.dart new file mode 100644 index 00000000..db2cf70c --- /dev/null +++ b/lib/services/trackers/tracker_id_resolver.dart @@ -0,0 +1,115 @@ +import '../../models/plex_metadata.dart'; +import '../../models/trackers/anime_ids.dart'; +import '../../models/trackers/fribb_mapping_row.dart'; +import '../../utils/plex_external_ids.dart'; +import '../plex_client.dart'; +import 'fribb_mapping_store.dart'; + +/// Paired ID output: always-present Plex external IDs (tvdb/imdb/tmdb) plus +/// optional Fribb-sourced anime IDs (mal/anilist/simkl). Simkl uses [external] +/// directly for non-anime titles; MAL/AniList no-op when [anime] is null. +class TrackerIds { + final PlexExternalIds external; + final AnimeIds? anime; + + const TrackerIds({required this.external, required this.anime}); +} + +/// Resolves Plex ratingKeys → tracker external IDs. Returns both Plex +/// external IDs (used by Trakt and by Simkl for non-anime matches) and Fribb +/// anime IDs (used by MAL/AniList, and by Simkl for anime precision). +/// Episodes resolve against the show's GUIDs because Fribb only maps +/// show-level external IDs; split-cour disambiguation uses the Plex season +/// number. +/// +/// The Fribb lookup is skipped when [needsFribb] returns false — set this way +/// for Trakt (which never uses anime IDs) and for a Simkl-only configuration, +/// so those users don't pay the 5.6 MB mapping download they'll never need. +class TrackerIdResolver { + final PlexClient _client; + final FribbMappingStore _store; + final bool Function() _needsFribb; + + /// Null entries mean "Plex had no GUIDs" — cached so scrubbing on an + /// un-matched item doesn't re-hit Plex every position update. + final Map _cache = {}; + + TrackerIdResolver( + this._client, { + bool Function()? needsFribb, + FribbMappingStore? store, + }) : _needsFribb = needsFribb ?? _returnTrue, + _store = store ?? FribbMappingStore.instance; + + static bool _returnTrue() => true; + + /// Resolve IDs for a movie. + Future resolveForMovie(String ratingKey) async { + if (_cache.containsKey(ratingKey)) return _cache[ratingKey]; + + final external = PlexExternalIds.fromGuids(await _client.fetchExternalGuids(ratingKey)); + final ids = await _build(external, isEpisodeSeason: null, isMovie: true); + _cache[ratingKey] = ids; + return ids; + } + + /// Resolve IDs for an episode. Looks up the *show's* external IDs (via + /// `grandparentRatingKey`), then disambiguates among candidate Fribb rows + /// using the episode's season number. + Future resolveShowForEpisode(PlexMetadata episode) async { + final showRatingKey = episode.grandparentRatingKey; + if (showRatingKey == null || showRatingKey.isEmpty) return null; + + final season = episode.parentIndex; + // Cache under the (showRatingKey, season) pair so a show with multiple + // Fribb rows caches each season separately during a marathon. + final cacheKey = season != null ? '$showRatingKey#s$season' : showRatingKey; + if (_cache.containsKey(cacheKey)) return _cache[cacheKey]; + + final external = PlexExternalIds.fromGuids(await _client.fetchExternalGuids(showRatingKey)); + final ids = await _build(external, isEpisodeSeason: season, isMovie: false); + _cache[cacheKey] = ids; + return ids; + } + + void clearCache() => _cache.clear(); + + Future _build(PlexExternalIds external, {int? isEpisodeSeason, required bool isMovie}) async { + if (!external.hasAny) return null; + if (!_needsFribb()) return TrackerIds(external: external, anime: null); + final rows = await _store.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb); + final row = isMovie ? _pickMovieRow(rows) : _pickShowRow(rows, season: isEpisodeSeason); + final anime = row == null ? null : AnimeIds.fromFribb(row); + return TrackerIds(external: external, anime: anime); + } + + /// Pick the best row for a movie lookup — prefer rows marked `type: MOVIE`. + FribbMappingRow? _pickMovieRow(List rows) { + if (rows.isEmpty) return null; + final movies = rows.where((r) => r.isMovie); + if (movies.isNotEmpty) return movies.first; + // Fall back to any row if no explicit MOVIE row matches — some rows have + // no type field. + return rows.first; + } + + /// Pick the best row for a show lookup. When Fribb has multiple rows + /// sharing the same show-level external ID (split-cour anime), prefer the + /// one whose `season.tvdb` matches the Plex episode's season; otherwise + /// the first non-MOVIE row. + FribbMappingRow? _pickShowRow(List rows, {int? season}) { + if (rows.isEmpty) return null; + + if (season != null) { + for (final row in rows) { + if (row.tvdbSeason == season || row.tmdbSeason == season) return row; + } + } + + // No season match — fall back to the first non-MOVIE row (prefer series). + for (final row in rows) { + if (!row.isMovie) return row; + } + return rows.first; + } +} diff --git a/lib/services/trakt/trakt_account_store.dart b/lib/services/trakt/trakt_account_store.dart index 12a5079e..008eeace 100644 --- a/lib/services/trakt/trakt_account_store.dart +++ b/lib/services/trakt/trakt_account_store.dart @@ -1,37 +1,9 @@ -import 'package:shared_preferences/shared_preferences.dart'; - -import 'trakt_constants.dart'; +import '../trackers/tracker_account_store.dart'; import 'trakt_session.dart'; /// Per-Plex-profile persistence of Trakt OAuth sessions. -/// -/// Each Plex Home user can link a different Trakt account. Pass an empty -/// `userUuid` to fall back to a single global slot (used before the user has -/// selected a profile). -class TraktAccountStore { - static const String _baseKey = 'trakt_session'; - - TraktAccountStore._(); - static final TraktAccountStore instance = TraktAccountStore._(); - - Future load(String userUuid) async { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(traktUserKey(userUuid, _baseKey)); - if (raw == null) return null; - try { - return TraktSession.decode(raw); - } catch (_) { - return null; - } - } - - Future save(String userUuid, TraktSession session) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(traktUserKey(userUuid, _baseKey), session.encode()); - } - - Future clear(String userUuid) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(traktUserKey(userUuid, _baseKey)); - } -} +final TrackerAccountStore traktAccountStore = TrackerAccountStore( + baseKey: 'trakt_session', + decode: TraktSession.decode, + encode: (s) => s.encode(), +); diff --git a/lib/services/trakt/trakt_auth_service.dart b/lib/services/trakt/trakt_auth_service.dart index de6b1cc1..2e96a418 100644 --- a/lib/services/trakt/trakt_auth_service.dart +++ b/lib/services/trakt/trakt_auth_service.dart @@ -3,11 +3,12 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../../models/trakt/trakt_device_code.dart'; +import '../../models/trackers/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 '../trackers/device_code_poller.dart' as poller; import 'trakt_constants.dart'; /// Trakt OAuth Device Authorization Grant flow (RFC 8628). @@ -22,9 +23,9 @@ class TraktAuthService { void dispose() => _http.close(); - /// Request a device code. The returned [TraktDeviceCode.userCode] is what - /// the user must enter at [TraktDeviceCode.verificationUrl]. - Future createDeviceCode() async { + /// Request a device code. The returned [DeviceCode.userCode] is what the + /// user must enter at [DeviceCode.verificationUrl]. + Future createDeviceCode() async { final uri = Uri.parse(TraktConstants.deviceCodeUrl); final sw = Stopwatch()..start(); final res = await _http @@ -37,86 +38,61 @@ class TraktAuthService { throw TraktAuthFlowException('Device code request failed: HTTP ${res.statusCode}: ${res.body}'); } - return TraktDeviceCode.fromJson(json.decode(res.body) as Map); + final body = json.decode(res.body) as Map; + final verificationUrl = body['verification_url'] as String; + final userCode = body['user_code'] as String; + return DeviceCode( + deviceCode: body['device_code'] as String, + userCode: userCode, + verificationUrl: verificationUrl, + verificationUrlComplete: '$verificationUrl/$userCode', + expiresIn: (body['expires_in'] as num).toInt(), + interval: (body['interval'] as num).toInt(), + ); } /// Poll the device-token endpoint until the user authorizes, denies, or the - /// code expires. Yields one event per poll attempt; the stream completes - /// after the first terminal event ([TraktDevicePollSuccess], - /// [TraktDevicePollDenied], [TraktDevicePollExpired]). - /// - /// Pass [shouldCancel] to abort polling early (e.g. user dismissed the - /// dialog). - Stream pollDeviceCode(TraktDeviceCode code, {bool Function()? shouldCancel}) async* { - var interval = Duration(seconds: code.interval); - final deadline = DateTime.now().add(Duration(seconds: code.expiresIn)); + /// code expires. Pass [shouldCancel] to abort early. + Stream pollDeviceCode(DeviceCode code, {bool Function()? shouldCancel}) { + return poller.pollDeviceCode(code, shouldCancel: shouldCancel, probe: () => _probe(code)); + } - while (DateTime.now().isBefore(deadline)) { - if (shouldCancel != null && shouldCancel()) return; - - await Future.delayed(interval); - - if (shouldCancel != null && shouldCancel()) return; - - http.Response res; - final tokenUri = Uri.parse(TraktConstants.deviceTokenUrl); - final sw = Stopwatch()..start(); - try { - res = await _http - .post( - tokenUri, - headers: TraktConstants.headers(), - body: json.encode({ - 'code': code.deviceCode, - 'client_id': TraktConstants.clientId, - 'client_secret': TraktConstants.clientSecret, - }), - ) - .timeout(const Duration(seconds: 15)); - sw.stop(); - appLogger.d('Trakt POST ${tokenUri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)'); - } catch (e) { - appLogger.d('Trakt device-code poll error (transient)', error: e); - yield const TraktDevicePollPending(); - continue; - } - - switch (res.statusCode) { - case 200: - final body = json.decode(res.body) as Map; - yield TraktDevicePollSuccess(body); - return; - case 400: - // Pending — keep polling - yield const TraktDevicePollPending(); - break; - case 404: - // Code not found / expired - yield const TraktDevicePollExpired(); - return; - case 409: - // Already used - yield const TraktDevicePollDenied(); - return; - case 410: - yield const TraktDevicePollExpired(); - return; - case 418: - // Denied - yield const TraktDevicePollDenied(); - return; - case 429: - // Slow down — increase interval by 5s - interval += const Duration(seconds: 5); - yield const TraktDevicePollSlowDown(); - break; - default: - appLogger.w('Trakt device-code unexpected HTTP ${res.statusCode}: ${res.body}'); - yield const TraktDevicePollPending(); - } + Future _probe(DeviceCode code) async { + final tokenUri = Uri.parse(TraktConstants.deviceTokenUrl); + final http.Response res; + 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)); + appLogger.d('Trakt POST ${tokenUri.path} → ${res.statusCode}'); + } catch (e) { + appLogger.d('Trakt device-code poll error (transient)', error: e); + return const DevicePollPending(); } - yield const TraktDevicePollExpired(); + switch (res.statusCode) { + case 200: + return DevicePollSuccess(json.decode(res.body) as Map); + case 400: + return const DevicePollPending(); + case 404 || 410: + return const DevicePollExpired(); + case 409 || 418: + return const DevicePollDenied(); + case 429: + return const DevicePollSlowDown(); + default: + appLogger.w('Trakt device-code unexpected HTTP ${res.statusCode}: ${res.body}'); + return const DevicePollPending(); + } } } diff --git a/lib/services/trakt/trakt_guid_resolver.dart b/lib/services/trakt/trakt_guid_resolver.dart deleted file mode 100644 index bf9b9fa8..00000000 --- a/lib/services/trakt/trakt_guid_resolver.dart +++ /dev/null @@ -1,51 +0,0 @@ -import '../../models/plex_metadata.dart'; -import '../../models/trakt/trakt_ids.dart'; -import '../plex_client.dart'; - -/// Resolves Plex rating keys to external IDs (imdb/tmdb/tvdb) for Trakt -/// matching. Caches per ratingKey for the duration of a play session. -/// -/// Trakt prefers a show-IDs-plus-season/episode shape for episode scrobbles -/// rather than episode-IDs alone, so episode resolution requires a separate -/// fetch on `grandparentRatingKey` to get the show's GUIDs. -class TraktGuidResolver { - final PlexClient _client; - - /// Cache of `ratingKey → TraktIds`. Holds both movies and shows. - final Map _cache = {}; - - TraktGuidResolver(this._client); - - /// Resolve external IDs for a movie rating key. - Future resolveForMovie(String ratingKey) async { - final cached = _cache[ratingKey]; - if (cached != null) return cached; - - final guids = await _client.fetchExternalGuids(ratingKey); - final ids = TraktIds.fromPlexGuids(guids); - _cache[ratingKey] = ids; - return ids; - } - - /// Resolve external IDs for an episode. Returns the show's GUIDs (Trakt's - /// preferred shape); season/episode index live on the [PlexMetadata] itself. - /// - /// Returns null if the episode is missing `grandparentRatingKey` (in which - /// case Trakt has no way to match the show). - Future resolveShowForEpisode(PlexMetadata episode) async { - final showRatingKey = episode.grandparentRatingKey; - if (showRatingKey == null || showRatingKey.isEmpty) return null; - - final cached = _cache[showRatingKey]; - if (cached != null) return cached; - - final guids = await _client.fetchExternalGuids(showRatingKey); - final ids = TraktIds.fromPlexGuids(guids); - _cache[showRatingKey] = ids; - return ids; - } - - /// Drop all cached IDs. Called on stopPlayback / profile switch so a - /// re-matched item gets fresh GUIDs the next time around. - void clearCache() => _cache.clear(); -} diff --git a/lib/services/trakt/trakt_scrobble_service.dart b/lib/services/trakt/trakt_scrobble_service.dart index 27868165..199821e4 100644 --- a/lib/services/trakt/trakt_scrobble_service.dart +++ b/lib/services/trakt/trakt_scrobble_service.dart @@ -1,13 +1,14 @@ import 'dart:async'; import '../../models/plex_metadata.dart'; +import '../../models/trakt/trakt_ids.dart'; import '../../models/trakt/trakt_scrobble_request.dart'; import '../../utils/app_logger.dart'; import '../plex_client.dart'; import '../settings_service.dart'; +import '../trackers/tracker_id_resolver.dart'; import 'trakt_client.dart'; import 'trakt_constants.dart'; -import 'trakt_guid_resolver.dart'; import 'trakt_session.dart'; /// Real-time scrobble service for Trakt. @@ -41,7 +42,7 @@ class TraktScrobbleService { bool _isEnabled = false; TraktClient? _client; - TraktGuidResolver? _resolver; + TrackerIdResolver? _resolver; TraktScrobbleRequest? _currentBody; Duration _currentPosition = Duration.zero; Duration _currentDuration = Duration.zero; @@ -95,7 +96,7 @@ class TraktScrobbleService { _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); + _resolver = TrackerIdResolver(plexClient, needsFribb: () => false); final body = await _buildBody(metadata); if (body == null) { @@ -153,8 +154,8 @@ class TraktScrobbleService { if (metadata.mediaType == PlexMediaType.movie) { final ids = await resolver.resolveForMovie(metadata.ratingKey); - if (!ids.hasAny) return null; - return TraktScrobbleRequest.movie(ids: ids); + if (ids == null) return null; + return TraktScrobbleRequest.movie(ids: TraktIds.fromExternal(ids.external)); } final season = metadata.parentIndex; @@ -162,9 +163,13 @@ class TraktScrobbleService { if (season == null || number == null) return null; final showIds = await resolver.resolveShowForEpisode(metadata); - if (showIds == null || !showIds.hasAny) return null; + if (showIds == null) return null; - return TraktScrobbleRequest.episode(showIds: showIds, season: season, number: number); + return TraktScrobbleRequest.episode( + showIds: TraktIds.fromExternal(showIds.external), + season: season, + number: number, + ); } double _progressPercent() { diff --git a/lib/services/trakt/trakt_sync_service.dart b/lib/services/trakt/trakt_sync_service.dart index ce23dca2..6a8fcb62 100644 --- a/lib/services/trakt/trakt_sync_service.dart +++ b/lib/services/trakt/trakt_sync_service.dart @@ -7,9 +7,9 @@ import '../../utils/app_logger.dart'; import '../../utils/watch_state_notifier.dart'; import '../multi_server_manager.dart'; import '../settings_service.dart'; +import '../trackers/tracker_id_resolver.dart'; import 'trakt_client.dart'; import 'trakt_constants.dart'; -import 'trakt_guid_resolver.dart'; import 'trakt_session.dart'; import 'trakt_sync_queue.dart'; @@ -39,7 +39,7 @@ class TraktSyncService { /// One resolver per Plex server, kept alive across events so the per-rating- /// key GUID cache survives a binge-watch session. - final Map _resolvers = {}; + final Map _resolvers = {}; /// Fallback buffer for items that failed to persist to the on-disk queue /// (e.g. SharedPreferences write threw). Retried on next `flushQueue`. @@ -90,14 +90,14 @@ class TraktSyncService { bool get _canPush => _isEnabled && _client != null; - TraktGuidResolver? _resolverFor(String serverId) { + TrackerIdResolver? _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); + final resolver = TrackerIdResolver(plexClient, needsFribb: () => false); _resolvers[serverId] = resolver; return resolver; } @@ -132,12 +132,12 @@ class TraktSyncService { return; } - TraktIds? ids; + TrackerIds? resolved; int? season; int? number; if (kind == TraktMediaKind.movie) { - ids = await resolver.resolveForMovie(ratingKey); + resolved = await resolver.resolveForMovie(ratingKey); } else { // Episode — need show IDs + season/episode index. The WatchStateEvent // doesn't carry the index, so fetch episode metadata. @@ -148,14 +148,15 @@ class TraktSyncService { season = episodeMeta.parentIndex; number = episodeMeta.index; if (season == null || number == null) return; - ids = await resolver.resolveShowForEpisode(episodeMeta); + resolved = await resolver.resolveShowForEpisode(episodeMeta); } - if (ids == null || !ids.hasAny) { + if (resolved == null) { appLogger.d('Trakt sync: no IDs for ${kind.name} $ratingKey, dropping'); return; } + final ids = TraktIds.fromExternal(resolved.external); final body = kind == TraktMediaKind.movie ? TraktScrobbleRequest.movie(ids: ids) : TraktScrobbleRequest.episode(showIds: ids, season: season!, number: number!); diff --git a/lib/utils/plex_external_ids.dart b/lib/utils/plex_external_ids.dart new file mode 100644 index 00000000..3fa855ce --- /dev/null +++ b/lib/utils/plex_external_ids.dart @@ -0,0 +1,33 @@ +/// External IDs parsed from a Plex `Guid` array (as returned by +/// `?includeGuids=1`). Shared by the Trakt and tracker resolvers. +/// +/// Plex GUIDs look like `imdb://tt123`, `tmdb://456`, `tvdb://789`. Callers +/// fetch the raw array via [PlexClient.fetchExternalGuids] and pass it here. +class PlexExternalIds { + final String? imdb; + final int? tmdb; + final int? tvdb; + + const PlexExternalIds({this.imdb, this.tmdb, this.tvdb}); + + bool get hasAny => imdb != null || tmdb != null || tvdb != null; + + factory PlexExternalIds.fromGuids(List guids) { + String? imdb; + int? tmdb; + int? tvdb; + for (final g in guids) { + if (g is! Map) continue; + final id = g['id']; + if (id is! String) continue; + if (id.startsWith('imdb://')) { + imdb = id.substring(7); + } else if (id.startsWith('tmdb://')) { + tmdb = int.tryParse(id.substring(7)); + } else if (id.startsWith('tvdb://')) { + tvdb = int.tryParse(id.substring(7)); + } + } + return PlexExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb); + } +} diff --git a/lib/widgets/device_code_dialog.dart b/lib/widgets/device_code_dialog.dart new file mode 100644 index 00000000..1fdda095 --- /dev/null +++ b/lib/widgets/device_code_dialog.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../i18n/strings.g.dart'; +import '../models/trackers/device_code.dart'; +import '../utils/snackbar_helper.dart'; +import 'dialog_action_button.dart'; + +/// Shared device-code activation dialog for Trakt and Simkl (RFC 8628). +/// +/// Shows the `userCode` with copy-to-clipboard, a button to launch the +/// verification URL in the browser, and a "waiting for authorization…" spinner +/// while the poll loop runs. Dismissing calls [onCancel] so the provider can +/// abort the poll. +class DeviceCodeDialog extends StatelessWidget { + final DeviceCode code; + final String serviceName; + final VoidCallback onCancel; + + const DeviceCodeDialog({ + super.key, + required this.code, + required this.serviceName, + required this.onCancel, + }); + + Future _open() async { + final url = code.verificationUrlComplete ?? code.verificationUrl; + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + + Future _copy(BuildContext context) async { + await Clipboard.setData(ClipboardData(text: code.userCode)); + if (!context.mounted) return; + showAppSnackBar(context, t.trackers.deviceCode.codeCopied); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: Text(t.trackers.deviceCode.title(service: serviceName)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.trackers.deviceCode.body(url: code.verificationUrl), style: theme.textTheme.bodyMedium), + const SizedBox(height: 16), + Center( + child: InkWell( + onTap: () => _copy(context), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Text( + 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.trackers.deviceCode.openToActivate(service: serviceName)), + 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.trackers.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall), + ), + ], + ), + ], + ), + actions: [ + DialogActionButton( + onPressed: () { + onCancel(); + Navigator.of(context, rootNavigator: true).pop(); + }, + label: t.common.cancel, + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index ab9a7447..7b8b2e11 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -117,6 +117,9 @@ flutter: - assets/plezy.png - assets/plezy_adaptive_foreground.svg - assets/trakt_circlemark.svg + - assets/mal_mark.svg + - assets/anilist_mark.svg + - assets/simkl_mark.svg - assets/go-noto-current-regular.ttf - assets/shaders/nvscaler/ - assets/shaders/anime4k/