@@ -75,6 +75,13 @@ export class Game {
7575 readonly status = new Moq . Signals . Signal < GameStatus | undefined > ( undefined ) ;
7676 readonly viewerId = new Moq . Signals . Signal < string | undefined > ( undefined ) ;
7777
78+ // The canvas to render into, owned here and wired into the renderer (read-only there).
79+ // The UI sets this once the <canvas> is mounted.
80+ readonly canvas = new Moq . Signals . Signal < HTMLCanvasElement | undefined > ( undefined ) ;
81+
82+ // The video rendition target, owned here and wired into the video source as an input.
83+ readonly #target = new Moq . Signals . Signal < Watch . Video . Target | undefined > ( undefined ) ;
84+
7885 // Watch API objects — exposed so UI can access canvas, etc.
7986 readonly broadcast : Watch . Broadcast ;
8087 readonly sync : Watch . Sync ;
@@ -110,30 +117,37 @@ export class Game {
110117 } ) ;
111118 this . #signals. cleanup ( ( ) => this . broadcast . close ( ) ) ;
112119
113- this . sync = new Watch . Sync ( { latency : this . latency , connection : connection . established } ) ;
114- this . #signals. cleanup ( ( ) => this . sync . close ( ) ) ;
115-
116- this . videoSource = new Watch . Video . Source ( this . sync , { broadcast : this . broadcast } ) ;
120+ // Sources produce the per-rendition jitter that Sync reads, so they're created
121+ // before Sync to avoid a construction cycle.
122+ this . videoSource = new Watch . Video . Source ( { broadcast : this . broadcast , target : this . #target } ) ;
117123 this . #signals. cleanup ( ( ) => this . videoSource . close ( ) ) ;
118124
125+ this . audioSource = new Watch . Audio . Source ( { broadcast : this . broadcast } ) ;
126+ this . #signals. cleanup ( ( ) => this . audioSource . close ( ) ) ;
127+
128+ this . sync = new Watch . Sync ( {
129+ latency : this . latency ,
130+ connection : connection . established ,
131+ video : this . videoSource . output . jitter ,
132+ audio : this . audioSource . output . jitter ,
133+ } ) ;
134+ this . #signals. cleanup ( ( ) => this . sync . close ( ) ) ;
135+
119136 this . #signals. run ( this . #runPixelBudget. bind ( this ) ) ;
120137
121138 // Video is enabled on the grid or when this game is expanded.
122139 const videoEnabled = new Moq . Signals . Signal ( true ) ;
123140 this . #signals. run ( this . #runVideoEnabled. bind ( this , videoEnabled ) ) ;
124141
125- this . videoDecoder = new Watch . Video . Decoder ( this . videoSource , { enabled : videoEnabled } ) ;
142+ this . videoDecoder = new Watch . Video . Decoder ( this . videoSource , this . sync , { enabled : videoEnabled } ) ;
126143 this . #signals. cleanup ( ( ) => this . videoDecoder . close ( ) ) ;
127144
128- // Renderer needs a canvas — created by the UI layer, set via setCanvas() .
129- this . videoRenderer = new Watch . Video . Renderer ( this . videoDecoder ) ;
145+ // Renderer needs a canvas — created by the UI layer, set via `canvas` .
146+ this . videoRenderer = new Watch . Video . Renderer ( this . videoDecoder , { canvas : this . canvas } ) ;
130147 this . #signals. cleanup ( ( ) => this . videoRenderer . close ( ) ) ;
131148
132149 // Audio pipeline — the emitter controls muted (volume) and paused (download).
133- this . audioSource = new Watch . Audio . Source ( this . sync , { broadcast : this . broadcast } ) ;
134- this . #signals. cleanup ( ( ) => this . audioSource . close ( ) ) ;
135-
136- this . audioDecoder = new Watch . Audio . Decoder ( this . audioSource ) ;
150+ this . audioDecoder = new Watch . Audio . Decoder ( this . audioSource , this . sync ) ;
137151 this . #signals. cleanup ( ( ) => this . audioDecoder . close ( ) ) ;
138152
139153 const audioPaused = new Moq . Signals . Signal ( true ) ;
@@ -149,7 +163,7 @@ export class Game {
149163 // Resume AudioContext on first user interaction (browser autoplay policy).
150164 for ( const event of [ "click" , "touchstart" , "touchend" , "mousedown" , "keydown" ] ) {
151165 this . #signals. event ( document , event , ( ) => {
152- const ctx = this . audioDecoder . context . peek ( ) ;
166+ const ctx = this . audioDecoder . output . context . peek ( ) ;
153167 if ( ctx ?. state === "suspended" ) ctx . resume ( ) ;
154168 } ) ;
155169 }
@@ -179,11 +193,11 @@ export class Game {
179193 /** Collect media timestamps at each pipeline stage for latency measurement. */
180194 #timestamps( ) : { label : string ; ts : number } [ ] {
181195 const entries : { label : string ; ts : number } [ ] = [ ] ;
182- const received = this . sync . timestamp . peek ( ) ;
196+ const received = this . sync . output . timestamp . peek ( ) ;
183197 if ( received != null ) entries . push ( { label : "received" , ts : received } ) ;
184- const decoded = this . videoDecoder . timestamp . peek ( ) ;
198+ const decoded = this . videoDecoder . output . timestamp . peek ( ) ;
185199 if ( decoded != null ) entries . push ( { label : "decoded" , ts : decoded } ) ;
186- const rendered = this . videoRenderer . timestamp . peek ( ) ;
200+ const rendered = this . videoRenderer . output . timestamp . peek ( ) ;
187201 if ( rendered != null ) entries . push ( { label : "rendered" , ts : rendered } ) ;
188202 return entries ;
189203 }
@@ -205,7 +219,7 @@ export class Game {
205219 const exp = effect . get ( this . expanded ) ;
206220 // Native GB is 160x144 = 23040 pixels. When expanded, allow 4x for quality.
207221 const pixels = exp === this . sessionId ? GB_PIXELS * 4 : GB_PIXELS ;
208- this . videoSource . target . set ( { pixels } ) ;
222+ this . # target. set ( { pixels } ) ;
209223 }
210224
211225 #runVideoEnabled( videoEnabled : Moq . Signals . Signal < boolean > , effect : Moq . Signals . Effect ) {
@@ -219,7 +233,7 @@ export class Game {
219233 }
220234
221235 #runStatus( effect : Moq . Signals . Effect ) {
222- const active = effect . get ( this . broadcast . active ) ;
236+ const active = effect . get ( this . broadcast . output . active ) ;
223237 if ( ! active ) return ;
224238
225239 const statusTrack = active . subscribe ( "status" , 10 ) ;
0 commit comments