From beff4f9ca01ab61dde484cac7eed63f82d720cb5 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:41:52 +0100 Subject: [PATCH] feat: add watch together playback rejoin flow close #639 --- lib/i18n/de.i18n.json | 4 + lib/i18n/en.i18n.json | 4 + lib/i18n/es.i18n.json | 4 + lib/i18n/fr.i18n.json | 4 + lib/i18n/it.i18n.json | 4 + lib/i18n/ko.i18n.json | 4 + lib/i18n/nl.i18n.json | 4 + lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_de.g.dart | 8 + lib/i18n/strings_en.g.dart | 16 ++ lib/i18n/strings_es.g.dart | 8 + lib/i18n/strings_fr.g.dart | 8 + lib/i18n/strings_it.g.dart | 8 + lib/i18n/strings_ko.g.dart | 8 + lib/i18n/strings_nl.g.dart | 8 + lib/i18n/strings_sv.g.dart | 8 + lib/i18n/strings_zh.g.dart | 8 + lib/i18n/sv.i18n.json | 4 + lib/i18n/zh.i18n.json | 4 + lib/screens/main_screen.dart | 27 +-- lib/utils/video_player_navigation.dart | 37 ++++ .../providers/watch_together_provider.dart | 159 +++++++++++++----- .../screens/watch_together_screen.dart | 106 +++++++++++- 23 files changed, 377 insertions(+), 72 deletions(-) diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index ffc6e4bd..70c1358b 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -704,6 +704,10 @@ "sessionCodeCopied": "Sitzungscode in Zwischenablage kopiert", "relayUnreachable": "Der Relay-Server ist nicht erreichbar. Möglicherweise blockiert dein Internetanbieter die Verbindung. Du kannst es trotzdem versuchen, aber Watch Together funktioniert eventuell nicht.", "reconnectingToHost": "Verbindung zum Host wird wiederhergestellt...", + "currentPlayback": "Aktuelle Wiedergabe", + "joinCurrentPlayback": "Aktuelle Wiedergabe beitreten", + "joinCurrentPlaybackDescription": "Springe zurück zu dem, was der Host gerade ansieht", + "failedToOpenCurrentPlayback": "Aktuelle Wiedergabe konnte nicht geöffnet werden", "participantJoined": "${name} ist beigetreten", "participantLeft": "${name} hat die Sitzung verlassen" }, diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index d99e431d..457d4215 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -684,6 +684,10 @@ "sessionCodeCopied": "Session code copied to clipboard", "relayUnreachable": "The relay server is unreachable. This may be caused by your ISP blocking the connection. You can still try, but Watch Together may not work.", "reconnectingToHost": "Reconnecting to host...", + "currentPlayback": "Current Playback", + "joinCurrentPlayback": "Join Current Playback", + "joinCurrentPlaybackDescription": "Jump back into what the host is currently watching", + "failedToOpenCurrentPlayback": "Failed to open current playback", "participantJoined": "${name} joined", "participantLeft": "${name} left" }, diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 1095a283..fd3c0a32 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -684,6 +684,10 @@ "sessionCodeCopied": "Código de sesión copiado al portapapeles", "relayUnreachable": "El servidor de retransmisión no está disponible. Esto puede deberse a que tu proveedor de internet bloquea la conexión. Puedes intentarlo de todos modos, pero Watch Together podría no funcionar.", "reconnectingToHost": "Reconectando con el anfitrión...", + "currentPlayback": "Reproducción actual", + "joinCurrentPlayback": "Unirse a la reproducción actual", + "joinCurrentPlaybackDescription": "Vuelve a lo que el anfitrión está viendo ahora mismo", + "failedToOpenCurrentPlayback": "No se pudo abrir la reproducción actual", "participantJoined": "${name} se unió", "participantLeft": "${name} se fue" }, diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 8b028995..7b19dd21 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -684,6 +684,10 @@ "sessionCodeCopied": "Code de session copié dans le presse-papiers", "relayUnreachable": "Le serveur relais est inaccessible. Cela peut être dû au blocage de la connexion par votre fournisseur d'accès. Vous pouvez quand même essayer, mais Watch Together pourrait ne pas fonctionner.", "reconnectingToHost": "Reconnexion à l'hôte...", + "currentPlayback": "Lecture en cours", + "joinCurrentPlayback": "Rejoindre la lecture en cours", + "joinCurrentPlaybackDescription": "Revenez à ce que l'hôte regarde actuellement", + "failedToOpenCurrentPlayback": "Impossible d'ouvrir la lecture en cours", "participantJoined": "${name} a rejoint", "participantLeft": "${name} est parti" }, diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 7d85feef..0c47cac4 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -704,6 +704,10 @@ "sessionCodeCopied": "Codice sessione copiato negli appunti", "relayUnreachable": "Il server di inoltro non è raggiungibile. Questo potrebbe essere causato dal blocco della connessione da parte del tuo provider. Puoi comunque provare, ma Watch Together potrebbe non funzionare.", "reconnectingToHost": "Riconnessione all'host...", + "currentPlayback": "Riproduzione corrente", + "joinCurrentPlayback": "Unisciti alla riproduzione corrente", + "joinCurrentPlaybackDescription": "Torna a ciò che l'host sta guardando in questo momento", + "failedToOpenCurrentPlayback": "Impossibile aprire la riproduzione corrente", "participantJoined": "${name} si è unito", "participantLeft": "${name} se ne è andato" }, diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 865e2228..096236cb 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -684,6 +684,10 @@ "sessionCodeCopied": "세션 코드가 클립보드에 복사되었습니다", "relayUnreachable": "릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.", "reconnectingToHost": "호스트에 재연결 중...", + "currentPlayback": "현재 재생", + "joinCurrentPlayback": "현재 재생 참여", + "joinCurrentPlaybackDescription": "호스트가 현재 보고 있는 항목으로 돌아갑니다", + "failedToOpenCurrentPlayback": "현재 재생을 열 수 없습니다", "participantJoined": "${name}님이 참여했습니다", "participantLeft": "${name}님이 나갔습니다" }, diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 620c721e..f4eb7dcb 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -704,6 +704,10 @@ "sessionCodeCopied": "Sessiecode gekopieerd naar klembord", "relayUnreachable": "De relayserver is niet bereikbaar. Dit kan worden veroorzaakt doordat je internetprovider de verbinding blokkeert. Je kunt het toch proberen, maar Watch Together werkt mogelijk niet.", "reconnectingToHost": "Opnieuw verbinden met host...", + "currentPlayback": "Huidige weergave", + "joinCurrentPlayback": "Deelnemen aan huidige weergave", + "joinCurrentPlaybackDescription": "Ga terug naar wat de host nu kijkt", + "failedToOpenCurrentPlayback": "Huidige weergave kon niet worden geopend", "participantJoined": "${name} is toegetreden", "participantLeft": "${name} heeft de sessie verlaten" }, diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 8fc66f30..cee9ba67 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 9 -/// Strings: 7254 (806 per locale) +/// Strings: 7290 (810 per locale) /// -/// Built on 2026-03-05 at 11:22 UTC +/// Built on 2026-03-06 at 19:39 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index be47ac2b..fda8a5df 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -972,6 +972,10 @@ class _TranslationsWatchTogetherDe implements TranslationsWatchTogetherEn { @override String get sessionCodeCopied => 'Sitzungscode in Zwischenablage kopiert'; @override String get relayUnreachable => 'Der Relay-Server ist nicht erreichbar. Möglicherweise blockiert dein Internetanbieter die Verbindung. Du kannst es trotzdem versuchen, aber Watch Together funktioniert eventuell nicht.'; @override String get reconnectingToHost => 'Verbindung zum Host wird wiederhergestellt...'; + @override String get currentPlayback => 'Aktuelle Wiedergabe'; + @override String get joinCurrentPlayback => 'Aktuelle Wiedergabe beitreten'; + @override String get joinCurrentPlaybackDescription => 'Springe zurück zu dem, was der Host gerade ansieht'; + @override String get failedToOpenCurrentPlayback => 'Aktuelle Wiedergabe konnte nicht geöffnet werden'; @override String participantJoined({required Object name}) => '${name} ist beigetreten'; @override String participantLeft({required Object name}) => '${name} hat die Sitzung verlassen'; } @@ -1937,6 +1941,10 @@ extension on TranslationsDe { 'watchTogether.sessionCodeCopied' => 'Sitzungscode in Zwischenablage kopiert', 'watchTogether.relayUnreachable' => 'Der Relay-Server ist nicht erreichbar. Möglicherweise blockiert dein Internetanbieter die Verbindung. Du kannst es trotzdem versuchen, aber Watch Together funktioniert eventuell nicht.', 'watchTogether.reconnectingToHost' => 'Verbindung zum Host wird wiederhergestellt...', + 'watchTogether.currentPlayback' => 'Aktuelle Wiedergabe', + 'watchTogether.joinCurrentPlayback' => 'Aktuelle Wiedergabe beitreten', + 'watchTogether.joinCurrentPlaybackDescription' => 'Springe zurück zu dem, was der Host gerade ansieht', + 'watchTogether.failedToOpenCurrentPlayback' => 'Aktuelle Wiedergabe konnte nicht geöffnet werden', 'watchTogether.participantJoined' => ({required Object name}) => '${name} ist beigetreten', 'watchTogether.participantLeft' => ({required Object name}) => '${name} hat die Sitzung verlassen', 'shaders.title' => 'Shader', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 90f41526..d51dd74a 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2108,6 +2108,18 @@ class TranslationsWatchTogetherEn { /// en: 'Reconnecting to host...' String get reconnectingToHost => 'Reconnecting to host...'; + /// en: 'Current Playback' + String get currentPlayback => 'Current Playback'; + + /// en: 'Join Current Playback' + String get joinCurrentPlayback => 'Join Current Playback'; + + /// en: 'Jump back into what the host is currently watching' + String get joinCurrentPlaybackDescription => 'Jump back into what the host is currently watching'; + + /// en: 'Failed to open current playback' + String get failedToOpenCurrentPlayback => 'Failed to open current playback'; + /// en: '${name} joined' String participantJoined({required Object name}) => '${name} joined'; @@ -3538,6 +3550,10 @@ extension on Translations { 'watchTogether.sessionCodeCopied' => 'Session code copied to clipboard', 'watchTogether.relayUnreachable' => 'The relay server is unreachable. This may be caused by your ISP blocking the connection. You can still try, but Watch Together may not work.', 'watchTogether.reconnectingToHost' => 'Reconnecting to host...', + 'watchTogether.currentPlayback' => 'Current Playback', + 'watchTogether.joinCurrentPlayback' => 'Join Current Playback', + 'watchTogether.joinCurrentPlaybackDescription' => 'Jump back into what the host is currently watching', + 'watchTogether.failedToOpenCurrentPlayback' => 'Failed to open current playback', 'watchTogether.participantJoined' => ({required Object name}) => '${name} joined', 'watchTogether.participantLeft' => ({required Object name}) => '${name} left', 'downloads.title' => 'Downloads', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index dbfdf9c2..a54b5537 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -945,6 +945,10 @@ class _TranslationsWatchTogetherEs implements TranslationsWatchTogetherEn { @override String get sessionCodeCopied => 'Código de sesión copiado al portapapeles'; @override String get relayUnreachable => 'El servidor de retransmisión no está disponible. Esto puede deberse a que tu proveedor de internet bloquea la conexión. Puedes intentarlo de todos modos, pero Watch Together podría no funcionar.'; @override String get reconnectingToHost => 'Reconectando con el anfitrión...'; + @override String get currentPlayback => 'Reproducción actual'; + @override String get joinCurrentPlayback => 'Unirse a la reproducción actual'; + @override String get joinCurrentPlaybackDescription => 'Vuelve a lo que el anfitrión está viendo ahora mismo'; + @override String get failedToOpenCurrentPlayback => 'No se pudo abrir la reproducción actual'; @override String participantJoined({required Object name}) => '${name} se unió'; @override String participantLeft({required Object name}) => '${name} se fue'; } @@ -1919,6 +1923,10 @@ extension on TranslationsEs { 'watchTogether.sessionCodeCopied' => 'Código de sesión copiado al portapapeles', 'watchTogether.relayUnreachable' => 'El servidor de retransmisión no está disponible. Esto puede deberse a que tu proveedor de internet bloquea la conexión. Puedes intentarlo de todos modos, pero Watch Together podría no funcionar.', 'watchTogether.reconnectingToHost' => 'Reconectando con el anfitrión...', + 'watchTogether.currentPlayback' => 'Reproducción actual', + 'watchTogether.joinCurrentPlayback' => 'Unirse a la reproducción actual', + 'watchTogether.joinCurrentPlaybackDescription' => 'Vuelve a lo que el anfitrión está viendo ahora mismo', + 'watchTogether.failedToOpenCurrentPlayback' => 'No se pudo abrir la reproducción actual', 'watchTogether.participantJoined' => ({required Object name}) => '${name} se unió', 'watchTogether.participantLeft' => ({required Object name}) => '${name} se fue', 'downloads.title' => 'Descargas', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 62e73b85..21d13fd1 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -945,6 +945,10 @@ class _TranslationsWatchTogetherFr implements TranslationsWatchTogetherEn { @override String get sessionCodeCopied => 'Code de session copié dans le presse-papiers'; @override String get relayUnreachable => 'Le serveur relais est inaccessible. Cela peut être dû au blocage de la connexion par votre fournisseur d\'accès. Vous pouvez quand même essayer, mais Watch Together pourrait ne pas fonctionner.'; @override String get reconnectingToHost => 'Reconnexion à l\'hôte...'; + @override String get currentPlayback => 'Lecture en cours'; + @override String get joinCurrentPlayback => 'Rejoindre la lecture en cours'; + @override String get joinCurrentPlaybackDescription => 'Revenez à ce que l\'hôte regarde actuellement'; + @override String get failedToOpenCurrentPlayback => 'Impossible d\'ouvrir la lecture en cours'; @override String participantJoined({required Object name}) => '${name} a rejoint'; @override String participantLeft({required Object name}) => '${name} est parti'; } @@ -1919,6 +1923,10 @@ extension on TranslationsFr { 'watchTogether.sessionCodeCopied' => 'Code de session copié dans le presse-papiers', 'watchTogether.relayUnreachable' => 'Le serveur relais est inaccessible. Cela peut être dû au blocage de la connexion par votre fournisseur d\'accès. Vous pouvez quand même essayer, mais Watch Together pourrait ne pas fonctionner.', 'watchTogether.reconnectingToHost' => 'Reconnexion à l\'hôte...', + 'watchTogether.currentPlayback' => 'Lecture en cours', + 'watchTogether.joinCurrentPlayback' => 'Rejoindre la lecture en cours', + 'watchTogether.joinCurrentPlaybackDescription' => 'Revenez à ce que l\'hôte regarde actuellement', + 'watchTogether.failedToOpenCurrentPlayback' => 'Impossible d\'ouvrir la lecture en cours', 'watchTogether.participantJoined' => ({required Object name}) => '${name} a rejoint', 'watchTogether.participantLeft' => ({required Object name}) => '${name} est parti', 'downloads.title' => 'Téléchargements', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 87ae56e5..ab886c80 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -972,6 +972,10 @@ class _TranslationsWatchTogetherIt implements TranslationsWatchTogetherEn { @override String get sessionCodeCopied => 'Codice sessione copiato negli appunti'; @override String get relayUnreachable => 'Il server di inoltro non è raggiungibile. Questo potrebbe essere causato dal blocco della connessione da parte del tuo provider. Puoi comunque provare, ma Watch Together potrebbe non funzionare.'; @override String get reconnectingToHost => 'Riconnessione all\'host...'; + @override String get currentPlayback => 'Riproduzione corrente'; + @override String get joinCurrentPlayback => 'Unisciti alla riproduzione corrente'; + @override String get joinCurrentPlaybackDescription => 'Torna a ciò che l\'host sta guardando in questo momento'; + @override String get failedToOpenCurrentPlayback => 'Impossibile aprire la riproduzione corrente'; @override String participantJoined({required Object name}) => '${name} si è unito'; @override String participantLeft({required Object name}) => '${name} se ne è andato'; } @@ -1937,6 +1941,10 @@ extension on TranslationsIt { 'watchTogether.sessionCodeCopied' => 'Codice sessione copiato negli appunti', 'watchTogether.relayUnreachable' => 'Il server di inoltro non è raggiungibile. Questo potrebbe essere causato dal blocco della connessione da parte del tuo provider. Puoi comunque provare, ma Watch Together potrebbe non funzionare.', 'watchTogether.reconnectingToHost' => 'Riconnessione all\'host...', + 'watchTogether.currentPlayback' => 'Riproduzione corrente', + 'watchTogether.joinCurrentPlayback' => 'Unisciti alla riproduzione corrente', + 'watchTogether.joinCurrentPlaybackDescription' => 'Torna a ciò che l\'host sta guardando in questo momento', + 'watchTogether.failedToOpenCurrentPlayback' => 'Impossibile aprire la riproduzione corrente', 'watchTogether.participantJoined' => ({required Object name}) => '${name} si è unito', 'watchTogether.participantLeft' => ({required Object name}) => '${name} se ne è andato', 'shaders.title' => 'Shader', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 976d1bd2..0a790652 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -945,6 +945,10 @@ class _TranslationsWatchTogetherKo implements TranslationsWatchTogetherEn { @override String get sessionCodeCopied => '세션 코드가 클립보드에 복사되었습니다'; @override String get relayUnreachable => '릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.'; @override String get reconnectingToHost => '호스트에 재연결 중...'; + @override String get currentPlayback => '현재 재생'; + @override String get joinCurrentPlayback => '현재 재생 참여'; + @override String get joinCurrentPlaybackDescription => '호스트가 현재 보고 있는 항목으로 돌아갑니다'; + @override String get failedToOpenCurrentPlayback => '현재 재생을 열 수 없습니다'; @override String participantJoined({required Object name}) => '${name}님이 참여했습니다'; @override String participantLeft({required Object name}) => '${name}님이 나갔습니다'; } @@ -1919,6 +1923,10 @@ extension on TranslationsKo { 'watchTogether.sessionCodeCopied' => '세션 코드가 클립보드에 복사되었습니다', 'watchTogether.relayUnreachable' => '릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.', 'watchTogether.reconnectingToHost' => '호스트에 재연결 중...', + 'watchTogether.currentPlayback' => '현재 재생', + 'watchTogether.joinCurrentPlayback' => '현재 재생 참여', + 'watchTogether.joinCurrentPlaybackDescription' => '호스트가 현재 보고 있는 항목으로 돌아갑니다', + 'watchTogether.failedToOpenCurrentPlayback' => '현재 재생을 열 수 없습니다', 'watchTogether.participantJoined' => ({required Object name}) => '${name}님이 참여했습니다', 'watchTogether.participantLeft' => ({required Object name}) => '${name}님이 나갔습니다', 'downloads.title' => '다운로드', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 6566ba44..11ef378b 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -972,6 +972,10 @@ class _TranslationsWatchTogetherNl implements TranslationsWatchTogetherEn { @override String get sessionCodeCopied => 'Sessiecode gekopieerd naar klembord'; @override String get relayUnreachable => 'De relayserver is niet bereikbaar. Dit kan worden veroorzaakt doordat je internetprovider de verbinding blokkeert. Je kunt het toch proberen, maar Watch Together werkt mogelijk niet.'; @override String get reconnectingToHost => 'Opnieuw verbinden met host...'; + @override String get currentPlayback => 'Huidige weergave'; + @override String get joinCurrentPlayback => 'Deelnemen aan huidige weergave'; + @override String get joinCurrentPlaybackDescription => 'Ga terug naar wat de host nu kijkt'; + @override String get failedToOpenCurrentPlayback => 'Huidige weergave kon niet worden geopend'; @override String participantJoined({required Object name}) => '${name} is toegetreden'; @override String participantLeft({required Object name}) => '${name} heeft de sessie verlaten'; } @@ -1937,6 +1941,10 @@ extension on TranslationsNl { 'watchTogether.sessionCodeCopied' => 'Sessiecode gekopieerd naar klembord', 'watchTogether.relayUnreachable' => 'De relayserver is niet bereikbaar. Dit kan worden veroorzaakt doordat je internetprovider de verbinding blokkeert. Je kunt het toch proberen, maar Watch Together werkt mogelijk niet.', 'watchTogether.reconnectingToHost' => 'Opnieuw verbinden met host...', + 'watchTogether.currentPlayback' => 'Huidige weergave', + 'watchTogether.joinCurrentPlayback' => 'Deelnemen aan huidige weergave', + 'watchTogether.joinCurrentPlaybackDescription' => 'Ga terug naar wat de host nu kijkt', + 'watchTogether.failedToOpenCurrentPlayback' => 'Huidige weergave kon niet worden geopend', 'watchTogether.participantJoined' => ({required Object name}) => '${name} is toegetreden', 'watchTogether.participantLeft' => ({required Object name}) => '${name} heeft de sessie verlaten', 'shaders.title' => 'Shaders', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 88f66d3a..d9bde520 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -972,6 +972,10 @@ class _TranslationsWatchTogetherSv implements TranslationsWatchTogetherEn { @override String get sessionCodeCopied => 'Sessionskod kopierad till urklipp'; @override String get relayUnreachable => 'Reläservern kan inte nås. Detta kan bero på att din internetleverantör blockerar anslutningen. Du kan fortfarande försöka, men Watch Together kanske inte fungerar.'; @override String get reconnectingToHost => 'Återansluter till värd...'; + @override String get currentPlayback => 'Aktuell uppspelning'; + @override String get joinCurrentPlayback => 'Gå med i aktuell uppspelning'; + @override String get joinCurrentPlaybackDescription => 'Hoppa tillbaka till det värden tittar på just nu'; + @override String get failedToOpenCurrentPlayback => 'Kunde inte öppna aktuell uppspelning'; @override String participantJoined({required Object name}) => '${name} gick med'; @override String participantLeft({required Object name}) => '${name} lämnade'; } @@ -1937,6 +1941,10 @@ extension on TranslationsSv { 'watchTogether.sessionCodeCopied' => 'Sessionskod kopierad till urklipp', 'watchTogether.relayUnreachable' => 'Reläservern kan inte nås. Detta kan bero på att din internetleverantör blockerar anslutningen. Du kan fortfarande försöka, men Watch Together kanske inte fungerar.', 'watchTogether.reconnectingToHost' => 'Återansluter till värd...', + 'watchTogether.currentPlayback' => 'Aktuell uppspelning', + 'watchTogether.joinCurrentPlayback' => 'Gå med i aktuell uppspelning', + 'watchTogether.joinCurrentPlaybackDescription' => 'Hoppa tillbaka till det värden tittar på just nu', + 'watchTogether.failedToOpenCurrentPlayback' => 'Kunde inte öppna aktuell uppspelning', 'watchTogether.participantJoined' => ({required Object name}) => '${name} gick med', 'watchTogether.participantLeft' => ({required Object name}) => '${name} lämnade', 'shaders.title' => 'Shaders', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index df15f3e8..0cf66126 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -972,6 +972,10 @@ class _TranslationsWatchTogetherZh implements TranslationsWatchTogetherEn { @override String get sessionCodeCopied => '会话代码已复制到剪贴板'; @override String get relayUnreachable => '无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。'; @override String get reconnectingToHost => '正在重新连接到主持人...'; + @override String get currentPlayback => '当前播放'; + @override String get joinCurrentPlayback => '加入当前播放'; + @override String get joinCurrentPlaybackDescription => '回到房主当前正在观看的内容'; + @override String get failedToOpenCurrentPlayback => '无法打开当前播放'; @override String participantJoined({required Object name}) => '${name} 加入了'; @override String participantLeft({required Object name}) => '${name} 离开了'; } @@ -1937,6 +1941,10 @@ extension on TranslationsZh { 'watchTogether.sessionCodeCopied' => '会话代码已复制到剪贴板', 'watchTogether.relayUnreachable' => '无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。', 'watchTogether.reconnectingToHost' => '正在重新连接到主持人...', + 'watchTogether.currentPlayback' => '当前播放', + 'watchTogether.joinCurrentPlayback' => '加入当前播放', + 'watchTogether.joinCurrentPlaybackDescription' => '回到房主当前正在观看的内容', + 'watchTogether.failedToOpenCurrentPlayback' => '无法打开当前播放', 'watchTogether.participantJoined' => ({required Object name}) => '${name} 加入了', 'watchTogether.participantLeft' => ({required Object name}) => '${name} 离开了', 'shaders.title' => '着色器', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 99841cb5..ccc3483a 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -704,6 +704,10 @@ "sessionCodeCopied": "Sessionskod kopierad till urklipp", "relayUnreachable": "Reläservern kan inte nås. Detta kan bero på att din internetleverantör blockerar anslutningen. Du kan fortfarande försöka, men Watch Together kanske inte fungerar.", "reconnectingToHost": "Återansluter till värd...", + "currentPlayback": "Aktuell uppspelning", + "joinCurrentPlayback": "Gå med i aktuell uppspelning", + "joinCurrentPlaybackDescription": "Hoppa tillbaka till det värden tittar på just nu", + "failedToOpenCurrentPlayback": "Kunde inte öppna aktuell uppspelning", "participantJoined": "${name} gick med", "participantLeft": "${name} lämnade" }, diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 2eea373e..0969144d 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -704,6 +704,10 @@ "sessionCodeCopied": "会话代码已复制到剪贴板", "relayUnreachable": "无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。", "reconnectingToHost": "正在重新连接到主持人...", + "currentPlayback": "当前播放", + "joinCurrentPlayback": "加入当前播放", + "joinCurrentPlaybackDescription": "回到房主当前正在观看的内容", + "failedToOpenCurrentPlayback": "无法打开当前播放", "participantJoined": "${name} 加入了", "participantLeft": "${name} 离开了" }, diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 5920293a..5aa01639 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -43,7 +43,6 @@ import 'livetv/live_tv_screen.dart'; import 'search_screen.dart'; import 'downloads/downloads_screen.dart'; import 'settings/settings_screen.dart'; -import 'video_player_screen.dart'; import 'profile/profile_switch_screen.dart'; import '../services/watch_next_service.dart'; import '../watch_together/watch_together.dart'; @@ -374,27 +373,7 @@ class _MainScreenState extends State with RouteAware, WindowListener if (!mounted) return; // Check before any context usage try { - final multiServer = context.read(); - final client = multiServer.getClientForServer(serverId); - - if (client == null) { - appLogger.w('WatchTogether: Server $serverId not available'); - return; - } - - // Fetch the metadata for the new media - final metadata = await client.getMetadataWithImages(ratingKey); - - if (metadata == null || !mounted) return; - - // Use push to preserve WatchTogetherScreen in navigation stack - // VideoPlayerScreen handles its own replacement via onPlayerMediaSwitched - Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - settings: const RouteSettings(name: kVideoPlayerRouteName), - builder: (_) => VideoPlayerScreen(metadata: metadata), - ), - ); + await navigateToWatchTogetherPlayback(context, ratingKey: ratingKey, serverId: serverId); } catch (e) { appLogger.e('WatchTogether: Failed to navigate to media', error: e); } @@ -1114,9 +1093,7 @@ class _MainScreenState extends State with RouteAware, WindowListener builder: (context, settingsProvider, child) { final hideLabels = !settingsProvider.showNavBarLabels; return NavigationBarTheme( - data: NavigationBarTheme.of(context).copyWith( - height: hideLabels ? 56 : null, - ), + data: NavigationBarTheme.of(context).copyWith(height: hideLabels ? 56 : null), child: NavigationBar( selectedIndex: _currentIndex, onDestinationSelected: _selectTab, diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 5beaaf86..fdec4c1a 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -6,6 +8,7 @@ import '../mpv/mpv.dart'; import '../models/plex_metadata.dart'; import '../models/plex_video_playback_data.dart'; import '../providers/download_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../screens/video_player_screen.dart'; import '../services/external_player_service.dart'; import '../services/settings_service.dart'; @@ -14,6 +17,15 @@ import 'app_logger.dart'; const String kVideoPlayerRouteName = '/video_player'; +class WatchTogetherPlaybackNavigationException implements Exception { + final String message; + + const WatchTogetherPlaybackNavigationException(this.message); + + @override + String toString() => message; +} + /// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash. /// /// This utility function provides a consistent way to navigate to the video player @@ -175,3 +187,28 @@ Future navigateToVideoPlayerWithRefresh( return result; } + +/// Resolves the current Watch Together media and opens the video player. +Future navigateToWatchTogetherPlayback( + BuildContext context, { + required String ratingKey, + required String serverId, + VoidCallback? onBeforeNavigate, +}) async { + final multiServer = context.read(); + final client = multiServer.getClientForServer(serverId); + + if (client == null) { + throw const WatchTogetherPlaybackNavigationException('Watch Together server is unavailable'); + } + + final metadata = await client.getMetadataWithImages(ratingKey); + if (metadata == null) { + throw const WatchTogetherPlaybackNavigationException('Current Watch Together media is unavailable'); + } + + if (!context.mounted) return; + + onBeforeNavigate?.call(); + unawaited(navigateToVideoPlayer(context, metadata: metadata)); +} diff --git a/lib/watch_together/providers/watch_together_provider.dart b/lib/watch_together/providers/watch_together_provider.dart index 4e791647..deca8f63 100644 --- a/lib/watch_together/providers/watch_together_provider.dart +++ b/lib/watch_together/providers/watch_together_provider.dart @@ -28,6 +28,7 @@ class WatchTogetherProvider with ChangeNotifier { final List _participants = []; bool _isSyncing = false; String _displayName = 'User'; + String? _lastHandledCurrentPlaybackKey; // Host reconnect grace period Timer? _hostReconnectTimer; @@ -79,12 +80,79 @@ class WatchTogetherProvider with ChangeNotifier { String? get currentMediaRatingKey => _session?.mediaRatingKey; String? get currentMediaServerId => _session?.mediaServerId; String? get currentMediaTitle => _session?.mediaTitle; + bool get hasCurrentPlayback => + currentMediaRatingKey != null && currentMediaServerId != null && currentMediaTitle != null; /// Set the display name for this user void setDisplayName(String name) { _displayName = name; } + String? _buildPlaybackKey(String? ratingKey, String? serverId) { + if (ratingKey == null || serverId == null) return null; + return '$serverId:$ratingKey'; + } + + void _updateCurrentPlaybackSnapshot({ + required String ratingKey, + required String serverId, + required String mediaTitle, + }) { + _session = _session?.copyWith(mediaRatingKey: ratingKey, mediaServerId: serverId, mediaTitle: mediaTitle); + } + + void _clearCurrentPlaybackSnapshot() { + final session = _session; + if (session == null) return; + + _session = WatchSession( + sessionId: session.sessionId, + role: session.role, + controlMode: session.controlMode, + state: session.state, + participants: session.participants, + errorMessage: session.errorMessage, + hostPeerId: session.hostPeerId, + ); + _lastHandledCurrentPlaybackKey = null; + } + + void _dispatchCurrentPlayback({ + required String ratingKey, + required String serverId, + required String mediaTitle, + required String source, + }) { + final callback = onPlayerMediaSwitched ?? onMediaSwitched; + if (callback == null) { + appLogger.d('WatchTogether: No media switch callback set, keeping snapshot from $source only'); + return; + } + + _lastHandledCurrentPlaybackKey = _buildPlaybackKey(ratingKey, serverId); + appLogger.d('WatchTogether: Dispatching current playback from $source: $mediaTitle'); + callback(ratingKey, serverId, mediaTitle); + } + + void markCurrentPlaybackHandled({required String ratingKey, required String serverId}) { + _lastHandledCurrentPlaybackKey = _buildPlaybackKey(ratingKey, serverId); + } + + void requestCurrentPlaybackSnapshot() { + if (isHost || _peerService == null || _session == null || _peerService!.myPeerId == null) { + return; + } + + final request = SyncMessage.requestSessionConfig(peerId: _peerService!.myPeerId); + if (_session!.hostPeerId != null) { + appLogger.d('WatchTogether: Requesting current playback snapshot from host'); + _peerService!.sendTo(_session!.hostPeerId!, request); + } else { + appLogger.d('WatchTogether: Host peer unknown, broadcasting current playback snapshot request'); + _peerService!.broadcast(request); + } + } + /// Wire up sync manager's state change callback to update provider state void _wireSyncStateChanges() { _syncManager!.onSyncStateChanged = (isSyncing) { @@ -102,6 +170,7 @@ class WatchTogetherProvider with ChangeNotifier { }) async { // Clean up any existing session await leaveSession(); + _lastHandledCurrentPlaybackKey = null; appLogger.d('WatchTogether: Creating session with control mode: $controlMode'); @@ -148,6 +217,7 @@ class WatchTogetherProvider with ChangeNotifier { Future joinSession(String sessionId) async { // Clean up any existing session await leaveSession(); + _lastHandledCurrentPlaybackKey = null; appLogger.d('WatchTogether: Joining session: $sessionId'); @@ -185,6 +255,7 @@ class WatchTogetherProvider with ChangeNotifier { // Announce join to other participants _syncManager!.announceJoin(_displayName); + requestCurrentPlaybackSnapshot(); notifyListeners(); appLogger.d('WatchTogether: Joined session successfully'); @@ -230,6 +301,7 @@ class WatchTogetherProvider with ChangeNotifier { _session = null; _participants.clear(); _isSyncing = false; + _lastHandledCurrentPlaybackKey = null; notifyListeners(); appLogger.d('WatchTogether: Session left'); @@ -266,6 +338,10 @@ class WatchTogetherProvider with ChangeNotifier { _cancelHostReconnectGracePeriod(); } + if (!isHost && peerId == _session?.hostPeerId) { + requestCurrentPlaybackSnapshot(); + } + // Peer will announce themselves with a join message notifyListeners(); }); @@ -413,16 +489,25 @@ class WatchTogetherProvider with ChangeNotifier { notifyListeners(); } - // If config contains media info that differs from our current state, - // trigger a media switch so the guest navigates to the correct content. - // This handles the case where a guest missed a mediaSwitch broadcast - // (e.g., host switched episodes while guest was popping out of the player). - if (message.ratingKey != null && - message.serverId != null && - message.mediaTitle != null && - message.ratingKey != _session?.mediaRatingKey) { - appLogger.d('WatchTogether: Session config contains different media, triggering switch'); - _handleMediaSwitch(message); + if (message.ratingKey != null && message.serverId != null && message.mediaTitle != null) { + final playbackKey = _buildPlaybackKey(message.ratingKey, message.serverId); + final shouldDispatch = playbackKey != _lastHandledCurrentPlaybackKey; + + _updateCurrentPlaybackSnapshot( + ratingKey: message.ratingKey!, + serverId: message.serverId!, + mediaTitle: message.mediaTitle!, + ); + notifyListeners(); + + if (shouldDispatch) { + _dispatchCurrentPlayback( + ratingKey: message.ratingKey!, + serverId: message.serverId!, + mediaTitle: message.mediaTitle!, + source: 'session config', + ); + } } } @@ -470,37 +555,33 @@ class WatchTogetherProvider with ChangeNotifier { void _handleMediaSwitch(SyncMessage message) { if (isHost) return; // Host doesn't need to handle their own switch - // Skip if already playing this media (prevents duplicate navigation from duplicate messages) - if (_session?.mediaRatingKey == message.ratingKey) { - appLogger.d('WatchTogether: Ignoring duplicate media switch for ${message.ratingKey}'); - return; - } - if (message.ratingKey == null || message.serverId == null || message.mediaTitle == null) { appLogger.w('WatchTogether: Received incomplete media switch message'); return; } - appLogger.d('WatchTogether: Received media switch: ${message.mediaTitle}'); + final playbackKey = _buildPlaybackKey(message.ratingKey, message.serverId); + final shouldDispatch = playbackKey != _lastHandledCurrentPlaybackKey; - // Dispatch callback BEFORE updating session state. - // If the callback fails silently (e.g., player disposed mid-animation), - // the ratingKey stays unchanged so the next positionSync or re-sent - // message can retry instead of being treated as a duplicate. - if (onPlayerMediaSwitched != null) { - onPlayerMediaSwitched!(message.ratingKey!, message.serverId!, message.mediaTitle!); - } else { - onMediaSwitched?.call(message.ratingKey!, message.serverId!, message.mediaTitle!); + _updateCurrentPlaybackSnapshot( + ratingKey: message.ratingKey!, + serverId: message.serverId!, + mediaTitle: message.mediaTitle!, + ); + notifyListeners(); + + if (!shouldDispatch) { + appLogger.d('WatchTogether: Ignoring duplicate media switch for ${message.ratingKey}'); + return; } - // Update local session state after successful dispatch - _session = _session?.copyWith( - mediaRatingKey: message.ratingKey, - mediaServerId: message.serverId, - mediaTitle: message.mediaTitle, + appLogger.d('WatchTogether: Received media switch: ${message.mediaTitle}'); + _dispatchCurrentPlayback( + ratingKey: message.ratingKey!, + serverId: message.serverId!, + mediaTitle: message.mediaTitle!, + source: 'media switch', ); - - notifyListeners(); } /// Notify guests that host is exiting the video player @@ -522,23 +603,13 @@ class WatchTogetherProvider with ChangeNotifier { appLogger.d('WatchTogether: Host exited player, callback set: ${onHostExitedPlayer != null}'); - // Clear media info so the next mediaSwitch (even same ratingKey) isn't - // treated as a duplicate by the guard in _handleMediaSwitch. - // copyWith uses ?? so it can't set fields to null — construct directly. - _session = WatchSession( - sessionId: _session!.sessionId, - role: _session!.role, - controlMode: _session!.controlMode, - state: _session!.state, - participants: _session!.participants, - errorMessage: _session!.errorMessage, - hostPeerId: _session!.hostPeerId, - ); + _clearCurrentPlaybackSnapshot(); // Clear the player callback BEFORE popping so that any mediaSwitch message // arriving during the pop animation routes to MainScreen's handler instead // of the dying VideoPlayerScreen. onPlayerMediaSwitched = null; + notifyListeners(); // Trigger callback for the app to navigate guest out of player if (onHostExitedPlayer != null) { diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index dd6d2295..51eedcd0 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -10,6 +10,8 @@ import '../../focus/focusable_button.dart'; import '../../focus/focusable_wrapper.dart'; import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; +import '../../utils/snackbar_helper.dart'; +import '../../utils/video_player_navigation.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../models/watch_session.dart'; import '../providers/watch_together_provider.dart'; @@ -391,11 +393,16 @@ class _ActiveSessionContent extends StatelessWidget { const SizedBox(height: 24), + if (!watchTogether.isHost && watchTogether.hasCurrentPlayback) ...[ + _JoinCurrentPlaybackCard(watchTogether: watchTogether), + const SizedBox(height: 16), + ], + // Leave/End Session Button SizedBox( width: double.infinity, child: FocusableButton( - autofocus: true, + autofocus: watchTogether.isHost || !watchTogether.hasCurrentPlayback, onPressed: () => _leaveSession(context), child: OutlinedButton.icon( onPressed: () => _leaveSession(context), @@ -427,6 +434,103 @@ class _ActiveSessionContent extends StatelessWidget { } } +class _JoinCurrentPlaybackCard extends StatefulWidget { + final WatchTogetherProvider watchTogether; + + const _JoinCurrentPlaybackCard({required this.watchTogether}); + + @override + State<_JoinCurrentPlaybackCard> createState() => _JoinCurrentPlaybackCardState(); +} + +class _JoinCurrentPlaybackCardState extends State<_JoinCurrentPlaybackCard> { + bool _isJoining = false; + + Future _joinCurrentPlayback() async { + final ratingKey = widget.watchTogether.currentMediaRatingKey; + final serverId = widget.watchTogether.currentMediaServerId; + if (ratingKey == null || serverId == null) return; + + setState(() => _isJoining = true); + + try { + await navigateToWatchTogetherPlayback( + context, + ratingKey: ratingKey, + serverId: serverId, + onBeforeNavigate: () { + widget.watchTogether.markCurrentPlaybackHandled(ratingKey: ratingKey, serverId: serverId); + }, + ); + } catch (e, stackTrace) { + appLogger.e('WatchTogether: Failed to open current playback', error: e, stackTrace: stackTrace); + if (mounted) { + showErrorSnackBar(context, t.watchTogether.failedToOpenCurrentPlayback); + } + } finally { + if (mounted) { + setState(() => _isJoining = false); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final mediaTitle = widget.watchTogether.currentMediaTitle; + if (widget.watchTogether.isHost || mediaTitle == null) { + return const SizedBox.shrink(); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Symbols.play_circle_rounded, color: theme.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.watchTogether.currentPlayback, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text( + t.watchTogether.joinCurrentPlaybackDescription, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Text(mediaTitle, style: theme.textTheme.bodyLarge), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FocusableButton( + autofocus: true, + onPressed: _isJoining ? null : _joinCurrentPlayback, + child: FilledButton.icon( + onPressed: _isJoining ? null : _joinCurrentPlayback, + icon: _isJoining + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Symbols.play_arrow_rounded), + label: Text(_isJoining ? t.watchTogether.joining : t.watchTogether.joinCurrentPlayback), + ), + ), + ), + ], + ), + ), + ); + } +} + /// Tappable session code row with copy functionality class _SessionCodeRow extends StatelessWidget { final String sessionId;