11import { Element } from '@core/element'
2- import { html , css } from 'lit'
2+ import { html , css , nothing } from 'lit'
33import { consume } from '@lit/context'
44
55import { type ComponentChildren , h , render , type VNode } from 'preact'
@@ -19,6 +19,12 @@ import '../placeholder.js'
1919
2020const MUTATION_SELECTOR = '__mutation-highlight__'
2121
22+ declare global {
23+ interface WindowEventMap {
24+ 'screencast-ready' : CustomEvent < { sessionId : string } >
25+ }
26+ }
27+
2228function transform ( node : any ) : VNode < { } > {
2329 if ( typeof node !== 'object' || node === null ) {
2430 // Plain string/number text node — return as-is for Preact to render as text.
@@ -47,6 +53,20 @@ export class DevtoolsBrowser extends Element {
4753 #activeUrl?: string
4854 /** Base64 PNG of the screenshot for the currently selected command, or null. */
4955 #screenshotData: string | null = null
56+ /**
57+ * All recorded videos received from the backend, in arrival order.
58+ * Each entry is { sessionId, url } — a new entry is pushed for every
59+ * browser session (initial + after every reloadSession() call).
60+ */
61+ #videos: Array < { sessionId : string ; url : string } > = [ ]
62+ /** Index into #videos of the currently displayed video. */
63+ #activeVideoIdx = 0
64+ /**
65+ * Which view is active in the browser panel.
66+ * 'video' — always show the screencast player (default when a recording exists)
67+ * 'snapshot' — show DOM mutations replay and per-command screenshots
68+ */
69+ #viewMode: 'snapshot' | 'video' = 'snapshot'
5070
5171 @consume ( { context : metadataContext , subscribe : true } )
5272 metadata : Metadata | undefined = undefined
@@ -136,13 +156,63 @@ export class DevtoolsBrowser extends Element {
136156 display: block;
137157 }
138158
159+ .screencast-player {
160+ width: 100%;
161+ height: 100%;
162+ object-fit: contain;
163+ background: #111;
164+ border-radius: 0 0 0.5rem 0.5rem;
165+ display: block;
166+ }
167+
139168 .iframe-wrapper {
140169 position: relative;
141170 flex: 1;
142171 min-height: 0;
143172 display: flex;
144173 flex-direction: column;
145174 }
175+
176+ .view-toggle {
177+ display: flex;
178+ gap: 2px;
179+ margin-left: 0.5rem;
180+ flex-shrink: 0;
181+ }
182+
183+ .view-toggle button {
184+ padding: 2px 10px;
185+ font-size: 11px;
186+ font-family: inherit;
187+ border: 1px solid var(--vscode-editorSuggestWidget-border, #454545);
188+ background: transparent;
189+ color: var(--vscode-input-foreground, #ccc);
190+ cursor: pointer;
191+ border-radius: 3px;
192+ line-height: 20px;
193+ transition:
194+ background 0.1s,
195+ color 0.1s;
196+ }
197+
198+ .view-toggle button.active {
199+ background: var(--vscode-button-background, #0e639c);
200+ color: var(--vscode-button-foreground, #fff);
201+ border-color: transparent;
202+ }
203+
204+ .video-select {
205+ font-size: 11px;
206+ font-family: inherit;
207+ padding: 2px 4px;
208+ border: 1px solid var(--vscode-dropdown-border, #454545);
209+ border-radius: 3px;
210+ background: var(--vscode-dropdown-background, #3c3c3c);
211+ color: var(--vscode-dropdown-foreground, #ccc);
212+ cursor: pointer;
213+ line-height: 20px;
214+ margin-left: 4px;
215+ }
146216 `
147217 ]
148218
@@ -170,6 +240,10 @@ export class DevtoolsBrowser extends Element {
170240 'show-command' ,
171241 this . #handleShowCommand as EventListener
172242 )
243+ window . addEventListener (
244+ 'screencast-ready' ,
245+ this . #handleScreencastReady as EventListener
246+ )
173247 await this . updateComplete
174248 }
175249
@@ -215,8 +289,34 @@ export class DevtoolsBrowser extends Element {
215289 ( event as CustomEvent < { command ?: CommandLog } > ) . detail ?. command
216290 )
217291
292+ #handleScreencastReady = ( event : Event ) => {
293+ const { sessionId } = ( event as CustomEvent < { sessionId : string } > ) . detail
294+ this . #videos. push ( { sessionId, url : `/api/video/${ sessionId } ` } )
295+ // Always show the latest video and switch to video mode automatically
296+ this . #activeVideoIdx = this . #videos. length - 1
297+ this . #viewMode = 'video'
298+ this . requestUpdate ( )
299+ }
300+
301+ #setViewMode( mode : 'snapshot' | 'video' ) {
302+ this . #viewMode = mode
303+ this . requestUpdate ( )
304+ }
305+
306+ #setActiveVideo( idx : number ) {
307+ this . #activeVideoIdx = idx
308+ this . requestUpdate ( )
309+ }
310+
311+ /** URL of the currently selected video, or null when no videos exist. */
312+ get #activeVideoUrl( ) : string | null {
313+ return this . #videos[ this . #activeVideoIdx] ?. url ?? null
314+ }
315+
218316 async #renderCommandScreenshot( command ?: CommandLog ) {
219317 this . #screenshotData = command ?. screenshot ?? null
318+ // Switch to snapshot mode so the command screenshot is visible instead of the video.
319+ this . #viewMode = 'snapshot'
220320 this . requestUpdate ( )
221321 }
222322
@@ -461,32 +561,79 @@ export class DevtoolsBrowser extends Element {
461561 > </ icon-mdi-world >
462562 < span class ="truncate "> ${ this . #activeUrl} </ span >
463563 </ div >
564+ ${ this . #videos. length > 0
565+ ? html `
566+ < div class ="view-toggle ">
567+ < button
568+ class =${ this . #viewMode === 'snapshot' ? 'active' : '' }
569+ @click =${ ( ) => this . #setViewMode( 'snapshot' ) }
570+ >
571+ Snapshot
572+ </ button >
573+ < button
574+ class =${ this . #viewMode === 'video' ? 'active' : '' }
575+ @click =${ ( ) => this . #setViewMode( 'video' ) }
576+ >
577+ Video
578+ </ button >
579+ ${ this . #videos. length > 1
580+ ? html `< select
581+ class ="video-select "
582+ @change =${ ( e : Event ) => {
583+ this . #setActiveVideo(
584+ Number ( ( e . target as HTMLSelectElement ) . value )
585+ )
586+ this . #setViewMode( 'video' )
587+ } }
588+ >
589+ ${ this . #videos. map (
590+ ( _v , i ) =>
591+ html `< option
592+ value =${ i }
593+ ?selected =${ this . #activeVideoIdx === i }
594+ >
595+ Recording ${ i + 1 }
596+ </ option > `
597+ ) }
598+ </ select > `
599+ : nothing }
600+ </ div >
601+ `
602+ : nothing }
464603 </ header >
465- ${ this . #screenshotData
466- ? html ` < div class ="iframe-wrapper ">
467- < div
468- class ="screenshot-overlay "
469- style ="position:relative;flex:1;min-height:0; "
470- >
471- < img src ="data:image/png;base64, ${ this . #screenshotData} " />
472- </ div >
604+ ${ this . #viewMode === 'video' && this . #activeVideoUrl
605+ ? html `< div class ="iframe-wrapper ">
606+ < video
607+ class ="screencast-player "
608+ src ="${ this . #activeVideoUrl} "
609+ controls
610+ > </ video >
473611 </ div > `
474- : hasMutations
475- ? html ` < div class ="iframe-wrapper ">
476- < iframe class ="origin-top-left "> </ iframe >
612+ : this . #screenshotData
613+ ? html `< div class ="iframe-wrapper ">
614+ < div
615+ class ="screenshot-overlay "
616+ style ="position:relative;flex:1;min-height:0; "
617+ >
618+ < img src ="data:image/png;base64, ${ this . #screenshotData} " />
619+ </ div >
477620 </ div > `
478- : displayScreenshot
479- ? html ` < div class ="iframe-wrapper ">
480- < div
481- class ="screenshot-overlay "
482- style ="position:relative;flex:1;min-height:0; "
483- >
484- < img src ="data:image/png;base64, ${ displayScreenshot } " />
485- </ div >
621+ : hasMutations
622+ ? html `< div class ="iframe-wrapper ">
623+ < iframe class ="origin-top-left "> </ iframe >
486624 </ div > `
487- : html `< wdio-devtools-placeholder
488- style ="height: 100% "
489- > </ wdio-devtools-placeholder > ` }
625+ : displayScreenshot
626+ ? html `< div class ="iframe-wrapper ">
627+ < div
628+ class ="screenshot-overlay "
629+ style ="position:relative;flex:1;min-height:0; "
630+ >
631+ < img src ="data:image/png;base64, ${ displayScreenshot } " />
632+ </ div >
633+ </ div > `
634+ : html `< wdio-devtools-placeholder
635+ style ="height: 100% "
636+ > </ wdio-devtools-placeholder > ` }
490637 </ section >
491638 `
492639 }
0 commit comments