From 4e27dfc3a108573e5f977a459130f1e2fb464119 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:48:29 +0500 Subject: [PATCH] Watch party: host controls, friend invites, chat history, better sync - Lobby host card: room size, host-only playback, lock room, wait for everyone, all live over party:settings - Invite friends sheet on the existing invite endpoint, with per-friend cooldowns - Make host / remove from party from the member bar (tap, long-press or D-pad), and a buffering badge per member - Chat history from party:state plus joined/left/host/removed lines - party:ping clock offset re-bases each sync so a slow link no longer trails the host by its one-way trip - Guests report buffering; with wait for everyone on, the host holds up to 15 s for a guest stuck over 3 s - A party invite opened while signed out is kept through sign-in or first-run setup and opens once afterwards - apple-app-site-association now lists /party/*, /link/* and /pair/* --- assets/translations/ar.json | 26 +- assets/translations/ckb.json | 26 +- assets/translations/de.json | 26 +- assets/translations/en.json | 26 +- assets/translations/es.json | 26 +- assets/translations/fr.json | 26 +- assets/translations/hi.json | 26 +- assets/translations/id.json | 26 +- assets/translations/ku.json | 26 +- assets/translations/nl.json | 26 +- assets/translations/pt.json | 26 +- assets/translations/ru.json | 26 +- assets/translations/tr.json | 26 +- assets/translations/uz.json | 26 +- assets/translations/vi.json | 26 +- assets/translations/yue.json | 26 +- deeplink/apple-app-site-association | 5 +- lib/core/deeplink/deeplink_service.dart | 7 + lib/core/di/injection.dart | 11 + .../presentation/pages/player_page.party.dart | 30 ++ .../main/presentation/pages/main_page.dart | 3 + .../presentation/onboarding_navigation.dart | 2 + .../data/watch_party_remote_data_source.dart | 48 ++- .../watch_party/data/watch_party_service.dart | 302 ++++++++++++++++-- .../domain/entities/party_chat_message.dart | 36 ++- .../domain/entities/party_member.dart | 12 + .../domain/entities/party_room.dart | 25 ++ .../watch_party/domain/invite_cooldowns.dart | 50 +++ .../watch_party/domain/party_clock.dart | 69 ++++ .../watch_party/domain/party_wait_policy.dart | 70 ++++ .../domain/pending_party_code.dart | 58 ++++ .../presentation/pages/watch_party_page.dart | 81 ++++- .../watch_party/presentation/party_entry.dart | 19 ++ .../widgets/party_chat_panel.dart | 76 +++-- .../widgets/party_error_views.dart | 10 +- .../widgets/party_host_settings.dart | 142 ++++++++ .../widgets/party_invite_sheet.dart | 277 ++++++++++++++++ .../widgets/party_member_bar.dart | 121 ++++++- .../features/watch_party/party_sync_test.dart | 220 +++++++++++++ .../watch_party/pending_party_code_test.dart | 63 ++++ 40 files changed, 2079 insertions(+), 74 deletions(-) create mode 100644 lib/features/watch_party/domain/invite_cooldowns.dart create mode 100644 lib/features/watch_party/domain/party_clock.dart create mode 100644 lib/features/watch_party/domain/party_wait_policy.dart create mode 100644 lib/features/watch_party/domain/pending_party_code.dart create mode 100644 lib/features/watch_party/presentation/widgets/party_host_settings.dart create mode 100644 lib/features/watch_party/presentation/widgets/party_invite_sheet.dart create mode 100644 test/features/watch_party/party_sync_test.dart create mode 100644 test/features/watch_party/pending_party_code_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 6e6bbb79..ee5e014d 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -1172,7 +1172,31 @@ "reconnecting": "جارٍ إعادة الاتصال…", "connecting": "جارٍ الاتصال…", "connect_failed_title": "تعذّر الاتصال", - "connect_failed_msg": "تحقّق من اتصالك بالإنترنت وحاول مرة أخرى." + "connect_failed_msg": "تحقّق من اتصالك بالإنترنت وحاول مرة أخرى.", + "settings_title": "إعدادات الغرفة", + "room_size": "حجم الغرفة", + "only_host_setting": "المضيف وحده يتحكم في التشغيل", + "lock_room": "قفل الغرفة", + "lock_room_hint": "لا يمكن لأحد جديد الانضمام", + "wait_for_all": "انتظار الجميع", + "wait_for_all_hint": "إيقاف مؤقت قصير عندما يكون أحدهم في التخزين المؤقت", + "invite_friends": "دعوة الأصدقاء", + "invite_sent": "تم إرسال الدعوة", + "invite_failed": "تعذّر إرسال الدعوة", + "invite_again_in": "مرة أخرى بعد {}", + "invite_no_friends": "أضف أصدقاء لتتمكن من دعوتهم هنا", + "make_host": "تعيين كمضيف", + "remove_member": "إزالة من الحفلة", + "remove_confirm": "إزالة {} من هذه الحفلة؟ لن يتمكن من الانضمام مجددًا.", + "buffering": "جارٍ التخزين المؤقت…", + "sys_joined": "انضم {}", + "sys_left": "غادر {}", + "sys_host": "{} هو المضيف الآن", + "sys_removed": "تمت إزالة {}", + "error_banned": "تمت إزالتك من هذه الحفلة", + "error_locked": "هذه الحفلة مقفلة", + "room_closed_kicked": "أزالك المضيف من هذه الحفلة", + "waiting_for_others": "بانتظار أن يلحق الجميع…" }, "notifications": { "title": "الإشعارات", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 6cef5071..77ac86bd 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -1172,7 +1172,31 @@ "reconnecting": "دووبارە پەیوەستبوونەوە…", "connecting": "پەیوەستبوون…", "connect_failed_title": "نەتوانرا پەیوەست بیت", - "connect_failed_msg": "هێڵی ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵبدەرەوە." + "connect_failed_msg": "هێڵی ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵبدەرەوە.", + "settings_title": "ڕێکخستنەکانی ژوور", + "room_size": "قەبارەی ژوور", + "only_host_setting": "تەنها خانەخوێ لێدان کۆنترۆڵ دەکات", + "lock_room": "قفڵکردنی ژوور", + "lock_room_hint": "کەسی نوێ ناتوانێت بەشداربێت", + "wait_for_all": "چاوەڕوانی هەمووان", + "wait_for_all_hint": "کاتێک کەسێک بارکردن دەکات کەمێک ڕابگرە", + "invite_friends": "بانگهێشتکردنی هاوڕێیان", + "invite_sent": "بانگهێشتەکە نێردرا", + "invite_failed": "نەتوانرا بانگهێشتەکە بنێردرێت", + "invite_again_in": "دووبارە دوای {}", + "invite_no_friends": "هاوڕێ زیاد بکە بۆ ئەوەی لێرە بانگهێشتیان بکەیت", + "make_host": "کردن بە خانەخوێ", + "remove_member": "لابردن لە کۆڕ", + "remove_confirm": "{} لەم کۆڕە لاببرێت؟ ناتوانێت دووبارە بگەڕێتەوە.", + "buffering": "بارکردن…", + "sys_joined": "{} بەشداربوو", + "sys_left": "{} ڕۆیشت", + "sys_host": "{} ئێستا خانەخوێیە", + "sys_removed": "{} لابرا", + "error_banned": "تۆ لەم کۆڕە لابرایت", + "error_locked": "ئەم کۆڕە قفڵدراوە", + "room_closed_kicked": "خانەخوێ تۆی لەم کۆڕە لابرد", + "waiting_for_others": "چاوەڕوانی ئەوەین هەمووان بگەنەوە…" }, "notifications": { "title": "ئاگادارکردنەوەکان", diff --git a/assets/translations/de.json b/assets/translations/de.json index 0970f03c..37fe0ed9 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -1172,7 +1172,31 @@ "reconnecting": "Verbindung wird wiederhergestellt …", "connecting": "Verbindung wird aufgebaut …", "connect_failed_title": "Verbindung fehlgeschlagen", - "connect_failed_msg": "Prüfe deine Internetverbindung und versuche es erneut." + "connect_failed_msg": "Prüfe deine Internetverbindung und versuche es erneut.", + "settings_title": "Raumeinstellungen", + "room_size": "Raumgröße", + "only_host_setting": "Nur der Host steuert die Wiedergabe", + "lock_room": "Raum sperren", + "lock_room_hint": "Niemand Neues kann beitreten", + "wait_for_all": "Auf alle warten", + "wait_for_all_hint": "Kurz pausieren, wenn jemand puffert", + "invite_friends": "Freunde einladen", + "invite_sent": "Einladung gesendet", + "invite_failed": "Einladung konnte nicht gesendet werden", + "invite_again_in": "Wieder in {}", + "invite_no_friends": "Füge Freunde hinzu, um sie hierher einzuladen", + "make_host": "Zum Host machen", + "remove_member": "Aus der Party entfernen", + "remove_confirm": "{} aus dieser Party entfernen? Ein erneuter Beitritt ist dann nicht möglich.", + "buffering": "Puffert…", + "sys_joined": "{} ist beigetreten", + "sys_left": "{} hat die Party verlassen", + "sys_host": "{} ist jetzt Host", + "sys_removed": "{} wurde entfernt", + "error_banned": "Du wurdest aus dieser Party entfernt", + "error_locked": "Diese Party ist gesperrt", + "room_closed_kicked": "Der Host hat dich aus dieser Party entfernt", + "waiting_for_others": "Warte, bis alle aufgeholt haben…" }, "notifications": { "title": "Benachrichtigungen", diff --git a/assets/translations/en.json b/assets/translations/en.json index 8fbca5aa..fb0a2b6d 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -1172,7 +1172,31 @@ "reconnecting": "Reconnecting…", "connecting": "Connecting…", "connect_failed_title": "Couldn't connect", - "connect_failed_msg": "Check your internet connection and try again." + "connect_failed_msg": "Check your internet connection and try again.", + "settings_title": "Room settings", + "room_size": "Room size", + "only_host_setting": "Only host controls playback", + "lock_room": "Lock room", + "lock_room_hint": "No one new can join", + "wait_for_all": "Wait for everyone", + "wait_for_all_hint": "Pause briefly when someone is buffering", + "invite_friends": "Invite friends", + "invite_sent": "Invite sent", + "invite_failed": "Couldn't send the invite", + "invite_again_in": "Again in {}", + "invite_no_friends": "Add friends to invite them here", + "make_host": "Make host", + "remove_member": "Remove from party", + "remove_confirm": "Remove {} from this party? They won't be able to rejoin.", + "buffering": "Buffering…", + "sys_joined": "{} joined", + "sys_left": "{} left", + "sys_host": "{} is now the host", + "sys_removed": "{} was removed", + "error_banned": "You were removed from this party", + "error_locked": "This party is locked", + "room_closed_kicked": "The host removed you from this party", + "waiting_for_others": "Waiting for everyone to catch up…" }, "notifications": { "title": "Notifications", diff --git a/assets/translations/es.json b/assets/translations/es.json index 319ddb48..27d43ca5 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -1172,7 +1172,31 @@ "reconnecting": "Reconectando…", "connecting": "Conectando…", "connect_failed_title": "No se pudo conectar", - "connect_failed_msg": "Comprueba tu conexión a internet e inténtalo de nuevo." + "connect_failed_msg": "Comprueba tu conexión a internet e inténtalo de nuevo.", + "settings_title": "Ajustes de la sala", + "room_size": "Tamaño de la sala", + "only_host_setting": "Solo el anfitrión controla la reproducción", + "lock_room": "Bloquear sala", + "lock_room_hint": "Nadie nuevo puede unirse", + "wait_for_all": "Esperar a todos", + "wait_for_all_hint": "Pausar un momento cuando alguien esté cargando", + "invite_friends": "Invitar amigos", + "invite_sent": "Invitación enviada", + "invite_failed": "No se pudo enviar la invitación", + "invite_again_in": "De nuevo en {}", + "invite_no_friends": "Añade amigos para invitarlos aquí", + "make_host": "Hacer anfitrión", + "remove_member": "Quitar de la fiesta", + "remove_confirm": "¿Quitar a {} de esta fiesta? No podrá volver a unirse.", + "buffering": "Cargando…", + "sys_joined": "{} se unió", + "sys_left": "{} salió", + "sys_host": "{} ahora es el anfitrión", + "sys_removed": "{} fue quitado", + "error_banned": "Te quitaron de esta fiesta", + "error_locked": "Esta fiesta está bloqueada", + "room_closed_kicked": "El anfitrión te quitó de esta fiesta", + "waiting_for_others": "Esperando a que todos se pongan al día…" }, "notifications": { "title": "Notificaciones", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 599703a9..83faf859 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -1172,7 +1172,31 @@ "reconnecting": "Reconnexion…", "connecting": "Connexion…", "connect_failed_title": "Connexion impossible", - "connect_failed_msg": "Vérifie ta connexion internet et réessaie." + "connect_failed_msg": "Vérifie ta connexion internet et réessaie.", + "settings_title": "Paramètres du salon", + "room_size": "Taille du salon", + "only_host_setting": "Seul l'hôte contrôle la lecture", + "lock_room": "Verrouiller le salon", + "lock_room_hint": "Personne de nouveau ne peut rejoindre", + "wait_for_all": "Attendre tout le monde", + "wait_for_all_hint": "Faire une courte pause quand quelqu'un charge", + "invite_friends": "Inviter des amis", + "invite_sent": "Invitation envoyée", + "invite_failed": "Impossible d'envoyer l'invitation", + "invite_again_in": "De nouveau dans {}", + "invite_no_friends": "Ajoutez des amis pour les inviter ici", + "make_host": "Nommer hôte", + "remove_member": "Retirer de la soirée", + "remove_confirm": "Retirer {} de cette soirée ? Cette personne ne pourra plus revenir.", + "buffering": "Chargement…", + "sys_joined": "{} a rejoint", + "sys_left": "{} est parti", + "sys_host": "{} est maintenant l'hôte", + "sys_removed": "{} a été retiré", + "error_banned": "Vous avez été retiré de cette soirée", + "error_locked": "Cette soirée est verrouillée", + "room_closed_kicked": "L'hôte vous a retiré de cette soirée", + "waiting_for_others": "On attend que tout le monde rattrape…" }, "notifications": { "title": "Notifications", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 12d0cb70..5fe22c0b 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -1172,7 +1172,31 @@ "reconnecting": "फिर से कनेक्ट हो रहा है…", "connecting": "कनेक्ट हो रहा है…", "connect_failed_title": "कनेक्ट नहीं हो सका", - "connect_failed_msg": "अपना इंटरनेट कनेक्शन जाँचें और फिर से कोशिश करें।" + "connect_failed_msg": "अपना इंटरनेट कनेक्शन जाँचें और फिर से कोशिश करें।", + "settings_title": "रूम सेटिंग्स", + "room_size": "रूम का आकार", + "only_host_setting": "सिर्फ़ होस्ट प्लेबैक नियंत्रित करता है", + "lock_room": "रूम लॉक करें", + "lock_room_hint": "कोई नया शामिल नहीं हो सकता", + "wait_for_all": "सबका इंतज़ार करें", + "wait_for_all_hint": "किसी के बफ़र होने पर थोड़ी देर रोकें", + "invite_friends": "दोस्तों को आमंत्रित करें", + "invite_sent": "आमंत्रण भेजा गया", + "invite_failed": "आमंत्रण नहीं भेजा जा सका", + "invite_again_in": "{} बाद फिर से", + "invite_no_friends": "यहाँ आमंत्रित करने के लिए दोस्त जोड़ें", + "make_host": "होस्ट बनाएँ", + "remove_member": "पार्टी से हटाएँ", + "remove_confirm": "{} को इस पार्टी से हटाएँ? वे दोबारा शामिल नहीं हो पाएँगे।", + "buffering": "बफ़र हो रहा है…", + "sys_joined": "{} शामिल हुए", + "sys_left": "{} चले गए", + "sys_host": "{} अब होस्ट हैं", + "sys_removed": "{} को हटाया गया", + "error_banned": "आपको इस पार्टी से हटा दिया गया", + "error_locked": "यह पार्टी लॉक है", + "room_closed_kicked": "होस्ट ने आपको इस पार्टी से हटा दिया", + "waiting_for_others": "सबके साथ आने का इंतज़ार…" }, "notifications": { "title": "नोटिफ़िकेशन", diff --git a/assets/translations/id.json b/assets/translations/id.json index 28442646..0c4fcb74 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -1172,7 +1172,31 @@ "reconnecting": "Menyambung ulang…", "connecting": "Menyambung…", "connect_failed_title": "Tidak bisa tersambung", - "connect_failed_msg": "Periksa koneksi internetmu lalu coba lagi." + "connect_failed_msg": "Periksa koneksi internetmu lalu coba lagi.", + "settings_title": "Pengaturan ruang", + "room_size": "Ukuran ruang", + "only_host_setting": "Hanya host yang mengontrol pemutaran", + "lock_room": "Kunci ruang", + "lock_room_hint": "Tidak ada orang baru yang bisa bergabung", + "wait_for_all": "Tunggu semua", + "wait_for_all_hint": "Jeda sebentar saat ada yang buffering", + "invite_friends": "Undang teman", + "invite_sent": "Undangan terkirim", + "invite_failed": "Gagal mengirim undangan", + "invite_again_in": "Lagi dalam {}", + "invite_no_friends": "Tambahkan teman untuk mengundang mereka ke sini", + "make_host": "Jadikan host", + "remove_member": "Keluarkan dari party", + "remove_confirm": "Keluarkan {} dari party ini? Mereka tidak bisa bergabung lagi.", + "buffering": "Buffering…", + "sys_joined": "{} bergabung", + "sys_left": "{} keluar", + "sys_host": "{} sekarang host", + "sys_removed": "{} dikeluarkan", + "error_banned": "Kamu dikeluarkan dari party ini", + "error_locked": "Party ini dikunci", + "room_closed_kicked": "Host mengeluarkanmu dari party ini", + "waiting_for_others": "Menunggu semua menyusul…" }, "notifications": { "title": "Notifikasi", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e876ed5b..c3a70dc7 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -1172,7 +1172,31 @@ "reconnecting": "Ji nû ve tê girêdan…", "connecting": "Tê girêdan…", "connect_failed_title": "Girêdan pêk nehat", - "connect_failed_msg": "Girêdana înternetê kontrol bike û dîsa biceribîne." + "connect_failed_msg": "Girêdana înternetê kontrol bike û dîsa biceribîne.", + "settings_title": "Mîhengên odeyê", + "room_size": "Mezinahiya odeyê", + "only_host_setting": "Tenê mazûvan lêdanê kontrol dike", + "lock_room": "Odeyê kilît bike", + "lock_room_hint": "Kesekî nû nikare tevlî bibe", + "wait_for_all": "Li hemûyan bisekine", + "wait_for_all_hint": "Dema yek bar dike, kurt rawestîne", + "invite_friends": "Hevalan vexwîne", + "invite_sent": "Vexwendin hat şandin", + "invite_failed": "Vexwendin nehat şandin", + "invite_again_in": "Dîsa piştî {}", + "invite_no_friends": "Hevalan lê zêde bike da ku li vir wan vexwînî", + "make_host": "Bike mazûvan", + "remove_member": "Ji şahiyê derxe", + "remove_confirm": "{} ji vê şahiyê were derxistin? Ew nikare dîsa tevlî bibe.", + "buffering": "Tê barkirin…", + "sys_joined": "{} tevlî bû", + "sys_left": "{} derket", + "sys_host": "{} niha mazûvan e", + "sys_removed": "{} hat derxistin", + "error_banned": "Tu ji vê şahiyê hatî derxistin", + "error_locked": "Ev şahî kilîtkirî ye", + "room_closed_kicked": "Mazûvan tu ji vê şahiyê derxistî", + "waiting_for_others": "Li benda hemûyan in ku bigihîjin…" }, "notifications": { "title": "Agahdarî", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 8ca39779..03ee2d84 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -1172,7 +1172,31 @@ "reconnecting": "Opnieuw verbinden…", "connecting": "Verbinden…", "connect_failed_title": "Verbinden mislukt", - "connect_failed_msg": "Controleer je internetverbinding en probeer het opnieuw." + "connect_failed_msg": "Controleer je internetverbinding en probeer het opnieuw.", + "settings_title": "Kamerinstellingen", + "room_size": "Kamergrootte", + "only_host_setting": "Alleen de host bedient het afspelen", + "lock_room": "Kamer vergrendelen", + "lock_room_hint": "Niemand nieuw kan meedoen", + "wait_for_all": "Op iedereen wachten", + "wait_for_all_hint": "Kort pauzeren als iemand aan het bufferen is", + "invite_friends": "Vrienden uitnodigen", + "invite_sent": "Uitnodiging verstuurd", + "invite_failed": "Kon de uitnodiging niet versturen", + "invite_again_in": "Weer over {}", + "invite_no_friends": "Voeg vrienden toe om ze hier uit te nodigen", + "make_host": "Host maken", + "remove_member": "Uit de party verwijderen", + "remove_confirm": "{} uit deze party verwijderen? Opnieuw meedoen is dan niet mogelijk.", + "buffering": "Bufferen…", + "sys_joined": "{} doet mee", + "sys_left": "{} is vertrokken", + "sys_host": "{} is nu de host", + "sys_removed": "{} is verwijderd", + "error_banned": "Je bent uit deze party verwijderd", + "error_locked": "Deze party is vergrendeld", + "room_closed_kicked": "De host heeft je uit deze party verwijderd", + "waiting_for_others": "Wachten tot iedereen bij is…" }, "notifications": { "title": "Meldingen", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index f79b5482..c4260ccc 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -1172,7 +1172,31 @@ "reconnecting": "Reconectando…", "connecting": "Conectando…", "connect_failed_title": "Não foi possível conectar", - "connect_failed_msg": "Verifique sua conexão com a internet e tente de novo." + "connect_failed_msg": "Verifique sua conexão com a internet e tente de novo.", + "settings_title": "Configurações da sala", + "room_size": "Tamanho da sala", + "only_host_setting": "Só o anfitrião controla a reprodução", + "lock_room": "Trancar sala", + "lock_room_hint": "Ninguém novo pode entrar", + "wait_for_all": "Esperar todos", + "wait_for_all_hint": "Pausar um pouco quando alguém estiver carregando", + "invite_friends": "Convidar amigos", + "invite_sent": "Convite enviado", + "invite_failed": "Não foi possível enviar o convite", + "invite_again_in": "De novo em {}", + "invite_no_friends": "Adicione amigos para convidá-los aqui", + "make_host": "Tornar anfitrião", + "remove_member": "Remover da festa", + "remove_confirm": "Remover {} desta festa? A pessoa não poderá voltar.", + "buffering": "Carregando…", + "sys_joined": "{} entrou", + "sys_left": "{} saiu", + "sys_host": "{} agora é o anfitrião", + "sys_removed": "{} foi removido", + "error_banned": "Você foi removido desta festa", + "error_locked": "Esta festa está trancada", + "room_closed_kicked": "O anfitrião removeu você desta festa", + "waiting_for_others": "Esperando todos alcançarem…" }, "notifications": { "title": "Notificações", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c85aef09..af1768e8 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -1172,7 +1172,31 @@ "reconnecting": "Переподключение…", "connecting": "Подключение…", "connect_failed_title": "Не удалось подключиться", - "connect_failed_msg": "Проверьте подключение к интернету и повторите попытку." + "connect_failed_msg": "Проверьте подключение к интернету и повторите попытку.", + "settings_title": "Настройки комнаты", + "room_size": "Размер комнаты", + "only_host_setting": "Воспроизведением управляет только хост", + "lock_room": "Закрыть комнату", + "lock_room_hint": "Новые участники не смогут войти", + "wait_for_all": "Ждать всех", + "wait_for_all_hint": "Ненадолго ставить паузу, когда у кого-то буферизация", + "invite_friends": "Пригласить друзей", + "invite_sent": "Приглашение отправлено", + "invite_failed": "Не удалось отправить приглашение", + "invite_again_in": "Снова через {}", + "invite_no_friends": "Добавьте друзей, чтобы приглашать их сюда", + "make_host": "Сделать хостом", + "remove_member": "Удалить из комнаты", + "remove_confirm": "Удалить {} из комнаты? Вернуться он не сможет.", + "buffering": "Буферизация…", + "sys_joined": "{} присоединился", + "sys_left": "{} вышел", + "sys_host": "{} теперь хост", + "sys_removed": "{} удалён из комнаты", + "error_banned": "Вас удалили из этой комнаты", + "error_locked": "Эта комната закрыта", + "room_closed_kicked": "Хост удалил вас из комнаты", + "waiting_for_others": "Ждём, пока все догонят…" }, "notifications": { "title": "Уведомления", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index f22f299e..e9000c0e 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -1172,7 +1172,31 @@ "reconnecting": "Yeniden bağlanılıyor…", "connecting": "Bağlanılıyor…", "connect_failed_title": "Bağlanılamadı", - "connect_failed_msg": "İnternet bağlantını kontrol edip tekrar dene." + "connect_failed_msg": "İnternet bağlantını kontrol edip tekrar dene.", + "settings_title": "Oda ayarları", + "room_size": "Oda boyutu", + "only_host_setting": "Oynatmayı yalnızca host kontrol eder", + "lock_room": "Odayı kilitle", + "lock_room_hint": "Yeni kimse katılamaz", + "wait_for_all": "Herkesi bekle", + "wait_for_all_hint": "Biri arabelleğe alırken kısa süre duraklat", + "invite_friends": "Arkadaşlarını davet et", + "invite_sent": "Davet gönderildi", + "invite_failed": "Davet gönderilemedi", + "invite_again_in": "{} sonra tekrar", + "invite_no_friends": "Buradan davet etmek için arkadaş ekle", + "make_host": "Host yap", + "remove_member": "Partiden çıkar", + "remove_confirm": "{} bu partiden çıkarılsın mı? Tekrar katılamaz.", + "buffering": "Arabelleğe alınıyor…", + "sys_joined": "{} katıldı", + "sys_left": "{} ayrıldı", + "sys_host": "{} artık host", + "sys_removed": "{} çıkarıldı", + "error_banned": "Bu partiden çıkarıldın", + "error_locked": "Bu parti kilitli", + "room_closed_kicked": "Host seni bu partiden çıkardı", + "waiting_for_others": "Herkesin yetişmesi bekleniyor…" }, "notifications": { "title": "Bildirimler", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index a0ec0494..e0d63db6 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -1172,7 +1172,31 @@ "reconnecting": "Qayta ulanmoqda…", "connecting": "Ulanmoqda…", "connect_failed_title": "Ulanib bo'lmadi", - "connect_failed_msg": "Internet aloqangizni tekshirib, qayta urinib ko'ring." + "connect_failed_msg": "Internet aloqangizni tekshirib, qayta urinib ko'ring.", + "settings_title": "Xona sozlamalari", + "room_size": "Xona hajmi", + "only_host_setting": "Ijroni faqat host boshqaradi", + "lock_room": "Xonani qulflash", + "lock_room_hint": "Yangi hech kim qo'shila olmaydi", + "wait_for_all": "Hammani kutish", + "wait_for_all_hint": "Kimdir yuklanayotganda biroz pauza qilish", + "invite_friends": "Do'stlarni taklif qilish", + "invite_sent": "Taklif yuborildi", + "invite_failed": "Taklifni yuborib bo'lmadi", + "invite_again_in": "{} dan keyin yana", + "invite_no_friends": "Bu yerda taklif qilish uchun do'st qo'shing", + "make_host": "Host qilish", + "remove_member": "Xonadan chiqarish", + "remove_confirm": "{} xonadan chiqarilsinmi? U qayta qo'shila olmaydi.", + "buffering": "Yuklanmoqda…", + "sys_joined": "{} qo'shildi", + "sys_left": "{} chiqib ketdi", + "sys_host": "{} endi host", + "sys_removed": "{} chiqarildi", + "error_banned": "Siz bu xonadan chiqarildingiz", + "error_locked": "Bu xona qulflangan", + "room_closed_kicked": "Host sizni xonadan chiqardi", + "waiting_for_others": "Hamma yetib olishini kutyapmiz…" }, "notifications": { "title": "Bildirishnomalar", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 5a8f5b47..3b04ba2a 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -1172,7 +1172,31 @@ "reconnecting": "Đang kết nối lại…", "connecting": "Đang kết nối…", "connect_failed_title": "Không kết nối được", - "connect_failed_msg": "Hãy kiểm tra kết nối mạng và thử lại." + "connect_failed_msg": "Hãy kiểm tra kết nối mạng và thử lại.", + "settings_title": "Cài đặt phòng", + "room_size": "Kích thước phòng", + "only_host_setting": "Chỉ chủ phòng điều khiển phát", + "lock_room": "Khóa phòng", + "lock_room_hint": "Không ai mới có thể tham gia", + "wait_for_all": "Chờ mọi người", + "wait_for_all_hint": "Tạm dừng một chút khi có người đang tải", + "invite_friends": "Mời bạn bè", + "invite_sent": "Đã gửi lời mời", + "invite_failed": "Không gửi được lời mời", + "invite_again_in": "Lại sau {}", + "invite_no_friends": "Thêm bạn bè để mời họ vào đây", + "make_host": "Đặt làm chủ phòng", + "remove_member": "Xóa khỏi phòng", + "remove_confirm": "Xóa {} khỏi phòng này? Họ sẽ không thể vào lại.", + "buffering": "Đang tải…", + "sys_joined": "{} đã tham gia", + "sys_left": "{} đã rời đi", + "sys_host": "{} giờ là chủ phòng", + "sys_removed": "{} đã bị xóa", + "error_banned": "Bạn đã bị xóa khỏi phòng này", + "error_locked": "Phòng này đã bị khóa", + "room_closed_kicked": "Chủ phòng đã xóa bạn khỏi phòng này", + "waiting_for_others": "Đang chờ mọi người theo kịp…" }, "notifications": { "title": "Thông báo", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 91e3e54a..7adcb5b4 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -1172,7 +1172,31 @@ "reconnecting": "重新連線緊…", "connecting": "連線緊…", "connect_failed_title": "連線唔到", - "connect_failed_msg": "檢查下你嘅網絡連線再試一次。" + "connect_failed_msg": "檢查下你嘅網絡連線再試一次。", + "settings_title": "房間設定", + "room_size": "房間人數", + "only_host_setting": "淨係房主可以控制播放", + "lock_room": "鎖住房間", + "lock_room_hint": "新人唔可以加入", + "wait_for_all": "等埋大家", + "wait_for_all_hint": "有人緩衝嗰陣暫停一陣", + "invite_friends": "邀請朋友", + "invite_sent": "已經發送邀請", + "invite_failed": "發送唔到邀請", + "invite_again_in": "{} 後再試", + "invite_no_friends": "加咗朋友就可以喺度邀請佢哋", + "make_host": "設為房主", + "remove_member": "移出房間", + "remove_confirm": "將 {} 移出呢個房間?佢之後唔可以再加入。", + "buffering": "緩衝緊…", + "sys_joined": "{} 加入咗", + "sys_left": "{} 離開咗", + "sys_host": "{} 而家係房主", + "sys_removed": "{} 已被移出", + "error_banned": "你已經被移出呢個房間", + "error_locked": "呢個房間已經鎖咗", + "room_closed_kicked": "房主將你移出咗呢個房間", + "waiting_for_others": "等緊大家追上…" }, "notifications": { "title": "通知", diff --git a/deeplink/apple-app-site-association b/deeplink/apple-app-site-association index f8136f94..4c45fa7e 100644 --- a/deeplink/apple-app-site-association +++ b/deeplink/apple-app-site-association @@ -8,7 +8,10 @@ "/detail", "/detail/*", "/play", - "/play/*" + "/play/*", + "/party/*", + "/link/*", + "/pair/*" ] } ] diff --git a/lib/core/deeplink/deeplink_service.dart b/lib/core/deeplink/deeplink_service.dart index 7ad3d38a..c082f70f 100644 --- a/lib/core/deeplink/deeplink_service.dart +++ b/lib/core/deeplink/deeplink_service.dart @@ -10,6 +10,7 @@ import 'package:soplay/features/anilist/data/anilist_constants.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/mal/data/mal_constants.dart'; import 'package:soplay/features/mal/data/mal_service.dart'; +import 'package:soplay/features/watch_party/domain/pending_party_code.dart'; class DeeplinkService { DeeplinkService({AppLinks? appLinks}) : _appLinks = appLinks ?? AppLinks(); @@ -131,6 +132,12 @@ class DeeplinkService { // Code lives in the PATH segment: /party/ — not a query param. final code = segments.length > 1 ? segments[1].trim() : ''; if (code.isEmpty) return null; + // A signed-out or first-run user is sent through sign-in or setup + // before the room can open; the code waits for them. + if (!getIt().isLoggedIn && + getIt.isRegistered()) { + unawaited(getIt().remember(code)); + } return '/watch-party?code=${Uri.encodeComponent(code)}'; default: return null; diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index f2f65fee..c1b2bb71 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -58,6 +58,9 @@ import 'package:soplay/features/streak/data/streak_remote_data_source.dart'; import 'package:soplay/features/streak/data/streak_service.dart'; import 'package:soplay/features/watch_party/data/watch_party_remote_data_source.dart'; import 'package:soplay/features/watch_party/data/watch_party_service.dart'; +import 'package:soplay/features/watch_party/domain/pending_party_code.dart'; +import 'package:hive_flutter/hive_flutter.dart' show Hive; +import 'package:soplay/core/constants/app_constants.dart'; import 'package:soplay/features/download/data/datasources/download_local_data_source.dart'; import 'package:soplay/features/download/data/datasources/download_native_data_source.dart'; import 'package:soplay/features/download/data/datasources/download_transfer_data_source.dart'; @@ -573,6 +576,14 @@ Future configureDependencies() async { tokenRefresher: getIt(), ), ); + getIt.registerLazySingleton(() { + const key = 'pending_party_code'; + final box = Hive.box(AppConstants.settingsBox); + return PendingPartyCode( + read: () => box.get(key) as String?, + write: (v) => v == null ? box.delete(key) : box.put(key, v), + ); + }); getIt.registerLazySingleton( () => WatchServicesDataSource(dio: getIt()), ); diff --git a/lib/features/detail/presentation/pages/player_page.party.dart b/lib/features/detail/presentation/pages/player_page.party.dart index b0d14510..92206a58 100644 --- a/lib/features/detail/presentation/pages/player_page.party.dart +++ b/lib/features/detail/presentation/pages/player_page.party.dart @@ -70,6 +70,7 @@ extension _PlayerParty on _PlayerPageState { void _stopPartyBinding() { _partyBindingActive = false; + _party.reportBuffering(false); _partyHeartbeat?.cancel(); _partyHeartbeat = null; _partyDrift?.cancel(); @@ -185,8 +186,37 @@ extension _PlayerParty on _PlayerPageState { } void _onDriftTick() { + _partyReportBuffering(); final pb = _lastPartyPlayback; if (pb != null && _followsParty) unawaited(_applyRemoteSync(pb)); + _partyWaitForAllTick(); + } + + /// Loading a new episode counts as buffering: from the room's side it is the + /// same wait. An error or a missing plugin does not, since waiting on it + /// would never end. + void _partyReportBuffering() { + if (!_inParty) return; + final c = _controller; + final stalled = + _errorMessage == null && + _pluginRequired == null && + (c == null || !c.value.isInitialized || c.value.isBuffering); + _party.reportBuffering(stalled); + } + + /// Host side of "wait for everyone": hold while a guest is stuck, then go + /// on. Not routed through [_partyEmit] because it is not a user action. + void _partyWaitForAllTick() { + final c = _controller; + if (c == null || !c.value.isInitialized || _isLive) return; + final paused = _party.waitForAllTick( + localPlaying: c.value.isPlaying, + positionSec: c.value.position.inMilliseconds / 1000.0, + pause: () => unawaited(c.pause()), + play: () => unawaited(c.play()), + ); + if (paused) _partyToast('watch_party.waiting_for_others'); } // --------------------------------------------------------------------------- diff --git a/lib/features/main/presentation/pages/main_page.dart b/lib/features/main/presentation/pages/main_page.dart index 5b60b0fb..a6d5f20e 100644 --- a/lib/features/main/presentation/pages/main_page.dart +++ b/lib/features/main/presentation/pages/main_page.dart @@ -12,6 +12,7 @@ import 'package:adaptive_platform_ui/adaptive_platform_ui.dart' import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:liquid_glass_widgets/liquid_glass_widgets.dart'; import 'package:soplay/core/deeplink/deeplink_opt_in.dart'; import 'package:soplay/core/di/injection.dart'; @@ -27,6 +28,7 @@ import 'package:soplay/features/home/presentation/bloc/home/home_state.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; import 'package:soplay/features/search/presentation/blocs/search_bloc.dart'; +import 'package:soplay/features/watch_party/presentation/party_entry.dart'; import 'package:showcaseview/showcaseview.dart'; import '../../../../core/navigation/app_tab.dart'; @@ -118,6 +120,7 @@ class _MainPageState extends State with WidgetsBindingObserver { ); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + unawaited(openPendingParty(GoRouter.of(context))); if (!isDesktopPlatform) { // An update is never held back — only queued, so nothing stacks on it. StartupPrompts.run( diff --git a/lib/features/onboarding/presentation/onboarding_navigation.dart b/lib/features/onboarding/presentation/onboarding_navigation.dart index 148bc776..8428a8b6 100644 --- a/lib/features/onboarding/presentation/onboarding_navigation.dart +++ b/lib/features/onboarding/presentation/onboarding_navigation.dart @@ -20,6 +20,7 @@ import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; import 'package:soplay/features/profiles/data/profile_session.dart'; import 'package:soplay/features/profiles/domain/household_profile.dart'; +import 'package:soplay/features/watch_party/presentation/party_entry.dart'; OnboardingController get _controller => getIt(); @@ -146,6 +147,7 @@ Future _goAfterAuth(BuildContext context) async { final c = _controller; if (!c.active || c.flow != OnboardingFlow.firstRun) { router.go('/main'); + await openPendingParty(router); return; } const beforeAuth = {OnboardingStep.welcome, OnboardingStep.account}; diff --git a/lib/features/watch_party/data/watch_party_remote_data_source.dart b/lib/features/watch_party/data/watch_party_remote_data_source.dart index eab4cab3..fb02f9e8 100644 --- a/lib/features/watch_party/data/watch_party_remote_data_source.dart +++ b/lib/features/watch_party/data/watch_party_remote_data_source.dart @@ -2,6 +2,12 @@ import 'package:dio/dio.dart'; import 'package:soplay/features/watch_party/domain/entities/party_content.dart'; +/// `429` from the invite endpoint: this friend was invited moments ago. +class PartyInviteCooldown implements Exception { + const PartyInviteCooldown(this.retryAfterSec); + final int retryAfterSec; +} + /// Thin REST layer for watch party rooms. Mirrors `StreakRemoteDataSource`: /// a single injected [Dio] whose baseUrl already ends with `/api`. class WatchPartyRemoteDataSource { @@ -9,11 +15,22 @@ class WatchPartyRemoteDataSource { const WatchPartyRemoteDataSource({required this.dio}); /// `POST /watch-party` → `{ code, party }`. - Future> createParty({PartyContent? content}) async { + Future> createParty({ + PartyContent? content, + int? maxMembers, + bool? onlyHostControls, + bool? waitForAll, + }) async { final res = await dio.post( '/watch-party', data: { if (content != null) 'content': content.toJson(), + 'maxMembers': ?maxMembers, + if (onlyHostControls != null || waitForAll != null) + 'settings': { + 'onlyHostControls': ?onlyHostControls, + 'waitForAll': ?waitForAll, + }, }, ); return _asMap(res.data); @@ -25,12 +42,29 @@ class WatchPartyRemoteDataSource { return _asMap(res.data); } - /// `POST /watch-party/:code/invite` `{ userId }` (host only). - Future invite(String code, String userId) async { - await dio.post( - '/watch-party/$code/invite', - data: {'userId': userId}, - ); + /// `POST /watch-party/:code/invite` `{ userId }` (host only). Returns the + /// cooldown the server started, in seconds. + Future invite(String code, String userId) async { + try { + final res = await dio.post( + '/watch-party/$code/invite', + data: {'userId': userId}, + ); + return (_asMap(res.data)['retryAfterSec'] as num?)?.ceil() ?? 0; + } on DioException catch (e) { + if (e.response?.statusCode == 429) { + final left = _asMap(e.response?.data)['retryAfterSec'] as num?; + throw PartyInviteCooldown(left?.ceil() ?? 600); + } + rethrow; + } + } + + /// `GET /watch-party/:code/invites` → `items: [{userId, retryAfterSec}]`. + Future> inviteStatus(String code) async { + final res = await dio.get('/watch-party/$code/invites'); + final items = _asMap(res.data)['items']; + return items is List ? items : const []; } /// `DELETE /watch-party/:code` (host only). diff --git a/lib/features/watch_party/data/watch_party_service.dart b/lib/features/watch_party/data/watch_party_service.dart index a82003b8..4a3b609c 100644 --- a/lib/features/watch_party/data/watch_party_service.dart +++ b/lib/features/watch_party/data/watch_party_service.dart @@ -15,6 +15,8 @@ import 'package:soplay/features/watch_party/domain/entities/party_playback.dart' import 'package:soplay/features/watch_party/domain/entities/party_reaction.dart'; import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; import 'package:soplay/features/watch_party/domain/entities/party_state.dart'; +import 'package:soplay/features/watch_party/domain/party_clock.dart'; +import 'package:soplay/features/watch_party/domain/party_wait_policy.dart'; /// Live watch-party session. Registered as an eager get_it singleton and is the /// single source of truth via [state]. Mirrors `StreakService`'s shape @@ -38,6 +40,20 @@ class WatchPartyService with WidgetsBindingObserver { final ValueNotifier state = ValueNotifier(PartyState.empty); + /// The room's chat as shown: history from `party:state`, then live lines. + final ValueNotifier> chatLog = + ValueNotifier>(const []); + static const int _chatLogMax = 200; + + final PartyClock clock = PartyClock(); + final PartyWaitPolicy waitPolicy = PartyWaitPolicy(); + static const Duration _pingPeriod = Duration(seconds: 10); + Timer? _pingTimer; + + // When each member was first seen buffering, on this device's clock. + final Map _bufferingSince = {}; + bool? _reportedBuffering; + final StreamController _chatCtrl = StreamController.broadcast(); final StreamController _reactionCtrl = @@ -81,15 +97,26 @@ class WatchPartyService with WidgetsBindingObserver { // --------------------------------------------------------------------------- /// REST create → connect → join (join emitted on the `connect` event). - Future createParty({PartyContent? content}) async { + Future createParty({ + PartyContent? content, + int? maxMembers, + bool? onlyHostControls, + bool? waitForAll, + }) async { // New session gets a fresh one-shot handshake recovery. _handshakeRetried = false; - final body = await remote.createParty(content: content); + final body = await remote.createParty( + content: content, + maxMembers: maxMembers, + onlyHostControls: onlyHostControls, + waitForAll: waitForAll, + ); final rawParty = body['party']; final partyJson = rawParty is Map ? Map.from(rawParty) : body; final room = PartyRoom.fromRest(partyJson); _currentCode = room.code; + _resetSession(); state.value = state.value.copyWith( room: room, phase: PartyPhase.joining, @@ -111,6 +138,7 @@ class WatchPartyService with WidgetsBindingObserver { Future joinParty(String code) async { // New session gets a fresh one-shot handshake recovery. _handshakeRetried = false; + if (_currentCode != code) _resetSession(); _currentCode = code; state.value = state.value.copyWith( phase: PartyPhase.joining, @@ -138,7 +166,9 @@ class WatchPartyService with WidgetsBindingObserver { _socket.emit('party:leave', {'code': code}); } _currentCode = null; + _stopPing(); _socket.disconnect(); + _resetSession(); state.value = PartyState.empty; } @@ -154,15 +184,115 @@ class WatchPartyService with WidgetsBindingObserver { rethrow; } finally { _currentCode = null; + _stopPing(); _socket.disconnect(); + _resetSession(); state.value = PartyState.empty; } } - Future invite(String userId) async { + /// Host-only push invite. Returns the seconds until [userId] can be invited + /// again; throws [PartyInviteCooldown] when they still are cooling down. + Future invite(String userId) async { final code = _currentCode ?? state.value.code; - if (code == null) return; - await remote.invite(code, userId); + if (code == null) return 0; + return remote.invite(code, userId); + } + + /// The caller's invite cooldowns as `[{userId, retryAfterSec}]`. + Future> inviteCooldowns() async { + final code = _currentCode ?? state.value.code; + if (code == null) return const []; + return remote.inviteStatus(code); + } + + /// Host-only. Only the fields given change; the server clamps [maxMembers]. + void updateSettings({ + int? maxMembers, + bool? onlyHostControls, + bool? locked, + bool? waitForAll, + }) { + if (!_canEmit) return; + _socket.emit('party:settings', { + 'code': _currentCode, + 'maxMembers': ?maxMembers, + 'onlyHostControls': ?onlyHostControls, + 'locked': ?locked, + 'waitForAll': ?waitForAll, + }); + } + + /// Host-only: removes [userId] and keeps them out of this room. + void kick(String userId) { + if (!_canEmit) return; + _socket.emit('party:kick', { + 'code': _currentCode, + 'userId': userId, + }); + } + + /// Host-only: hands the host role to [userId]. + void transferHost(String userId) { + if (!_canEmit) return; + _socket.emit('party:transfer', { + 'code': _currentCode, + 'userId': userId, + }); + } + + /// Tells the room whether this device's player is stalled. Sent only on + /// change. + void reportBuffering(bool buffering) { + if (!_canEmit || _reportedBuffering == buffering) return; + _reportedBuffering = buffering; + _socket.emit('party:buffering', { + 'code': _currentCode, + 'buffering': buffering, + }); + } + + /// Host side of "wait for everyone", run from the player's tick. Drives the + /// local player through [pause]/[play] and tells the room. Returns true when + /// it has just paused. + bool waitForAllTick({ + required bool localPlaying, + required double positionSec, + required void Function() pause, + required void Function() play, + }) { + final s = state.value; + final action = waitPolicy.tick( + enabled: s.room?.waitForAll ?? false, + isHost: s.isHost && s.inParty, + localPlaying: localPlaying, + bufferingSince: othersBufferingSince(), + now: DateTime.now(), + ); + switch (action) { + case PartyWaitAction.pause: + pause(); + sendControl(action: 'pause', positionSec: positionSec); + return true; + case PartyWaitAction.resume: + play(); + sendControl(action: 'play', positionSec: positionSec); + return false; + case PartyWaitAction.none: + return false; + } + } + + /// When each OTHER online member started buffering. + Iterable othersBufferingSince() { + final me = _myUserId; + final online = { + for (final m in state.value.room?.members ?? const []) + if (m.online) m.userId, + }; + return _bufferingSince.entries + .where((e) => e.key != me && online.contains(e.key)) + .map((e) => e.value); } void sendControl({required String action, double? positionSec, double? rate}) { @@ -209,22 +339,22 @@ class WatchPartyService with WidgetsBindingObserver { 'clientId': clientId, }); } + final me = hive.getUser(); + final local = PartyChatMessage( + userId: _myUserId ?? '', + username: me?.username ?? me?.displayName, + photoURL: me?.photoURL, + text: capped, + ts: DateTime.now().millisecondsSinceEpoch, + ); + _appendLog(local); // Optimistic local echo: show instantly instead of waiting for the server // round-trip (which can be slow on a distant server), and — crucially — // render the sender's own message even during a brief reconnect so it never // looks like the chat is dead. The server echoes our clientId back; _onChat // suppresses only the matching pending entry on THIS device, so a second // device on the same account still renders the message. - if (!_chatCtrl.isClosed) { - final me = hive.getUser(); - _chatCtrl.add(PartyChatMessage( - userId: _myUserId ?? '', - username: me?.username ?? me?.displayName, - photoURL: me?.photoURL, - text: capped, - ts: DateTime.now().millisecondsSinceEpoch, - )); - } + if (!_chatCtrl.isClosed) _chatCtrl.add(local); } void sendReaction(String emoji) { @@ -292,10 +422,73 @@ class WatchPartyService with WidgetsBindingObserver { _socket.on('party:reaction', _onReaction); _socket.on('party:closed', _onClosed); _socket.on('party:error', _onErrorEvent); + _socket.on('party:settings', _onSettings); + _socket.on('party:system', _onSystem); + _socket.on('party:buffering', _onBuffering); + _socket.on('party:pong', _onPong); + } + + void _resetSession() { + chatLog.value = const []; + _bufferingSince.clear(); + _reportedBuffering = null; + waitPolicy.release(); + clock.reset(); + } + + void _startPing() { + _pingTimer?.cancel(); + _ping(); + _pingTimer = Timer.periodic(_pingPeriod, (_) => _ping()); + } + + void _stopPing() { + _pingTimer?.cancel(); + _pingTimer = null; + } + + void _ping() { + if (!_socket.connected) return; + _socket.emit('party:ping', { + 't0': DateTime.now().millisecondsSinceEpoch, + }); + } + + void _appendLog(PartyChatMessage msg) { + final next = [...chatLog.value, msg]; + chatLog.value = next.length > _chatLogMax + ? next.sublist(next.length - _chatLogMax) + : next; + } + + /// [pb] re-based from "when it arrived" to "when the server meant it". + PartyPlayback _aligned(PartyPlayback pb, Map json) => + PartyPlayback( + positionSec: pb.positionSec, + isPlaying: pb.isPlaying, + rate: pb.rate, + receivedAt: clock.align( + serverTime: (json['serverTime'] as num?)?.toInt(), + receivedAt: pb.receivedAt, + ), + ); + + void _syncBuffering(List members) { + final now = DateTime.now(); + final buffering = { + for (final m in members) + if (m.online && m.buffering) m.userId, + }; + _bufferingSince.removeWhere((id, _) => !buffering.contains(id)); + for (final id in buffering) { + _bufferingSince.putIfAbsent(id, () => now); + } } void _handleConnect() { _handshakeRetried = false; + _reportedBuffering = null; + _startPing(); state.value = state.value.copyWith(connection: PartyConnection.connected); // Room membership is bound to the socket — (re)join after every connect. final code = _currentCode; @@ -325,6 +518,7 @@ class WatchPartyService with WidgetsBindingObserver { void _handleDisconnect() { if (_disposed) return; + _stopPing(); if (_currentCode != null && state.value.phase != PartyPhase.closed) { // socket.io will auto-reconnect; reflect that in the UI. state.value = @@ -339,7 +533,18 @@ class WatchPartyService with WidgetsBindingObserver { void _onState(dynamic d) { final j = _asMap(d); if (j == null) return; - final room = PartyRoom.fromSnapshot(j); + var room = PartyRoom.fromSnapshot(j); + final pbJson = _asMap(j['playback']); + if (pbJson != null) { + room = room.copyWith(playback: _aligned(room.playback, pbJson)); + } + if (j.containsKey('chat')) { + final history = PartyChatMessage.listFrom(j['chat']); + chatLog.value = history.length > _chatLogMax + ? history.sublist(history.length - _chatLogMax) + : history; + } + _syncBuffering(room.members); state.value = state.value.copyWith( room: room, phase: PartyPhase.inRoom, @@ -354,7 +559,10 @@ class WatchPartyService with WidgetsBindingObserver { if (j == null) return; final pbJson = _asMap(j['playback']); if (pbJson == null) return; - final pb = PartyPlayback.fromJson(pbJson, receivedAt: DateTime.now()); + final pb = _aligned( + PartyPlayback.fromJson(pbJson, receivedAt: DateTime.now()), + pbJson, + ); final room = state.value.room; if (room != null) { state.value = state.value.copyWith(room: room.copyWith(playback: pb)); @@ -370,7 +578,10 @@ class WatchPartyService with WidgetsBindingObserver { contentJson != null ? PartyContent.fromJson(contentJson) : null; final pbJson = _asMap(j['playback']); final pb = pbJson != null - ? PartyPlayback.fromJson(pbJson, receivedAt: DateTime.now()) + ? _aligned( + PartyPlayback.fromJson(pbJson, receivedAt: DateTime.now()), + pbJson, + ) : null; final room = state.value.room; if (room != null) { @@ -387,6 +598,7 @@ class WatchPartyService with WidgetsBindingObserver { if (j == null) return; final members = PartyMember.listFrom(j['members']); final hostUserId = j['hostUserId'] as String?; + _syncBuffering(members); final room = state.value.room; if (room != null) { state.value = state.value.copyWith( @@ -410,9 +622,60 @@ class WatchPartyService with WidgetsBindingObserver { // Legacy fallback for a server that doesn't echo the clientId. return; } + _appendLog(msg); if (!_chatCtrl.isClosed) _chatCtrl.add(msg); } + void _onSystem(dynamic d) { + final j = _asMap(d); + if (j == null) return; + final msg = PartyChatMessage.tryParse(j); + if (msg == null || !msg.isSystem) return; + _appendLog(msg); + } + + void _onSettings(dynamic d) { + final j = _asMap(d); + final room = state.value.room; + if (j == null || room == null) return; + final settings = _asMap(j['settings']) ?? const {}; + state.value = state.value.copyWith( + room: room.copyWith( + onlyHostControls: settings['onlyHostControls'] as bool?, + locked: settings['locked'] as bool?, + waitForAll: settings['waitForAll'] as bool?, + maxMembers: (j['maxMembers'] as num?)?.toInt(), + maxMembersLimit: (j['maxMembersLimit'] as num?)?.toInt(), + ), + ); + } + + void _onBuffering(dynamic d) { + final j = _asMap(d); + final room = state.value.room; + final userId = j?['userId'] as String?; + if (j == null || room == null || userId == null) return; + final buffering = j['buffering'] == true; + final members = [ + for (final m in room.members) + m.userId == userId ? m.copyWith(buffering: buffering) : m, + ]; + _syncBuffering(members); + state.value = state.value.copyWith(room: room.copyWith(members: members)); + } + + void _onPong(dynamic d) { + final j = _asMap(d); + final t0 = (j?['t0'] as num?)?.toInt(); + final serverTime = (j?['serverTime'] as num?)?.toInt(); + if (t0 == null || serverTime == null) return; + clock.add( + sentAt: t0, + serverTime: serverTime, + receivedAt: DateTime.now().millisecondsSinceEpoch, + ); + } + void _onReaction(dynamic d) { final j = _asMap(d); if (j == null) return; @@ -435,6 +698,7 @@ class WatchPartyService with WidgetsBindingObserver { closedReason: reason, ); _currentCode = null; + _stopPing(); _socket.disconnect(); } @@ -494,9 +758,11 @@ class WatchPartyService with WidgetsBindingObserver { void dispose() { _disposed = true; + _stopPing(); WidgetsBinding.instance.removeObserver(this); _socket.dispose(); state.dispose(); + chatLog.dispose(); _chatCtrl.close(); _reactionCtrl.close(); _contentCtrl.close(); diff --git a/lib/features/watch_party/domain/entities/party_chat_message.dart b/lib/features/watch_party/domain/entities/party_chat_message.dart index e52c6706..a191819c 100644 --- a/lib/features/watch_party/domain/entities/party_chat_message.dart +++ b/lib/features/watch_party/domain/entities/party_chat_message.dart @@ -1,11 +1,16 @@ +/// What a system line in the chat says happened. +enum PartySystemKind { joined, left, host, removed } + /// A chat line broadcast in a watch party. Server payload nests the author -/// under `user: {id, username, photoURL}`. +/// under `user: {id, username, photoURL}`. A system line (`type: 'system'`) +/// has a [system] kind and no text; [userId] is who it is about. class PartyChatMessage { final String userId; final String? username; final String? photoURL; final String text; final int ts; + final PartySystemKind? system; const PartyChatMessage({ required this.userId, @@ -13,8 +18,36 @@ class PartyChatMessage { this.photoURL, required this.text, required this.ts, + this.system, }); + bool get isSystem => system != null; + + static PartySystemKind? _kind(dynamic raw) => switch (raw) { + 'joined' => PartySystemKind.joined, + 'left' => PartySystemKind.left, + 'host' => PartySystemKind.host, + 'removed' => PartySystemKind.removed, + _ => null, + }; + + /// Null for a system line of a kind this app does not know, so a newer + /// server cannot put a blank row in an older chat. + static PartyChatMessage? tryParse(Map j) => + j['type'] == 'system' && _kind(j['kind']) == null + ? null + : PartyChatMessage.fromJson(j); + + /// A `party:state` `chat` array, oldest first. + static List listFrom(dynamic raw) { + if (raw is! List) return const []; + return raw + .whereType() + .map((m) => tryParse(Map.from(m))) + .whereType() + .toList(); + } + factory PartyChatMessage.fromJson(Map j) { final rawUser = j['user']; final user = rawUser is Map @@ -26,6 +59,7 @@ class PartyChatMessage { photoURL: user['photoURL'] as String?, text: j['text'] as String? ?? '', ts: (j['ts'] as num?)?.toInt() ?? 0, + system: j['type'] == 'system' ? _kind(j['kind']) : null, ); } } diff --git a/lib/features/watch_party/domain/entities/party_member.dart b/lib/features/watch_party/domain/entities/party_member.dart index c6f4ea39..89fa5fcc 100644 --- a/lib/features/watch_party/domain/entities/party_member.dart +++ b/lib/features/watch_party/domain/entities/party_member.dart @@ -5,6 +5,7 @@ class PartyMember { final String? photoURL; final String role; // 'host' | 'member' final bool online; + final bool buffering; const PartyMember({ required this.userId, @@ -12,8 +13,18 @@ class PartyMember { this.photoURL, this.role = 'member', this.online = false, + this.buffering = false, }); + PartyMember copyWith({bool? buffering}) => PartyMember( + userId: userId, + username: username, + photoURL: photoURL, + role: role, + online: online, + buffering: buffering ?? this.buffering, + ); + bool get isHost => role == 'host'; factory PartyMember.fromJson(Map j) => PartyMember( @@ -22,6 +33,7 @@ class PartyMember { photoURL: j['photoURL'] as String?, role: j['role'] as String? ?? 'member', online: j['online'] as bool? ?? false, + buffering: j['buffering'] as bool? ?? false, ); /// Defensively parse a `members` array from a socket/REST payload. diff --git a/lib/features/watch_party/domain/entities/party_room.dart b/lib/features/watch_party/domain/entities/party_room.dart index dd3bbd09..fc66bd68 100644 --- a/lib/features/watch_party/domain/entities/party_room.dart +++ b/lib/features/watch_party/domain/entities/party_room.dart @@ -8,7 +8,12 @@ class PartyRoom { final String code; final String? hostUserId; final bool onlyHostControls; + final bool locked; + final bool waitForAll; final int maxMembers; + + /// The largest size the host may pick, from the server's hard cap. + final int maxMembersLimit; final List members; final PartyContent? content; final PartyPlayback playback; @@ -17,7 +22,10 @@ class PartyRoom { required this.code, this.hostUserId, this.onlyHostControls = true, + this.locked = false, + this.waitForAll = false, this.maxMembers = 2, + this.maxMembersLimit = 10, this.members = const [], this.content, required this.playback, @@ -32,6 +40,11 @@ class PartyRoom { return true; } + static bool _flag(dynamic settings, String key) => + settings is Map && settings[key] == true; + + static int _limit(dynamic raw) => (raw as num?)?.toInt() ?? 10; + static PartyContent? _content(dynamic raw) => raw is Map ? PartyContent.fromJson(Map.from(raw)) : null; @@ -47,7 +60,10 @@ class PartyRoom { code: j['code'] as String? ?? '', hostUserId: j['hostUserId'] as String?, onlyHostControls: _onlyHostControls(j['settings']), + locked: _flag(j['settings'], 'locked'), + waitForAll: _flag(j['settings'], 'waitForAll'), maxMembers: (j['maxMembers'] as num?)?.toInt() ?? 2, + maxMembersLimit: _limit(j['maxMembersLimit']), members: PartyMember.listFrom(j['members']), content: _content(j['content']), playback: _playback(j['playback']), @@ -62,7 +78,10 @@ class PartyRoom { code: j['code'] as String? ?? '', hostUserId: (j['hostUserId'] as String?) ?? host?['userId'] as String?, onlyHostControls: _onlyHostControls(j['settings']), + locked: _flag(j['settings'], 'locked') || j['locked'] == true, + waitForAll: _flag(j['settings'], 'waitForAll'), maxMembers: (j['maxMembers'] as num?)?.toInt() ?? 2, + maxMembersLimit: _limit(j['maxMembersLimit']), members: PartyMember.listFrom(j['members']), content: _content(j['content']), playback: _playback(j['playback']), @@ -73,7 +92,10 @@ class PartyRoom { String? code, String? hostUserId, bool? onlyHostControls, + bool? locked, + bool? waitForAll, int? maxMembers, + int? maxMembersLimit, List? members, PartyContent? content, PartyPlayback? playback, @@ -82,7 +104,10 @@ class PartyRoom { code: code ?? this.code, hostUserId: hostUserId ?? this.hostUserId, onlyHostControls: onlyHostControls ?? this.onlyHostControls, + locked: locked ?? this.locked, + waitForAll: waitForAll ?? this.waitForAll, maxMembers: maxMembers ?? this.maxMembers, + maxMembersLimit: maxMembersLimit ?? this.maxMembersLimit, members: members ?? this.members, content: content ?? this.content, playback: playback ?? this.playback, diff --git a/lib/features/watch_party/domain/invite_cooldowns.dart b/lib/features/watch_party/domain/invite_cooldowns.dart new file mode 100644 index 00000000..340d0dfe --- /dev/null +++ b/lib/features/watch_party/domain/invite_cooldowns.dart @@ -0,0 +1,50 @@ +/// When each friend can be invited again. The server keeps the real cooldown +/// and reports it in seconds; this turns that into deadlines on this device's +/// clock so a picker can count down without asking again. +class InviteCooldowns { + InviteCooldowns({DateTime Function()? clock}) : _clock = clock ?? DateTime.now; + + final DateTime Function() _clock; + final Map _until = {}; + + void set(String userId, int retryAfterSec) { + if (retryAfterSec <= 0) { + _until.remove(userId); + return; + } + _until[userId] = _clock().add(Duration(seconds: retryAfterSec)); + } + + /// From `GET /watch-party/:code/invites` → `items: [{userId, retryAfterSec}]`. + void applyServer(dynamic items) { + if (items is! List) return; + for (final raw in items.whereType()) { + final id = raw['userId']; + final sec = raw['retryAfterSec']; + if (id is String && sec is num) set(id, sec.ceil()); + } + } + + Duration remaining(String userId) { + final until = _until[userId]; + if (until == null) return Duration.zero; + final left = until.difference(_clock()); + if (left <= Duration.zero) { + _until.remove(userId); + return Duration.zero; + } + return left; + } + + bool coolingDown(String userId) => remaining(userId) > Duration.zero; + + bool get any => _until.keys.toList().any(coolingDown); + + /// `9:05`, rounded up so it never reads `0:00` while still blocked. + static String format(Duration d) { + final total = (d.inMilliseconds / 1000).ceil(); + final m = total ~/ 60; + final s = total % 60; + return '$m:${s.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/features/watch_party/domain/party_clock.dart b/lib/features/watch_party/domain/party_clock.dart new file mode 100644 index 00000000..5a3c3d10 --- /dev/null +++ b/lib/features/watch_party/domain/party_clock.dart @@ -0,0 +1,69 @@ +/// Server clock offset from `party:ping` / `party:pong` round trips. +/// +/// A sync's `positionSec` is where the server put the room at `serverTime`. +/// Reading it as "where the room is at the moment it arrived" is late by the +/// one-way trip, so a guest on a slow link always trails the host by that +/// much. With an offset, the local instant the server meant can be recovered +/// and the trip taken out. +/// +/// Pure: millisecond timestamps in, numbers out. +class PartyClock { + PartyClock({this.window = 8}); + + /// How many recent round trips are kept. The one with the shortest trip + /// wins: queueing only ever adds delay, so the fastest sample is the least + /// skewed. + final int window; + + /// Round trips slower than this say more about the network than the clock. + static const int maxRttMs = 5000; + + /// The most an alignment may move a receipt back. Beyond this the offset is + /// stale or wrong and the receipt time is the safer guess. + static const int maxShiftMs = 3000; + + final List<_Sample> _samples = <_Sample>[]; + + /// Records one round trip: [sentAt] and [receivedAt] on this device's clock, + /// [serverTime] on the server's. Returns false when the sample is unusable. + bool add({required int sentAt, required int serverTime, required int receivedAt}) { + final rtt = receivedAt - sentAt; + if (rtt < 0 || rtt > maxRttMs) return false; + _samples.add(_Sample(rtt, serverTime - (sentAt + rtt / 2).round())); + if (_samples.length > window) _samples.removeAt(0); + return true; + } + + void reset() => _samples.clear(); + + _Sample? get _best { + _Sample? best; + for (final s in _samples) { + if (best == null || s.rtt < best.rtt) best = s; + } + return best; + } + + /// Server clock minus local clock, or null before the first usable sample. + int? get offsetMs => _best?.offset; + + int? get rttMs => _best?.rtt; + + /// The local instant the server meant by [serverTime], for a message that + /// arrived at [receivedAt]. Falls back to [receivedAt] without an offset, or + /// when the answer would be in the future or implausibly far back. + DateTime align({required int? serverTime, required DateTime receivedAt}) { + final offset = offsetMs; + if (serverTime == null || offset == null) return receivedAt; + final local = serverTime - offset; + final shift = receivedAt.millisecondsSinceEpoch - local; + if (shift < 0 || shift > maxShiftMs) return receivedAt; + return DateTime.fromMillisecondsSinceEpoch(local); + } +} + +class _Sample { + const _Sample(this.rtt, this.offset); + final int rtt; + final int offset; +} diff --git a/lib/features/watch_party/domain/party_wait_policy.dart b/lib/features/watch_party/domain/party_wait_policy.dart new file mode 100644 index 00000000..b86eede4 --- /dev/null +++ b/lib/features/watch_party/domain/party_wait_policy.dart @@ -0,0 +1,70 @@ +/// "Wait for everyone": the host's player holds for a guest who is buffering. +/// +/// A short stall is not worth a room-wide pause, so a guest has to buffer for +/// [threshold] first. The hold is also capped at [maxHold]: a guest on a link +/// that never recovers must not freeze the room for good. +/// +/// After giving up on a guest it ignores that same stall, otherwise the room +/// would pause again on the next tick and every [maxHold] after that. +enum PartyWaitAction { none, pause, resume } + +class PartyWaitPolicy { + PartyWaitPolicy({ + this.threshold = const Duration(seconds: 3), + this.maxHold = const Duration(seconds: 15), + }); + + final Duration threshold; + final Duration maxHold; + + DateTime? _heldSince; + DateTime? _ignoreBefore; + + bool get holding => _heldSince != null; + + /// [bufferingSince] is when each other member was first seen buffering. + PartyWaitAction tick({ + required bool enabled, + required bool isHost, + required bool localPlaying, + required Iterable bufferingSince, + required DateTime now, + }) { + final held = _heldSince; + if (!enabled || !isHost) { + _heldSince = null; + return held != null && !localPlaying + ? PartyWaitAction.resume + : PartyWaitAction.none; + } + + if (held != null) { + if (localPlaying) { + // Someone pressed play while we held; the hold is over. + _heldSince = null; + return PartyWaitAction.none; + } + final stillWaiting = bufferingSince.isNotEmpty; + if (!stillWaiting || now.difference(held) >= maxHold) { + _heldSince = null; + if (stillWaiting) _ignoreBefore = now; + return PartyWaitAction.resume; + } + return PartyWaitAction.none; + } + + if (!localPlaying) return PartyWaitAction.none; + final ignore = _ignoreBefore; + final stalled = bufferingSince.any( + (t) => + (ignore == null || t.isAfter(ignore)) && + now.difference(t) >= threshold, + ); + if (!stalled) return PartyWaitAction.none; + _heldSince = now; + return PartyWaitAction.pause; + } + + /// The host took over playback themselves; the hold is theirs now. + void release() => _heldSince = null; +} diff --git a/lib/features/watch_party/domain/pending_party_code.dart b/lib/features/watch_party/domain/pending_party_code.dart new file mode 100644 index 00000000..0f680ed9 --- /dev/null +++ b/lib/features/watch_party/domain/pending_party_code.dart @@ -0,0 +1,58 @@ +/// A party invite that arrived before the user could join it: signed out, or +/// still in first-run setup. Kept through sign-in or sign-up, then opened once. +/// +/// Stored as `CODE|savedAtMs` through [read]/[write] so it survives the app +/// being killed while the user goes to fetch a password. Old invites expire: +/// opening a room someone shared yesterday, right after an unrelated sign-in, +/// would be a surprise. +class PendingPartyCode { + PendingPartyCode({ + required this.read, + required this.write, + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; + + final String? Function() read; + final Future Function(String? value) write; + final DateTime Function() _clock; + + static const Duration ttl = Duration(hours: 1); + + static final RegExp _shape = RegExp(r'^[A-Z0-9]{4,12}$'); + + /// Upper-cased, trimmed, or null when it cannot be a party code. + static String? normalize(String? raw) { + final code = raw?.trim().toUpperCase() ?? ''; + return _shape.hasMatch(code) ? code : null; + } + + Future remember(String? code) async { + final c = normalize(code); + if (c == null) return; + await write('$c|${_clock().millisecondsSinceEpoch}'); + } + + /// The pending code if there is a fresh one, without clearing it. + String? peek() { + final raw = read(); + if (raw == null || raw.isEmpty) return null; + final bar = raw.indexOf('|'); + final code = normalize(bar < 0 ? raw : raw.substring(0, bar)); + final savedAt = bar < 0 ? null : int.tryParse(raw.substring(bar + 1)); + if (code == null || savedAt == null) return null; + final age = _clock().difference( + DateTime.fromMillisecondsSinceEpoch(savedAt), + ); + if (age.isNegative || age > ttl) return null; + return code; + } + + /// The pending code, cleared so it opens once. + Future take() async { + final code = peek(); + if (read() != null) await write(null); + return code; + } + + Future clear() => write(null); +} diff --git a/lib/features/watch_party/presentation/pages/watch_party_page.dart b/lib/features/watch_party/presentation/pages/watch_party_page.dart index 3ebb82ed..afc21c63 100644 --- a/lib/features/watch_party/presentation/pages/watch_party_page.dart +++ b/lib/features/watch_party/presentation/pages/watch_party_page.dart @@ -12,13 +12,18 @@ import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; import 'package:soplay/features/detail/domain/usecases/resolve_media_usecase.dart'; import 'package:soplay/features/watch_party/data/watch_party_service.dart'; +import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/watch_party/domain/entities/party_content.dart'; +import 'package:soplay/features/watch_party/domain/entities/party_member.dart'; import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; import 'package:soplay/features/watch_party/domain/entities/party_state.dart'; import 'package:soplay/features/watch_party/domain/party_resolve_gate.dart'; +import 'package:soplay/features/watch_party/domain/pending_party_code.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_chat_panel.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_code_sheet.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_error_views.dart'; +import 'package:soplay/features/watch_party/presentation/widgets/party_host_settings.dart'; +import 'package:soplay/features/watch_party/presentation/widgets/party_invite_sheet.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_member_bar.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_plugin_required_view.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_reactions_bar.dart'; @@ -123,6 +128,23 @@ class _WatchPartyPageState extends State { final current = _service.state.value; if (current.inParty && current.code == code) return; + // Signed out (often a fresh install opened from an invite link): keep the + // code through sign-in or sign-up. A successful sign-in re-opens the room + // from the pending code; this page is replaced by then. + if (!getIt().isLoggedIn) { + final pending = getIt(); + await pending.remember(code); + if (!mounted) return; + await context.push('/login'); + if (!mounted) return; + if (!getIt().isLoggedIn) { + await pending.clear(); + if (mounted) setState(() => _joinErrorCode = 'unauthorized'); + return; + } + await pending.clear(); + } + setState(() { _joining = true; _joinErrorCode = null; @@ -149,6 +171,42 @@ class _WatchPartyPageState extends State { ); } + Future _onMemberAction( + PartyMember member, + PartyMemberAction action, + ) async { + switch (action) { + case PartyMemberAction.makeHost: + _service.transferHost(member.userId); + case PartyMemberAction.remove: + final name = (member.username ?? '').trim().isNotEmpty + ? member.username!.trim() + : '—'; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.surface, + content: Text('watch_party.remove_confirm'.tr(args: [name])), + actions: [ + TextButton( + autofocus: true, + onPressed: () => Navigator.pop(ctx, false), + child: Text('general.cancel'.tr()), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text( + 'general.remove'.tr(), + style: const TextStyle(color: AppColors.error), + ), + ), + ], + ), + ); + if (ok == true) _service.kick(member.userId); + } + } + Future _copyCode(String code) async { await Clipboard.setData(ClipboardData(text: code)); if (!mounted) return; @@ -311,7 +369,7 @@ class _WatchPartyPageState extends State { return PartyErrorView( code: code, actionLabel: 'general.try_again'.tr(), - onAction: _retryConnect, + onAction: code == 'unauthorized' ? _bootstrap : _retryConnect, ); } // A failed connection, or a join that never lands, must NOT sit forever @@ -352,8 +410,27 @@ class _WatchPartyPageState extends State { _ContentCard(content: content), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: PartyMemberBar(room: room, myUserId: s.myUserId), + child: PartyMemberBar( + room: room, + myUserId: s.myUserId, + isHost: s.isHost, + onAction: _onMemberAction, + ), ), + if (s.isHost) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => showPartyInviteSheet(context), + icon: const Icon(Icons.person_add_alt_1_rounded), + label: Text('watch_party.invite_friends'.tr()), + ), + ), + ), + PartyHostSettings(room: room, service: _service), + ], const SizedBox(height: 8), _BottomActions( isHost: s.isHost, diff --git a/lib/features/watch_party/presentation/party_entry.dart b/lib/features/watch_party/presentation/party_entry.dart index bb115d75..be623dba 100644 --- a/lib/features/watch_party/presentation/party_entry.dart +++ b/lib/features/watch_party/presentation/party_entry.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -6,6 +8,7 @@ import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/watch_party/domain/entities/party_content.dart'; +import 'package:soplay/features/watch_party/domain/pending_party_code.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_code_sheet.dart'; /// Watch Party is auth-gated on the server (REST + socket). Returns true when a @@ -16,6 +19,22 @@ Future _ensurePartyLogin(BuildContext context) async { return getIt().isLoggedIn; } +/// Opens the party invite kept through sign-in or first-run setup, once. +/// Called wherever a signed-in user lands on Home; concurrent calls (sign-in +/// and Home's first frame) share one navigation. +Future openPendingParty(GoRouter router) => _pendingOpen ??= + _openPendingParty(router).whenComplete(() => _pendingOpen = null); + +Future? _pendingOpen; + +Future _openPendingParty(GoRouter router) async { + if (!getIt.isRegistered()) return; + if (!getIt().isLoggedIn) return; + final code = await getIt().take(); + if (code == null) return; + unawaited(router.push('/watch-party?code=${Uri.encodeComponent(code)}')); +} + /// Navigation payload for `/watch-party` (passed via `GoRouter` `state.extra`). class WatchPartyArgs { const WatchPartyArgs({this.code, this.content}); diff --git a/lib/features/watch_party/presentation/widgets/party_chat_panel.dart b/lib/features/watch_party/presentation/widgets/party_chat_panel.dart index dd6dd73f..fc1b9aff 100644 --- a/lib/features/watch_party/presentation/widgets/party_chat_panel.dart +++ b/lib/features/watch_party/presentation/widgets/party_chat_panel.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -9,7 +7,8 @@ import 'package:soplay/features/watch_party/data/watch_party_service.dart'; import 'package:soplay/features/watch_party/domain/entities/party_chat_message.dart'; /// Live chat for a watch party: a scrolling message list fed by -/// `service.chat`, plus a composer wired to `service.sendChat`. +/// `service.chatLog` (history first, then live lines), plus a composer wired +/// to `service.sendChat`. class PartyChatPanel extends StatefulWidget { const PartyChatPanel({super.key, required this.service, this.myUserId}); @@ -21,40 +20,36 @@ class PartyChatPanel extends StatefulWidget { } class _PartyChatPanelState extends State { - static const int _maxMessages = 200; - - final List _messages = []; final TextEditingController _controller = TextEditingController(); final FocusNode _focus = FocusNode(); final ScrollController _scroll = ScrollController(); - StreamSubscription? _sub; @override void initState() { super.initState(); - _sub = widget.service.chat.listen(_onMessage); + widget.service.chatLog.addListener(_onLog); + WidgetsBinding.instance.addPostFrameCallback((_) => _jumpToEnd()); } @override void dispose() { - _sub?.cancel(); + widget.service.chatLog.removeListener(_onLog); _controller.dispose(); _focus.dispose(); _scroll.dispose(); super.dispose(); } - void _onMessage(PartyChatMessage msg) { + void _onLog() { if (!mounted) return; - setState(() { - _messages.add(msg); - if (_messages.length > _maxMessages) { - _messages.removeRange(0, _messages.length - _maxMessages); - } - }); + setState(() {}); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToEnd()); } + void _jumpToEnd() { + if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent); + } + void _scrollToEnd() { if (!_scroll.hasClients) return; _scroll.animateTo( @@ -73,17 +68,25 @@ class _PartyChatPanelState extends State { @override Widget build(BuildContext context) { + final messages = widget.service.chatLog.value; return Column( children: [ Expanded( - child: _messages.isEmpty + child: messages.isEmpty ? _EmptyChat() : ListView.builder( controller: _scroll, padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _messages.length, + itemCount: messages.length, itemBuilder: (_, i) { - final m = _messages[i]; + final m = messages[i]; + if (m.isSystem) { + return _SystemRow( + message: m, + isMe: widget.myUserId != null && + m.userId == widget.myUserId, + ); + } return _ChatRow( message: m, isMe: widget.myUserId != null && @@ -126,6 +129,41 @@ class _EmptyChat extends StatelessWidget { } } +class _SystemRow extends StatelessWidget { + const _SystemRow({required this.message, required this.isMe}); + + final PartyChatMessage message; + final bool isMe; + + @override + Widget build(BuildContext context) { + final name = isMe + ? 'watch_party.you'.tr() + : (message.username ?? '').trim().isNotEmpty + ? message.username!.trim() + : '—'; + final text = switch (message.system) { + PartySystemKind.joined => 'watch_party.sys_joined'.tr(args: [name]), + PartySystemKind.left => 'watch_party.sys_left'.tr(args: [name]), + PartySystemKind.host => 'watch_party.sys_host'.tr(args: [name]), + PartySystemKind.removed => 'watch_party.sys_removed'.tr(args: [name]), + null => '', + }; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + fontStyle: FontStyle.italic, + ), + ), + ); + } +} + class _ChatRow extends StatelessWidget { const _ChatRow({required this.message, required this.isMe}); diff --git a/lib/features/watch_party/presentation/widgets/party_error_views.dart b/lib/features/watch_party/presentation/widgets/party_error_views.dart index 5ec005cb..8903e36d 100644 --- a/lib/features/watch_party/presentation/widgets/party_error_views.dart +++ b/lib/features/watch_party/presentation/widgets/party_error_views.dart @@ -150,6 +150,8 @@ class PartyErrorView extends StatelessWidget { 'watch_party.error_not_found', ), 'full' => (Icons.group_off_rounded, 'watch_party.error_full'), + 'locked' => (Icons.lock_rounded, 'watch_party.error_locked'), + 'banned' => (Icons.block_rounded, 'watch_party.error_banned'), 'forbidden' => (Icons.lock_outline_rounded, 'watch_party.error_forbidden'), 'rate' => (Icons.timer_outlined, 'watch_party.error_rate'), 'unauthorized' => ( @@ -185,9 +187,11 @@ class PartyClosedView extends StatelessWidget { return PartyStateView( icon: Icons.meeting_room_outlined, title: 'watch_party.room_closed'.tr(), - message: reason == 'empty' - ? 'watch_party.room_closed_empty'.tr() - : null, + message: switch (reason) { + 'empty' => 'watch_party.room_closed_empty'.tr(), + 'kicked' => 'watch_party.room_closed_kicked'.tr(), + _ => null, + }, actionLabel: actionLabel, onAction: onAction, ); diff --git a/lib/features/watch_party/presentation/widgets/party_host_settings.dart b/lib/features/watch_party/presentation/widgets/party_host_settings.dart new file mode 100644 index 00000000..83b00e31 --- /dev/null +++ b/lib/features/watch_party/presentation/widgets/party_host_settings.dart @@ -0,0 +1,142 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/watch_party/data/watch_party_service.dart'; +import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; + +/// The host's room controls in the lobby: size, who drives playback, the lock +/// and "wait for everyone". Each change goes out as `party:settings` and comes +/// back to every member, this one included, so the room is the only state. +class PartyHostSettings extends StatelessWidget { + const PartyHostSettings({ + super.key, + required this.room, + required this.service, + }); + + final PartyRoom room; + final WatchPartyService service; + + static const int minMembers = 2; + + @override + Widget build(BuildContext context) { + final limit = room.maxMembersLimit < minMembers + ? minMembers + : room.maxMembersLimit; + final size = room.maxMembers.clamp(minMembers, limit); + return Container( + margin: const EdgeInsets.fromLTRB(16, 4, 16, 4), + padding: const EdgeInsets.fromLTRB(14, 10, 6, 6), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'watch_party.settings_title'.tr(), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + 'watch_party.room_size'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + ), + ), + ), + IconButton( + icon: const Icon(Icons.remove_rounded), + onPressed: size > minMembers + ? () => service.updateSettings(maxMembers: size - 1) + : null, + ), + SizedBox( + width: 28, + child: Text( + '$size', + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + icon: const Icon(Icons.add_rounded), + onPressed: size < limit + ? () => service.updateSettings(maxMembers: size + 1) + : null, + ), + ], + ), + _Toggle( + title: 'watch_party.only_host_setting'.tr(), + value: room.onlyHostControls, + onChanged: (v) => service.updateSettings(onlyHostControls: v), + ), + _Toggle( + title: 'watch_party.lock_room'.tr(), + subtitle: 'watch_party.lock_room_hint'.tr(), + value: room.locked, + onChanged: (v) => service.updateSettings(locked: v), + ), + _Toggle( + title: 'watch_party.wait_for_all'.tr(), + subtitle: 'watch_party.wait_for_all_hint'.tr(), + value: room.waitForAll, + onChanged: (v) => service.updateSettings(waitForAll: v), + ), + ], + ), + ); + } +} + +class _Toggle extends StatelessWidget { + const _Toggle({ + required this.title, + required this.value, + required this.onChanged, + this.subtitle, + }); + + final String title; + final String? subtitle; + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SwitchListTile( + dense: true, + contentPadding: const EdgeInsets.only(right: 4), + value: value, + onChanged: onChanged, + activeThumbColor: AppColors.primary, + title: Text( + title, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + ), + subtitle: subtitle == null + ? null + : Text( + subtitle!, + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ), + ); + } +} diff --git a/lib/features/watch_party/presentation/widgets/party_invite_sheet.dart b/lib/features/watch_party/presentation/widgets/party_invite_sheet.dart new file mode 100644 index 00000000..bd46d1f3 --- /dev/null +++ b/lib/features/watch_party/presentation/widgets/party_invite_sheet.dart @@ -0,0 +1,277 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/system/responsive.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/comments/domain/entities/comment_author.dart'; +import 'package:soplay/features/comments/presentation/widgets/comment_avatar.dart'; +import 'package:soplay/features/social/data/social_remote_data_source.dart'; +import 'package:soplay/features/social/domain/social_models.dart'; +import 'package:soplay/features/watch_party/data/watch_party_remote_data_source.dart'; +import 'package:soplay/features/watch_party/data/watch_party_service.dart'; +import 'package:soplay/features/watch_party/domain/invite_cooldowns.dart'; + +/// Host-only: pick friends to send a push invite to the current room. +Future showPartyInviteSheet(BuildContext context) { + return showAdaptiveModal( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => const PartyInviteSheet(), + ); +} + +class PartyInviteSheet extends StatefulWidget { + const PartyInviteSheet({super.key}); + + @override + State createState() => _PartyInviteSheetState(); +} + +class _PartyInviteSheetState extends State { + final WatchPartyService _service = getIt(); + final InviteCooldowns _cooldowns = InviteCooldowns(); + final Set _sending = {}; + + static const int _maxFriends = 200; + + List? _friends; + bool _failed = false; + Timer? _ticker; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + Future _load() async { + setState(() { + _failed = false; + _friends = null; + }); + try { + final social = getIt(); + final out = []; + String? cursor; + do { + final page = await social.friends(cursor: cursor, limit: 50); + out.addAll(page.items.map((f) => f.user)); + cursor = page.nextCursor; + } while (cursor != null && out.length < _maxFriends); + try { + _cooldowns.applyServer(await _service.inviteCooldowns()); + } catch (_) { + // An older server has no status endpoint; cooldowns then show up as + // they are hit. + } + if (!mounted) return; + setState(() => _friends = out); + _syncTicker(); + } catch (_) { + if (mounted) setState(() => _failed = true); + } + } + + void _syncTicker() { + if (_cooldowns.any) { + _ticker ??= Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() {}); + if (!_cooldowns.any) { + _ticker?.cancel(); + _ticker = null; + } + }); + } + } + + Future _invite(SocialUser user) async { + if (_sending.contains(user.id) || _cooldowns.coolingDown(user.id)) return; + setState(() => _sending.add(user.id)); + try { + final left = await _service.invite(user.id); + _cooldowns.set(user.id, left > 0 ? left : 600); + _toast('watch_party.invite_sent'.tr()); + } on PartyInviteCooldown catch (e) { + _cooldowns.set(user.id, e.retryAfterSec); + } catch (_) { + _toast('watch_party.invite_failed'.tr()); + } finally { + if (mounted) { + setState(() => _sending.remove(user.id)); + _syncTicker(); + } + } + } + + void _toast(String text) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(text), + behavior: SnackBarBehavior.floating, + backgroundColor: AppColors.surfaceVariant, + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + final friends = _friends; + final Widget body; + if (_failed) { + body = Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + Text( + 'watch_party.error_generic'.tr(), + style: const TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: 8), + TextButton( + autofocus: true, + onPressed: _load, + child: Text('general.try_again'.tr()), + ), + ], + ), + ); + } else if (friends == null) { + body = const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: CircularProgressIndicator(strokeWidth: 2.4)), + ); + } else if (friends.isEmpty) { + body = Padding( + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 12), + child: Text( + 'watch_party.invite_no_friends'.tr(), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), + ), + ); + } else { + body = ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.6, + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: friends.length, + itemBuilder: (_, i) => _FriendRow( + user: friends[i], + autofocus: i == 0, + sending: _sending.contains(friends[i].id), + cooldown: _cooldowns.remaining(friends[i].id), + onInvite: () => _invite(friends[i]), + ), + ), + ); + } + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 10), + child: Text( + 'watch_party.invite_friends'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w800, + ), + ), + ), + body, + ], + ), + ), + ); + } +} + +class _FriendRow extends StatelessWidget { + const _FriendRow({ + required this.user, + required this.autofocus, + required this.sending, + required this.cooldown, + required this.onInvite, + }); + + final SocialUser user; + final bool autofocus; + final bool sending; + final Duration cooldown; + final VoidCallback onInvite; + + @override + Widget build(BuildContext context) { + final cooling = cooldown > Duration.zero; + final Widget trailing; + if (sending) { + trailing = const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } else if (cooling) { + trailing = Text( + 'watch_party.invite_again_in'.tr( + args: [InviteCooldowns.format(cooldown)], + ), + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ); + } else { + trailing = const Icon(Icons.send_rounded, size: 20); + } + return ListTile( + autofocus: autofocus, + enabled: !cooling && !sending, + onTap: onInvite, + leading: CommentAvatar( + author: CommentAuthor( + id: user.id, + username: user.username, + photoURL: user.photoURL, + ), + size: 38, + ), + title: Text( + user.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + ), + subtitle: user.displayName != null + ? Text( + '@${user.username}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ) + : null, + trailing: trailing, + ); + } +} diff --git a/lib/features/watch_party/presentation/widgets/party_member_bar.dart b/lib/features/watch_party/presentation/widgets/party_member_bar.dart index 81b19bbb..3401d331 100644 --- a/lib/features/watch_party/presentation/widgets/party_member_bar.dart +++ b/lib/features/watch_party/presentation/widgets/party_member_bar.dart @@ -1,18 +1,94 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/comments/domain/entities/comment_author.dart'; import 'package:soplay/features/comments/presentation/widgets/comment_avatar.dart'; import 'package:soplay/features/watch_party/domain/entities/party_member.dart'; import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; -/// Horizontal strip of party members: avatar (with host crown + online dot), -/// name, and an `online/max` counter chip. +/// What the host picked from a member's menu. +enum PartyMemberAction { makeHost, remove } + +/// Horizontal strip of party members: avatar (with host crown, online dot and +/// a buffering badge), name, and an `online/max` counter chip. +/// +/// For the host, every other member is a button (tap, long-press, or D-pad +/// select on TV) that opens [onAction]'s menu. class PartyMemberBar extends StatelessWidget { - const PartyMemberBar({super.key, required this.room, this.myUserId}); + const PartyMemberBar({ + super.key, + required this.room, + this.myUserId, + this.isHost = false, + this.onAction, + }); final PartyRoom room; final String? myUserId; + final bool isHost; + final void Function(PartyMember member, PartyMemberAction action)? onAction; + + Future _openMenu(BuildContext context, PartyMember member) async { + final onAction = this.onAction; + if (onAction == null) return; + final name = (member.username ?? '').trim().isNotEmpty + ? member.username!.trim() + : '—'; + final picked = await showAdaptiveModal( + context: context, + backgroundColor: AppColors.surface, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + if (member.online) + ListTile( + autofocus: true, + leading: const Icon( + Icons.workspace_premium_rounded, + color: AppColors.rating, + ), + title: Text('watch_party.make_host'.tr()), + onTap: () => + Navigator.of(ctx).pop(PartyMemberAction.makeHost), + ), + ListTile( + autofocus: !member.online, + leading: const Icon( + Icons.person_remove_rounded, + color: AppColors.error, + ), + title: Text( + 'watch_party.remove_member'.tr(), + style: const TextStyle(color: AppColors.error), + ), + onTap: () => Navigator.of(ctx).pop(PartyMemberAction.remove), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + if (picked != null) onAction(member, picked); + } @override Widget build(BuildContext context) { @@ -56,10 +132,20 @@ class PartyMemberBar extends StatelessWidget { scrollDirection: Axis.horizontal, itemCount: members.length, separatorBuilder: (_, _) => const SizedBox(width: 14), - itemBuilder: (_, i) => _MemberTile( - member: members[i], - isMe: myUserId != null && members[i].userId == myUserId, - ), + itemBuilder: (context, i) { + final m = members[i]; + final isMe = myUserId != null && m.userId == myUserId; + final actionable = + isHost && onAction != null && !isMe && !m.isHost; + final tile = _MemberTile(member: m, isMe: isMe); + if (!actionable) return tile; + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _openMenu(context, m), + onLongPress: () => _openMenu(context, m), + child: tile, + ); + }, ), ), ], @@ -115,6 +201,27 @@ class _MemberTile extends StatelessWidget { ), ), ), + if (member.buffering) + Positioned( + top: -4, + right: -4, + child: Tooltip( + message: 'watch_party.buffering'.tr(), + child: Container( + width: 18, + height: 18, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.background, + shape: BoxShape.circle, + ), + child: const CircularProgressIndicator( + strokeWidth: 1.8, + color: AppColors.rating, + ), + ), + ), + ), Positioned( right: 0, bottom: 0, diff --git a/test/features/watch_party/party_sync_test.dart b/test/features/watch_party/party_sync_test.dart new file mode 100644 index 00000000..a9cb8330 --- /dev/null +++ b/test/features/watch_party/party_sync_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/watch_party/domain/entities/party_chat_message.dart'; +import 'package:soplay/features/watch_party/domain/entities/party_playback.dart'; +import 'package:soplay/features/watch_party/domain/invite_cooldowns.dart'; +import 'package:soplay/features/watch_party/domain/party_clock.dart'; +import 'package:soplay/features/watch_party/domain/party_wait_policy.dart'; + +void main() { + group('PartyClock', () { + test('has no offset before a usable sample', () { + final clock = PartyClock(); + expect(clock.offsetMs, isNull); + expect(clock.add(sentAt: 100, serverTime: 0, receivedAt: 50), isFalse); + expect(clock.add(sentAt: 0, serverTime: 0, receivedAt: 9000), isFalse); + expect(clock.offsetMs, isNull); + }); + + test('offset is server time minus the local midpoint of the trip', () { + final clock = PartyClock(); + // Sent at 1000, answered at 1200 local: midpoint 1100. The server said + // 5100, so the server clock runs 4000 ahead. + clock.add(sentAt: 1000, serverTime: 5100, receivedAt: 1200); + expect(clock.offsetMs, 4000); + expect(clock.rttMs, 200); + }); + + test('the fastest round trip wins', () { + final clock = PartyClock(); + clock.add(sentAt: 0, serverTime: 4400, receivedAt: 800); + clock.add(sentAt: 2000, serverTime: 6020, receivedAt: 2040); + clock.add(sentAt: 3000, serverTime: 7300, receivedAt: 3600); + expect(clock.rttMs, 40); + expect(clock.offsetMs, 4000); + }); + + test('keeps only the last window of samples', () { + final clock = PartyClock(window: 2); + clock.add(sentAt: 0, serverTime: 10, receivedAt: 20); + clock.add(sentAt: 100, serverTime: 4250, receivedAt: 400); + clock.add(sentAt: 500, serverTime: 4700, receivedAt: 900); + expect(clock.rttMs, 300); + }); + + test('align takes the one-way trip out of a sync', () { + final clock = PartyClock() + ..add(sentAt: 1000, serverTime: 5100, receivedAt: 1200); + final arrived = DateTime.fromMillisecondsSinceEpoch(10150); + final meant = clock.align(serverTime: 14000, receivedAt: arrived); + expect(meant.millisecondsSinceEpoch, 10000); + + final pb = PartyPlayback( + positionSec: 60, + isPlaying: true, + rate: 1, + receivedAt: meant, + ); + // 150 ms of trip plus 850 ms since arrival: a full second ahead. + expect( + pb.expectedPositionAt(DateTime.fromMillisecondsSinceEpoch(11000)), + closeTo(61, 1e-9), + ); + }); + + test('align falls back to the receipt time when it cannot be trusted', () { + final arrived = DateTime.fromMillisecondsSinceEpoch(10000); + expect( + PartyClock().align(serverTime: 9000, receivedAt: arrived), + arrived, + ); + final clock = PartyClock() + ..add(sentAt: 0, serverTime: 100, receivedAt: 200); + expect(clock.align(serverTime: null, receivedAt: arrived), arrived); + // In the future of the receipt: noise, not latency. + expect(clock.align(serverTime: 10500, receivedAt: arrived), arrived); + // Ten seconds back is a stale offset, not a slow network. + expect(clock.align(serverTime: 0, receivedAt: arrived), arrived); + }); + }); + + group('PartyWaitPolicy', () { + final t0 = DateTime(2026, 1, 1, 12); + DateTime at(int sec) => t0.add(Duration(seconds: sec)); + + PartyWaitAction tick( + PartyWaitPolicy p, { + required int now, + List buffering = const [], + bool playing = true, + bool enabled = true, + bool isHost = true, + }) => p.tick( + enabled: enabled, + isHost: isHost, + localPlaying: playing, + bufferingSince: buffering.map(at), + now: at(now), + ); + + test('a short stall does not pause the room', () { + final p = PartyWaitPolicy(); + expect(tick(p, now: 2, buffering: [0]), PartyWaitAction.none); + }); + + test('pauses after three seconds and resumes when the guest is back', () { + final p = PartyWaitPolicy(); + expect(tick(p, now: 3, buffering: [0]), PartyWaitAction.pause); + expect(p.holding, isTrue); + expect( + tick(p, now: 5, buffering: [0], playing: false), + PartyWaitAction.none, + ); + expect(tick(p, now: 7, playing: false), PartyWaitAction.resume); + expect(p.holding, isFalse); + }); + + test('gives up after the cap and does not hold for the same stall', () { + final p = PartyWaitPolicy(); + expect(tick(p, now: 4, buffering: [0]), PartyWaitAction.pause); + expect( + tick(p, now: 19, buffering: [0], playing: false), + PartyWaitAction.resume, + ); + expect(tick(p, now: 25, buffering: [0]), PartyWaitAction.none); + expect(tick(p, now: 33, buffering: [0, 30]), PartyWaitAction.pause); + }); + + test('a host who presses play during the hold ends it', () { + final p = PartyWaitPolicy(); + tick(p, now: 4, buffering: [0]); + expect(tick(p, now: 6, buffering: [0]), PartyWaitAction.none); + expect(p.holding, isFalse); + }); + + test('never acts for a guest, or when the setting is off', () { + expect( + tick(PartyWaitPolicy(), now: 9, buffering: [0], isHost: false), + PartyWaitAction.none, + ); + expect( + tick(PartyWaitPolicy(), now: 9, buffering: [0], enabled: false), + PartyWaitAction.none, + ); + }); + + test('turning the setting off mid-hold lets the room go', () { + final p = PartyWaitPolicy(); + tick(p, now: 4, buffering: [0]); + expect( + tick(p, now: 5, buffering: [0], playing: false, enabled: false), + PartyWaitAction.resume, + ); + }); + + test('a paused host is never paused again', () { + expect( + tick(PartyWaitPolicy(), now: 9, buffering: [0], playing: false), + PartyWaitAction.none, + ); + }); + }); + + group('chat history', () { + test('parses chat and system lines and drops unknown kinds', () { + final list = PartyChatMessage.listFrom([ + { + 'type': 'chat', + 'user': {'id': 'a', 'username': 'ann'}, + 'text': 'hi', + 'ts': 1, + }, + { + 'type': 'system', + 'kind': 'host', + 'user': {'id': 'b', 'username': 'bob'}, + 'ts': 2, + }, + { + 'type': 'system', + 'kind': 'dance', + 'user': {'id': 'c'}, + 'ts': 3, + }, + 'garbage', + ]); + expect(list, hasLength(2)); + expect(list[0].isSystem, isFalse); + expect(list[0].text, 'hi'); + expect(list[1].system, PartySystemKind.host); + expect(list[1].username, 'bob'); + }); + + test('a line from an older server without a type is chat', () { + final m = PartyChatMessage.tryParse({ + 'user': {'id': 'a'}, + 'text': 'x', + 'ts': 1, + }); + expect(m?.isSystem, isFalse); + }); + }); + + group('InviteCooldowns', () { + test('counts down from the server seconds and expires', () { + var now = DateTime(2026, 1, 1); + final c = InviteCooldowns(clock: () => now); + c.applyServer([ + {'userId': 'a', 'retryAfterSec': 90}, + {'userId': 'b', 'retryAfterSec': 'nope'}, + ]); + expect(c.coolingDown('a'), isTrue); + expect(c.coolingDown('b'), isFalse); + expect(InviteCooldowns.format(c.remaining('a')), '1:30'); + now = now.add(const Duration(seconds: 89, milliseconds: 500)); + expect(InviteCooldowns.format(c.remaining('a')), '0:01'); + now = now.add(const Duration(seconds: 1)); + expect(c.coolingDown('a'), isFalse); + expect(c.any, isFalse); + }); + }); +} diff --git a/test/features/watch_party/pending_party_code_test.dart b/test/features/watch_party/pending_party_code_test.dart new file mode 100644 index 00000000..16ebf0a0 --- /dev/null +++ b/test/features/watch_party/pending_party_code_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/watch_party/domain/pending_party_code.dart'; + +void main() { + late String? stored; + late DateTime now; + late PendingPartyCode pending; + + setUp(() { + stored = null; + now = DateTime(2026, 11, 7, 12); + pending = PendingPartyCode( + read: () => stored, + write: (v) async => stored = v, + clock: () => now, + ); + }); + + test('normalizes codes and rejects what cannot be one', () { + expect(PendingPartyCode.normalize(' ab2c9z '), 'AB2C9Z'); + expect(PendingPartyCode.normalize('abc'), isNull); + expect(PendingPartyCode.normalize('ab-12'), isNull); + expect(PendingPartyCode.normalize(null), isNull); + }); + + test('a code kept through sign-in opens exactly once', () async { + await pending.remember('k7m2qp'); + expect(pending.peek(), 'K7M2QP'); + // Sign-in took a while. + now = now.add(const Duration(minutes: 20)); + expect(await pending.take(), 'K7M2QP'); + expect(await pending.take(), isNull); + expect(stored, isNull); + }); + + test('an invite left too long is dropped', () async { + await pending.remember('K7M2QP'); + now = now.add(PendingPartyCode.ttl + const Duration(minutes: 1)); + expect(await pending.take(), isNull); + expect(stored, isNull); + }); + + test('a bad code is never stored, and a newer invite replaces an older', () async { + await pending.remember('x'); + expect(stored, isNull); + await pending.remember('AAAA22'); + await pending.remember('BBBB33'); + expect(await pending.take(), 'BBBB33'); + }); + + test('garbage in storage reads as nothing', () async { + stored = 'not a code|abc'; + expect(pending.peek(), isNull); + expect(await pending.take(), isNull); + expect(stored, isNull); + }); + + test('cancelling sign-in clears it', () async { + await pending.remember('K7M2QP'); + await pending.clear(); + expect(pending.peek(), isNull); + }); +}