refactor: share tracker/trakt auth and connect flow

This commit is contained in:
edde746
2026-04-24 12:32:25 +02:00
parent bf2584d90f
commit e961bc6293
41 changed files with 249 additions and 293 deletions
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "Realtids-scrobbling",
"scrobbleDescription": "Send afspil-, pause- og stop-begivenheder til Trakt under afspilning.",
"watchedSync": "Synkroniser sét-status",
"watchedSyncDescription": "Når du markerer ting som sét i Plezy, markeres de også på Trakt.",
"connectFailed": "Kunne ikke forbinde til Trakt. Prøv igen."
"watchedSyncDescription": "Når du markerer ting som sét i Plezy, markeres de også på Trakt."
},
"trackers": {
"title": "Trackere",
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "Echtzeit-Scrobbling",
"scrobbleDescription": "Sende Play-, Pause- und Stopp-Ereignisse während der Wiedergabe an Trakt.",
"watchedSync": "Gesehen-Status synchronisieren",
"watchedSyncDescription": "Wenn du Inhalte in Plezy als gesehen markierst, werden sie auch auf Trakt markiert.",
"connectFailed": "Verbindung zu Trakt fehlgeschlagen. Versuche es erneut."
"watchedSyncDescription": "Wenn du Inhalte in Plezy als gesehen markierst, werden sie auch auf Trakt markiert."
},
"trackers": {
"title": "Tracker",
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "Real-time scrobbling",
"scrobbleDescription": "Send play, pause, and stop events to Trakt during playback.",
"watchedSync": "Sync watched status",
"watchedSyncDescription": "When you mark items watched in Plezy, mark them on Trakt.",
"connectFailed": "Couldn't connect to Trakt. Try again."
"watchedSyncDescription": "When you mark items watched in Plezy, mark them on Trakt."
},
"trackers": {
"title": "Trackers",
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "Scrobbling en tiempo real",
"scrobbleDescription": "Enviar eventos de reproducción, pausa y parada a Trakt durante la reproducción.",
"watchedSync": "Sincronizar estado de visto",
"watchedSyncDescription": "Cuando marques elementos como vistos en Plezy, también se marcarán en Trakt.",
"connectFailed": "No se pudo conectar a Trakt. Inténtalo de nuevo."
"watchedSyncDescription": "Cuando marques elementos como vistos en Plezy, también se marcarán en Trakt."
},
"trackers": {
"title": "Rastreadores",
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "Scrobbling en temps réel",
"scrobbleDescription": "Envoyer les événements de lecture, pause et arrêt à Trakt pendant la lecture.",
"watchedSync": "Synchroniser le statut « vu »",
"watchedSyncDescription": "Lorsque vous marquez un élément comme vu dans Plezy, il l'est aussi sur Trakt.",
"connectFailed": "Échec de la connexion à Trakt. Réessayez."
"watchedSyncDescription": "Lorsque vous marquez un élément comme vu dans Plezy, il l'est aussi sur Trakt."
},
"trackers": {
"title": "Trackers",
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "Scrobbling in tempo reale",
"scrobbleDescription": "Invia eventi di riproduzione, pausa e arresto a Trakt durante la riproduzione.",
"watchedSync": "Sincronizza stato visualizzato",
"watchedSyncDescription": "Quando segni elementi come visti in Plezy, vengono segnati anche su Trakt.",
"connectFailed": "Impossibile connettersi a Trakt. Riprova."
"watchedSyncDescription": "Quando segni elementi come visti in Plezy, vengono segnati anche su Trakt."
},
"trackers": {
"title": "Tracker",
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "リアルタイムのスクロブル",
"scrobbleDescription": "再生中に再生・一時停止・停止イベントを Trakt に送信します。",
"watchedSync": "視聴済みステータスを同期",
"watchedSyncDescription": "Plezy で項目を視聴済みにすると、Trakt でも視聴済みになります。",
"connectFailed": "Trakt に接続できませんでした。もう一度お試しください。"
"watchedSyncDescription": "Plezy で項目を視聴済みにすると、Trakt でも視聴済みになります。"
},
"trackers": {
"title": "トラッカー",
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "실시간 스크로블",
"scrobbleDescription": "재생 중 재생, 일시정지, 정지 이벤트를 Trakt로 전송합니다.",
"watchedSync": "시청 상태 동기화",
"watchedSyncDescription": "Plezy에서 시청 완료로 표시한 항목이 Trakt에도 시청 완료로 표시됩니다.",
"connectFailed": "Trakt에 연결할 수 없습니다. 다시 시도하세요."
"watchedSyncDescription": "Plezy에서 시청 완료로 표시한 항목이 Trakt에도 시청 완료로 표시됩니다."
},
"trackers": {
"title": "트래커",
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "Sanntids-scrobbling",
"scrobbleDescription": "Send avspillings-, pause- og stopphendelser til Trakt under avspilling.",
"watchedSync": "Synkroniser sett-status",
"watchedSyncDescription": "Når du markerer noe som sett i Plezy, markeres det også på Trakt.",
"connectFailed": "Kunne ikke koble til Trakt. Prøv igjen."
"watchedSyncDescription": "Når du markerer noe som sett i Plezy, markeres det også på Trakt."
},
"trackers": {
"title": "Trackere",
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "Realtime scrobbling",
"scrobbleDescription": "Verstuur play-, pauze- en stopgebeurtenissen tijdens afspelen naar Trakt.",
"watchedSync": "Bekeken-status synchroniseren",
"watchedSyncDescription": "Wanneer je items als bekeken markeert in Plezy, worden ze ook op Trakt gemarkeerd.",
"connectFailed": "Kan niet verbinden met Trakt. Probeer opnieuw."
"watchedSyncDescription": "Wanneer je items als bekeken markeert in Plezy, worden ze ook op Trakt gemarkeerd."
},
"trackers": {
"title": "Trackers",
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "Scrobbling w czasie rzeczywistym",
"scrobbleDescription": "Wysyłaj zdarzenia odtwarzania, pauzy i zatrzymania do Trakt podczas odtwarzania.",
"watchedSync": "Synchronizuj status obejrzane",
"watchedSyncDescription": "Gdy oznaczysz pozycje jako obejrzane w Plezy, zostaną też oznaczone w Trakt.",
"connectFailed": "Nie udało się połączyć z Trakt. Spróbuj ponownie."
"watchedSyncDescription": "Gdy oznaczysz pozycje jako obejrzane w Plezy, zostaną też oznaczone w Trakt."
},
"trackers": {
"title": "Trackery",
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "Scrobbling em tempo real",
"scrobbleDescription": "Envia eventos de reprodução, pausa e parada ao Trakt durante a exibição.",
"watchedSync": "Sincronizar status de assistido",
"watchedSyncDescription": "Ao marcar itens como assistidos no Plezy, eles também serão marcados no Trakt.",
"connectFailed": "Não foi possível conectar ao Trakt. Tente novamente."
"watchedSyncDescription": "Ao marcar itens como assistidos no Plezy, eles também serão marcados no Trakt."
},
"trackers": {
"title": "Rastreadores",
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "Скробблинг в реальном времени",
"scrobbleDescription": "Отправлять события воспроизведения, паузы и остановки в Trakt во время просмотра.",
"watchedSync": "Синхронизация статуса просмотра",
"watchedSyncDescription": "Когда вы отмечаете элементы как просмотренные в Plezy, они отмечаются и в Trakt.",
"connectFailed": "Не удалось подключиться к Trakt. Попробуйте ещё раз."
"watchedSyncDescription": "Когда вы отмечаете элементы как просмотренные в Plezy, они отмечаются и в Trakt."
},
"trackers": {
"title": "Трекеры",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 15
/// Strings: 14385 (959 per locale)
/// Strings: 14370 (958 per locale)
///
/// Built on 2026-04-24 at 08:07 UTC
/// Built on 2026-04-24 at 08:58 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
-2
View File
@@ -1277,7 +1277,6 @@ 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 connectFailed => 'Kunne ikke forbinde til Trakt. Prøv igen.';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ 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.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',
-2
View File
@@ -1277,7 +1277,6 @@ 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 connectFailed => 'Verbindung zu Trakt fehlgeschlagen. Versuche es erneut.';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ 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.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',
-4
View File
@@ -2948,9 +2948,6 @@ 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: 'Couldn't connect to Trakt. Try again.'
String get connectFailed => 'Couldn\'t connect to Trakt. Try again.';
}
// Path: trackers
@@ -4379,7 +4376,6 @@ 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.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',
-2
View File
@@ -1277,7 +1277,6 @@ 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 connectFailed => 'No se pudo conectar a Trakt. Inténtalo de nuevo.';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ 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.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',
-2
View File
@@ -1277,7 +1277,6 @@ 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 connectFailed => 'Échec de la connexion à Trakt. Réessayez.';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ 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.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é',
-2
View File
@@ -1277,7 +1277,6 @@ 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 connectFailed => 'Impossibile connettersi a Trakt. Riprova.';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ 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.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',
-2
View File
@@ -1277,7 +1277,6 @@ class _TranslationsTraktJa implements TranslationsTraktEn {
@override String get scrobbleDescription => '再生中に再生・一時停止・停止イベントを Trakt に送信します。';
@override String get watchedSync => '視聴済みステータスを同期';
@override String get watchedSyncDescription => 'Plezy で項目を視聴済みにすると、Trakt でも視聴済みになります。';
@override String get connectFailed => 'Trakt に接続できませんでした。もう一度お試しください。';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ extension on TranslationsJa {
'trakt.scrobbleDescription' => '再生中に再生・一時停止・停止イベントを Trakt に送信します。',
'trakt.watchedSync' => '視聴済みステータスを同期',
'trakt.watchedSyncDescription' => 'Plezy で項目を視聴済みにすると、Trakt でも視聴済みになります。',
'trakt.connectFailed' => 'Trakt に接続できませんでした。もう一度お試しください。',
'trackers.title' => 'トラッカー',
'trackers.hubSubtitle' => '視聴進捗を Trakt や他のサービスで同期します。',
'trackers.notConnected' => '未接続',
-2
View File
@@ -1277,7 +1277,6 @@ class _TranslationsTraktKo implements TranslationsTraktEn {
@override String get scrobbleDescription => '재생 중 재생, 일시정지, 정지 이벤트를 Trakt로 전송합니다.';
@override String get watchedSync => '시청 상태 동기화';
@override String get watchedSyncDescription => 'Plezy에서 시청 완료로 표시한 항목이 Trakt에도 시청 완료로 표시됩니다.';
@override String get connectFailed => 'Trakt에 연결할 수 없습니다. 다시 시도하세요.';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ extension on TranslationsKo {
'trakt.scrobbleDescription' => '재생 중 재생, 일시정지, 정지 이벤트를 Trakt로 전송합니다.',
'trakt.watchedSync' => '시청 상태 동기화',
'trakt.watchedSyncDescription' => 'Plezy에서 시청 완료로 표시한 항목이 Trakt에도 시청 완료로 표시됩니다.',
'trakt.connectFailed' => 'Trakt에 연결할 수 없습니다. 다시 시도하세요.',
'trackers.title' => '트래커',
'trackers.hubSubtitle' => '시청 진행률을 Trakt 및 기타 서비스와 동기화하세요.',
'trackers.notConnected' => '연결되지 않음',
-2
View File
@@ -1277,7 +1277,6 @@ 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 connectFailed => 'Kunne ikke koble til Trakt. Prøv igjen.';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ 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.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',
-2
View File
@@ -1277,7 +1277,6 @@ 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 connectFailed => 'Kan niet verbinden met Trakt. Probeer opnieuw.';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ 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.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',
-2
View File
@@ -1277,7 +1277,6 @@ 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 connectFailed => 'Nie udało się połączyć z Trakt. Spróbuj ponownie.';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ 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.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',
-2
View File
@@ -1277,7 +1277,6 @@ 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 connectFailed => 'Não foi possível conectar ao Trakt. Tente novamente.';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ 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.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',
-2
View File
@@ -1277,7 +1277,6 @@ class _TranslationsTraktRu implements TranslationsTraktEn {
@override String get scrobbleDescription => 'Отправлять события воспроизведения, паузы и остановки в Trakt во время просмотра.';
@override String get watchedSync => 'Синхронизация статуса просмотра';
@override String get watchedSyncDescription => 'Когда вы отмечаете элементы как просмотренные в Plezy, они отмечаются и в Trakt.';
@override String get connectFailed => 'Не удалось подключиться к Trakt. Попробуйте ещё раз.';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ extension on TranslationsRu {
'trakt.scrobbleDescription' => 'Отправлять события воспроизведения, паузы и остановки в Trakt во время просмотра.',
'trakt.watchedSync' => 'Синхронизация статуса просмотра',
'trakt.watchedSyncDescription' => 'Когда вы отмечаете элементы как просмотренные в Plezy, они отмечаются и в Trakt.',
'trakt.connectFailed' => 'Не удалось подключиться к Trakt. Попробуйте ещё раз.',
'trackers.title' => 'Трекеры',
'trackers.hubSubtitle' => 'Синхронизируйте прогресс просмотра с Trakt и другими сервисами.',
'trackers.notConnected' => 'Не подключено',
-2
View File
@@ -1277,7 +1277,6 @@ 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 connectFailed => 'Kunde inte ansluta till Trakt. Försök igen.';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ 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.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',
-2
View File
@@ -1277,7 +1277,6 @@ class _TranslationsTraktZh implements TranslationsTraktEn {
@override String get scrobbleDescription => '在播放时向 Trakt 发送播放、暂停和停止事件。';
@override String get watchedSync => '同步已观看状态';
@override String get watchedSyncDescription => '在 Plezy 中将内容标记为已观看时,也会在 Trakt 上标记为已观看。';
@override String get connectFailed => '无法连接到 Trakt。请重试。';
}
// Path: trackers
@@ -2453,7 +2452,6 @@ extension on TranslationsZh {
'trakt.scrobbleDescription' => '在播放时向 Trakt 发送播放、暂停和停止事件。',
'trakt.watchedSync' => '同步已观看状态',
'trakt.watchedSyncDescription' => '在 Plezy 中将内容标记为已观看时,也会在 Trakt 上标记为已观看。',
'trakt.connectFailed' => '无法连接到 Trakt。请重试。',
'trackers.title' => '追踪器',
'trackers.hubSubtitle' => '让你的观看进度与 Trakt 及其他服务保持同步。',
'trackers.notConnected' => '未连接',
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "Realtids-scrobbling",
"scrobbleDescription": "Skicka uppspelnings-, paus- och stopphändelser till Trakt under uppspelning.",
"watchedSync": "Synkronisera tittad-status",
"watchedSyncDescription": "När du markerar något som tittat i Plezy markeras det också på Trakt.",
"connectFailed": "Kunde inte ansluta till Trakt. Försök igen."
"watchedSyncDescription": "När du markerar något som tittat i Plezy markeras det också på Trakt."
},
"trackers": {
"title": "Spårare",
+1 -2
View File
@@ -1016,8 +1016,7 @@
"scrobble": "实时 Scrobble",
"scrobbleDescription": "在播放时向 Trakt 发送播放、暂停和停止事件。",
"watchedSync": "同步已观看状态",
"watchedSyncDescription": "在 Plezy 中将内容标记为已观看时,也会在 Trakt 上标记为已观看。",
"connectFailed": "无法连接到 Trakt。请重试。"
"watchedSyncDescription": "在 Plezy 中将内容标记为已观看时,也会在 Trakt 上标记为已观看。"
},
"trackers": {
"title": "追踪器",
+13 -28
View File
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/trackers/device_code.dart';
@@ -20,6 +18,7 @@ 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_connect_runner.dart';
import '../services/trackers/tracker_constants.dart';
import '../services/trackers/tracker_coordinator.dart';
import '../utils/app_logger.dart';
@@ -124,7 +123,10 @@ class TrackersProvider extends ChangeNotifier {
Future<bool> connectSimkl({required void Function(DeviceCode code) onCodeReady}) => _runConnect<SimklSession>(
service: TrackerService.simkl,
alreadyConnected: isSimklConnected,
authorize: () => _simklAuthorize(onCodeReady),
authorize: () => _simklAuth.authorize(
onCodeReady: onCodeReady,
shouldCancel: () => _cancelRequested,
),
enrich: _enrichSimkl,
store: simklAccountStore,
assign: (s) {
@@ -140,9 +142,7 @@ class TrackersProvider extends ChangeNotifier {
// ───── 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.
/// Guard-if-busy, set in-flight flag, run the shared pipeline, clear flag.
Future<bool> _runConnect<T>({
required TrackerService service,
required bool alreadyConnected,
@@ -156,15 +156,13 @@ class TrackersProvider extends ChangeNotifier {
_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;
return await runConnectPipeline<T>(
logLabel: service.name,
authorize: authorize,
enrich: enrich,
save: (s) => store.save(_activeUserUuid, s),
assign: assign,
);
} finally {
_connecting = null;
notifyListeners();
@@ -177,19 +175,6 @@ class TrackersProvider extends ChangeNotifier {
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: () {});
+18 -31
View File
@@ -1,8 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/trackers/device_code.dart';
import '../services/trackers/tracker_connect_runner.dart';
import '../services/trakt/trakt_account_store.dart';
import '../services/trakt/trakt_auth_service.dart';
import '../services/trakt/trakt_client.dart';
@@ -47,49 +46,37 @@ 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(DeviceCode code) onCodeReady}) async {
if (_isConnecting) return false;
if (_isConnecting || isConnected) return false;
_isConnecting = true;
_cancelRequested = false;
notifyListeners();
try {
final code = await _auth.createDeviceCode();
onCodeReady(code);
await for (final event in _auth.pollDeviceCode(code, shouldCancel: () => _cancelRequested)) {
if (event is DevicePollSuccess) {
await _completeConnect(event.tokenResponse);
return true;
}
if (event is DevicePollDenied || event is DevicePollExpired) {
return false;
}
}
return false;
} catch (e) {
appLogger.w('Trakt connect failed', error: e);
return false;
return await runConnectPipeline<TraktSession>(
logLabel: 'Trakt',
authorize: () => _auth.authorize(
onCodeReady: onCodeReady,
shouldCancel: () => _cancelRequested,
),
enrich: _enrichUsername,
save: (s) => _store.save(_activeUserUuid, s),
assign: _setSessionAndRebind,
);
} finally {
_isConnecting = false;
notifyListeners();
}
}
Future<void> _completeConnect(Map<String, dynamic> tokenResponse) async {
final raw = TraktSession.fromTokenResponse(tokenResponse);
// Best-effort fetch of the username for display.
TraktSession enriched = raw;
Future<TraktSession> _enrichUsername(TraktSession raw) async {
try {
final tmpClient = TraktClient(raw, onSessionInvalidated: () {});
final user = await tmpClient.getUserSettings();
enriched = raw.copyWith(username: user.username);
tmpClient.dispose();
final tmp = TraktClient(raw, onSessionInvalidated: () {});
final user = await tmp.getUserSettings();
tmp.dispose();
return raw.copyWith(username: user.username);
} catch (e) {
appLogger.d('Trakt: getUserSettings failed (non-fatal)', error: e);
return raw;
}
await _store.save(_activeUserUuid, enriched);
_setSessionAndRebind(enriched);
}
/// Revoke the access token and clear local state.
@@ -0,0 +1,61 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../focus/input_mode_tracker.dart';
import '../../i18n/strings.g.dart';
import '../../utils/app_logger.dart';
import '../../utils/snackbar_helper.dart';
/// Shared "connect this tracker" launcher.
///
/// Handles the busy/already-connected guard, shows the service's code dialog
/// once `connect` hands us a payload, auto-launches the browser on pointer
/// platforms, closes the dialog when the flow resolves, and surfaces a failure
/// snack. Service-specific pieces are supplied via [connect], [buildDialog],
/// and [urlFor] so both `TrackersProvider`-backed and `TraktAccountProvider`-
/// backed flows share one code path.
Future<void> launchTrackerConnect<T>(
BuildContext context, {
required bool isBusyOrConnected,
required String serviceName,
required Future<bool> Function(void Function(T)) connect,
required VoidCallback onCancel,
required Widget Function(T payload, VoidCallback onCancel) buildDialog,
required String Function(T payload) urlFor,
}) async {
if (isBusyOrConnected) return;
final autoLaunchBrowser = !InputModeTracker.isKeyboardMode(context);
var dialogOpen = false;
final ok = await connect((payload) {
if (!context.mounted) return;
dialogOpen = true;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => buildDialog(payload, onCancel),
).whenComplete(() => dialogOpen = false);
if (autoLaunchBrowser) {
unawaited(
launchUrl(Uri.parse(urlFor(payload)), mode: LaunchMode.externalApplication).catchError((Object e) {
appLogger.d('$serviceName: failed to auto-launch browser', error: e);
return false;
}),
);
}
});
if (!context.mounted) return;
// Close the dialog iff we showed one and it's still up (not already closed by
// the Cancel button). This is the ONLY site that dismisses the dialog —
// popping here and having the dialog self-pop would pop the screen behind.
if (dialogOpen) {
Navigator.of(context, rootNavigator: true).pop();
}
if (!ok) {
showAppSnackBar(context, t.trackers.connectFailed(service: serviceName));
}
}
@@ -1,11 +1,7 @@
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 '../../models/trackers/device_code.dart';
import '../../providers/trackers_provider.dart';
@@ -15,71 +11,24 @@ import '../../services/trackers/oauth_proxy_client.dart';
import '../../services/trackers/simkl/simkl_tracker.dart';
import '../../services/trackers/tracker_constants.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/oauth_proxy_dialog.dart';
import '../../widgets/settings_section.dart';
import 'tracker_connect_launcher.dart';
import 'tracker_library_filter_screen.dart';
/// Shared dialog-driven connect flow used by all three trackers. Handles the
/// guard, auto-launch-on-pointer, dialog lifecycle, and failure snackbar —
/// service-specific pieces are delegated via [connect], [buildDialog], and
/// [urlFor].
Future<void> _startConnectionWithDialog<T>(
BuildContext context, {
required TrackerService service,
required bool alreadyConnected,
required String serviceName,
required Future<bool> Function(void Function(T)) connect,
required Widget Function(T payload, VoidCallback onCancel) buildDialog,
required String Function(T payload) urlFor,
}) async {
final account = context.read<TrackersProvider>();
if (account.isConnecting(service) || alreadyConnected) return;
final autoLaunchBrowser = !InputModeTracker.isKeyboardMode(context);
var dialogOpen = false;
final ok = await connect((payload) {
if (!context.mounted) return;
dialogOpen = true;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => buildDialog(payload, account.cancelConnect),
).whenComplete(() => dialogOpen = false);
if (autoLaunchBrowser) {
unawaited(
launchUrl(Uri.parse(urlFor(payload)), mode: LaunchMode.externalApplication).catchError((Object e) {
appLogger.d('${service.name}: failed to auto-launch browser', error: e);
return false;
}),
);
}
});
if (!context.mounted) return;
if (dialogOpen) {
Navigator.of(context, rootNavigator: true).pop();
}
if (!ok) {
showAppSnackBar(context, t.trackers.connectFailed(service: serviceName));
}
}
Future<void> startMalConnection(BuildContext context) {
final account = context.read<TrackersProvider>();
final name = t.trackers.services.mal;
return _startConnectionWithDialog<OAuthProxyStart>(
return launchTrackerConnect<OAuthProxyStart>(
context,
service: TrackerService.mal,
alreadyConnected: account.isMalConnected,
isBusyOrConnected: account.isConnecting(TrackerService.mal) || account.isMalConnected,
serviceName: name,
connect: (cb) => account.connectMal(onCodeReady: cb),
onCancel: account.cancelConnect,
buildDialog: (p, cancel) => OAuthProxyDialog(start: p, serviceName: name, onCancel: cancel),
urlFor: (p) => p.url,
);
@@ -88,12 +37,12 @@ Future<void> startMalConnection(BuildContext context) {
Future<void> startAnilistConnection(BuildContext context) {
final account = context.read<TrackersProvider>();
final name = t.trackers.services.anilist;
return _startConnectionWithDialog<OAuthProxyStart>(
return launchTrackerConnect<OAuthProxyStart>(
context,
service: TrackerService.anilist,
alreadyConnected: account.isAnilistConnected,
isBusyOrConnected: account.isConnecting(TrackerService.anilist) || account.isAnilistConnected,
serviceName: name,
connect: (cb) => account.connectAnilist(onCodeReady: cb),
onCancel: account.cancelConnect,
buildDialog: (p, cancel) => OAuthProxyDialog(start: p, serviceName: name, onCancel: cancel),
urlFor: (p) => p.url,
);
@@ -102,12 +51,12 @@ Future<void> startAnilistConnection(BuildContext context) {
Future<void> startSimklConnection(BuildContext context) {
final account = context.read<TrackersProvider>();
final name = t.trackers.services.simkl;
return _startConnectionWithDialog<DeviceCode>(
return launchTrackerConnect<DeviceCode>(
context,
service: TrackerService.simkl,
alreadyConnected: account.isSimklConnected,
isBusyOrConnected: account.isConnecting(TrackerService.simkl) || account.isSimklConnected,
serviceName: name,
connect: (cb) => account.connectSimkl(onCodeReady: cb),
onCancel: account.cancelConnect,
buildDialog: (p, cancel) => DeviceCodeDialog(code: p, serviceName: name, onCancel: cancel),
urlFor: (p) => p.verificationUrlComplete ?? p.verificationUrl,
);
+12 -55
View File
@@ -1,77 +1,34 @@
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 '../../models/trackers/device_code.dart';
import '../../providers/trakt_account_provider.dart';
import '../../services/settings_service.dart';
import '../../services/trackers/tracker_constants.dart';
import '../../services/trakt/trakt_scrobble_service.dart';
import '../../services/trakt/trakt_sync_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';
import 'tracker_connect_launcher.dart';
import 'tracker_library_filter_screen.dart';
/// Start the Trakt device-code OAuth flow from anywhere in the app.
///
/// Shows the code dialog + polls until the user completes authorization.
/// On pointer-driven platforms (desktop + mobile when dpad/keyboard mode
/// isn't active) this additionally auto-launches the browser at
/// `trakt.tv/activate` with the code prefilled — the dialog still shows
/// as a fallback/progress indicator.
Future<void> startTraktConnection(BuildContext context) async {
Future<void> startTraktConnection(BuildContext context) {
final account = context.read<TraktAccountProvider>();
if (account.isConnecting || account.isConnected) return;
final autoLaunchBrowser = !InputModeTracker.isKeyboardMode(context);
var dialogOpen = false;
final ok = await account.connect(
onCodeReady: (code) {
if (!context.mounted) return;
dialogOpen = true;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => DeviceCodeDialog(
code: code,
serviceName: t.trakt.title,
onCancel: account.cancelConnect,
),
).whenComplete(() => dialogOpen = false);
if (autoLaunchBrowser) {
final url = code.verificationUrlComplete ?? code.verificationUrl;
unawaited(
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication).catchError((Object e) {
appLogger.d('Trakt: failed to auto-launch browser', error: e);
return false;
}),
);
}
},
final name = t.trakt.title;
return launchTrackerConnect<DeviceCode>(
context,
isBusyOrConnected: account.isConnecting || account.isConnected,
serviceName: name,
connect: (cb) => account.connect(onCodeReady: cb),
onCancel: account.cancelConnect,
buildDialog: (code, cancel) => DeviceCodeDialog(code: code, serviceName: name, onCancel: cancel),
urlFor: (code) => code.verificationUrlComplete ?? code.verificationUrl,
);
if (!context.mounted) return;
// Close the dialog iff we showed one and it's still up (not already closed by
// the Cancel button). This is the ONLY site that dismisses the dialog —
// popping here and having the dialog self-pop would pop the screen behind.
if (dialogOpen) {
Navigator.of(context, rootNavigator: true).pop();
}
if (!ok && account.session == null) {
showAppSnackBar(context, t.trakt.connectFailed);
}
}
class TraktSettingsScreen extends StatefulWidget {
@@ -0,0 +1,59 @@
import 'dart:async';
import 'package:http/http.dart' as http;
import '../../models/trackers/device_code.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;
/// Base for RFC 8628 device-code auth services.
///
/// Owns the HTTP client lifecycle and the full "request code → invoke UI →
/// poll → build session" dance. Subclasses only implement the three
/// service-specific hooks: [createDeviceCode], [probe], [buildSession].
abstract class DeviceCodeAuthServiceBase<T> {
final http.Client httpClient;
DeviceCodeAuthServiceBase({http.Client? httpClient})
: httpClient = httpClient ?? platform.createPlatformClient();
void dispose() => httpClient.close();
/// Service-specific device-code request.
Future<DeviceCode> createDeviceCode();
/// One service-specific poll attempt — called at each interval tick.
Future<DevicePollEvent> probe(DeviceCode code);
/// Build the session object from a successful token response.
T buildSession(Map<String, dynamic> tokenResponse);
/// Drive the full flow. Invokes [onCodeReady] once with the code so the UI
/// can render the dialog, then polls until the user authorizes, denies, or
/// the code expires. Returns null on denied/expired/cancel.
Future<T?> authorize({
required void Function(DeviceCode code) onCodeReady,
bool Function()? shouldCancel,
}) async {
final code = await createDeviceCode();
onCodeReady(code);
await for (final event in poller.pollDeviceCode(
code,
shouldCancel: shouldCancel,
probe: () => probe(code),
)) {
if (event is DevicePollSuccess) return buildSession(event.tokenResponse);
if (event is DevicePollDenied || event is DevicePollExpired) return null;
}
return null;
}
}
class DeviceCodeAuthFlowException implements Exception {
final String message;
const DeviceCodeAuthFlowException(this.message);
@override
String toString() => 'DeviceCodeAuthFlowException: $message';
}
@@ -4,12 +4,10 @@ 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 '../device_code_auth_service.dart';
import '../oauth_proxy_client.dart';
import 'simkl_constants.dart';
import 'simkl_session.dart';
/// Simkl OAuth PIN (device-code) flow.
///
@@ -17,13 +15,10 @@ import 'simkl_constants.dart';
/// user enters at https://simkl.com/pin. After entry Simkl redirects the
/// browser to the relay's static "signed in" page. The app polls
/// `/oauth/pin/<user_code>?client_id=...` until `result == "OK"`.
class SimklAuthService {
final http.Client _http;
SimklAuthService({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient();
void dispose() => _http.close();
class SimklAuthService extends DeviceCodeAuthServiceBase<SimklSession> {
SimklAuthService({super.httpClient});
@override
Future<DeviceCode> createDeviceCode() async {
final uri = Uri.parse(SimklConstants.pinUrl).replace(
queryParameters: {
@@ -31,9 +26,9 @@ class SimklAuthService {
'redirect': '${OAuthProxyClient.baseUrl}/auth/done',
},
);
final res = await _http.get(uri, headers: SimklConstants.headers()).timeout(const Duration(seconds: 15));
final res = await httpClient.get(uri, headers: SimklConstants.headers()).timeout(const Duration(seconds: 15));
if (res.statusCode != 200) {
throw SimklAuthFlowException('PIN request failed: HTTP ${res.statusCode}: ${res.body}');
throw DeviceCodeAuthFlowException('Simkl PIN request failed: HTTP ${res.statusCode}: ${res.body}');
}
final body = json.decode(res.body) as Map<String, dynamic>;
return DeviceCode(
@@ -47,17 +42,14 @@ class SimklAuthService {
);
}
Stream<DevicePollEvent> pollDeviceCode(DeviceCode code, {bool Function()? shouldCancel}) {
return poller.pollDeviceCode(code, shouldCancel: shouldCancel, probe: () => _probe(code));
}
Future<DevicePollEvent> _probe(DeviceCode code) async {
@override
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));
res = await httpClient.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();
@@ -73,11 +65,7 @@ class SimklAuthService {
}
return const DevicePollPending();
}
}
class SimklAuthFlowException implements Exception {
final String message;
const SimklAuthFlowException(this.message);
@override
String toString() => 'SimklAuthFlowException: $message';
SimklSession buildSession(Map<String, dynamic> tokenResponse) => SimklSession.fromTokenResponse(tokenResponse);
}
@@ -29,6 +29,13 @@ class SimklSession {
createdAt: (json['created_at'] as num).toInt(),
);
/// Build a session from Simkl's device-code `/oauth/pin/<code>` response.
/// Simkl doesn't expose a creation timestamp so we stamp "now".
factory SimklSession.fromTokenResponse(Map<String, dynamic> json) => SimklSession(
accessToken: json['access_token'] as String,
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
String encode() => json.encode(toJson());
static SimklSession decode(String raw) => SimklSession.fromJson(json.decode(raw) as Map<String, dynamic>);
}
@@ -0,0 +1,28 @@
import '../../utils/app_logger.dart';
/// Shared "authorize → enrich → save → assign" pipeline.
///
/// Callers manage the in-flight flag + `notifyListeners` around this call;
/// this helper only owns the inner steps so `TrackersProvider` and
/// `TraktAccountProvider` can't drift. Returns `true` only on a fully applied
/// session — null-from-authorize (cancel/denied/expired) and any exception
/// both surface as `false`.
Future<bool> runConnectPipeline<T>({
required String logLabel,
required Future<T?> Function() authorize,
required Future<T> Function(T raw) enrich,
required Future<void> Function(T enriched) save,
required void Function(T enriched) assign,
}) async {
try {
final raw = await authorize();
if (raw == null) return false;
final enriched = await enrich(raw);
await save(enriched);
assign(enriched);
return true;
} catch (e) {
appLogger.w('$logLabel connect failed', error: e);
return false;
}
}
+13 -31
View File
@@ -1,41 +1,32 @@
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 '../trackers/device_code_poller.dart' as poller;
import '../trackers/device_code_auth_service.dart';
import 'trakt_constants.dart';
import 'trakt_session.dart';
/// Trakt OAuth Device Authorization Grant flow (RFC 8628).
///
/// Mirrors the shape of `PlexAuthService.pollPinUntilClaimed`. The user enters
/// a short code at `trakt.tv/activate` (in any browser); the app polls
/// `/oauth/device/token` until the user completes the flow.
class TraktAuthService {
final http.Client _http;
/// The user enters a short code at `trakt.tv/activate` (in any browser); the
/// app polls `/oauth/device/token` until the user completes the flow.
class TraktAuthService extends DeviceCodeAuthServiceBase<TraktSession> {
TraktAuthService({super.httpClient});
TraktAuthService({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient();
void dispose() => _http.close();
/// Request a device code. The returned [DeviceCode.userCode] is what the
/// user must enter at [DeviceCode.verificationUrl].
@override
Future<DeviceCode> createDeviceCode() async {
final uri = Uri.parse(TraktConstants.deviceCodeUrl);
final sw = Stopwatch()..start();
final res = await _http
final res = await httpClient
.post(uri, headers: TraktConstants.headers(), body: json.encode({'client_id': TraktConstants.clientId}))
.timeout(const Duration(seconds: 15));
sw.stop();
appLogger.d('Trakt POST ${uri.path}${res.statusCode} (${sw.elapsedMilliseconds}ms)');
if (res.statusCode != 200) {
throw TraktAuthFlowException('Device code request failed: HTTP ${res.statusCode}: ${res.body}');
throw DeviceCodeAuthFlowException('Trakt device code request failed: HTTP ${res.statusCode}: ${res.body}');
}
final body = json.decode(res.body) as Map<String, dynamic>;
@@ -51,17 +42,12 @@ class TraktAuthService {
);
}
/// Poll the device-token endpoint until the user authorizes, denies, or the
/// code expires. Pass [shouldCancel] to abort early.
Stream<DevicePollEvent> pollDeviceCode(DeviceCode code, {bool Function()? shouldCancel}) {
return poller.pollDeviceCode(code, shouldCancel: shouldCancel, probe: () => _probe(code));
}
Future<DevicePollEvent> _probe(DeviceCode code) async {
@override
Future<DevicePollEvent> probe(DeviceCode code) async {
final tokenUri = Uri.parse(TraktConstants.deviceTokenUrl);
final http.Response res;
try {
res = await _http
res = await httpClient
.post(
tokenUri,
headers: TraktConstants.headers(),
@@ -94,11 +80,7 @@ class TraktAuthService {
return const DevicePollPending();
}
}
}
class TraktAuthFlowException implements Exception {
final String message;
const TraktAuthFlowException(this.message);
@override
String toString() => 'TraktAuthFlowException: $message';
TraktSession buildSession(Map<String, dynamic> tokenResponse) => TraktSession.fromTokenResponse(tokenResponse);
}