feat: add watch together playback rejoin flow

close #639
This commit is contained in:
edde746
2026-03-06 20:42:18 +01:00
parent 1318c54c0c
commit beff4f9ca0
23 changed files with 377 additions and 72 deletions
+4
View File
@@ -704,6 +704,10 @@
"sessionCodeCopied": "Sitzungscode in Zwischenablage kopiert", "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.", "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...", "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", "participantJoined": "${name} ist beigetreten",
"participantLeft": "${name} hat die Sitzung verlassen" "participantLeft": "${name} hat die Sitzung verlassen"
}, },
+4
View File
@@ -684,6 +684,10 @@
"sessionCodeCopied": "Session code copied to clipboard", "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.", "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...", "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", "participantJoined": "${name} joined",
"participantLeft": "${name} left" "participantLeft": "${name} left"
}, },
+4
View File
@@ -684,6 +684,10 @@
"sessionCodeCopied": "Código de sesión copiado al portapapeles", "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.", "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...", "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ó", "participantJoined": "${name} se unió",
"participantLeft": "${name} se fue" "participantLeft": "${name} se fue"
}, },
+4
View File
@@ -684,6 +684,10 @@
"sessionCodeCopied": "Code de session copié dans le presse-papiers", "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.", "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...", "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", "participantJoined": "${name} a rejoint",
"participantLeft": "${name} est parti" "participantLeft": "${name} est parti"
}, },
+4
View File
@@ -704,6 +704,10 @@
"sessionCodeCopied": "Codice sessione copiato negli appunti", "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.", "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...", "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", "participantJoined": "${name} si è unito",
"participantLeft": "${name} se ne è andato" "participantLeft": "${name} se ne è andato"
}, },
+4
View File
@@ -684,6 +684,10 @@
"sessionCodeCopied": "세션 코드가 클립보드에 복사되었습니다", "sessionCodeCopied": "세션 코드가 클립보드에 복사되었습니다",
"relayUnreachable": "릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.", "relayUnreachable": "릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.",
"reconnectingToHost": "호스트에 재연결 중...", "reconnectingToHost": "호스트에 재연결 중...",
"currentPlayback": "현재 재생",
"joinCurrentPlayback": "현재 재생 참여",
"joinCurrentPlaybackDescription": "호스트가 현재 보고 있는 항목으로 돌아갑니다",
"failedToOpenCurrentPlayback": "현재 재생을 열 수 없습니다",
"participantJoined": "${name}님이 참여했습니다", "participantJoined": "${name}님이 참여했습니다",
"participantLeft": "${name}님이 나갔습니다" "participantLeft": "${name}님이 나갔습니다"
}, },
+4
View File
@@ -704,6 +704,10 @@
"sessionCodeCopied": "Sessiecode gekopieerd naar klembord", "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.", "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...", "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", "participantJoined": "${name} is toegetreden",
"participantLeft": "${name} heeft de sessie verlaten" "participantLeft": "${name} heeft de sessie verlaten"
}, },
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 9 /// 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 // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
+8
View File
@@ -972,6 +972,10 @@ class _TranslationsWatchTogetherDe implements TranslationsWatchTogetherEn {
@override String get sessionCodeCopied => 'Sitzungscode in Zwischenablage kopiert'; @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 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 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 participantJoined({required Object name}) => '${name} ist beigetreten';
@override String participantLeft({required Object name}) => '${name} hat die Sitzung verlassen'; @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.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.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.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.participantJoined' => ({required Object name}) => '${name} ist beigetreten',
'watchTogether.participantLeft' => ({required Object name}) => '${name} hat die Sitzung verlassen', 'watchTogether.participantLeft' => ({required Object name}) => '${name} hat die Sitzung verlassen',
'shaders.title' => 'Shader', 'shaders.title' => 'Shader',
+16
View File
@@ -2108,6 +2108,18 @@ class TranslationsWatchTogetherEn {
/// en: 'Reconnecting to host...' /// en: 'Reconnecting to host...'
String get reconnectingToHost => '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' /// en: '${name} joined'
String participantJoined({required Object name}) => '${name} joined'; String participantJoined({required Object name}) => '${name} joined';
@@ -3538,6 +3550,10 @@ extension on Translations {
'watchTogether.sessionCodeCopied' => 'Session code copied to clipboard', '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.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.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.participantJoined' => ({required Object name}) => '${name} joined',
'watchTogether.participantLeft' => ({required Object name}) => '${name} left', 'watchTogether.participantLeft' => ({required Object name}) => '${name} left',
'downloads.title' => 'Downloads', 'downloads.title' => 'Downloads',
+8
View File
@@ -945,6 +945,10 @@ class _TranslationsWatchTogetherEs implements TranslationsWatchTogetherEn {
@override String get sessionCodeCopied => 'Código de sesión copiado al portapapeles'; @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 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 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 participantJoined({required Object name}) => '${name} se unió';
@override String participantLeft({required Object name}) => '${name} se fue'; @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.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.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.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.participantJoined' => ({required Object name}) => '${name} se unió',
'watchTogether.participantLeft' => ({required Object name}) => '${name} se fue', 'watchTogether.participantLeft' => ({required Object name}) => '${name} se fue',
'downloads.title' => 'Descargas', 'downloads.title' => 'Descargas',
+8
View File
@@ -945,6 +945,10 @@ class _TranslationsWatchTogetherFr implements TranslationsWatchTogetherEn {
@override String get sessionCodeCopied => 'Code de session copié dans le presse-papiers'; @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 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 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 participantJoined({required Object name}) => '${name} a rejoint';
@override String participantLeft({required Object name}) => '${name} est parti'; @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.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.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.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.participantJoined' => ({required Object name}) => '${name} a rejoint',
'watchTogether.participantLeft' => ({required Object name}) => '${name} est parti', 'watchTogether.participantLeft' => ({required Object name}) => '${name} est parti',
'downloads.title' => 'Téléchargements', 'downloads.title' => 'Téléchargements',
+8
View File
@@ -972,6 +972,10 @@ class _TranslationsWatchTogetherIt implements TranslationsWatchTogetherEn {
@override String get sessionCodeCopied => 'Codice sessione copiato negli appunti'; @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 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 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 participantJoined({required Object name}) => '${name} si è unito';
@override String participantLeft({required Object name}) => '${name} se ne è andato'; @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.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.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.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.participantJoined' => ({required Object name}) => '${name} si è unito',
'watchTogether.participantLeft' => ({required Object name}) => '${name} se ne è andato', 'watchTogether.participantLeft' => ({required Object name}) => '${name} se ne è andato',
'shaders.title' => 'Shader', 'shaders.title' => 'Shader',
+8
View File
@@ -945,6 +945,10 @@ class _TranslationsWatchTogetherKo implements TranslationsWatchTogetherEn {
@override String get sessionCodeCopied => '세션 코드가 클립보드에 복사되었습니다'; @override String get sessionCodeCopied => '세션 코드가 클립보드에 복사되었습니다';
@override String get relayUnreachable => '릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.'; @override String get relayUnreachable => '릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.';
@override String get reconnectingToHost => '호스트에 재연결 중...'; @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 participantJoined({required Object name}) => '${name}님이 참여했습니다';
@override String participantLeft({required Object name}) => '${name}님이 나갔습니다'; @override String participantLeft({required Object name}) => '${name}님이 나갔습니다';
} }
@@ -1919,6 +1923,10 @@ extension on TranslationsKo {
'watchTogether.sessionCodeCopied' => '세션 코드가 클립보드에 복사되었습니다', 'watchTogether.sessionCodeCopied' => '세션 코드가 클립보드에 복사되었습니다',
'watchTogether.relayUnreachable' => '릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.', 'watchTogether.relayUnreachable' => '릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.',
'watchTogether.reconnectingToHost' => '호스트에 재연결 중...', 'watchTogether.reconnectingToHost' => '호스트에 재연결 중...',
'watchTogether.currentPlayback' => '현재 재생',
'watchTogether.joinCurrentPlayback' => '현재 재생 참여',
'watchTogether.joinCurrentPlaybackDescription' => '호스트가 현재 보고 있는 항목으로 돌아갑니다',
'watchTogether.failedToOpenCurrentPlayback' => '현재 재생을 열 수 없습니다',
'watchTogether.participantJoined' => ({required Object name}) => '${name}님이 참여했습니다', 'watchTogether.participantJoined' => ({required Object name}) => '${name}님이 참여했습니다',
'watchTogether.participantLeft' => ({required Object name}) => '${name}님이 나갔습니다', 'watchTogether.participantLeft' => ({required Object name}) => '${name}님이 나갔습니다',
'downloads.title' => '다운로드', 'downloads.title' => '다운로드',
+8
View File
@@ -972,6 +972,10 @@ class _TranslationsWatchTogetherNl implements TranslationsWatchTogetherEn {
@override String get sessionCodeCopied => 'Sessiecode gekopieerd naar klembord'; @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 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 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 participantJoined({required Object name}) => '${name} is toegetreden';
@override String participantLeft({required Object name}) => '${name} heeft de sessie verlaten'; @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.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.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.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.participantJoined' => ({required Object name}) => '${name} is toegetreden',
'watchTogether.participantLeft' => ({required Object name}) => '${name} heeft de sessie verlaten', 'watchTogether.participantLeft' => ({required Object name}) => '${name} heeft de sessie verlaten',
'shaders.title' => 'Shaders', 'shaders.title' => 'Shaders',
+8
View File
@@ -972,6 +972,10 @@ class _TranslationsWatchTogetherSv implements TranslationsWatchTogetherEn {
@override String get sessionCodeCopied => 'Sessionskod kopierad till urklipp'; @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 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 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 participantJoined({required Object name}) => '${name} gick med';
@override String participantLeft({required Object name}) => '${name} lämnade'; @override String participantLeft({required Object name}) => '${name} lämnade';
} }
@@ -1937,6 +1941,10 @@ extension on TranslationsSv {
'watchTogether.sessionCodeCopied' => 'Sessionskod kopierad till urklipp', '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.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.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.participantJoined' => ({required Object name}) => '${name} gick med',
'watchTogether.participantLeft' => ({required Object name}) => '${name} lämnade', 'watchTogether.participantLeft' => ({required Object name}) => '${name} lämnade',
'shaders.title' => 'Shaders', 'shaders.title' => 'Shaders',
+8
View File
@@ -972,6 +972,10 @@ class _TranslationsWatchTogetherZh implements TranslationsWatchTogetherEn {
@override String get sessionCodeCopied => '会话代码已复制到剪贴板'; @override String get sessionCodeCopied => '会话代码已复制到剪贴板';
@override String get relayUnreachable => '无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。'; @override String get relayUnreachable => '无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。';
@override String get reconnectingToHost => '正在重新连接到主持人...'; @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 participantJoined({required Object name}) => '${name} 加入了';
@override String participantLeft({required Object name}) => '${name} 离开了'; @override String participantLeft({required Object name}) => '${name} 离开了';
} }
@@ -1937,6 +1941,10 @@ extension on TranslationsZh {
'watchTogether.sessionCodeCopied' => '会话代码已复制到剪贴板', 'watchTogether.sessionCodeCopied' => '会话代码已复制到剪贴板',
'watchTogether.relayUnreachable' => '无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。', 'watchTogether.relayUnreachable' => '无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。',
'watchTogether.reconnectingToHost' => '正在重新连接到主持人...', 'watchTogether.reconnectingToHost' => '正在重新连接到主持人...',
'watchTogether.currentPlayback' => '当前播放',
'watchTogether.joinCurrentPlayback' => '加入当前播放',
'watchTogether.joinCurrentPlaybackDescription' => '回到房主当前正在观看的内容',
'watchTogether.failedToOpenCurrentPlayback' => '无法打开当前播放',
'watchTogether.participantJoined' => ({required Object name}) => '${name} 加入了', 'watchTogether.participantJoined' => ({required Object name}) => '${name} 加入了',
'watchTogether.participantLeft' => ({required Object name}) => '${name} 离开了', 'watchTogether.participantLeft' => ({required Object name}) => '${name} 离开了',
'shaders.title' => '着色器', 'shaders.title' => '着色器',
+4
View File
@@ -704,6 +704,10 @@
"sessionCodeCopied": "Sessionskod kopierad till urklipp", "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.", "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...", "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", "participantJoined": "${name} gick med",
"participantLeft": "${name} lämnade" "participantLeft": "${name} lämnade"
}, },
+4
View File
@@ -704,6 +704,10 @@
"sessionCodeCopied": "会话代码已复制到剪贴板", "sessionCodeCopied": "会话代码已复制到剪贴板",
"relayUnreachable": "无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。", "relayUnreachable": "无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。",
"reconnectingToHost": "正在重新连接到主持人...", "reconnectingToHost": "正在重新连接到主持人...",
"currentPlayback": "当前播放",
"joinCurrentPlayback": "加入当前播放",
"joinCurrentPlaybackDescription": "回到房主当前正在观看的内容",
"failedToOpenCurrentPlayback": "无法打开当前播放",
"participantJoined": "${name} 加入了", "participantJoined": "${name} 加入了",
"participantLeft": "${name} 离开了" "participantLeft": "${name} 离开了"
}, },
+2 -25
View File
@@ -43,7 +43,6 @@ import 'livetv/live_tv_screen.dart';
import 'search_screen.dart'; import 'search_screen.dart';
import 'downloads/downloads_screen.dart'; import 'downloads/downloads_screen.dart';
import 'settings/settings_screen.dart'; import 'settings/settings_screen.dart';
import 'video_player_screen.dart';
import 'profile/profile_switch_screen.dart'; import 'profile/profile_switch_screen.dart';
import '../services/watch_next_service.dart'; import '../services/watch_next_service.dart';
import '../watch_together/watch_together.dart'; import '../watch_together/watch_together.dart';
@@ -374,27 +373,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
if (!mounted) return; // Check before any context usage if (!mounted) return; // Check before any context usage
try { try {
final multiServer = context.read<MultiServerProvider>(); await navigateToWatchTogetherPlayback(context, ratingKey: ratingKey, serverId: serverId);
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),
),
);
} catch (e) { } catch (e) {
appLogger.e('WatchTogether: Failed to navigate to media', error: e); appLogger.e('WatchTogether: Failed to navigate to media', error: e);
} }
@@ -1114,9 +1093,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
builder: (context, settingsProvider, child) { builder: (context, settingsProvider, child) {
final hideLabels = !settingsProvider.showNavBarLabels; final hideLabels = !settingsProvider.showNavBarLabels;
return NavigationBarTheme( return NavigationBarTheme(
data: NavigationBarTheme.of(context).copyWith( data: NavigationBarTheme.of(context).copyWith(height: hideLabels ? 56 : null),
height: hideLabels ? 56 : null,
),
child: NavigationBar( child: NavigationBar(
selectedIndex: _currentIndex, selectedIndex: _currentIndex,
onDestinationSelected: _selectTab, onDestinationSelected: _selectTab,
+37
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -6,6 +8,7 @@ import '../mpv/mpv.dart';
import '../models/plex_metadata.dart'; import '../models/plex_metadata.dart';
import '../models/plex_video_playback_data.dart'; import '../models/plex_video_playback_data.dart';
import '../providers/download_provider.dart'; import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart';
import '../screens/video_player_screen.dart'; import '../screens/video_player_screen.dart';
import '../services/external_player_service.dart'; import '../services/external_player_service.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
@@ -14,6 +17,15 @@ import 'app_logger.dart';
const String kVideoPlayerRouteName = '/video_player'; 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. /// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash.
/// ///
/// This utility function provides a consistent way to navigate to the video player /// This utility function provides a consistent way to navigate to the video player
@@ -175,3 +187,28 @@ Future<bool?> navigateToVideoPlayerWithRefresh(
return result; return result;
} }
/// Resolves the current Watch Together media and opens the video player.
Future<void> navigateToWatchTogetherPlayback(
BuildContext context, {
required String ratingKey,
required String serverId,
VoidCallback? onBeforeNavigate,
}) async {
final multiServer = context.read<MultiServerProvider>();
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));
}
@@ -28,6 +28,7 @@ class WatchTogetherProvider with ChangeNotifier {
final List<Participant> _participants = []; final List<Participant> _participants = [];
bool _isSyncing = false; bool _isSyncing = false;
String _displayName = 'User'; String _displayName = 'User';
String? _lastHandledCurrentPlaybackKey;
// Host reconnect grace period // Host reconnect grace period
Timer? _hostReconnectTimer; Timer? _hostReconnectTimer;
@@ -79,12 +80,79 @@ class WatchTogetherProvider with ChangeNotifier {
String? get currentMediaRatingKey => _session?.mediaRatingKey; String? get currentMediaRatingKey => _session?.mediaRatingKey;
String? get currentMediaServerId => _session?.mediaServerId; String? get currentMediaServerId => _session?.mediaServerId;
String? get currentMediaTitle => _session?.mediaTitle; String? get currentMediaTitle => _session?.mediaTitle;
bool get hasCurrentPlayback =>
currentMediaRatingKey != null && currentMediaServerId != null && currentMediaTitle != null;
/// Set the display name for this user /// Set the display name for this user
void setDisplayName(String name) { void setDisplayName(String name) {
_displayName = 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 /// Wire up sync manager's state change callback to update provider state
void _wireSyncStateChanges() { void _wireSyncStateChanges() {
_syncManager!.onSyncStateChanged = (isSyncing) { _syncManager!.onSyncStateChanged = (isSyncing) {
@@ -102,6 +170,7 @@ class WatchTogetherProvider with ChangeNotifier {
}) async { }) async {
// Clean up any existing session // Clean up any existing session
await leaveSession(); await leaveSession();
_lastHandledCurrentPlaybackKey = null;
appLogger.d('WatchTogether: Creating session with control mode: $controlMode'); appLogger.d('WatchTogether: Creating session with control mode: $controlMode');
@@ -148,6 +217,7 @@ class WatchTogetherProvider with ChangeNotifier {
Future<void> joinSession(String sessionId) async { Future<void> joinSession(String sessionId) async {
// Clean up any existing session // Clean up any existing session
await leaveSession(); await leaveSession();
_lastHandledCurrentPlaybackKey = null;
appLogger.d('WatchTogether: Joining session: $sessionId'); appLogger.d('WatchTogether: Joining session: $sessionId');
@@ -185,6 +255,7 @@ class WatchTogetherProvider with ChangeNotifier {
// Announce join to other participants // Announce join to other participants
_syncManager!.announceJoin(_displayName); _syncManager!.announceJoin(_displayName);
requestCurrentPlaybackSnapshot();
notifyListeners(); notifyListeners();
appLogger.d('WatchTogether: Joined session successfully'); appLogger.d('WatchTogether: Joined session successfully');
@@ -230,6 +301,7 @@ class WatchTogetherProvider with ChangeNotifier {
_session = null; _session = null;
_participants.clear(); _participants.clear();
_isSyncing = false; _isSyncing = false;
_lastHandledCurrentPlaybackKey = null;
notifyListeners(); notifyListeners();
appLogger.d('WatchTogether: Session left'); appLogger.d('WatchTogether: Session left');
@@ -266,6 +338,10 @@ class WatchTogetherProvider with ChangeNotifier {
_cancelHostReconnectGracePeriod(); _cancelHostReconnectGracePeriod();
} }
if (!isHost && peerId == _session?.hostPeerId) {
requestCurrentPlaybackSnapshot();
}
// Peer will announce themselves with a join message // Peer will announce themselves with a join message
notifyListeners(); notifyListeners();
}); });
@@ -413,16 +489,25 @@ class WatchTogetherProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
// If config contains media info that differs from our current state, if (message.ratingKey != null && message.serverId != null && message.mediaTitle != null) {
// trigger a media switch so the guest navigates to the correct content. final playbackKey = _buildPlaybackKey(message.ratingKey, message.serverId);
// This handles the case where a guest missed a mediaSwitch broadcast final shouldDispatch = playbackKey != _lastHandledCurrentPlaybackKey;
// (e.g., host switched episodes while guest was popping out of the player).
if (message.ratingKey != null && _updateCurrentPlaybackSnapshot(
message.serverId != null && ratingKey: message.ratingKey!,
message.mediaTitle != null && serverId: message.serverId!,
message.ratingKey != _session?.mediaRatingKey) { mediaTitle: message.mediaTitle!,
appLogger.d('WatchTogether: Session config contains different media, triggering switch'); );
_handleMediaSwitch(message); 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) { void _handleMediaSwitch(SyncMessage message) {
if (isHost) return; // Host doesn't need to handle their own switch 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) { if (message.ratingKey == null || message.serverId == null || message.mediaTitle == null) {
appLogger.w('WatchTogether: Received incomplete media switch message'); appLogger.w('WatchTogether: Received incomplete media switch message');
return; 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. _updateCurrentPlaybackSnapshot(
// If the callback fails silently (e.g., player disposed mid-animation), ratingKey: message.ratingKey!,
// the ratingKey stays unchanged so the next positionSync or re-sent serverId: message.serverId!,
// message can retry instead of being treated as a duplicate. mediaTitle: message.mediaTitle!,
if (onPlayerMediaSwitched != null) { );
onPlayerMediaSwitched!(message.ratingKey!, message.serverId!, message.mediaTitle!); notifyListeners();
} else {
onMediaSwitched?.call(message.ratingKey!, message.serverId!, message.mediaTitle!); if (!shouldDispatch) {
appLogger.d('WatchTogether: Ignoring duplicate media switch for ${message.ratingKey}');
return;
} }
// Update local session state after successful dispatch appLogger.d('WatchTogether: Received media switch: ${message.mediaTitle}');
_session = _session?.copyWith( _dispatchCurrentPlayback(
mediaRatingKey: message.ratingKey, ratingKey: message.ratingKey!,
mediaServerId: message.serverId, serverId: message.serverId!,
mediaTitle: message.mediaTitle, mediaTitle: message.mediaTitle!,
source: 'media switch',
); );
notifyListeners();
} }
/// Notify guests that host is exiting the video player /// 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}'); appLogger.d('WatchTogether: Host exited player, callback set: ${onHostExitedPlayer != null}');
// Clear media info so the next mediaSwitch (even same ratingKey) isn't _clearCurrentPlaybackSnapshot();
// 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,
);
// Clear the player callback BEFORE popping so that any mediaSwitch message // Clear the player callback BEFORE popping so that any mediaSwitch message
// arriving during the pop animation routes to MainScreen's handler instead // arriving during the pop animation routes to MainScreen's handler instead
// of the dying VideoPlayerScreen. // of the dying VideoPlayerScreen.
onPlayerMediaSwitched = null; onPlayerMediaSwitched = null;
notifyListeners();
// Trigger callback for the app to navigate guest out of player // Trigger callback for the app to navigate guest out of player
if (onHostExitedPlayer != null) { if (onHostExitedPlayer != null) {
@@ -10,6 +10,8 @@ import '../../focus/focusable_button.dart';
import '../../focus/focusable_wrapper.dart'; import '../../focus/focusable_wrapper.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../utils/dialogs.dart'; import '../../utils/dialogs.dart';
import '../../utils/snackbar_helper.dart';
import '../../utils/video_player_navigation.dart';
import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/focused_scroll_scaffold.dart';
import '../models/watch_session.dart'; import '../models/watch_session.dart';
import '../providers/watch_together_provider.dart'; import '../providers/watch_together_provider.dart';
@@ -391,11 +393,16 @@ class _ActiveSessionContent extends StatelessWidget {
const SizedBox(height: 24), const SizedBox(height: 24),
if (!watchTogether.isHost && watchTogether.hasCurrentPlayback) ...[
_JoinCurrentPlaybackCard(watchTogether: watchTogether),
const SizedBox(height: 16),
],
// Leave/End Session Button // Leave/End Session Button
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FocusableButton( child: FocusableButton(
autofocus: true, autofocus: watchTogether.isHost || !watchTogether.hasCurrentPlayback,
onPressed: () => _leaveSession(context), onPressed: () => _leaveSession(context),
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () => _leaveSession(context), 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<void> _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 /// Tappable session code row with copy functionality
class _SessionCodeRow extends StatelessWidget { class _SessionCodeRow extends StatelessWidget {
final String sessionId; final String sessionId;