2424AudioBandsEvent , EVT_AUDIO_BANDS = wx .lib .newevent .NewEvent ()
2525# Custom event for incoming chat messages
2626ChatMessageEvent , EVT_CHAT_MESSAGE = wx .lib .newevent .NewEvent ()
27+ # Custom event for video codec/stats updates
28+ VideoCodecEvent , EVT_VIDEO_CODEC = wx .lib .newevent .NewEvent ()
2729
2830CONFIG_FILE = os .path .join (os .path .dirname (os .path .abspath (__file__ )), "wxpy_room_cfg.ini" )
2931
32+ def _extract_video_codec (stats ):
33+ """Return (mime_type, implementation) for the active video codec, or (None, None).
34+
35+ `implementation` is `encoder_implementation` for outbound (local) tracks and
36+ `decoder_implementation` for inbound (remote) tracks — e.g. "libvpx", "ExternalDecoder (VideoToolbox)".
37+ """
38+ codec_id_to_mime = {}
39+ video_codec_id = None
40+ implementation = None
41+ for s in stats :
42+ which = s .WhichOneof ("stats" )
43+ if which == "codec" :
44+ codec_id_to_mime [s .codec .rtc .id ] = s .codec .codec .mime_type
45+ elif which == "inbound_rtp" and s .inbound_rtp .stream .kind == "video" :
46+ video_codec_id = s .inbound_rtp .stream .codec_id
47+ if s .inbound_rtp .inbound .decoder_implementation :
48+ implementation = s .inbound_rtp .inbound .decoder_implementation
49+ elif which == "outbound_rtp" and s .outbound_rtp .stream .kind == "video" :
50+ video_codec_id = s .outbound_rtp .stream .codec_id
51+ if s .outbound_rtp .outbound .encoder_implementation :
52+ implementation = s .outbound_rtp .outbound .encoder_implementation
53+ mime = None
54+ if video_codec_id and video_codec_id in codec_id_to_mime :
55+ mime = codec_id_to_mime [video_codec_id ]
56+ else :
57+ for m in codec_id_to_mime .values ():
58+ if m .lower ().startswith ("video/" ):
59+ mime = m
60+ break
61+ return mime , implementation
62+
63+
3064CODEC_MAP = {
3165 "VP8" : rtc .VideoCodec .VP8 ,
3266 "VP9" : rtc .VideoCodec .VP9 ,
@@ -97,6 +131,7 @@ def __init__(self, wx_target):
97131 self ._video_streams = {} # participant_identity -> VideoStream
98132 self ._audio_streams = {} # participant_identity -> AudioStream
99133 self ._track_to_participant = {} # track_sid -> participant_identity
134+ self ._stats_tasks = {} # participant_identity -> asyncio.Task (video codec poller)
100135 self ._disconnecting = False # True when _async_disconnect is in progress
101136 self ._test_video_task = None # asyncio.Task for test video publishing
102137 self ._test_video_track = None # LocalVideoTrack
@@ -325,6 +360,9 @@ async def _async_disconnect(self):
325360 for stream in list (self ._audio_streams .values ()):
326361 await stream .aclose ()
327362 self ._audio_streams .clear ()
363+ for task in list (self ._stats_tasks .values ()):
364+ task .cancel ()
365+ self ._stats_tasks .clear ()
328366 self ._track_to_participant .clear ()
329367 await self .room .disconnect ()
330368 except Exception :
@@ -348,6 +386,7 @@ def _on_track_subscribed(self, track, publication, participant):
348386 self ._track_to_participant [track .sid ] = identity
349387 if self .loop :
350388 asyncio .ensure_future (self ._receive_video (identity , stream ))
389+ self ._start_codec_poll (identity , track )
351390 elif track .kind == rtc .TrackKind .KIND_AUDIO :
352391 old_stream = self ._audio_streams .pop (identity , None )
353392 if old_stream :
@@ -366,6 +405,7 @@ def _on_track_unsubscribed(self, track, publication, participant):
366405 stream = self ._video_streams .pop (identity , None )
367406 if stream :
368407 asyncio .ensure_future (stream .aclose ())
408+ self ._stop_codec_poll (identity )
369409 elif track .kind == rtc .TrackKind .KIND_AUDIO :
370410 stream = self ._audio_streams .pop (identity , None )
371411 if stream :
@@ -384,6 +424,7 @@ def _on_local_track_published(self, publication, track):
384424 self ._post_participants ()
385425 if self .loop :
386426 asyncio .ensure_future (self ._receive_video (identity , stream ))
427+ self ._start_codec_poll (identity , track )
387428
388429 def _on_local_track_unpublished (self , publication ):
389430 logger .info ("Local track unpublished: %s" , publication .sid )
@@ -393,6 +434,7 @@ def _on_local_track_unpublished(self, publication):
393434 stream = self ._video_streams .pop (identity , None )
394435 if stream :
395436 asyncio .ensure_future (stream .aclose ())
437+ self ._stop_codec_poll (identity )
396438 self ._post_participants ()
397439
398440 def _on_participant_connected (self , participant ):
@@ -408,6 +450,7 @@ def _on_participant_disconnected(self, participant):
408450 stream = self ._audio_streams .pop (identity , None )
409451 if stream :
410452 asyncio .ensure_future (stream .aclose ())
453+ self ._stop_codec_poll (identity )
411454 self ._track_to_participant = {
412455 k : v for k , v in self ._track_to_participant .items () if v != identity
413456 }
@@ -425,6 +468,9 @@ def _on_disconnected(self, reason):
425468 for stream in list (self ._audio_streams .values ()):
426469 asyncio .ensure_future (stream .aclose ())
427470 self ._audio_streams .clear ()
471+ for task in list (self ._stats_tasks .values ()):
472+ task .cancel ()
473+ self ._stats_tasks .clear ()
428474 self ._track_to_participant .clear ()
429475 self ._post_state ("disconnected" , str (reason ))
430476 if self .loop and self .loop .is_running ():
@@ -506,6 +552,37 @@ async def _receive_audio(self, identity, stream):
506552 finally :
507553 logger .info ("Stopped receiving audio for %s (total frames: %d)" , identity , frame_count )
508554
555+ def _start_codec_poll (self , identity , track ):
556+ self ._stop_codec_poll (identity )
557+ self ._stats_tasks [identity ] = asyncio .ensure_future (
558+ self ._poll_video_codec (identity , track )
559+ )
560+
561+ def _stop_codec_poll (self , identity ):
562+ task = self ._stats_tasks .pop (identity , None )
563+ if task and not task .done ():
564+ task .cancel ()
565+
566+ async def _poll_video_codec (self , identity , track ):
567+ last = (None , None )
568+ try :
569+ # Brief delay so the first stats sample has data
570+ await asyncio .sleep (1.0 )
571+ while True :
572+ try :
573+ stats = await track .get_stats ()
574+ codec , impl = _extract_video_codec (stats )
575+ except Exception as e :
576+ logger .debug ("get_stats failed for %s: %s" , identity , e )
577+ codec , impl = None , None
578+ if codec and (codec , impl ) != last :
579+ last = (codec , impl )
580+ evt = VideoCodecEvent (identity = identity , codec = codec , implementation = impl or "" )
581+ wx .PostEvent (self .wx_target , evt )
582+ await asyncio .sleep (2.0 )
583+ except asyncio .CancelledError :
584+ pass
585+
509586 def _post_state (self , state , message = "" , ** kwargs ):
510587 evt = RoomStateEvent (state = state , message = message , ** kwargs )
511588 wx .PostEvent (self .wx_target , evt )
@@ -532,6 +609,8 @@ def __init__(self, parent, label="", bg_color=None):
532609 self .label = label
533610 self .video_width = 0
534611 self .video_height = 0
612+ self .video_codec = ""
613+ self .video_codec_impl = ""
535614 self .audio_bands = [0.0 ] * 5 # 5 frequency band levels (0..1)
536615 self .bitmap = None
537616 self .bg_color = bg_color or wx .Colour (30 , 30 , 30 )
@@ -578,6 +657,11 @@ def on_paint(self, event):
578657 overlay_text = self .label
579658 if self .video_width > 0 and self .video_height > 0 :
580659 overlay_text += f" { self .video_width } x{ self .video_height } "
660+ if self .video_codec :
661+ if self .video_codec_impl :
662+ overlay_text += f" [{ self .video_codec } / { self .video_codec_impl } ]"
663+ else :
664+ overlay_text += f" [{ self .video_codec } ]"
581665 dc .SetTextForeground (wx .WHITE )
582666 dc .SetFont (wx .Font (10 , wx .FONTFAMILY_DEFAULT , wx .FONTSTYLE_NORMAL , wx .FONTWEIGHT_NORMAL ))
583667 tw , th = dc .GetTextExtent (overlay_text )
@@ -628,6 +712,16 @@ def update_rgba_frame(self, width, height, rgba_data):
628712 self .bitmap = wx .Bitmap (img )
629713 self .Refresh ()
630714
715+ def update_video_codec (self , codec , implementation = "" ):
716+ """Set the codec mime_type (e.g. 'video/VP8') and implementation shown in the overlay."""
717+ # Strip the 'video/' prefix for a tighter overlay label.
718+ if codec and codec .lower ().startswith ("video/" ):
719+ codec = codec .split ("/" , 1 )[1 ]
720+ if codec != self .video_codec or implementation != self .video_codec_impl :
721+ self .video_codec = codec
722+ self .video_codec_impl = implementation
723+ self .Refresh ()
724+
631725 def update_audio_bands (self , bands ):
632726 """Update the 5-band audio spectrum levels (list of floats 0..1).
633727 Does not trigger a repaint — the next video frame refresh will draw them.
@@ -678,6 +772,12 @@ def update_audio_bands(self, identity, bands):
678772 if panel :
679773 panel .update_audio_bands (bands )
680774
775+ def update_video_codec (self , identity , codec , implementation = "" ):
776+ """Update a participant's video panel codec overlay."""
777+ panel = self .video_panels .get (identity )
778+ if panel :
779+ panel .update_video_codec (codec , implementation )
780+
681781 def update_video (self , identity , bitmap , label = None ):
682782 panel = self .video_panels .get (identity )
683783 if panel :
@@ -1007,6 +1107,7 @@ def __init__(self):
10071107 self .Bind (EVT_PARTICIPANT , self .on_participant_update )
10081108 self .Bind (EVT_AUDIO_BANDS , self .on_audio_bands )
10091109 self .Bind (EVT_CHAT_MESSAGE , self .on_chat_message )
1110+ self .Bind (EVT_VIDEO_CODEC , self .on_video_codec )
10101111
10111112 self .Centre ()
10121113
@@ -1075,6 +1176,10 @@ def on_audio_bands(self, event):
10751176 """Handle audio spectrum bands from async thread."""
10761177 self .video_grid .update_audio_bands (event .identity , event .bands )
10771178
1179+ def on_video_codec (self , event ):
1180+ """Handle video codec updates from async thread."""
1181+ self .video_grid .update_video_codec (event .identity , event .codec , event .implementation )
1182+
10781183 def on_chat_message (self , event ):
10791184 self .right_panel .append_chat (event .sender , event .message )
10801185
0 commit comments