fix: watch together bugs, TV focus issues, system locale default

- Fix guest controls locked on join (use reactive context.select)
- Fix Google TV back button when controls hidden (focus recovery + global fallback)
- Fix profile switch screen focus lost on TV (explicit focus request)
- Add 15s host reconnect grace period with reconnecting overlay
- Add participant join/leave notification toasts
- Default to system language on first launch via findDeviceLocale()
This commit is contained in:
edde746
2026-02-11 00:04:09 +01:00
parent df844757ec
commit f33f58339d
25 changed files with 327 additions and 21 deletions
+4 -1
View File
@@ -644,7 +644,10 @@
"failedToCreate": "Sitzung konnte nicht erstellt werden", "failedToCreate": "Sitzung konnte nicht erstellt werden",
"failedToJoin": "Sitzung konnte nicht beigetreten werden", "failedToJoin": "Sitzung konnte nicht beigetreten werden",
"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...",
"participantJoined": "${name} ist beigetreten",
"participantLeft": "${name} hat die Sitzung verlassen"
}, },
"shaders": { "shaders": {
"title": "Shader", "title": "Shader",
+4 -1
View File
@@ -624,7 +624,10 @@
"failedToCreate": "Failed to create session", "failedToCreate": "Failed to create session",
"failedToJoin": "Failed to join session", "failedToJoin": "Failed to join session",
"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...",
"participantJoined": "${name} joined",
"participantLeft": "${name} left"
}, },
"downloads": { "downloads": {
"title": "Downloads", "title": "Downloads",
+4 -1
View File
@@ -624,7 +624,10 @@
"failedToCreate": "Error al crear la sesión", "failedToCreate": "Error al crear la sesión",
"failedToJoin": "Error al unirse a la sesión", "failedToJoin": "Error al unirse a la sesión",
"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...",
"participantJoined": "${name} se unió",
"participantLeft": "${name} se fue"
}, },
"downloads": { "downloads": {
"title": "Descargas", "title": "Descargas",
+4 -1
View File
@@ -624,7 +624,10 @@
"failedToCreate": "Échec de la création de la session", "failedToCreate": "Échec de la création de la session",
"failedToJoin": "Échec de la connexion à la session", "failedToJoin": "Échec de la connexion à la session",
"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...",
"participantJoined": "${name} a rejoint",
"participantLeft": "${name} est parti"
}, },
"downloads": { "downloads": {
"title": "Téléchargements", "title": "Téléchargements",
+4 -1
View File
@@ -644,7 +644,10 @@
"failedToCreate": "Impossibile creare la sessione", "failedToCreate": "Impossibile creare la sessione",
"failedToJoin": "Impossibile unirsi alla sessione", "failedToJoin": "Impossibile unirsi alla sessione",
"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...",
"participantJoined": "${name} si è unito",
"participantLeft": "${name} se ne è andato"
}, },
"shaders": { "shaders": {
"title": "Shader", "title": "Shader",
+4 -1
View File
@@ -624,7 +624,10 @@
"failedToCreate": "세션 생성 실패", "failedToCreate": "세션 생성 실패",
"failedToJoin": "세션 참여 실패", "failedToJoin": "세션 참여 실패",
"sessionCodeCopied": "세션 코드가 클립보드에 복사되었습니다", "sessionCodeCopied": "세션 코드가 클립보드에 복사되었습니다",
"relayUnreachable": "릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다." "relayUnreachable": "릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.",
"reconnectingToHost": "호스트에 재연결 중...",
"participantJoined": "${name}님이 참여했습니다",
"participantLeft": "${name}님이 나갔습니다"
}, },
"downloads": { "downloads": {
"title": "다운로드", "title": "다운로드",
+4 -1
View File
@@ -644,7 +644,10 @@
"failedToCreate": "Sessie maken mislukt", "failedToCreate": "Sessie maken mislukt",
"failedToJoin": "Sessie deelnemen mislukt", "failedToJoin": "Sessie deelnemen mislukt",
"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...",
"participantJoined": "${name} is toegetreden",
"participantLeft": "${name} heeft de sessie verlaten"
}, },
"shaders": { "shaders": {
"title": "Shaders", "title": "Shaders",
+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: 6057 (673 per locale) /// Strings: 6084 (676 per locale)
/// ///
/// Built on 2026-02-10 at 21:37 UTC /// Built on 2026-02-10 at 21:45 UTC
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
+6
View File
@@ -905,6 +905,9 @@ class _TranslationsWatchTogetherDe implements TranslationsWatchTogetherEn {
@override String get failedToJoin => 'Sitzung konnte nicht beigetreten werden'; @override String get failedToJoin => 'Sitzung konnte nicht beigetreten werden';
@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 participantJoined({required Object name}) => '${name} ist beigetreten';
@override String participantLeft({required Object name}) => '${name} hat die Sitzung verlassen';
} }
// Path: shaders // Path: shaders
@@ -1717,6 +1720,9 @@ extension on TranslationsDe {
'watchTogether.failedToJoin' => 'Sitzung konnte nicht beigetreten werden', 'watchTogether.failedToJoin' => 'Sitzung konnte nicht beigetreten werden',
'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.participantJoined' => ({required Object name}) => '${name} ist beigetreten',
'watchTogether.participantLeft' => ({required Object name}) => '${name} hat die Sitzung verlassen',
'shaders.title' => 'Shader', 'shaders.title' => 'Shader',
'shaders.noShaderDescription' => 'Keine Videoverbesserung', 'shaders.noShaderDescription' => 'Keine Videoverbesserung',
'shaders.nvscalerDescription' => 'NVIDIA-Bildskalierung für schärferes Video', 'shaders.nvscalerDescription' => 'NVIDIA-Bildskalierung für schärferes Video',
+12
View File
@@ -1930,6 +1930,15 @@ class TranslationsWatchTogetherEn {
/// en: '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.' /// en: '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.'
String get 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.'; String get 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.';
/// en: 'Reconnecting to host...'
String get reconnectingToHost => 'Reconnecting to host...';
/// en: '${name} joined'
String participantJoined({required Object name}) => '${name} joined';
/// en: '${name} left'
String participantLeft({required Object name}) => '${name} left';
} }
// Path: downloads // Path: downloads
@@ -3052,6 +3061,9 @@ extension on Translations {
'watchTogether.failedToJoin' => 'Failed to join session', 'watchTogether.failedToJoin' => 'Failed to join session',
'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.participantJoined' => ({required Object name}) => '${name} joined',
'watchTogether.participantLeft' => ({required Object name}) => '${name} left',
'downloads.title' => 'Downloads', 'downloads.title' => 'Downloads',
'downloads.manage' => 'Manage', 'downloads.manage' => 'Manage',
'downloads.tvShows' => 'TV Shows', 'downloads.tvShows' => 'TV Shows',
+6
View File
@@ -878,6 +878,9 @@ class _TranslationsWatchTogetherEs implements TranslationsWatchTogetherEn {
@override String get failedToJoin => 'Error al unirse a la sesión'; @override String get failedToJoin => 'Error al unirse a la sesión';
@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 participantJoined({required Object name}) => '${name} se unió';
@override String participantLeft({required Object name}) => '${name} se fue';
} }
// Path: downloads // Path: downloads
@@ -1699,6 +1702,9 @@ extension on TranslationsEs {
'watchTogether.failedToJoin' => 'Error al unirse a la sesión', 'watchTogether.failedToJoin' => 'Error al unirse a la sesión',
'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.participantJoined' => ({required Object name}) => '${name} se unió',
'watchTogether.participantLeft' => ({required Object name}) => '${name} se fue',
'downloads.title' => 'Descargas', 'downloads.title' => 'Descargas',
'downloads.manage' => 'Gestionar', 'downloads.manage' => 'Gestionar',
'downloads.tvShows' => 'Series de TV', 'downloads.tvShows' => 'Series de TV',
+6
View File
@@ -878,6 +878,9 @@ class _TranslationsWatchTogetherFr implements TranslationsWatchTogetherEn {
@override String get failedToJoin => 'Échec de la connexion à la session'; @override String get failedToJoin => 'Échec de la connexion à la session';
@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 participantJoined({required Object name}) => '${name} a rejoint';
@override String participantLeft({required Object name}) => '${name} est parti';
} }
// Path: downloads // Path: downloads
@@ -1699,6 +1702,9 @@ extension on TranslationsFr {
'watchTogether.failedToJoin' => 'Échec de la connexion à la session', 'watchTogether.failedToJoin' => 'Échec de la connexion à la session',
'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.participantJoined' => ({required Object name}) => '${name} a rejoint',
'watchTogether.participantLeft' => ({required Object name}) => '${name} est parti',
'downloads.title' => 'Téléchargements', 'downloads.title' => 'Téléchargements',
'downloads.manage' => 'Gérer', 'downloads.manage' => 'Gérer',
'downloads.tvShows' => 'Show TV', 'downloads.tvShows' => 'Show TV',
+6
View File
@@ -905,6 +905,9 @@ class _TranslationsWatchTogetherIt implements TranslationsWatchTogetherEn {
@override String get failedToJoin => 'Impossibile unirsi alla sessione'; @override String get failedToJoin => 'Impossibile unirsi alla sessione';
@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 participantJoined({required Object name}) => '${name} si è unito';
@override String participantLeft({required Object name}) => '${name} se ne è andato';
} }
// Path: shaders // Path: shaders
@@ -1717,6 +1720,9 @@ extension on TranslationsIt {
'watchTogether.failedToJoin' => 'Impossibile unirsi alla sessione', 'watchTogether.failedToJoin' => 'Impossibile unirsi alla sessione',
'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.participantJoined' => ({required Object name}) => '${name} si è unito',
'watchTogether.participantLeft' => ({required Object name}) => '${name} se ne è andato',
'shaders.title' => 'Shader', 'shaders.title' => 'Shader',
'shaders.noShaderDescription' => 'Nessun miglioramento video', 'shaders.noShaderDescription' => 'Nessun miglioramento video',
'shaders.nvscalerDescription' => 'Ridimensionamento NVIDIA per video più nitido', 'shaders.nvscalerDescription' => 'Ridimensionamento NVIDIA per video più nitido',
+6
View File
@@ -878,6 +878,9 @@ class _TranslationsWatchTogetherKo implements TranslationsWatchTogetherEn {
@override String get failedToJoin => '세션 참여 실패'; @override String get failedToJoin => '세션 참여 실패';
@override String get sessionCodeCopied => '세션 코드가 클립보드에 복사되었습니다'; @override String get sessionCodeCopied => '세션 코드가 클립보드에 복사되었습니다';
@override String get relayUnreachable => '릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.'; @override String get relayUnreachable => '릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.';
@override String get reconnectingToHost => '호스트에 재연결 중...';
@override String participantJoined({required Object name}) => '${name}님이 참여했습니다';
@override String participantLeft({required Object name}) => '${name}님이 나갔습니다';
} }
// Path: downloads // Path: downloads
@@ -1699,6 +1702,9 @@ extension on TranslationsKo {
'watchTogether.failedToJoin' => '세션 참여 실패', 'watchTogether.failedToJoin' => '세션 참여 실패',
'watchTogether.sessionCodeCopied' => '세션 코드가 클립보드에 복사되었습니다', 'watchTogether.sessionCodeCopied' => '세션 코드가 클립보드에 복사되었습니다',
'watchTogether.relayUnreachable' => '릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.', 'watchTogether.relayUnreachable' => '릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.',
'watchTogether.reconnectingToHost' => '호스트에 재연결 중...',
'watchTogether.participantJoined' => ({required Object name}) => '${name}님이 참여했습니다',
'watchTogether.participantLeft' => ({required Object name}) => '${name}님이 나갔습니다',
'downloads.title' => '다운로드', 'downloads.title' => '다운로드',
'downloads.manage' => '관리', 'downloads.manage' => '관리',
'downloads.tvShows' => 'TV 프로그램', 'downloads.tvShows' => 'TV 프로그램',
+6
View File
@@ -905,6 +905,9 @@ class _TranslationsWatchTogetherNl implements TranslationsWatchTogetherEn {
@override String get failedToJoin => 'Sessie deelnemen mislukt'; @override String get failedToJoin => 'Sessie deelnemen mislukt';
@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 participantJoined({required Object name}) => '${name} is toegetreden';
@override String participantLeft({required Object name}) => '${name} heeft de sessie verlaten';
} }
// Path: shaders // Path: shaders
@@ -1717,6 +1720,9 @@ extension on TranslationsNl {
'watchTogether.failedToJoin' => 'Sessie deelnemen mislukt', 'watchTogether.failedToJoin' => 'Sessie deelnemen mislukt',
'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.participantJoined' => ({required Object name}) => '${name} is toegetreden',
'watchTogether.participantLeft' => ({required Object name}) => '${name} heeft de sessie verlaten',
'shaders.title' => 'Shaders', 'shaders.title' => 'Shaders',
'shaders.noShaderDescription' => 'Geen videoverbetering', 'shaders.noShaderDescription' => 'Geen videoverbetering',
'shaders.nvscalerDescription' => 'NVIDIA-beeldschaling voor scherpere video', 'shaders.nvscalerDescription' => 'NVIDIA-beeldschaling voor scherpere video',
+6
View File
@@ -905,6 +905,9 @@ class _TranslationsWatchTogetherSv implements TranslationsWatchTogetherEn {
@override String get failedToJoin => 'Det gick inte att gå med i session'; @override String get failedToJoin => 'Det gick inte att gå med i session';
@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 participantJoined({required Object name}) => '${name} gick med';
@override String participantLeft({required Object name}) => '${name} lämnade';
} }
// Path: shaders // Path: shaders
@@ -1717,6 +1720,9 @@ extension on TranslationsSv {
'watchTogether.failedToJoin' => 'Det gick inte att gå med i session', 'watchTogether.failedToJoin' => 'Det gick inte att gå med i session',
'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.participantJoined' => ({required Object name}) => '${name} gick med',
'watchTogether.participantLeft' => ({required Object name}) => '${name} lämnade',
'shaders.title' => 'Shaders', 'shaders.title' => 'Shaders',
'shaders.noShaderDescription' => 'Ingen videoförbättring', 'shaders.noShaderDescription' => 'Ingen videoförbättring',
'shaders.nvscalerDescription' => 'NVIDIA-bildskalning för skarpare video', 'shaders.nvscalerDescription' => 'NVIDIA-bildskalning för skarpare video',
+6
View File
@@ -905,6 +905,9 @@ class _TranslationsWatchTogetherZh implements TranslationsWatchTogetherEn {
@override String get failedToJoin => '加入会话失败'; @override String get failedToJoin => '加入会话失败';
@override String get sessionCodeCopied => '会话代码已复制到剪贴板'; @override String get sessionCodeCopied => '会话代码已复制到剪贴板';
@override String get relayUnreachable => '无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。'; @override String get relayUnreachable => '无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。';
@override String get reconnectingToHost => '正在重新连接到主持人...';
@override String participantJoined({required Object name}) => '${name} 加入了';
@override String participantLeft({required Object name}) => '${name} 离开了';
} }
// Path: shaders // Path: shaders
@@ -1717,6 +1720,9 @@ extension on TranslationsZh {
'watchTogether.failedToJoin' => '加入会话失败', 'watchTogether.failedToJoin' => '加入会话失败',
'watchTogether.sessionCodeCopied' => '会话代码已复制到剪贴板', 'watchTogether.sessionCodeCopied' => '会话代码已复制到剪贴板',
'watchTogether.relayUnreachable' => '无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。', 'watchTogether.relayUnreachable' => '无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。',
'watchTogether.reconnectingToHost' => '正在重新连接到主持人...',
'watchTogether.participantJoined' => ({required Object name}) => '${name} 加入了',
'watchTogether.participantLeft' => ({required Object name}) => '${name} 离开了',
'shaders.title' => '着色器', 'shaders.title' => '着色器',
'shaders.noShaderDescription' => '无视频增强', 'shaders.noShaderDescription' => '无视频增强',
'shaders.nvscalerDescription' => 'NVIDIA 图像缩放,使视频更清晰', 'shaders.nvscalerDescription' => 'NVIDIA 图像缩放,使视频更清晰',
+4 -1
View File
@@ -644,7 +644,10 @@
"failedToCreate": "Det gick inte att skapa session", "failedToCreate": "Det gick inte att skapa session",
"failedToJoin": "Det gick inte att gå med i session", "failedToJoin": "Det gick inte att gå med i session",
"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...",
"participantJoined": "${name} gick med",
"participantLeft": "${name} lämnade"
}, },
"shaders": { "shaders": {
"title": "Shaders", "title": "Shaders",
+4 -1
View File
@@ -644,7 +644,10 @@
"failedToCreate": "创建会话失败", "failedToCreate": "创建会话失败",
"failedToJoin": "加入会话失败", "failedToJoin": "加入会话失败",
"sessionCodeCopied": "会话代码已复制到剪贴板", "sessionCodeCopied": "会话代码已复制到剪贴板",
"relayUnreachable": "无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。" "relayUnreachable": "无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。",
"reconnectingToHost": "正在重新连接到主持人...",
"participantJoined": "${name} 加入了",
"participantLeft": "${name} 离开了"
}, },
"shaders": { "shaders": {
"title": "着色器", "title": "着色器",
@@ -22,6 +22,14 @@ class ProfileSwitchScreen extends StatefulWidget {
class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> { class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> {
bool _allowPop = false; bool _allowPop = false;
final FocusNode _firstSelectableFocusNode = FocusNode();
bool _focusRequested = false;
@override
void dispose() {
_firstSelectableFocusNode.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -77,10 +85,18 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> {
final isFirstSelectable = final isFirstSelectable =
!isCurrentUser && !users.take(index).any((u) => u.uuid != userProvider.currentUser?.uuid); !isCurrentUser && !users.take(index).any((u) => u.uuid != userProvider.currentUser?.uuid);
if (isFirstSelectable && !_focusRequested) {
_focusRequested = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _firstSelectableFocusNode.requestFocus();
});
}
return Padding( return Padding(
padding: EdgeInsets.only(left: 16, right: 16, top: index == 0 ? 16 : 0, bottom: 8), padding: EdgeInsets.only(left: 16, right: 16, top: index == 0 ? 16 : 0, bottom: 8),
child: FocusableWrapper( child: FocusableWrapper(
autofocus: isFirstSelectable, autofocus: isFirstSelectable,
focusNode: isFirstSelectable ? _firstSelectableFocusNode : null,
disableScale: true, disableScale: true,
onSelect: isCurrentUser ? null : () => _switchToUser(context, user), onSelect: isCurrentUser ? null : () => _switchToUser(context, user),
child: Card( child: Card(
+36 -5
View File
@@ -58,6 +58,7 @@ import '../focus/dpad_navigator.dart';
import '../focus/key_event_utils.dart'; import '../focus/key_event_utils.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../watch_together/providers/watch_together_provider.dart'; import '../watch_together/providers/watch_together_provider.dart';
import '../watch_together/widgets/watch_together_overlay.dart';
class VideoPlayerScreen extends StatefulWidget { class VideoPlayerScreen extends StatefulWidget {
final PlexMetadata metadata; final PlexMetadata metadata;
@@ -1986,13 +1987,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
} }
}); });
// Compute canControl from Watch Together provider // Compute canControl from Watch Together provider (reactive)
bool canControl = true; bool canControl = true;
try { try {
final watchTogether = this.context.read<WatchTogetherProvider>(); canControl = context.select<WatchTogetherProvider, bool>(
if (watchTogether.isInSession) { (wt) => wt.isInSession ? wt.canControl() : true,
canControl = watchTogether.canControl(); );
}
} catch (e) { } catch (e) {
// Watch Together not available, default to can control // Watch Together not available, default to can control
} }
@@ -2249,6 +2249,37 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
); );
}, },
), ),
// Watch Together: reconnecting to host overlay
Consumer<WatchTogetherProvider>(
builder: (context, provider, child) {
if (!provider.isWaitingForHostReconnect) return const SizedBox.shrink();
return Positioned(
bottom: 120,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
),
const SizedBox(width: 8),
Text(t.watchTogether.reconnectingToHost, style: const TextStyle(color: Colors.white, fontSize: 12)),
],
),
),
),
);
},
),
// Watch Together: participant join/leave notifications
const ParticipantNotificationOverlay(),
// Black overlay during exit (no spinner - just covers transparency) // Black overlay during exit (no spinner - just covers transparency)
ValueListenableBuilder<bool>( ValueListenableBuilder<bool>(
valueListenable: _isExiting, valueListenable: _isExiting,
+1 -1
View File
@@ -810,7 +810,7 @@ class SettingsService extends BaseSharedPreferencesService {
AppLocale getAppLocale() { AppLocale getAppLocale() {
final localeString = prefs.getString(_keyAppLocale); final localeString = prefs.getString(_keyAppLocale);
if (localeString == null) return AppLocale.en; // Default to English if (localeString == null) return AppLocaleUtils.findDeviceLocale();
return AppLocale.values.firstWhere((locale) => locale.languageCode == localeString, orElse: () => AppLocale.en); return AppLocale.values.firstWhere((locale) => locale.languageCode == localeString, orElse: () => AppLocale.en);
} }
@@ -29,6 +29,10 @@ class WatchTogetherProvider with ChangeNotifier {
bool _isSyncing = false; bool _isSyncing = false;
String _displayName = 'User'; String _displayName = 'User';
// Host reconnect grace period
Timer? _hostReconnectTimer;
bool _isWaitingForHostReconnect = false;
/// Generate a random display name for this session /// Generate a random display name for this session
static String _generateDisplayName() { static String _generateDisplayName() {
const adjectives = ['Happy', 'Sleepy', 'Sunny', 'Cozy', 'Chill', 'Swift', 'Brave', 'Calm', 'Jolly', 'Lucky']; const adjectives = ['Happy', 'Sleepy', 'Sunny', 'Cozy', 'Chill', 'Swift', 'Brave', 'Calm', 'Jolly', 'Lucky'];
@@ -65,6 +69,11 @@ class WatchTogetherProvider with ChangeNotifier {
ControlMode get controlMode => _session?.controlMode ?? ControlMode.hostOnly; ControlMode get controlMode => _session?.controlMode ?? ControlMode.hostOnly;
String? get sessionId => _session?.sessionId; String? get sessionId => _session?.sessionId;
WatchTogetherSyncManager? get syncManager => _syncManager; WatchTogetherSyncManager? get syncManager => _syncManager;
bool get isWaitingForHostReconnect => _isWaitingForHostReconnect;
// Participant join/leave event stream
final StreamController<ParticipantEvent> _participantEventController = StreamController<ParticipantEvent>.broadcast();
Stream<ParticipantEvent> get participantEvents => _participantEventController.stream;
// Current media getters // Current media getters
String? get currentMediaRatingKey => _session?.mediaRatingKey; String? get currentMediaRatingKey => _session?.mediaRatingKey;
@@ -207,6 +216,9 @@ class WatchTogetherProvider with ChangeNotifier {
_messageSubscription = null; _messageSubscription = null;
_errorSubscription = null; _errorSubscription = null;
// Cancel host reconnect grace period
_cancelHostReconnectGracePeriod();
// Clean up services // Clean up services
_syncManager?.dispose(); _syncManager?.dispose();
_syncManager = null; _syncManager = null;
@@ -248,19 +260,29 @@ class WatchTogetherProvider with ChangeNotifier {
void _setupPeerServiceListeners() { void _setupPeerServiceListeners() {
_peerConnectedSubscription = _peerService!.onPeerConnected.listen((peerId) { _peerConnectedSubscription = _peerService!.onPeerConnected.listen((peerId) {
appLogger.d('WatchTogether: Peer connected: $peerId'); appLogger.d('WatchTogether: Peer connected: $peerId');
// If host reconnected during grace period, cancel the timer
if (!isHost && peerId == _session?.hostPeerId && _isWaitingForHostReconnect) {
_cancelHostReconnectGracePeriod();
}
// Peer will announce themselves with a join message // Peer will announce themselves with a join message
notifyListeners(); notifyListeners();
}); });
_peerDisconnectedSubscription = _peerService!.onPeerDisconnected.listen((peerId) { _peerDisconnectedSubscription = _peerService!.onPeerDisconnected.listen((peerId) {
appLogger.d('WatchTogether: Peer disconnected: $peerId'); appLogger.d('WatchTogether: Peer disconnected: $peerId');
// Capture display name before removal for notification
final disconnectedName = _participants.where((p) => p.peerId == peerId).map((p) => p.displayName).firstOrNull;
_participants.removeWhere((p) => p.peerId == peerId); _participants.removeWhere((p) => p.peerId == peerId);
// If host disconnected, end session for guests // If host disconnected, start grace period for reconnection
if (!isHost && peerId == _session?.hostPeerId) { if (!isHost && peerId == _session?.hostPeerId) {
_session = _session?.copyWith(state: SessionState.error, errorMessage: 'Host left the session'); _startHostReconnectGracePeriod();
// Ensure guests exit the player if host disappears } else if (disconnectedName != null) {
onHostExitedPlayer?.call(); _participantEventController.add(ParticipantEvent(displayName: disconnectedName, type: ParticipantEventType.left));
} }
notifyListeners(); notifyListeners();
@@ -300,6 +322,9 @@ class WatchTogetherProvider with ChangeNotifier {
_participants.add( _participants.add(
Participant(peerId: message.peerId!, displayName: message.displayName!, isHost: message.isHost ?? false), Participant(peerId: message.peerId!, displayName: message.displayName!, isHost: message.isHost ?? false),
); );
_participantEventController.add(
ParticipantEvent(displayName: message.displayName!, type: ParticipantEventType.joined),
);
} }
// If we're the host, send our join info back so the new peer // If we're the host, send our join info back so the new peer
@@ -319,7 +344,11 @@ class WatchTogetherProvider with ChangeNotifier {
case SyncMessageType.leave: case SyncMessageType.leave:
if (message.peerId != null) { if (message.peerId != null) {
final leavingName = _participants.where((p) => p.peerId == message.peerId).map((p) => p.displayName).firstOrNull;
_participants.removeWhere((p) => p.peerId == message.peerId); _participants.removeWhere((p) => p.peerId == message.peerId);
if (leavingName != null) {
_participantEventController.add(ParticipantEvent(displayName: leavingName, type: ParticipantEventType.left));
}
notifyListeners(); notifyListeners();
} }
break; break;
@@ -512,9 +541,51 @@ class WatchTogetherProvider with ChangeNotifier {
} }
} }
/// Start a grace period for host reconnection (guest only)
void _startHostReconnectGracePeriod() {
_cancelHostReconnectGracePeriod();
_isWaitingForHostReconnect = true;
appLogger.d('WatchTogether: Host disconnected, waiting 15s for reconnection');
notifyListeners();
_hostReconnectTimer = Timer(const Duration(seconds: 15), () {
if (_isWaitingForHostReconnect) {
appLogger.d('WatchTogether: Host reconnect grace period expired');
_isWaitingForHostReconnect = false;
_session = _session?.copyWith(state: SessionState.error, errorMessage: 'Host left the session');
onHostExitedPlayer?.call();
notifyListeners();
}
});
}
/// Cancel host reconnect grace period
void _cancelHostReconnectGracePeriod() {
_hostReconnectTimer?.cancel();
_hostReconnectTimer = null;
if (_isWaitingForHostReconnect) {
_isWaitingForHostReconnect = false;
appLogger.d('WatchTogether: Host reconnected, grace period cancelled');
notifyListeners();
}
}
@override @override
void dispose() { void dispose() {
_cancelHostReconnectGracePeriod();
_participantEventController.close();
leaveSession(); leaveSession();
super.dispose(); super.dispose();
} }
} }
/// Type of participant event
enum ParticipantEventType { joined, left }
/// Event emitted when a participant joins or leaves
class ParticipantEvent {
final String displayName;
final ParticipantEventType type;
const ParticipantEvent({required this.displayName, required this.type});
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -276,6 +278,86 @@ class _SessionMenuSheet extends StatelessWidget {
} }
} }
/// Auto-dismissing toast for participant join/leave events
class ParticipantNotificationOverlay extends StatefulWidget {
const ParticipantNotificationOverlay({super.key});
@override
State<ParticipantNotificationOverlay> createState() => _ParticipantNotificationOverlayState();
}
class _ParticipantNotificationOverlayState extends State<ParticipantNotificationOverlay> {
StreamSubscription<ParticipantEvent>? _subscription;
final List<_NotificationEntry> _notifications = [];
int _nextId = 0;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_subscription?.cancel();
try {
final provider = context.read<WatchTogetherProvider>();
_subscription = provider.participantEvents.listen(_onEvent);
} catch (_) {}
}
void _onEvent(ParticipantEvent event) {
if (!mounted) return;
final id = _nextId++;
setState(() {
_notifications.add(_NotificationEntry(id: id, event: event));
});
Future.delayed(const Duration(seconds: 3), () {
if (mounted) {
setState(() {
_notifications.removeWhere((n) => n.id == id);
});
}
});
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_notifications.isEmpty) return const SizedBox.shrink();
return Positioned(
top: 60,
left: 0,
right: 0,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: _notifications.map((n) {
final text = n.event.type == ParticipantEventType.joined
? t.watchTogether.participantJoined(name: n.event.displayName)
: t.watchTogether.participantLeft(name: n.event.displayName);
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(20)),
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)),
),
);
}).toList(),
),
),
);
}
}
class _NotificationEntry {
final int id;
final ParticipantEvent event;
const _NotificationEntry({required this.id, required this.event});
}
/// Compact sync indicator for showing during drift correction /// Compact sync indicator for showing during drift correction
class SyncingIndicator extends StatelessWidget { class SyncingIndicator extends StatelessWidget {
const SyncingIndicator({super.key}); const SyncingIndicator({super.key});
@@ -627,6 +627,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
if (Platform.isMacOS) { if (Platform.isMacOS) {
_updateTrafficLightVisibility(); _updateTrafficLightVisibility();
} }
// Immediately try to reclaim focus (important for TV where global handler
// won't fire if _focusNode lost focus)
if (!_focusNode.hasFocus) {
_focusNode.requestFocus();
}
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_focusNode.hasFocus) { if (mounted && !_focusNode.hasFocus) {
_focusNode.requestFocus(); _focusNode.requestFocus();
@@ -1334,6 +1339,20 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
bool _handleGlobalKeyEvent(KeyEvent event) { bool _handleGlobalKeyEvent(KeyEvent event) {
if (!mounted) return false; if (!mounted) return false;
// TV back key fallback — Focus.onKeyEvent won't fire if _focusNode lost focus
if (PlatformDetector.isTV() && event.logicalKey.isBackKey) {
if (!_focusNode.hasFocus) {
final backResult = handleBackKeyAction(event, () {
if (!_showControls) {
_showControlsWithFocus();
} else {
(widget.onBack ?? () => Navigator.of(context).pop(true))();
}
});
if (backResult != KeyEventResult.ignored) return true;
}
}
// Only handle when video player navigation is disabled (desktop mode without D-pad nav) // Only handle when video player navigation is disabled (desktop mode without D-pad nav)
if (_videoPlayerNavigationEnabled) return false; if (_videoPlayerNavigationEnabled) return false;