From f33f58339dc19134b818b3b417742a36b5e14228 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:04:09 +0100 Subject: [PATCH] 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() --- lib/i18n/de.i18n.json | 5 +- lib/i18n/en.i18n.json | 5 +- lib/i18n/es.i18n.json | 5 +- lib/i18n/fr.i18n.json | 5 +- lib/i18n/it.i18n.json | 5 +- lib/i18n/ko.i18n.json | 5 +- lib/i18n/nl.i18n.json | 5 +- lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_de.g.dart | 6 ++ lib/i18n/strings_en.g.dart | 12 +++ lib/i18n/strings_es.g.dart | 6 ++ lib/i18n/strings_fr.g.dart | 6 ++ lib/i18n/strings_it.g.dart | 6 ++ lib/i18n/strings_ko.g.dart | 6 ++ lib/i18n/strings_nl.g.dart | 6 ++ lib/i18n/strings_sv.g.dart | 6 ++ lib/i18n/strings_zh.g.dart | 6 ++ lib/i18n/sv.i18n.json | 5 +- lib/i18n/zh.i18n.json | 5 +- .../profile/profile_switch_screen.dart | 16 ++++ lib/screens/video_player_screen.dart | 41 ++++++++-- lib/services/settings_service.dart | 2 +- .../providers/watch_together_provider.dart | 79 +++++++++++++++++- .../widgets/watch_together_overlay.dart | 82 +++++++++++++++++++ .../video_controls/video_controls.dart | 19 +++++ 25 files changed, 327 insertions(+), 21 deletions(-) diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 8e138bcc..bafb0bec 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -644,7 +644,10 @@ "failedToCreate": "Sitzung konnte nicht erstellt werden", "failedToJoin": "Sitzung konnte nicht beigetreten werden", "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": { "title": "Shader", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 8c2fb28b..a3a2baef 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -624,7 +624,10 @@ "failedToCreate": "Failed to create session", "failedToJoin": "Failed to join session", "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": { "title": "Downloads", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 2446edb3..fc0eeb68 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -624,7 +624,10 @@ "failedToCreate": "Error al crear la sesión", "failedToJoin": "Error al unirse a la sesión", "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": { "title": "Descargas", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index f74ec1b1..2c394f26 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -624,7 +624,10 @@ "failedToCreate": "Échec de la création de la session", "failedToJoin": "Échec de la connexion à la session", "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": { "title": "Téléchargements", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 2f5a6fdc..6c19d6e1 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -644,7 +644,10 @@ "failedToCreate": "Impossibile creare la sessione", "failedToJoin": "Impossibile unirsi alla sessione", "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": { "title": "Shader", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 5bd4ee33..5c90a72e 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -624,7 +624,10 @@ "failedToCreate": "세션 생성 실패", "failedToJoin": "세션 참여 실패", "sessionCodeCopied": "세션 코드가 클립보드에 복사되었습니다", - "relayUnreachable": "릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다." + "relayUnreachable": "릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.", + "reconnectingToHost": "호스트에 재연결 중...", + "participantJoined": "${name}님이 참여했습니다", + "participantLeft": "${name}님이 나갔습니다" }, "downloads": { "title": "다운로드", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 670e6842..5a133175 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -644,7 +644,10 @@ "failedToCreate": "Sessie maken mislukt", "failedToJoin": "Sessie deelnemen mislukt", "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": { "title": "Shaders", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 3e134b34..ee76c869 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 9 -/// Strings: 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 // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index acad49cc..fb942653 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -905,6 +905,9 @@ class _TranslationsWatchTogetherDe implements TranslationsWatchTogetherEn { @override String get failedToJoin => 'Sitzung konnte nicht beigetreten werden'; @override String get sessionCodeCopied => 'Sitzungscode in Zwischenablage kopiert'; @override String get relayUnreachable => 'Der Relay-Server ist nicht erreichbar. Möglicherweise blockiert dein Internetanbieter die Verbindung. Du kannst es trotzdem versuchen, aber Watch Together funktioniert eventuell nicht.'; + @override String get reconnectingToHost => 'Verbindung zum Host wird wiederhergestellt...'; + @override String participantJoined({required Object name}) => '${name} ist beigetreten'; + @override String participantLeft({required Object name}) => '${name} hat die Sitzung verlassen'; } // Path: shaders @@ -1717,6 +1720,9 @@ extension on TranslationsDe { 'watchTogether.failedToJoin' => 'Sitzung konnte nicht beigetreten werden', 'watchTogether.sessionCodeCopied' => 'Sitzungscode in Zwischenablage kopiert', 'watchTogether.relayUnreachable' => 'Der Relay-Server ist nicht erreichbar. Möglicherweise blockiert dein Internetanbieter die Verbindung. Du kannst es trotzdem versuchen, aber Watch Together funktioniert eventuell nicht.', + 'watchTogether.reconnectingToHost' => 'Verbindung zum Host wird wiederhergestellt...', + 'watchTogether.participantJoined' => ({required Object name}) => '${name} ist beigetreten', + 'watchTogether.participantLeft' => ({required Object name}) => '${name} hat die Sitzung verlassen', 'shaders.title' => 'Shader', 'shaders.noShaderDescription' => 'Keine Videoverbesserung', 'shaders.nvscalerDescription' => 'NVIDIA-Bildskalierung für schärferes Video', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index b1da3b8d..2d1f8050 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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.' 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 @@ -3052,6 +3061,9 @@ extension on Translations { 'watchTogether.failedToJoin' => 'Failed to join session', 'watchTogether.sessionCodeCopied' => 'Session code copied to clipboard', 'watchTogether.relayUnreachable' => 'The relay server is unreachable. This may be caused by your ISP blocking the connection. You can still try, but Watch Together may not work.', + 'watchTogether.reconnectingToHost' => 'Reconnecting to host...', + 'watchTogether.participantJoined' => ({required Object name}) => '${name} joined', + 'watchTogether.participantLeft' => ({required Object name}) => '${name} left', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Manage', 'downloads.tvShows' => 'TV Shows', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 7ae7bcaf..8df5148c 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -878,6 +878,9 @@ class _TranslationsWatchTogetherEs implements TranslationsWatchTogetherEn { @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 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 @@ -1699,6 +1702,9 @@ extension on TranslationsEs { 'watchTogether.failedToJoin' => 'Error al unirse a la sesión', 'watchTogether.sessionCodeCopied' => 'Código de sesión copiado al portapapeles', 'watchTogether.relayUnreachable' => 'El servidor de retransmisión no está disponible. Esto puede deberse a que tu proveedor de internet bloquea la conexión. Puedes intentarlo de todos modos, pero Watch Together podría no funcionar.', + 'watchTogether.reconnectingToHost' => 'Reconectando con el anfitrión...', + 'watchTogether.participantJoined' => ({required Object name}) => '${name} se unió', + 'watchTogether.participantLeft' => ({required Object name}) => '${name} se fue', 'downloads.title' => 'Descargas', 'downloads.manage' => 'Gestionar', 'downloads.tvShows' => 'Series de TV', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 1f12f72f..b4c0a902 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -878,6 +878,9 @@ class _TranslationsWatchTogetherFr implements TranslationsWatchTogetherEn { @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 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 @@ -1699,6 +1702,9 @@ extension on TranslationsFr { 'watchTogether.failedToJoin' => 'Échec de la connexion à la session', 'watchTogether.sessionCodeCopied' => 'Code de session copié dans le presse-papiers', 'watchTogether.relayUnreachable' => 'Le serveur relais est inaccessible. Cela peut être dû au blocage de la connexion par votre fournisseur d\'accès. Vous pouvez quand même essayer, mais Watch Together pourrait ne pas fonctionner.', + 'watchTogether.reconnectingToHost' => 'Reconnexion à l\'hôte...', + 'watchTogether.participantJoined' => ({required Object name}) => '${name} a rejoint', + 'watchTogether.participantLeft' => ({required Object name}) => '${name} est parti', 'downloads.title' => 'Téléchargements', 'downloads.manage' => 'Gérer', 'downloads.tvShows' => 'Show TV', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index c0fd9d5a..6209e695 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -905,6 +905,9 @@ class _TranslationsWatchTogetherIt implements TranslationsWatchTogetherEn { @override String get failedToJoin => 'Impossibile unirsi alla sessione'; @override String get sessionCodeCopied => 'Codice sessione copiato negli appunti'; @override String get relayUnreachable => 'Il server di inoltro non è raggiungibile. Questo potrebbe essere causato dal blocco della connessione da parte del tuo provider. Puoi comunque provare, ma Watch Together potrebbe non funzionare.'; + @override String get reconnectingToHost => 'Riconnessione all\'host...'; + @override String participantJoined({required Object name}) => '${name} si è unito'; + @override String participantLeft({required Object name}) => '${name} se ne è andato'; } // Path: shaders @@ -1717,6 +1720,9 @@ extension on TranslationsIt { 'watchTogether.failedToJoin' => 'Impossibile unirsi alla sessione', 'watchTogether.sessionCodeCopied' => 'Codice sessione copiato negli appunti', 'watchTogether.relayUnreachable' => 'Il server di inoltro non è raggiungibile. Questo potrebbe essere causato dal blocco della connessione da parte del tuo provider. Puoi comunque provare, ma Watch Together potrebbe non funzionare.', + 'watchTogether.reconnectingToHost' => 'Riconnessione all\'host...', + 'watchTogether.participantJoined' => ({required Object name}) => '${name} si è unito', + 'watchTogether.participantLeft' => ({required Object name}) => '${name} se ne è andato', 'shaders.title' => 'Shader', 'shaders.noShaderDescription' => 'Nessun miglioramento video', 'shaders.nvscalerDescription' => 'Ridimensionamento NVIDIA per video più nitido', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 4b17587a..1b1b606f 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -878,6 +878,9 @@ class _TranslationsWatchTogetherKo implements TranslationsWatchTogetherEn { @override String get failedToJoin => '세션 참여 실패'; @override String get sessionCodeCopied => '세션 코드가 클립보드에 복사되었습니다'; @override String get relayUnreachable => '릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.'; + @override String get reconnectingToHost => '호스트에 재연결 중...'; + @override String participantJoined({required Object name}) => '${name}님이 참여했습니다'; + @override String participantLeft({required Object name}) => '${name}님이 나갔습니다'; } // Path: downloads @@ -1699,6 +1702,9 @@ extension on TranslationsKo { 'watchTogether.failedToJoin' => '세션 참여 실패', 'watchTogether.sessionCodeCopied' => '세션 코드가 클립보드에 복사되었습니다', 'watchTogether.relayUnreachable' => '릴레이 서버에 연결할 수 없습니다. 인터넷 제공업체가 연결을 차단하고 있을 수 있습니다. 시도해 볼 수 있지만 함께 보기가 작동하지 않을 수 있습니다.', + 'watchTogether.reconnectingToHost' => '호스트에 재연결 중...', + 'watchTogether.participantJoined' => ({required Object name}) => '${name}님이 참여했습니다', + 'watchTogether.participantLeft' => ({required Object name}) => '${name}님이 나갔습니다', 'downloads.title' => '다운로드', 'downloads.manage' => '관리', 'downloads.tvShows' => 'TV 프로그램', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 4c8468c0..2597674a 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -905,6 +905,9 @@ class _TranslationsWatchTogetherNl implements TranslationsWatchTogetherEn { @override String get failedToJoin => 'Sessie deelnemen mislukt'; @override String get sessionCodeCopied => 'Sessiecode gekopieerd naar klembord'; @override String get relayUnreachable => 'De relayserver is niet bereikbaar. Dit kan worden veroorzaakt doordat je internetprovider de verbinding blokkeert. Je kunt het toch proberen, maar Watch Together werkt mogelijk niet.'; + @override String get reconnectingToHost => 'Opnieuw verbinden met host...'; + @override String participantJoined({required Object name}) => '${name} is toegetreden'; + @override String participantLeft({required Object name}) => '${name} heeft de sessie verlaten'; } // Path: shaders @@ -1717,6 +1720,9 @@ extension on TranslationsNl { 'watchTogether.failedToJoin' => 'Sessie deelnemen mislukt', 'watchTogether.sessionCodeCopied' => 'Sessiecode gekopieerd naar klembord', 'watchTogether.relayUnreachable' => 'De relayserver is niet bereikbaar. Dit kan worden veroorzaakt doordat je internetprovider de verbinding blokkeert. Je kunt het toch proberen, maar Watch Together werkt mogelijk niet.', + 'watchTogether.reconnectingToHost' => 'Opnieuw verbinden met host...', + 'watchTogether.participantJoined' => ({required Object name}) => '${name} is toegetreden', + 'watchTogether.participantLeft' => ({required Object name}) => '${name} heeft de sessie verlaten', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'Geen videoverbetering', 'shaders.nvscalerDescription' => 'NVIDIA-beeldschaling voor scherpere video', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 0e9ece85..48fbcf83 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -905,6 +905,9 @@ class _TranslationsWatchTogetherSv implements TranslationsWatchTogetherEn { @override String get failedToJoin => 'Det gick inte att gå med i session'; @override String get sessionCodeCopied => 'Sessionskod kopierad till urklipp'; @override String get relayUnreachable => 'Reläservern kan inte nås. Detta kan bero på att din internetleverantör blockerar anslutningen. Du kan fortfarande försöka, men Watch Together kanske inte fungerar.'; + @override String get reconnectingToHost => 'Återansluter till värd...'; + @override String participantJoined({required Object name}) => '${name} gick med'; + @override String participantLeft({required Object name}) => '${name} lämnade'; } // Path: shaders @@ -1717,6 +1720,9 @@ extension on TranslationsSv { 'watchTogether.failedToJoin' => 'Det gick inte att gå med i session', 'watchTogether.sessionCodeCopied' => 'Sessionskod kopierad till urklipp', 'watchTogether.relayUnreachable' => 'Reläservern kan inte nås. Detta kan bero på att din internetleverantör blockerar anslutningen. Du kan fortfarande försöka, men Watch Together kanske inte fungerar.', + 'watchTogether.reconnectingToHost' => 'Återansluter till värd...', + 'watchTogether.participantJoined' => ({required Object name}) => '${name} gick med', + 'watchTogether.participantLeft' => ({required Object name}) => '${name} lämnade', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'Ingen videoförbättring', 'shaders.nvscalerDescription' => 'NVIDIA-bildskalning för skarpare video', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 43b19347..3b1a6380 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -905,6 +905,9 @@ class _TranslationsWatchTogetherZh implements TranslationsWatchTogetherEn { @override String get failedToJoin => '加入会话失败'; @override String get sessionCodeCopied => '会话代码已复制到剪贴板'; @override String get relayUnreachable => '无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。'; + @override String get reconnectingToHost => '正在重新连接到主持人...'; + @override String participantJoined({required Object name}) => '${name} 加入了'; + @override String participantLeft({required Object name}) => '${name} 离开了'; } // Path: shaders @@ -1717,6 +1720,9 @@ extension on TranslationsZh { 'watchTogether.failedToJoin' => '加入会话失败', 'watchTogether.sessionCodeCopied' => '会话代码已复制到剪贴板', 'watchTogether.relayUnreachable' => '无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。', + 'watchTogether.reconnectingToHost' => '正在重新连接到主持人...', + 'watchTogether.participantJoined' => ({required Object name}) => '${name} 加入了', + 'watchTogether.participantLeft' => ({required Object name}) => '${name} 离开了', 'shaders.title' => '着色器', 'shaders.noShaderDescription' => '无视频增强', 'shaders.nvscalerDescription' => 'NVIDIA 图像缩放,使视频更清晰', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 6b78e583..bb23c980 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -644,7 +644,10 @@ "failedToCreate": "Det gick inte att skapa session", "failedToJoin": "Det gick inte att gå med i session", "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": { "title": "Shaders", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 4c04d7c7..f3c30837 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -644,7 +644,10 @@ "failedToCreate": "创建会话失败", "failedToJoin": "加入会话失败", "sessionCodeCopied": "会话代码已复制到剪贴板", - "relayUnreachable": "无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。" + "relayUnreachable": "无法连接到中继服务器。这可能是由于您的网络运营商屏蔽了连接。您仍然可以尝试,但一起观看功能可能无法正常使用。", + "reconnectingToHost": "正在重新连接到主持人...", + "participantJoined": "${name} 加入了", + "participantLeft": "${name} 离开了" }, "shaders": { "title": "着色器", diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index c7e35718..d535ef9b 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -22,6 +22,14 @@ class ProfileSwitchScreen extends StatefulWidget { class _ProfileSwitchScreenState extends State { bool _allowPop = false; + final FocusNode _firstSelectableFocusNode = FocusNode(); + bool _focusRequested = false; + + @override + void dispose() { + _firstSelectableFocusNode.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -77,10 +85,18 @@ class _ProfileSwitchScreenState extends State { final isFirstSelectable = !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( padding: EdgeInsets.only(left: 16, right: 16, top: index == 0 ? 16 : 0, bottom: 8), child: FocusableWrapper( autofocus: isFirstSelectable, + focusNode: isFirstSelectable ? _firstSelectableFocusNode : null, disableScale: true, onSelect: isCurrentUser ? null : () => _switchToUser(context, user), child: Card( diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 6a50bd76..1809fc19 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -58,6 +58,7 @@ import '../focus/dpad_navigator.dart'; import '../focus/key_event_utils.dart'; import '../i18n/strings.g.dart'; import '../watch_together/providers/watch_together_provider.dart'; +import '../watch_together/widgets/watch_together_overlay.dart'; class VideoPlayerScreen extends StatefulWidget { final PlexMetadata metadata; @@ -1986,13 +1987,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin } }); - // Compute canControl from Watch Together provider + // Compute canControl from Watch Together provider (reactive) bool canControl = true; try { - final watchTogether = this.context.read(); - if (watchTogether.isInSession) { - canControl = watchTogether.canControl(); - } + canControl = context.select( + (wt) => wt.isInSession ? wt.canControl() : true, + ); } catch (e) { // Watch Together not available, default to can control } @@ -2249,6 +2249,37 @@ class VideoPlayerScreenState extends State with WidgetsBindin ); }, ), + // Watch Together: reconnecting to host overlay + Consumer( + 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) ValueListenableBuilder( valueListenable: _isExiting, diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index d33c0680..f488c917 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -810,7 +810,7 @@ class SettingsService extends BaseSharedPreferencesService { AppLocale getAppLocale() { 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); } diff --git a/lib/watch_together/providers/watch_together_provider.dart b/lib/watch_together/providers/watch_together_provider.dart index 097413e7..ef95c88d 100644 --- a/lib/watch_together/providers/watch_together_provider.dart +++ b/lib/watch_together/providers/watch_together_provider.dart @@ -29,6 +29,10 @@ class WatchTogetherProvider with ChangeNotifier { bool _isSyncing = false; String _displayName = 'User'; + // Host reconnect grace period + Timer? _hostReconnectTimer; + bool _isWaitingForHostReconnect = false; + /// Generate a random display name for this session static String _generateDisplayName() { 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; String? get sessionId => _session?.sessionId; WatchTogetherSyncManager? get syncManager => _syncManager; + bool get isWaitingForHostReconnect => _isWaitingForHostReconnect; + + // Participant join/leave event stream + final StreamController _participantEventController = StreamController.broadcast(); + Stream get participantEvents => _participantEventController.stream; // Current media getters String? get currentMediaRatingKey => _session?.mediaRatingKey; @@ -207,6 +216,9 @@ class WatchTogetherProvider with ChangeNotifier { _messageSubscription = null; _errorSubscription = null; + // Cancel host reconnect grace period + _cancelHostReconnectGracePeriod(); + // Clean up services _syncManager?.dispose(); _syncManager = null; @@ -248,19 +260,29 @@ class WatchTogetherProvider with ChangeNotifier { void _setupPeerServiceListeners() { _peerConnectedSubscription = _peerService!.onPeerConnected.listen((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 notifyListeners(); }); _peerDisconnectedSubscription = _peerService!.onPeerDisconnected.listen((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); - // If host disconnected, end session for guests + // If host disconnected, start grace period for reconnection if (!isHost && peerId == _session?.hostPeerId) { - _session = _session?.copyWith(state: SessionState.error, errorMessage: 'Host left the session'); - // Ensure guests exit the player if host disappears - onHostExitedPlayer?.call(); + _startHostReconnectGracePeriod(); + } else if (disconnectedName != null) { + _participantEventController.add(ParticipantEvent(displayName: disconnectedName, type: ParticipantEventType.left)); } notifyListeners(); @@ -300,6 +322,9 @@ class WatchTogetherProvider with ChangeNotifier { _participants.add( 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 @@ -319,7 +344,11 @@ class WatchTogetherProvider with ChangeNotifier { case SyncMessageType.leave: 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); + if (leavingName != null) { + _participantEventController.add(ParticipantEvent(displayName: leavingName, type: ParticipantEventType.left)); + } notifyListeners(); } 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 void dispose() { + _cancelHostReconnectGracePeriod(); + _participantEventController.close(); leaveSession(); 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}); +} diff --git a/lib/watch_together/widgets/watch_together_overlay.dart b/lib/watch_together/widgets/watch_together_overlay.dart index 3284a094..50982edb 100644 --- a/lib/watch_together/widgets/watch_together_overlay.dart +++ b/lib/watch_together/widgets/watch_together_overlay.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.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 createState() => _ParticipantNotificationOverlayState(); +} + +class _ParticipantNotificationOverlayState extends State { + StreamSubscription? _subscription; + final List<_NotificationEntry> _notifications = []; + int _nextId = 0; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _subscription?.cancel(); + try { + final provider = context.read(); + _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 class SyncingIndicator extends StatelessWidget { const SyncingIndicator({super.key}); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 71413013..5ea3b6df 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -627,6 +627,11 @@ class _PlexVideoControlsState extends State with WindowListen if (Platform.isMacOS) { _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((_) { if (mounted && !_focusNode.hasFocus) { _focusNode.requestFocus(); @@ -1334,6 +1339,20 @@ class _PlexVideoControlsState extends State with WindowListen bool _handleGlobalKeyEvent(KeyEvent event) { 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) if (_videoPlayerNavigationEnabled) return false;