@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="75 75 363 363"><path fill="#02a9ff" d="M321.92 323.27V136.6c0-10.698-5.887-16.602-16.558-16.602h-36.433c-10.672 0-16.561 5.904-16.561 16.602v88.651c0 2.497 23.996 14.089 24.623 16.541 18.282 71.61 3.972 128.92-13.359 131.6 28.337 1.405 31.455 15.064 10.348 5.731 3.229-38.209 15.828-38.134 52.049-1.406.31.317 7.427 15.282 7.87 15.282h85.545c10.672 0 16.558-5.9 16.558-16.6v-36.524c0-10.698-5.886-16.602-16.558-16.602z"/><path fill="currentColor" d="M170.68 120 74.999 393h74.338l16.192-47.222h80.96L262.315 393h73.968l-95.314-273zm11.776 165.28 23.183-75.629 25.393 75.629z"/></svg>
|
||||
|
After Width: | Height: | Size: 633 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="25 37 900 900"><path fill="currentColor" d="M360.18,640.05h-93.37v-215.38h-.85l-71.97,95.92-70.16-95.92h-.96v215.38H29.5v-356.23h88.57l75.91,104.66,77.61-104.66h88.58v356.23Z"/><path fill="currentColor" d="M667.97,640.05h-93.37v-112.96h-101.99c.5,36.48,12.42,74.14,35.77,112.96l-75.8,50.14c-11.5-17.25-21.42-35.61-29.76-55.09-8.34-19.48-14.71-39.57-19.11-60.26-4.4-20.69-6.6-41.26-6.6-61.7s2.31-42.21,6.92-62.76c4.61-20.55,11.71-40.12,21.29-58.72,9.58-18.59,21.58-35.1,35.98-49.51,15.83-15.97,32.77-28.19,50.84-36.68,18.06-8.48,36.89-14.21,56.48-17.19,19.59-2.98,42.09-4.47,67.5-4.47h45.78l21.83,80.27h-73.25c-30.38,0-54.35,4.36-71.92,13.08-17.57,8.72-29.85,18.47-36.84,29.24-6.99,10.77-13.65,24.24-19.96,40.4h92.84v-71.76h93.37v264.99Z"/><path fill="currentColor" d="M797.69,559.78h122.54l-21.83,80.28h-194.08v-356.23h93.37v275.96Z"/></svg>
|
||||
|
After Width: | Height: | Size: 891 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="205 196 1124 1124"><path fill="currentColor" d="M205.832 970.284c0 35.322 1.82 65.9 5.433 91.774 6.709 53.176 28.198 94.359 64.507 123.51 33.579 27.302 88.907 45.656 165.984 55.065 63.178 7.983 165.983 11.993 308.45 11.993 234.287 0 377.455-7.982 429.428-23.947 52.859-15.964 90.936-45.77 114.232-89.46 23.295-44.153 34.944-113.446 34.944-207.843 0-84.525-11.907-145.798-35.645-183.858-15.702-25.836-36.64-45.772-62.885-59.885-26.244-14.074-61.52-24.64-105.9-31.697-43.937-7.018-145.452-12.917-304.58-17.622-175.752-5.63-274.576-11.49-296.545-17.584-26.024-7.519-39-28.188-39-62.006 0-35.205 12.312-57.03 36.972-65.514 21.97-7.018 92.115-10.566 210.476-10.566 119.65 0 194.955 1.427 225.92 4.203 34.942 3.317 55.584 18.04 61.85 44.26 2.25 8.908 3.835 24.833 4.72 47.777h271c.368-22.442.589-38.83.589-49.127 0-95.475-21.527-161.676-64.58-198.657-31.847-26.683-83.858-45.386-156.032-56.183-54.7-7.944-148.4-11.915-281.099-11.915-204.024 0-333.149 4.704-387.407 14.075-60.082 10.797-104.942 33.047-134.505 66.748-39.477 44.538-59.198 121.158-59.198 229.823 0 51.555 4.017 93.278 12.09 125.129 14.338 57.649 44.822 98.407 91.451 122.276 36.309 18.741 98.861 29.538 187.622 32.353 34.058.926 108.444 4.204 223.228 9.833 111.172 5.63 184.23 8.445 219.212 8.445 50.645.926 82.678 8.213 96.168 21.787 9.843 9.832 14.783 26.915 14.783 51.286 0 35.63-7.852 58.612-23.518 68.908-11.206 7.018-34.502 11.953-69.887 14.73-18.837.963-87.84 1.658-207.011 2.12-86.954-.462-138.265-.924-153.93-1.387-31.369-.926-53.338-2.584-65.871-4.936-12.532-2.314-23.738-6.44-33.58-12.996-18.835-13.573-28.014-44.036-27.571-91.389H205.832v50.824-.347z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
+25
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -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é"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -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": "コードをコピーしました"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -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": "코드가 복사되었습니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -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": "Код скопирован"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsDa with BaseTranslations<AppLocale, Translations> 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 <da>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsDe with BaseTranslations<AppLocale, Translations> 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 <de>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+109
-22
@@ -80,6 +80,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||
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 <en>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsEs with BaseTranslations<AppLocale, Translations> 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 <es>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsFr with BaseTranslations<AppLocale, Translations> 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 <fr>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsIt with BaseTranslations<AppLocale, Translations> 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 <it>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsJa with BaseTranslations<AppLocale, Translations> 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 <ja>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsKo with BaseTranslations<AppLocale, Translations> 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 <ko>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsNb with BaseTranslations<AppLocale, Translations> 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 <nb>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsNl with BaseTranslations<AppLocale, Translations> 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 <nl>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsPl with BaseTranslations<AppLocale, Translations> 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 <pl>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsPt with BaseTranslations<AppLocale, Translations> 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 <pt>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsRu with BaseTranslations<AppLocale, Translations> 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 <ru>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsSv with BaseTranslations<AppLocale, Translations> 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 <sv>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+70
-12
@@ -77,6 +77,7 @@ class TranslationsZh with BaseTranslations<AppLocale, Translations> 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 <zh>.
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
+25
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -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": "代码已复制"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+60
-39
@@ -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<MainApp> with WidgetsBindingObserver {
|
||||
// Existing providers
|
||||
ChangeNotifierProvider(create: (context) => UserProfileProvider()),
|
||||
ChangeNotifierProvider(create: (context) => ThemeProvider()),
|
||||
// Trakt account — depends on UserProfileProvider for per-profile token scoping.
|
||||
// Hydrated and rebound in `_TraktBootstrap` below.
|
||||
// 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<MainApp> with WidgetsBindingObserver {
|
||||
child: Consumer<ThemeProvider>(
|
||||
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<TraktAccountProvider>();
|
||||
final trackers = context.read<TrackersProvider>();
|
||||
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<MainApp> 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<Future<void> 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<UserProfileProvider>();
|
||||
final trakt = context.read<TraktAccountProvider>();
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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<String, dynamic> tokenResponse;
|
||||
const DevicePollSuccess(this.tokenResponse);
|
||||
}
|
||||
@@ -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<String, dynamic> 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> json) {
|
||||
final verificationUrl = json['verification_url'] as String;
|
||||
final userCode = json['user_code'] as String;
|
||||
return TraktDeviceCode(
|
||||
deviceCode: json['device_code'] as String,
|
||||
userCode: userCode,
|
||||
verificationUrl: verificationUrl,
|
||||
verificationUrlComplete: '$verificationUrl/$userCode',
|
||||
expiresIn: (json['expires_in'] as num).toInt(),
|
||||
interval: (json['interval'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Discriminated event emitted by `TraktAuthService.pollDeviceCode`.
|
||||
sealed class TraktDevicePollEvent {
|
||||
const TraktDevicePollEvent();
|
||||
}
|
||||
|
||||
class TraktDevicePollPending extends TraktDevicePollEvent {
|
||||
const TraktDevicePollPending();
|
||||
}
|
||||
|
||||
class TraktDevicePollSlowDown extends TraktDevicePollEvent {
|
||||
const TraktDevicePollSlowDown();
|
||||
}
|
||||
|
||||
class TraktDevicePollDenied extends TraktDevicePollEvent {
|
||||
const TraktDevicePollDenied();
|
||||
}
|
||||
|
||||
class TraktDevicePollExpired extends TraktDevicePollEvent {
|
||||
const TraktDevicePollExpired();
|
||||
}
|
||||
|
||||
class TraktDevicePollSuccess extends TraktDevicePollEvent {
|
||||
final Map<String, dynamic> tokenResponse;
|
||||
const TraktDevicePollSuccess(this.tokenResponse);
|
||||
}
|
||||
@@ -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<dynamic> guids) {
|
||||
String? imdb;
|
||||
int? tmdb;
|
||||
int? tvdb;
|
||||
for (final g in guids) {
|
||||
if (g is! Map) continue;
|
||||
final id = g['id'];
|
||||
if (id is! String) continue;
|
||||
if (id.startsWith('imdb://')) {
|
||||
imdb = id.substring(7);
|
||||
} else if (id.startsWith('tmdb://')) {
|
||||
tmdb = int.tryParse(id.substring(7));
|
||||
} else if (id.startsWith('tvdb://')) {
|
||||
tvdb = int.tryParse(id.substring(7));
|
||||
}
|
||||
}
|
||||
return TraktIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb);
|
||||
}
|
||||
factory TraktIds.fromExternal(PlexExternalIds ids) => TraktIds(
|
||||
imdb: ids.imdb,
|
||||
tmdb: ids.tmdb,
|
||||
tvdb: ids.tvdb,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void> 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<bool> connectMal() => _runConnect<MalSession>(
|
||||
service: TrackerService.mal,
|
||||
alreadyConnected: isMalConnected,
|
||||
authorize: _malAuth.authorize,
|
||||
enrich: _enrichMal,
|
||||
store: malAccountStore,
|
||||
assign: (s) {
|
||||
_mal = s;
|
||||
_rebindMal();
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> disconnectMal() => _clearAndRebind(malAccountStore, () {
|
||||
_mal = null;
|
||||
_rebindMal();
|
||||
});
|
||||
|
||||
Future<bool> connectAnilist() => _runConnect<AnilistSession>(
|
||||
service: TrackerService.anilist,
|
||||
alreadyConnected: isAnilistConnected,
|
||||
authorize: AnilistAuthService().authorize,
|
||||
enrich: _enrichAnilist,
|
||||
store: anilistAccountStore,
|
||||
assign: (s) {
|
||||
_anilist = s;
|
||||
_rebindAnilist();
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> disconnectAnilist() => _clearAndRebind(anilistAccountStore, () {
|
||||
_anilist = null;
|
||||
_rebindAnilist();
|
||||
});
|
||||
|
||||
Future<bool> connectSimkl({required void Function(DeviceCode code) onCodeReady}) => _runConnect<SimklSession>(
|
||||
service: TrackerService.simkl,
|
||||
alreadyConnected: isSimklConnected,
|
||||
authorize: () => _simklAuthorize(onCodeReady),
|
||||
enrich: _enrichSimkl,
|
||||
store: simklAccountStore,
|
||||
assign: (s) {
|
||||
_simkl = s;
|
||||
_rebindSimkl();
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> 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<bool> _runConnect<T>({
|
||||
required TrackerService service,
|
||||
required bool alreadyConnected,
|
||||
required Future<T?> Function() authorize,
|
||||
required Future<T> Function(T raw) enrich,
|
||||
required TrackerAccountStore<T> 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<void> _clearAndRebind<T>(TrackerAccountStore<T> store, void Function() clearAndRebind) async {
|
||||
await store.clear(_activeUserUuid);
|
||||
clearAndRebind();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<SimklSession?> _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<MalSession> _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<AnilistSession> _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<SimklSession> _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<T>(TrackerAccountStore<T> store, void Function() clearSession, void Function() rebind) {
|
||||
store.clear(_activeUserUuid);
|
||||
clearSession();
|
||||
rebind();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_malAuth.dispose();
|
||||
_simklAuth.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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<bool> connect({required void Function(TraktDeviceCode code) onCodeReady}) async {
|
||||
Future<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> with FocusableTab {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTraktTile() {
|
||||
return Consumer<TraktAccountProvider>(
|
||||
builder: (context, account, _) {
|
||||
final connected = account.isConnected;
|
||||
final username = account.username;
|
||||
final iconColor = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface;
|
||||
Widget _buildTrackersTile() {
|
||||
return Consumer2<TraktAccountProvider, TrackersProvider>(
|
||||
builder: (context, trakt, trackers, _) {
|
||||
final connectedNames = <String>[
|
||||
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()));
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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<void> startMalConnection(BuildContext context) async {
|
||||
final account = context.read<TrackersProvider>();
|
||||
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<void> startAnilistConnection(BuildContext context) async {
|
||||
final account = context.read<TrackersProvider>();
|
||||
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<void> startSimklConnection(BuildContext context) async {
|
||||
final account = context.read<TrackersProvider>();
|
||||
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<void>(
|
||||
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<void> Function(SettingsService, bool) setScrobbleEnabled;
|
||||
final Future<void> 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<TrackerSettingsScreen> createState() => _TrackerSettingsScreenState();
|
||||
}
|
||||
|
||||
class _TrackerSettingsScreenState extends State<TrackerSettingsScreen> {
|
||||
SettingsService? _settings;
|
||||
bool _scrobbleEnabled = true;
|
||||
bool _loaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final s = await SettingsService.getInstance();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_settings = s;
|
||||
_scrobbleEnabled = widget.config.readScrobbleEnabled(s);
|
||||
_loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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<TrackersProvider>(
|
||||
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),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<TraktAccountProvider>(
|
||||
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<TrackersProvider>(
|
||||
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<TrackersProvider>(
|
||||
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<TrackersProvider>(
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<void> startTraktConnection(BuildContext context) async {
|
||||
showDialog<void>(
|
||||
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<TraktSettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceCodeDialog extends StatefulWidget {
|
||||
final TraktDeviceCode code;
|
||||
final TraktAccountProvider account;
|
||||
const _DeviceCodeDialog({required this.code, required this.account});
|
||||
|
||||
@override
|
||||
State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState();
|
||||
}
|
||||
|
||||
class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
|
||||
Future<void> _open() async {
|
||||
final url = widget.code.verificationUrlComplete ?? widget.code.verificationUrl;
|
||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<void> _copy() async {
|
||||
await Clipboard.setData(ClipboardData(text: widget.code.userCode));
|
||||
if (!mounted) return;
|
||||
showAppSnackBar(context, t.trakt.codeCopied);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(t.trakt.deviceCodeTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(t.trakt.deviceCodeBody(url: widget.code.verificationUrl), style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: InkWell(
|
||||
onTap: _copy,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Text(
|
||||
widget.code.userCode,
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
letterSpacing: 4,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(t.trakt.openTraktActivate),
|
||||
onPressed: _open,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(t.trakt.waitingForAuthorization, style: theme.textTheme.bodySmall)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(
|
||||
onPressed: () {
|
||||
widget.account.cancelConnect();
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
},
|
||||
label: t.common.cancel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import '../providers/companion_remote_provider.dart';
|
||||
import '../services/companion_remote/companion_remote_receiver.dart';
|
||||
import '../services/fullscreen_state_manager.dart';
|
||||
import '../services/discord_rpc_service.dart';
|
||||
import '../services/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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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 {
|
||||
|
||||
@@ -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<void> setEnableMalScrobble(bool enabled) async {
|
||||
await prefs.setBool(_keyEnableMalScrobble, enabled);
|
||||
}
|
||||
|
||||
bool getEnableMalScrobble() {
|
||||
return prefs.getBool(_keyEnableMalScrobble) ?? true;
|
||||
}
|
||||
|
||||
Future<void> setEnableAnilistScrobble(bool enabled) async {
|
||||
await prefs.setBool(_keyEnableAnilistScrobble, enabled);
|
||||
}
|
||||
|
||||
bool getEnableAnilistScrobble() {
|
||||
return prefs.getBool(_keyEnableAnilistScrobble) ?? true;
|
||||
}
|
||||
|
||||
Future<void> setEnableSimklScrobble(bool enabled) async {
|
||||
await prefs.setBool(_keyEnableSimklScrobble, enabled);
|
||||
}
|
||||
|
||||
bool getEnableSimklScrobble() {
|
||||
return prefs.getBool(_keyEnableSimklScrobble) ?? true;
|
||||
}
|
||||
|
||||
// Companion Remote Server
|
||||
Future<void> 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),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import '../tracker_account_store.dart';
|
||||
import 'anilist_session.dart';
|
||||
|
||||
final TrackerAccountStore<AnilistSession> anilistAccountStore = TrackerAccountStore<AnilistSession>(
|
||||
baseKey: 'anilist_session',
|
||||
decode: AnilistSession.decode,
|
||||
encode: (s) => s.encode(),
|
||||
);
|
||||
@@ -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<AnilistSession?> 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';
|
||||
}
|
||||
@@ -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<String?> 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<void> 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<Map<String, dynamic>> query(String query, {Map<String, dynamic>? 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<String, dynamic>;
|
||||
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<String, dynamic>() : <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
@@ -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<String, String> headers({String? accessToken}) => {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
if (accessToken != null) 'Authorization': 'Bearer $accessToken',
|
||||
};
|
||||
}
|
||||
@@ -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<String, dynamic> toJson() => {
|
||||
'access_token': accessToken,
|
||||
'expires_at': expiresAt,
|
||||
'username': username,
|
||||
'created_at': createdAt,
|
||||
};
|
||||
|
||||
factory AnilistSession.fromJson(Map<String, dynamic> 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<String, dynamic>);
|
||||
}
|
||||
@@ -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<void> 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)');
|
||||
}
|
||||
}
|
||||
@@ -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<DevicePollEvent> pollDeviceCode(
|
||||
DeviceCode code, {
|
||||
required Future<DevicePollEvent> 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<void>.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();
|
||||
}
|
||||
@@ -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<int, List<FribbMappingRow>> byTvdb;
|
||||
final Map<int, List<FribbMappingRow>> byTmdb;
|
||||
final Map<String, List<FribbMappingRow>> 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<FribbIndex>? _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<FribbIndex> _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<FribbIndex> _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<String?> _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<List<FribbMappingRow>> 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<void> 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<void> _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<void> _deleteDiskCopy() async {
|
||||
try {
|
||||
await File(await _diskPath()).delete();
|
||||
} on FileSystemException {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _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 = <int, List<FribbMappingRow>>{};
|
||||
final byTmdb = <int, List<FribbMappingRow>>{};
|
||||
final byImdb = <String, List<FribbMappingRow>>{};
|
||||
|
||||
for (final raw in decoded) {
|
||||
if (raw is! Map) continue;
|
||||
final row = FribbMappingRow.fromJson(raw.cast<String, dynamic>());
|
||||
final tvdb = row.tvdbId;
|
||||
if (tvdb != null) {
|
||||
(byTvdb[tvdb] ??= <FribbMappingRow>[]).add(row);
|
||||
}
|
||||
final tmdb = row.tmdbId;
|
||||
if (tmdb != null) {
|
||||
(byTmdb[tmdb] ??= <FribbMappingRow>[]).add(row);
|
||||
}
|
||||
final imdb = row.imdbId;
|
||||
if (imdb != null && imdb.isNotEmpty) {
|
||||
(byImdb[imdb] ??= <FribbMappingRow>[]).add(row);
|
||||
}
|
||||
}
|
||||
|
||||
return FribbIndex(byTvdb: byTvdb, byTmdb: byTmdb, byImdb: byImdb);
|
||||
}
|
||||
@@ -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<Uri> 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<Uri>();
|
||||
|
||||
late StreamSubscription<HttpRequest> 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<Uri?> 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<void> _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 `<head>`
|
||||
/// 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 = '<!doctype html>'
|
||||
'<meta charset="utf-8">'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
'<title>Signed in</title>'
|
||||
'<script>'
|
||||
'if(location.hash&&location.hash.length>1){'
|
||||
'location.replace(location.pathname+"?"+location.hash.substring(1));'
|
||||
'}'
|
||||
'</script>'
|
||||
'<style>'
|
||||
'html,body{margin:0;height:100%}'
|
||||
'body{display:flex;flex-direction:column;align-items:center;justify-content:center;'
|
||||
'font-family:-apple-system,system-ui,sans-serif;background:#fff;color:#1a1a1a;text-align:center;padding:1em;box-sizing:border-box}'
|
||||
'@media(prefers-color-scheme:dark){body{background:#0f0f0f;color:#f5f5f5}}'
|
||||
'.check{width:72px;height:72px;margin-bottom:20px}'
|
||||
'h2{margin:0 0 8px;font-weight:600;font-size:1.25rem}'
|
||||
'p{margin:0;opacity:.7;font-size:.95rem}'
|
||||
'</style>'
|
||||
'<body>'
|
||||
'<svg class="check" viewBox="0 0 24 24">'
|
||||
'<circle cx="12" cy="12" r="10" fill="#22c55e"/>'
|
||||
'<path d="M7 12.5l3 3 7-7" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
'</svg>'
|
||||
'<h2>Signed in to Plezy</h2>'
|
||||
'<p>You can close this tab and return to the app.</p>'
|
||||
'</body>';
|
||||
}
|
||||
|
||||
class LoopbackBindException implements Exception {
|
||||
final String message;
|
||||
const LoopbackBindException(this.message);
|
||||
@override
|
||||
String toString() => 'LoopbackBindException: $message';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import '../tracker_account_store.dart';
|
||||
import 'mal_session.dart';
|
||||
|
||||
final TrackerAccountStore<MalSession> malAccountStore = TrackerAccountStore<MalSession>(
|
||||
baseKey: 'mal_session',
|
||||
decode: MalSession.decode,
|
||||
encode: (s) => s.encode(),
|
||||
);
|
||||
@@ -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<MalSession?> 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<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<MalSession> 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<String, dynamic>);
|
||||
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';
|
||||
}
|
||||
@@ -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<MalSession>? _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<Map<String, dynamic>?> getMyUser() async {
|
||||
final res = await _request('GET', '/users/@me');
|
||||
return res is Map ? res.cast<String, dynamic>() : null;
|
||||
}
|
||||
|
||||
/// Update the user's list entry for an anime. Body shape:
|
||||
/// ```
|
||||
/// {"status": "watching", "num_watched_episodes": 5}
|
||||
/// ```
|
||||
Future<void> updateMyListStatus(int animeId, Map<String, String> fields) async {
|
||||
// MAL's list-status endpoint is form-encoded (not JSON).
|
||||
await _request('PATCH', '/anime/$animeId/my_list_status', formBody: fields);
|
||||
}
|
||||
|
||||
Future<MalSession> _refresh() {
|
||||
final existing = _refreshLock;
|
||||
if (existing != null) return existing;
|
||||
final lock = _doRefresh();
|
||||
_refreshLock = lock;
|
||||
return lock.whenComplete(() => _refreshLock = null);
|
||||
}
|
||||
|
||||
Future<MalSession> _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<dynamic> _request(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, dynamic>? body,
|
||||
Map<String, String>? 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<http.Response> _send(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, dynamic>? body,
|
||||
Map<String, String>? 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';
|
||||
}
|
||||
@@ -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<String, String> headers({String? accessToken}) => {
|
||||
'Accept': 'application/json',
|
||||
if (accessToken != null) 'Authorization': 'Bearer $accessToken',
|
||||
};
|
||||
}
|
||||
@@ -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<String, dynamic> toJson() => {
|
||||
'access_token': accessToken,
|
||||
'refresh_token': refreshToken,
|
||||
'expires_at': expiresAt,
|
||||
'username': username,
|
||||
'created_at': createdAt,
|
||||
};
|
||||
|
||||
factory MalSession.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>);
|
||||
}
|
||||
@@ -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<void> 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)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import '../tracker_account_store.dart';
|
||||
import 'simkl_session.dart';
|
||||
|
||||
final TrackerAccountStore<SimklSession> simklAccountStore = TrackerAccountStore<SimklSession>(
|
||||
baseKey: 'simkl_session',
|
||||
decode: SimklSession.decode,
|
||||
encode: (s) => s.encode(),
|
||||
);
|
||||
@@ -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/<user_code>?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<DeviceCode> 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<String, dynamic>;
|
||||
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<DevicePollEvent> pollDeviceCode(DeviceCode code, {bool Function()? shouldCancel}) {
|
||||
return poller.pollDeviceCode(code, shouldCancel: shouldCancel, probe: () => _probe(code));
|
||||
}
|
||||
|
||||
Future<DevicePollEvent> _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<String, dynamic>;
|
||||
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';
|
||||
}
|
||||
@@ -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<Map<String, dynamic>?> getUserSettings() async {
|
||||
final res = await _request('GET', '/users/settings');
|
||||
return res is Map ? res.cast<String, dynamic>() : null;
|
||||
}
|
||||
|
||||
/// Mark one or more items as watched. Body shape:
|
||||
/// ```
|
||||
/// {"movies": [{"ids": {"simkl": 123}}], "shows": [...]}
|
||||
/// ```
|
||||
Future<void> addToHistory(Map<String, dynamic> body) => _request('POST', '/sync/history', body: body);
|
||||
|
||||
Future<dynamic> _request(String method, String path, {Map<String, dynamic>? 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';
|
||||
}
|
||||
@@ -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 `/<userCode>?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<String, String> headers({String? accessToken}) => {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'simkl-api-key': clientId,
|
||||
if (accessToken != null) 'Authorization': 'Bearer $accessToken',
|
||||
};
|
||||
}
|
||||
@@ -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<String, dynamic> toJson() => {
|
||||
'access_token': accessToken,
|
||||
'username': username,
|
||||
'created_at': createdAt,
|
||||
};
|
||||
|
||||
factory SimklSession.fromJson(Map<String, dynamic> 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<String, dynamic>);
|
||||
}
|
||||
@@ -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<void> 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<String, Object> _buildIds(TrackerContext ctx) {
|
||||
final ids = <String, Object>{};
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> initialize();
|
||||
Future<void> setEnabled(bool enabled);
|
||||
|
||||
Future<void> 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<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
_isEnabled = readEnabledSetting(await SettingsService.getInstance());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
_isEnabled = enabled;
|
||||
}
|
||||
}
|
||||
@@ -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<T> {
|
||||
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<T?> 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<void> save(String userUuid, T session) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_scopedKey(userUuid), _encode(session));
|
||||
}
|
||||
|
||||
Future<void> clear(String userUuid) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_scopedKey(userUuid));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Tracker> _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<void> initialize() async {
|
||||
await Future.wait(_trackers.map((t) => t.initialize()));
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<void> _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<TrackerContext?> _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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, TrackerIds?> _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<TrackerIds?> 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<TrackerIds?> 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<TrackerIds?> _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<FribbMappingRow> 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<FribbMappingRow> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<TraktSession?> load(String userUuid) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(traktUserKey(userUuid, _baseKey));
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
return TraktSession.decode(raw);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save(String userUuid, TraktSession session) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(traktUserKey(userUuid, _baseKey), session.encode());
|
||||
}
|
||||
|
||||
Future<void> clear(String userUuid) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(traktUserKey(userUuid, _baseKey));
|
||||
}
|
||||
}
|
||||
final TrackerAccountStore<TraktSession> traktAccountStore = TrackerAccountStore<TraktSession>(
|
||||
baseKey: 'trakt_session',
|
||||
decode: TraktSession.decode,
|
||||
encode: (s) => s.encode(),
|
||||
);
|
||||
|
||||
@@ -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<TraktDeviceCode> createDeviceCode() async {
|
||||
/// Request a device code. The returned [DeviceCode.userCode] is what the
|
||||
/// user must enter at [DeviceCode.verificationUrl].
|
||||
Future<DeviceCode> 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<String, dynamic>);
|
||||
final body = json.decode(res.body) as Map<String, dynamic>;
|
||||
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<TraktDevicePollEvent> 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<DevicePollEvent> 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<void>.delayed(interval);
|
||||
|
||||
if (shouldCancel != null && shouldCancel()) return;
|
||||
|
||||
http.Response res;
|
||||
final tokenUri = Uri.parse(TraktConstants.deviceTokenUrl);
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
res = await _http
|
||||
.post(
|
||||
tokenUri,
|
||||
headers: TraktConstants.headers(),
|
||||
body: json.encode({
|
||||
'code': code.deviceCode,
|
||||
'client_id': TraktConstants.clientId,
|
||||
'client_secret': TraktConstants.clientSecret,
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
sw.stop();
|
||||
appLogger.d('Trakt POST ${tokenUri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)');
|
||||
} catch (e) {
|
||||
appLogger.d('Trakt device-code poll error (transient)', error: e);
|
||||
yield const TraktDevicePollPending();
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (res.statusCode) {
|
||||
case 200:
|
||||
final body = json.decode(res.body) as Map<String, dynamic>;
|
||||
yield TraktDevicePollSuccess(body);
|
||||
return;
|
||||
case 400:
|
||||
// Pending — keep polling
|
||||
yield const TraktDevicePollPending();
|
||||
break;
|
||||
case 404:
|
||||
// Code not found / expired
|
||||
yield const TraktDevicePollExpired();
|
||||
return;
|
||||
case 409:
|
||||
// Already used
|
||||
yield const TraktDevicePollDenied();
|
||||
return;
|
||||
case 410:
|
||||
yield const TraktDevicePollExpired();
|
||||
return;
|
||||
case 418:
|
||||
// Denied
|
||||
yield const TraktDevicePollDenied();
|
||||
return;
|
||||
case 429:
|
||||
// Slow down — increase interval by 5s
|
||||
interval += const Duration(seconds: 5);
|
||||
yield const TraktDevicePollSlowDown();
|
||||
break;
|
||||
default:
|
||||
appLogger.w('Trakt device-code unexpected HTTP ${res.statusCode}: ${res.body}');
|
||||
yield const TraktDevicePollPending();
|
||||
}
|
||||
Future<DevicePollEvent> _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<String, dynamic>);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, TraktIds> _cache = {};
|
||||
|
||||
TraktGuidResolver(this._client);
|
||||
|
||||
/// Resolve external IDs for a movie rating key.
|
||||
Future<TraktIds> resolveForMovie(String ratingKey) async {
|
||||
final cached = _cache[ratingKey];
|
||||
if (cached != null) return cached;
|
||||
|
||||
final guids = await _client.fetchExternalGuids(ratingKey);
|
||||
final ids = TraktIds.fromPlexGuids(guids);
|
||||
_cache[ratingKey] = ids;
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Resolve external IDs for an episode. Returns the show's GUIDs (Trakt's
|
||||
/// preferred shape); season/episode index live on the [PlexMetadata] itself.
|
||||
///
|
||||
/// Returns null if the episode is missing `grandparentRatingKey` (in which
|
||||
/// case Trakt has no way to match the show).
|
||||
Future<TraktIds?> resolveShowForEpisode(PlexMetadata episode) async {
|
||||
final showRatingKey = episode.grandparentRatingKey;
|
||||
if (showRatingKey == null || showRatingKey.isEmpty) return null;
|
||||
|
||||
final cached = _cache[showRatingKey];
|
||||
if (cached != null) return cached;
|
||||
|
||||
final guids = await _client.fetchExternalGuids(showRatingKey);
|
||||
final ids = TraktIds.fromPlexGuids(guids);
|
||||
_cache[showRatingKey] = ids;
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Drop all cached IDs. Called on stopPlayback / profile switch so a
|
||||
/// re-matched item gets fresh GUIDs the next time around.
|
||||
void clearCache() => _cache.clear();
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<String, TraktGuidResolver> _resolvers = {};
|
||||
final Map<String, TrackerIdResolver> _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!);
|
||||
|
||||
@@ -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<dynamic> guids) {
|
||||
String? imdb;
|
||||
int? tmdb;
|
||||
int? tvdb;
|
||||
for (final g in guids) {
|
||||
if (g is! Map) continue;
|
||||
final id = g['id'];
|
||||
if (id is! String) continue;
|
||||
if (id.startsWith('imdb://')) {
|
||||
imdb = id.substring(7);
|
||||
} else if (id.startsWith('tmdb://')) {
|
||||
tmdb = int.tryParse(id.substring(7));
|
||||
} else if (id.startsWith('tvdb://')) {
|
||||
tvdb = int.tryParse(id.substring(7));
|
||||
}
|
||||
}
|
||||
return PlexExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb);
|
||||
}
|
||||
}
|
||||
@@ -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<void> _open() async {
|
||||
final url = code.verificationUrlComplete ?? code.verificationUrl;
|
||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<void> _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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
|
||||
Reference in New Issue
Block a user