مباشر (غرف LiveKit): الانتقال من صفحة الغرفة إلى واجهة المكالمة يلغي اشتراكك في مقاطع فيديو الجميع (مربعات سوداء) - مع اقتراح إصلاح

مرحباً للجميع، هذه مساهمة أولى لي، لذا أنا آسف إذا لم يكن تنسيق هذا المنشور بالشكل الصحيح.

أنا أقوم بإعداد منتدى Discourse لمجموعة من عشاق السيارات، وأرادوا استخدام الدردشات المرئية المباشرة عبر LiveKit للغرف الكبيرة/العروض التقديمية.

أثناء الاختبار، واجهنا باستمرار مشاكل في ظهور بلاطات فيديو سوداء للبث المرئي، ولكن فقط عند استخدام تكامل LiveKit. لقد استخدمت Claude Code للتحقق من المشكلة ووجدت ما يبدو أنه السبب والحل، وتقرير Claude المُنشأ يتبع أدناه.

يسعدني إعداد طلب سحب (PR) مع الحل المقترح إذا كان ذلك مفيداً.

ملخص

في غرفة LiveKit (SFU)، يؤدي الانتقال من صفحة الغرفة إلى أداة الاتصال العائمة (floating call widget) إلى ترك المشاهد مع بلاطات سوداء لجميع المشاركين الآخرين. لا يزال المشاركون الآخرون يرون المشاهد بشكل طبيعي. العودة إلى صفحة الغرفة، أو المغادرة وإعادة الانضمام أثناء استخدام الأداة العائمة، يعيد تشغيل الفيديو. يحدث هذا في كل مرة، لأن عملية تفكيك صفحة الغرفة ترسل watching=false بعد أن تكون الأداة العائمة قد أرسلت watching=true.

الإصدار

  • Discourse عند الالتزام 1f09a33bffd908d6e38ef4b51697e8390e2a4c7f (فرع latest، 2026-10)، وإضافة Live كما تم تضمينها
  • LiveKit Cloud كخادم الوسائط (live_livekit_room_policy = per_room)
  • Firefox 159 على نظام Linux (تمت ملاحظته أيضاً على متصفحات وهواتف أخرى)

الخطوات

  1. ينضم حسابان (أحدهما في نافذة خاصة) إلى نفس الغرفة المدعومة بـ LiveKit مع تشغيل الكاميرات.
  2. في إحدى النوافذ، انقر فوق رابط يغادر صفحة الغرفة، مما ينقل المكالمة إلى الأداة العائمة.
  3. في تلك النافذة، تظهر بلاطة المشارك الآخر سوداء. ترى النافذة الأخرى كليهما بشكل جيد.

ما لاحظته

  • في النافذة السوداء، يحتوي عنصر <video> الخاص بالشخص الآخر على تدفق، وليس متوقفاً مؤقتاً، وله readyState بقيمة 4. لكن مساره (track) لديه readyState: "live"، muted: true، الدقة 8×8، و getVideoPlaybackQuality().totalVideoFrames يساوي 0. لم تصل أي وسائط إليه أبداً.
  • علامة التبويب الشبكة، التبديل من الصفحة إلى الأداة العائمة: ثلاث عمليات POST /live/rooms/<id>/state، مع قيم watching = true، true، false بهذا الترتيب.

السبب (من قراءة الكود في الالتزام أعلاه)

  • room-page.gjs، willDestroy: next(() => liveWebrtc.setWatching(roomId, false, { keepVideo }))
  • call-widget.gjs: تستدعي الأداة العائمة watchWidgetRoom عند didInsert، والتي تشغل next(() => setWatching(roomId, true))
  • live-webrtc.js، setWatching: في الغرف غير الشبكية الكاملة (non-mesh rooms)، تستدعي setVideoSubscriptionsEnabled(watching)، والتي تزيل اشتراك المشاهد في مسارات الكاميرا، والشاشة، وصوت الشاشة (livekit-session.js، #applyDesiredSubscription)

تصل قيمة false المؤجلة من الصفحة بعد قيمة true من الأداة العائمة، لذا ينتهي الأمر بالمشاهد في حالة عدم المشاهدة بينما تكون الأداة العائمة معروضة على الشاشة. يعمل التبديل من الأداة العائمة إلى صفحة الغرفة لأن stopWatchingWidgetRoom الخاص بالأداة العائمة يعمل بشكل متزامن عند التفكيك وقيمة true الخاصة بالصفحة مؤجلة، لذا تفوز قيمة true. يعمل الانضمام الجديد في الأداة العائمة لأن لا توجد صفحة قديمة.

غرف النظير لنظير (Peer-to-peer) لا تزيل اشتراك أي شخص (يتم تخطي فرع SFU)، مما يتوافق مع كون هذه المشكلة خاصة بغرف LiveKit.

حل بديل مؤكد، عبر وحدة تحكم المتصفح

  1. عندما تكون البلاطة سوداء، Discourse.__container__.lookup("service:live-webrtc").setWatching(1, true) أعادت تشغيل الفيديو.
  2. حافظ هذا الغلاف (wrapper)، المثبت قبل التبديل، على بقاء البلاطة نشطة عند كل تبديل من الصفحة إلى الأداة العائمة:
const svc = Discourse.__container__.lookup("service:live-webrtc");
const orig = svc.setWatching.bind(svc);
svc.setWatching = (roomId, watching, options = {}) => {
  if (watching) return orig(roomId, true, options);
  setTimeout(() => {
    const shown = document.querySelector(".live-call-widget, .live-room-page");
    orig(roomId, !!shown, options);
  }, 500);
};

الحل المقترح

لا ينبغي لعملية تفكيك صفحة الغرفة إيقاف حالة المشاهدة عندما تكون الأداة العائمة على وشك عرض نفس الغرفة. على سبيل المثال، استنتاج حالة المشاهدة بناءً على ما إذا كانت الصفحة أو الأداة العائمة مثبتة (mounted)، بدلاً من السماح لكل منهما بإرسال إعداد/مسح خاص به. بهذه الطريقة لا يهم الترتيب.

ذو صلة، قد تكون مشكلة منفصلة

تم الرد على إحدى عمليات POST .../state في نفس الجلسة بـ 429. يتم تجاهل الفشل في هذه الاتصالات (.catch(() => {}))، لذا يفشل تحديث “watching” المرفوض بصمت.