experiments of a tiny cytube-like player with yt-dlp
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor: separate video to dedicated WebSocket, eliminate HOL blocking

karitham (May 17, 2026, 5:36 PM +0200) 2dc59391 edd96aaa

+746 -340
+1 -1
src/main.rs
··· 81 81 } 82 82 }); 83 83 84 - let app = web::router(rooms, pool, args.cache_dir, sources); 84 + let app = web::router(rooms, pool); 85 85 let listener = tokio::net::TcpListener::bind(args.http_addr).await?; 86 86 87 87 tracing::info!("HTTP server listening on {}", args.http_addr);
+23
src/playlist.rs
··· 165 165 .await?; 166 166 Ok(()) 167 167 } 168 + 169 + /// Update metadata fields after async extraction completes. 170 + /// 171 + /// Updates title, duration, thumbnail, and source for the given item. 172 + /// Used when yt-dlp extraction finishes after the item was already 173 + /// queued with placeholder values. 174 + pub(crate) async fn update_metadata( 175 + &self, 176 + id: i64, 177 + meta: &TrackMeta, 178 + ) -> Result<(), sqlx::Error> { 179 + sqlx::query( 180 + "UPDATE queue_items SET title = ?, duration = ?, thumbnail = ?, source = ? WHERE id = ?", 181 + ) 182 + .bind(&meta.title) 183 + .bind(&meta.duration) 184 + .bind(&meta.thumbnail) 185 + .bind(meta.source.to_string()) 186 + .bind(id) 187 + .execute(&self.pool) 188 + .await?; 189 + Ok(()) 190 + } 168 191 } 169 192 170 193 #[cfg(test)]
+147 -1
src/room.rs
··· 122 122 room_name: String, 123 123 publishers: TrackPublishers, 124 124 pool: Pool<Sqlite>, 125 + source_registry: Arc<SourceRegistry>, 126 + cache_dir: PathBuf, 127 + /// Clone of the command sender — used by spawned extraction tasks to 128 + /// deliver MetadataReady / MetadataFailed back to the actor. 129 + cmd_tx: mpsc::Sender<RoomCommand>, 125 130 126 131 // Mutable state (owned by this actor, no locks needed) 127 132 state: PlaybackState, ··· 184 189 room_name: room_name.clone(), 185 190 publishers: publishers.clone(), 186 191 pool: self.pool.clone(), 192 + source_registry: self.sources.clone(), 193 + cache_dir: self.cache_dir.clone(), 194 + cmd_tx: tx.clone(), 187 195 state: PlaybackState::default(), 188 196 player, 189 197 client_count: 0, ··· 240 248 } 241 249 242 250 /// Push a track onto a room's queue (persisted to SQLite via Playlist). 251 + #[allow(dead_code)] // only used by tests 243 252 pub(crate) async fn push(&self, id: &RoomId, meta: TrackMeta) { 244 253 if let Some(handle) = self.handle(id).await { 245 254 let _ = handle.cmd_tx.send(RoomCommand::QueueTrack(meta)).await; 246 255 } 256 + } 257 + 258 + /// Push a raw URL onto a room's queue. Metadata extraction (yt-dlp) runs 259 + /// asynchronously in a spawned task — the caller returns immediately. 260 + pub(crate) async fn push_url(&self, id: &RoomId, url: String) { 261 + let t0 = std::time::Instant::now(); 262 + let handle = match self.handle(id).await { 263 + Some(h) => h, 264 + None => return, 265 + }; 266 + let t1 = t0.elapsed(); 267 + let _ = handle.cmd_tx.send(RoomCommand::QueueUrl(url)).await; 268 + let t2 = t0.elapsed(); 269 + tracing::info!( 270 + room = %id, 271 + "push_url timing: handle={:.0?} send={:.0?} total={:.0?}", 272 + t1, t2 - t1, t2 273 + ); 247 274 } 248 275 249 276 /// Remove a room (from SQLite and in-memory state). ··· 436 463 url: item.url, 437 464 duration: item.duration, 438 465 thumbnail: item.thumbnail, 466 + pending: false, 439 467 }), 440 468 now, 441 469 ); ··· 444 472 self.execute_effects(effects).await; 445 473 } 446 474 475 + RoomCommand::QueueUrl(url) => { 476 + let t0 = std::time::Instant::now(); 477 + tracing::debug!(room = %self.room_id, %url, "track url queued"); 478 + 479 + // ── Gather ────────────────────────────────────────── 480 + let playlist = Playlist::new(self.pool.clone(), &self.room_id.0); 481 + let placeholder = TrackMeta { 482 + title: "Loading…".into(), 483 + duration: "--:--".into(), 484 + thumbnail: None, 485 + url: url.clone(), 486 + source: crate::types::SourceKind::Direct, 487 + }; 488 + let item = match playlist.push(&placeholder).await { 489 + Ok(item) => item, 490 + Err(e) => { 491 + tracing::warn!("failed to persist queue item: {e}"); 492 + return false; 493 + } 494 + }; 495 + let t1 = t0.elapsed(); 496 + let now = Utc::now().timestamp_millis(); 497 + 498 + // ── Transition (pure) ─────────────────────────────── 499 + let effects = self.state.transition( 500 + &Event::TrackQueued(state::QueuedTrack { 501 + id: item.id, 502 + title: placeholder.title, 503 + url: placeholder.url, 504 + duration: placeholder.duration, 505 + thumbnail: placeholder.thumbnail, 506 + pending: true, 507 + }), 508 + now, 509 + ); 510 + let t2 = t0.elapsed(); 511 + 512 + // ── Commit ────────────────────────────────────────── 513 + self.execute_effects(effects).await; 514 + let t3 = t0.elapsed(); 515 + 516 + // ── Spawn async extraction (non-blocking) ─────────── 517 + let item_id = item.id; 518 + let cmd_tx = self.cmd_tx.clone(); 519 + let sources = self.source_registry.clone(); 520 + let cache_dir = self.cache_dir.clone(); 521 + tokio::spawn(async move { 522 + match sources.extract(&url, &cache_dir).await { 523 + Ok(meta) => { 524 + let _ = cmd_tx 525 + .send(RoomCommand::MetadataReady { item_id, meta }) 526 + .await; 527 + } 528 + Err(e) => { 529 + let _ = cmd_tx 530 + .send(RoomCommand::MetadataFailed { 531 + item_id, 532 + error: e.to_string(), 533 + }) 534 + .await; 535 + } 536 + } 537 + }); 538 + 539 + tracing::info!( 540 + room = %self.room_id, 541 + "QueueUrl actor timing: db_push={:.0?} transition={:.0?} effects={:.0?} total={:.0?}", 542 + t1, t2 - t1, t3 - t2, t3 543 + ); 544 + } 545 + 546 + RoomCommand::MetadataReady { item_id, meta } => { 547 + tracing::debug!(room = %self.room_id, item_id, title = %meta.title, "metadata ready"); 548 + 549 + // ── Gather ────────────────────────────────────────── 550 + let now = Utc::now().timestamp_millis(); 551 + 552 + // ── DB update (synchronous commit, actor-safe) ────── 553 + let playlist = Playlist::new(self.pool.clone(), &self.room_id.0); 554 + if let Err(e) = playlist.update_metadata(item_id, &meta).await { 555 + tracing::warn!(item_id, error = %e, "failed to persist metadata update"); 556 + } 557 + 558 + // ── Transition (pure) ─────────────────────────────── 559 + let effects = self.state.transition( 560 + &Event::MetadataUpdated { 561 + item_id, 562 + title: meta.title, 563 + duration: meta.duration, 564 + thumbnail: meta.thumbnail, 565 + }, 566 + now, 567 + ); 568 + 569 + // ── Commit ────────────────────────────────────────── 570 + self.execute_effects(effects).await; 571 + } 572 + 573 + RoomCommand::MetadataFailed { item_id, error } => { 574 + tracing::debug!(room = %self.room_id, item_id, %error, "metadata extraction failed"); 575 + 576 + // ── Transition (pure) — set error state in title ──── 577 + let now = Utc::now().timestamp_millis(); 578 + let effects = self.state.transition( 579 + &Event::MetadataUpdated { 580 + item_id, 581 + title: format!("Failed to load — {error}"), 582 + duration: "--:--".into(), 583 + thumbnail: None, 584 + }, 585 + now, 586 + ); 587 + self.execute_effects(effects).await; 588 + } 589 + 447 590 RoomCommand::Skip => { 448 591 tracing::info!(room = %self.room_id, "skip requested"); 449 592 ··· 534 677 } 535 678 536 679 Effect::PersistStarted(id) => { 680 + let t0 = std::time::Instant::now(); 537 681 let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); 538 682 let playlist = Playlist::new(self.pool.clone(), &self.room_id.0); 539 683 let _ = playlist.mark_started(id, &now).await; 684 + tracing::debug!(room = %self.room_id, id, "PersistStarted: {:.0?}", t0.elapsed()); 540 685 } 541 686 542 687 Effect::StartPipeline(track) => { ··· 587 732 url: q.url.clone(), 588 733 duration: q.duration.clone(), 589 734 thumbnail: q.thumbnail.clone(), 735 + pending: q.pending, 590 736 }) 591 737 .collect(); 592 738 ··· 608 754 609 755 tracing::trace!( 610 756 room = %self.room_id, queue_len = %queue.len(), history_len = %history.len(), 611 - clients = %client_count, "state snapshot published" 757 + clients = %client_count, "publish_state_snapshot", 612 758 ); 613 759 614 760 let snapshot = serde_json::json!({
+50 -41
src/source/ytdlp.rs
··· 7 7 use std::path::Path; 8 8 9 9 use serde::Deserialize; 10 - use tokio::process::Command; 11 10 12 11 use crate::media; 13 12 use crate::source::{MediaSource, SourceError}; ··· 49 48 return Err(SourceError::Unsupported(format!("not an HTTP(S) URL: {url}"))); 50 49 } 51 50 52 - let output = Command::new("yt-dlp") 53 - .args([ 54 - "--dump-json", 55 - "--no-playlist", 56 - "--no-warnings", 57 - "--cache-dir", 58 - ]) 59 - .arg(cache_dir) 60 - .arg(url) 61 - .kill_on_drop(true) 62 - .output() 63 - .await 64 - .map_err(|e| { 65 - if e.kind() == std::io::ErrorKind::NotFound { 66 - SourceError::Unsupported("yt-dlp not found on PATH".into()) 67 - } else { 68 - SourceError::Io(e) 69 - } 70 - })?; 51 + let cache_dir = cache_dir.to_path_buf(); 52 + let url = url.to_string(); 53 + let result = tokio::task::spawn_blocking(move || { 54 + std::process::Command::new("yt-dlp") 55 + .args([ 56 + "--dump-json", 57 + "--no-playlist", 58 + "--no-warnings", 59 + "--cache-dir", 60 + ]) 61 + .arg(&cache_dir) 62 + .arg(&url) 63 + .output() 64 + }) 65 + .await 66 + .map_err(|e| SourceError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?; 67 + 68 + let output = result.map_err(|e| { 69 + if e.kind() == std::io::ErrorKind::NotFound { 70 + SourceError::Unsupported("yt-dlp not found on PATH".into()) 71 + } else { 72 + SourceError::Io(e) 73 + } 74 + })?; 71 75 72 76 if !output.status.success() { 73 77 let stderr = String::from_utf8_lossy(&output.stderr); ··· 99 103 return Err(SourceError::Unsupported(format!("not an HTTP(S) URL: {url}"))); 100 104 } 101 105 102 - let output = Command::new("yt-dlp") 103 - .args([ 104 - "-f", 105 - "b", 106 - "-g", 107 - "--no-playlist", 108 - "--no-warnings", 109 - "--cache-dir", 110 - ]) 111 - .arg(cache_dir) 112 - .arg(url) 113 - .kill_on_drop(true) 114 - .output() 115 - .await 116 - .map_err(|e| { 117 - if e.kind() == std::io::ErrorKind::NotFound { 118 - SourceError::Unsupported("yt-dlp not found on PATH".into()) 119 - } else { 120 - SourceError::Io(e) 121 - } 122 - })?; 106 + let cache_dir = cache_dir.to_path_buf(); 107 + let url = url.to_string(); 108 + let result = tokio::task::spawn_blocking(move || { 109 + std::process::Command::new("yt-dlp") 110 + .args([ 111 + "-f", 112 + "b", 113 + "-g", 114 + "--no-playlist", 115 + "--no-warnings", 116 + "--cache-dir", 117 + ]) 118 + .arg(&cache_dir) 119 + .arg(&url) 120 + .output() 121 + }) 122 + .await 123 + .map_err(|e| SourceError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?; 124 + 125 + let output = result.map_err(|e| { 126 + if e.kind() == std::io::ErrorKind::NotFound { 127 + SourceError::Unsupported("yt-dlp not found on PATH".into()) 128 + } else { 129 + SourceError::Io(e) 130 + } 131 + })?; 123 132 124 133 if !output.status.success() { 125 134 let stderr = String::from_utf8_lossy(&output.stderr);
+232 -41
src/state.rs
··· 27 27 pub url: String, 28 28 pub duration: String, 29 29 pub thumbnail: Option<String>, 30 + /// True while metadata is being extracted asynchronously. 31 + pub pending: bool, 30 32 } 31 33 32 34 /// A finished track in history. ··· 49 51 Skip, 50 52 /// The transcoding pipeline finished (naturally or via abort). 51 53 TrackEnded { item_id: i64 }, 54 + /// Asynchronous metadata extraction completed for a queued/playing track. 55 + MetadataUpdated { 56 + item_id: i64, 57 + title: String, 58 + duration: String, 59 + thumbnail: Option<String>, 60 + }, 52 61 } 53 62 54 63 /// Side effects to execute after a transition. ··· 102 111 Event::TrackQueued(track) => self.handle_queued(track), 103 112 Event::Skip => self.handle_skip(now), 104 113 Event::TrackEnded { item_id } => self.handle_track_ended(*item_id, now), 114 + Event::MetadataUpdated { 115 + item_id, 116 + title, 117 + duration, 118 + thumbnail, 119 + } => self.handle_metadata_updated(*item_id, title, duration, thumbnail), 105 120 } 106 121 } 107 122 ··· 193 208 effects 194 209 } 195 210 211 + /// Asynchronous metadata resolution completed. Updates the matching item 212 + /// in the queue and/or the active track in-place. Does not change playback 213 + /// state — only updates display fields. 214 + fn handle_metadata_updated( 215 + &mut self, 216 + item_id: i64, 217 + title: &str, 218 + duration: &str, 219 + thumbnail: &Option<String>, 220 + ) -> Vec<Effect> { 221 + if let Some(item) = self.queue.iter_mut().find(|t| t.id == item_id) { 222 + item.title = title.to_string(); 223 + item.duration = duration.to_string(); 224 + item.thumbnail.clone_from(thumbnail); 225 + item.pending = false; 226 + } 227 + if let Some(ref mut active) = self.active { 228 + if active.id == item_id { 229 + active.title = title.to_string(); 230 + active.duration = duration.to_string(); 231 + active.thumbnail.clone_from(thumbnail); 232 + } 233 + } 234 + vec![Effect::PublishSnapshot] 235 + } 236 + 196 237 /// Pop the first track from the queue and start playing it. 197 238 /// 198 239 /// Returns effects for starting the pipeline and publishing state. ··· 246 287 url: format!("https://example.com/{title}"), 247 288 duration: "3:45".into(), 248 289 thumbnail: None, 290 + pending: false, 249 291 } 250 292 } 251 293 294 + // ── Effect ordering tests ───────────────────────────────────────────── 295 + // These verify the exact effect sequence returned by transition(). Order 296 + // matters: PersistStarted must precede StartPipeline (DB commit before 297 + // pipeline spawn), AbortPipeline must precede PersistFinished, etc. 298 + 299 + #[test] 300 + fn idle_to_playing_effect_order() { 301 + let mut s = PlaybackState::default(); 302 + let effects = s.transition(&Event::TrackQueued(queued("A", 1)), 0); 303 + 304 + assert_eq!( 305 + effects, 306 + vec![ 307 + Effect::PersistStarted(1), 308 + Effect::StartPipeline(queued("A", 1)), 309 + Effect::PublishSnapshot, 310 + ], 311 + "idle → first track: persist started_at, spawn pipeline, notify clients" 312 + ); 313 + } 314 + 315 + #[test] 316 + fn queue_second_while_playing_effect_order() { 317 + let mut s = PlaybackState::default(); 318 + s.transition(&Event::TrackQueued(queued("A", 1)), 0); 319 + let effects = s.transition(&Event::TrackQueued(queued("B", 2)), 0); 320 + 321 + assert_eq!( 322 + effects, 323 + vec![Effect::PublishSnapshot], 324 + "playing → queue another: just notify — pipeline untouched" 325 + ); 326 + } 327 + 328 + #[test] 329 + fn skip_with_next_effect_order() { 330 + let mut s = PlaybackState::default(); 331 + s.transition(&Event::TrackQueued(queued("A", 1)), 0); 332 + s.transition(&Event::TrackQueued(queued("B", 2)), 0); 333 + let effects = s.transition(&Event::Skip, 1_700_000_000_001); 334 + 335 + assert_eq!( 336 + &effects[..3], 337 + &[ 338 + Effect::AbortPipeline, 339 + Effect::PersistFinished(1), 340 + Effect::PersistStarted(2), 341 + ], 342 + "skip with next: abort current, finalize A, persist B started_at" 343 + ); 344 + assert!( 345 + effects 346 + .iter() 347 + .any(|e| matches!(e, Effect::StartPipeline(t) if t.title == "B")), 348 + "skip with next: must include StartPipeline for B" 349 + ); 350 + assert!( 351 + effects.contains(&Effect::PublishSnapshot), 352 + "skip with next: must notify clients" 353 + ); 354 + } 355 + 356 + #[test] 357 + fn skip_last_track_effect_order() { 358 + let mut s = PlaybackState::default(); 359 + s.transition(&Event::TrackQueued(queued("A", 1)), 0); 360 + let effects = s.transition(&Event::Skip, 0); 361 + 362 + assert_eq!( 363 + effects, 364 + vec![ 365 + Effect::AbortPipeline, 366 + Effect::PersistFinished(1), 367 + Effect::PublishSnapshot, 368 + ], 369 + "skip last: abort, finalize, notify — no StartPipeline" 370 + ); 371 + } 372 + 373 + #[test] 374 + fn track_ended_with_next_effect_order() { 375 + let mut s = PlaybackState::default(); 376 + s.transition(&Event::TrackQueued(queued("A", 1)), 0); 377 + s.transition(&Event::TrackQueued(queued("B", 2)), 0); 378 + let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0); 379 + 380 + assert_eq!( 381 + &effects[..2], 382 + &[Effect::PersistFinished(1), Effect::PersistStarted(2),], 383 + "track ended (next): finalize A, persist B started_at — no AbortPipeline" 384 + ); 385 + assert!( 386 + effects 387 + .iter() 388 + .any(|e| matches!(e, Effect::StartPipeline(t) if t.title == "B")), 389 + "track ended (next): must start B" 390 + ); 391 + assert!( 392 + effects.contains(&Effect::PublishSnapshot), 393 + "track ended (next): must notify" 394 + ); 395 + } 396 + 397 + #[test] 398 + fn track_ended_last_goes_idle_effect_order() { 399 + let mut s = PlaybackState::default(); 400 + s.transition(&Event::TrackQueued(queued("A", 1)), 0); 401 + let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0); 402 + 403 + assert_eq!( 404 + effects, 405 + vec![Effect::PersistFinished(1), Effect::PublishSnapshot,], 406 + "track ended last: finalize, notify — no StartPipeline, no AbortPipeline" 407 + ); 408 + } 409 + 410 + // ── State invariant tests ───────────────────────────────────────────── 411 + 252 412 #[test] 253 413 fn idle_room_starts_playing_on_first_track() { 254 414 let mut s = PlaybackState::default(); 255 - let effects = s.transition(&Event::TrackQueued(queued("A", 1)), 0); 415 + s.transition(&Event::TrackQueued(queued("A", 1)), 0); 256 416 257 417 assert!(s.active.is_some()); 258 418 assert_eq!(s.active.as_ref().unwrap().title, "A"); 259 419 assert!(s.queue.is_empty()); 260 - assert!(effects.contains(&Effect::PublishSnapshot)); 261 - assert!(effects 262 - .iter() 263 - .any(|e| matches!(e, Effect::StartPipeline(_)))); 264 420 } 265 421 266 422 #[test] 267 423 fn second_track_stays_in_queue() { 268 424 let mut s = PlaybackState::default(); 269 425 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 270 - let effects = s.transition(&Event::TrackQueued(queued("B", 2)), 0); 426 + s.transition(&Event::TrackQueued(queued("B", 2)), 0); 271 427 272 428 assert_eq!(s.queue.len(), 1); 273 429 assert_eq!(s.queue[0].title, "B"); 274 430 assert_eq!(s.active.as_ref().unwrap().title, "A"); 275 - assert_eq!(effects, vec![Effect::PublishSnapshot]); 276 431 } 277 432 278 433 #[test] ··· 280 435 let mut s = PlaybackState::default(); 281 436 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 282 437 s.transition(&Event::TrackQueued(queued("B", 2)), 0); 283 - let effects = s.transition(&Event::Skip, 1_700_000_000_001); 438 + s.transition(&Event::Skip, 1_700_000_000_001); 284 439 285 - // Should have advanced to B. 286 440 assert_eq!(s.active.as_ref().unwrap().title, "B"); 287 441 assert!(s.queue.is_empty()); 288 - 289 - // A should be in history. 290 442 assert_eq!(s.history.len(), 1); 291 443 assert_eq!(s.history[0].title, "A"); 292 - 293 - // Effects: abort prev, persist finished, start B, publish snapshot. 294 - assert!(effects.contains(&Effect::AbortPipeline)); 295 - assert!(effects.contains(&Effect::PersistFinished(1))); 296 - assert!(effects 297 - .iter() 298 - .any(|e| matches!(e, Effect::StartPipeline(t) if t.title == "B"))); 299 - assert!(effects.contains(&Effect::PublishSnapshot)); 300 444 } 301 445 302 446 #[test] ··· 312 456 fn skip_last_track_goes_idle() { 313 457 let mut s = PlaybackState::default(); 314 458 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 315 - let effects = s.transition(&Event::Skip, 0); 459 + s.transition(&Event::Skip, 0); 316 460 317 461 assert!(s.active.is_none()); 318 462 assert_eq!(s.history.len(), 1); 319 463 assert_eq!(s.history[0].title, "A"); 320 - assert!(effects.contains(&Effect::AbortPipeline)); 321 - assert!(effects.contains(&Effect::PersistFinished(1))); 322 - assert!(effects.contains(&Effect::PublishSnapshot)); 323 - // No StartPipeline since queue is empty. 324 - assert!(!effects 325 - .iter() 326 - .any(|e| matches!(e, Effect::StartPipeline(_)))); 327 464 } 328 465 329 466 #[test] ··· 331 468 let mut s = PlaybackState::default(); 332 469 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 333 470 s.transition(&Event::TrackQueued(queued("B", 2)), 0); 334 - 335 - let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0); 471 + s.transition(&Event::TrackEnded { item_id: 1 }, 0); 336 472 337 473 assert_eq!(s.active.as_ref().unwrap().title, "B"); 338 474 assert_eq!(s.history.len(), 1); 339 475 assert_eq!(s.history[0].title, "A"); 340 - assert!(effects.contains(&Effect::PersistFinished(1))); 341 - assert!(effects.contains(&Effect::PublishSnapshot)); 342 476 } 343 477 344 478 #[test] ··· 346 480 let mut s = PlaybackState::default(); 347 481 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 348 482 349 - // Stale TrackEnded for a different id. 350 483 let effects = s.transition(&Event::TrackEnded { item_id: 999 }, 0); 351 484 352 485 assert!(s.active.is_some()); ··· 358 491 fn track_ended_last_track_goes_idle() { 359 492 let mut s = PlaybackState::default(); 360 493 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 361 - 362 - let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0); 494 + s.transition(&Event::TrackEnded { item_id: 1 }, 0); 363 495 364 496 assert!(s.active.is_none()); 365 497 assert_eq!(s.history.len(), 1); 366 - assert!(effects.contains(&Effect::PublishSnapshot)); 367 - assert!(!effects 368 - .iter() 369 - .any(|e| matches!(e, Effect::StartPipeline(_)))); 370 498 } 371 499 372 500 #[test] ··· 383 511 let mut s = PlaybackState::default(); 384 512 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 385 513 s.transition(&Event::TrackQueued(queued("B", 2)), 0); 386 - 387 - // Skip A → B starts playing. 388 514 s.transition(&Event::Skip, 0); 389 515 assert_eq!(s.active.as_ref().unwrap().title, "B"); 390 516 391 - // Stale TrackEnded for A arrives — should be ignored. 392 517 let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0); 393 518 assert!(effects.is_empty()); 394 519 assert_eq!(s.active.as_ref().unwrap().title, "B"); ··· 399 524 let mut s = PlaybackState::default(); 400 525 s.resolve_started_at(42); 401 526 assert!(s.active.is_none()); 527 + } 528 + 529 + // ── Metadata update tests ───────────────────────────────────────── 530 + 531 + #[test] 532 + fn metadata_update_updates_queue_item() { 533 + let mut s = PlaybackState::default(); 534 + s.transition(&Event::TrackQueued(queued("A", 1)), 0); 535 + s.transition( 536 + &Event::MetadataUpdated { 537 + item_id: 1, 538 + title: "Track A (Real)".into(), 539 + duration: "4:20".into(), 540 + thumbnail: Some("https://img.example/a.jpg".into()), 541 + }, 542 + 0, 543 + ); 544 + 545 + // Queue should have empty (track was advanced to active), but active 546 + // should have updated metadata. 547 + assert_eq!(s.active.as_ref().unwrap().title, "Track A (Real)"); 548 + assert_eq!(s.active.as_ref().unwrap().duration, "4:20"); 549 + assert_eq!( 550 + s.active.as_ref().unwrap().thumbnail, 551 + Some("https://img.example/a.jpg".into()) 552 + ); 553 + } 554 + 555 + #[test] 556 + fn metadata_update_on_queued_item() { 557 + let mut s = PlaybackState::default(); 558 + s.transition(&Event::TrackQueued(queued("A", 1)), 0); 559 + s.transition(&Event::TrackQueued(queued("B", 2)), 0); 560 + 561 + // Update the queued item (B, id=2). 562 + let effects = s.transition( 563 + &Event::MetadataUpdated { 564 + item_id: 2, 565 + title: "Track B (Real)".into(), 566 + duration: "5:55".into(), 567 + thumbnail: None, 568 + }, 569 + 0, 570 + ); 571 + assert_eq!(effects, vec![Effect::PublishSnapshot]); 572 + assert_eq!(s.queue[0].title, "Track B (Real)"); 573 + assert!(!s.queue[0].pending); 574 + } 575 + 576 + #[test] 577 + fn metadata_update_on_unknown_id_is_noop() { 578 + let mut s = PlaybackState::default(); 579 + let effects = s.transition( 580 + &Event::MetadataUpdated { 581 + item_id: 999, 582 + title: "Ghost".into(), 583 + duration: "0:00".into(), 584 + thumbnail: None, 585 + }, 586 + 0, 587 + ); 588 + // Still publishes snapshot even if nothing changed — clients may want 589 + // to know the update was processed. 590 + assert_eq!(effects, vec![Effect::PublishSnapshot]); 591 + assert!(s.active.is_none()); 592 + assert!(s.queue.is_empty()); 402 593 } 403 594 }
+86 -127
src/transport.rs
··· 261 261 /// Cloning is cheap (all inner channels are `Arc`-based). 262 262 #[derive(Clone)] 263 263 pub(crate) struct TrackPublishers { 264 - pub(crate) audio: broadcast::Sender<MoqObject>, 265 264 pub(crate) video: broadcast::Sender<MoqObject>, 266 265 pub(crate) chat: broadcast::Sender<MoqObject>, 267 266 pub(crate) state: watch::Sender<StateValue>, ··· 273 272 274 273 impl TrackPublishers { 275 274 pub(crate) fn new() -> Self { 276 - let (audio, _) = broadcast::channel(256); 277 275 let (video, _) = broadcast::channel(256); 278 276 let (chat, _) = broadcast::channel(256); 279 277 let (state, _) = watch::channel(StateValue { ··· 282 280 }); 283 281 let (video_init_watch, _) = watch::channel(false); 284 282 TrackPublishers { 285 - audio, 286 283 video, 287 284 chat, 288 285 state, ··· 455 452 } 456 453 } 457 454 455 + /// WebSocket session for video-only delivery. 456 + /// 457 + /// No MoQ protocol negotiation — the server auto-subscribes the client to 458 + /// the video broadcast. Messages are raw fMP4 bytes (no MoQ framing). 459 + /// A separate WS-control connection handles chat, state, and MoQ handshake. 460 + pub(crate) async fn handle_video_session( 461 + ws: WebSocket, 462 + publishers: TrackPublishers, 463 + ) { 464 + let (mut ws_tx, mut ws_rx) = ws.split(); 465 + 466 + // Collect the cached init (or wait for the pipeline to produce one) 467 + // but don't send it yet — we need to subscribe to the broadcast first 468 + // so we can discard any init that was also published via broadcast, 469 + // which would otherwise arrive as a duplicate through the forward loop. 470 + let init = match publishers.get_init_segment() { 471 + Some(cached) => { 472 + tracing::debug!("video ws: using cached init segment"); 473 + Some(cached) 474 + } 475 + None => { 476 + tracing::debug!("video ws: no cached init, waiting for watch signal"); 477 + let mut init_rx = publishers.video_init_waiter(); 478 + let _ = init_rx.changed().await; 479 + let result = publishers.get_init_segment(); 480 + if result.is_some() { 481 + tracing::debug!("video ws: received init segment via watch"); 482 + } else { 483 + tracing::warn!("video ws: watch fired but no init available"); 484 + } 485 + result 486 + } 487 + }; 488 + 489 + // Subscribe to the broadcast before sending cached init so that 490 + // any init published via broadcast is caught by the catch-up loop 491 + // and discarded, preventing a duplicate on the forward path. 492 + let mut video_rx = publishers.video.subscribe(); 493 + loop { 494 + match video_rx.try_recv() { 495 + Ok(_) => continue, 496 + Err(broadcast::error::TryRecvError::Empty) => break, 497 + Err(broadcast::error::TryRecvError::Closed) => return, 498 + Err(broadcast::error::TryRecvError::Lagged(_)) => break, 499 + } 500 + } 501 + 502 + // Now send the cached init — the broadcast buffer has been drained 503 + // so the forward loop below won't re-deliver it. 504 + if let Some((_group_id, payload)) = init { 505 + if ws_tx.send(Message::Binary(payload)).await.is_err() { 506 + return; 507 + } 508 + } 509 + 510 + // Forward live video objects as raw fMP4 payloads. 511 + loop { 512 + tokio::select! { 513 + msg = ws_rx.next() => { 514 + match msg { 515 + Some(Ok(Message::Close(_))) | None => break, 516 + _ => continue, 517 + } 518 + } 519 + result = video_rx.recv() => { 520 + match result { 521 + Ok(obj) => { 522 + if ws_tx.send(Message::Binary(obj.payload)).await.is_err() { 523 + break; 524 + } 525 + } 526 + Err(broadcast::error::RecvError::Lagged(n)) => { 527 + tracing::debug!("video ws: broadcast lagged by {n}"); 528 + } 529 + Err(broadcast::error::RecvError::Closed) => break, 530 + } 531 + } 532 + } 533 + } 534 + } 535 + 458 536 async fn handle_moq_message( 459 537 msg: MoqMessage, 460 538 room_id: &RoomId, ··· 573 651 } 574 652 } 575 653 })); 576 - } 577 - TrackId::Video => { 578 - // Send cached init (or wait for it) and forward live broadcast 579 - // from the same spawned task to guarantee ordering for late joiners. 580 - let init_rx = publishers.video_init_waiter(); 581 - let cached = publishers.get_init_segment(); // snapshot before spawn 582 - let tx = object_tx.clone(); 583 - let publishers_for_task = publishers.clone(); 584 - let rx = publishers.video.subscribe(); 585 - task_handles.push(tokio::spawn(async move { 586 - // 1. Ensure init segment is available (wait if pipeline 587 - // hasn't produced it yet). 588 - let init = if let Some(cached) = cached { 589 - tracing::debug!("forward task: using cached init segment"); 590 - Some(cached) 591 - } else { 592 - tracing::debug!( 593 - "forward task: no cached init, waiting for watch signal" 594 - ); 595 - let mut init_rx = init_rx; 596 - let _ = init_rx.changed().await; 597 - let result = publishers_for_task.get_init_segment(); 598 - if result.is_some() { 599 - tracing::debug!("forward task: received init segment via watch"); 600 - } else { 601 - tracing::warn!("forward task: watch fired but no init available"); 602 - } 603 - result 604 - }; 605 - 606 - if let Some((group_id, init)) = init { 607 - tracing::debug!( 608 - group_id, 609 - size = init.len(), 610 - "forward task: sending init segment" 611 - ); 612 - let obj = encode_message(&MoqMessage::Object { 613 - track_id: TrackId::Video as u64, 614 - group_id, 615 - object_id: 0, 616 - payload: init, 617 - }); 618 - if tx.send(obj).is_err() { 619 - return; 620 - } 621 - } else { 622 - // No init and never got one — nothing to play. 623 - tracing::warn!("forward task: no init segment available, aborting"); 624 - return; 625 - } 626 - 627 - // 2. Catch up to live edge: discard any messages that were 628 - // already in the broadcast buffer before subscription. 629 - // Without this, a late subscriber would replay all prior 630 - // segments from the oldest in the buffer. 631 - let mut rx = rx; 632 - loop { 633 - match rx.try_recv() { 634 - Ok(_) => continue, 635 - Err(broadcast::error::TryRecvError::Empty) => break, 636 - Err(broadcast::error::TryRecvError::Closed) => return, 637 - Err(broadcast::error::TryRecvError::Lagged(_)) => break, 638 - } 639 - } 640 - 641 - // 3. Forward live broadcast messages. 642 - tracing::debug!("forward task: entering broadcast loop"); 643 - loop { 644 - match rx.recv().await { 645 - Ok(obj) => { 646 - let msg = encode_message(&MoqMessage::Object { 647 - track_id: obj.track_id as u64, 648 - group_id: obj.group_id, 649 - object_id: obj.object_id, 650 - payload: obj.payload, 651 - }); 652 - if tx.send(msg).is_err() { 653 - break; 654 - } 655 - } 656 - Err(broadcast::error::RecvError::Lagged(n)) => { 657 - tracing::debug!("forward task: video broadcast lagged by {n}"); 658 - } 659 - Err(broadcast::error::RecvError::Closed) => break, 660 - } 661 - } 662 - })); 663 - } 664 - TrackId::Audio => { 665 - let rx = publishers.audio.subscribe(); 666 - let tx = object_tx.clone(); 667 - task_handles.push(tokio::spawn(async move { 668 - let mut rx = rx; 669 - // Catch up to live edge — discard prior messages. 670 - loop { 671 - match rx.try_recv() { 672 - Ok(_) => continue, 673 - Err(broadcast::error::TryRecvError::Empty) => break, 674 - Err(broadcast::error::TryRecvError::Closed) => return, 675 - Err(broadcast::error::TryRecvError::Lagged(_)) => break, 676 - } 677 - } 678 - loop { 679 - match rx.recv().await { 680 - Ok(obj) => { 681 - let msg = encode_message(&MoqMessage::Object { 682 - track_id: obj.track_id as u64, 683 - group_id: obj.group_id, 684 - object_id: obj.object_id, 685 - payload: obj.payload, 686 - }); 687 - if tx.send(msg).is_err() { 688 - break; 689 - } 690 - } 691 - Err(broadcast::error::RecvError::Lagged(_)) => { 692 - // Live media — skip missed objects. 693 - continue; 694 - } 695 - Err(broadcast::error::RecvError::Closed) => break, 696 - } 697 - } 698 - })); 699 - } 654 + } 655 + _ => { 656 + // Video and audio use a dedicated WebSocket endpoint. 657 + // Ignore subscribe requests on the control WS. 700 658 } 701 659 } 660 + } 702 661 703 662 MoqMessage::Object { 704 663 track_id,
+21
src/types.rs
··· 79 79 pub(crate) url: String, 80 80 pub(crate) duration: String, 81 81 pub(crate) thumbnail: Option<String>, 82 + /// True while metadata is being extracted asynchronously. 83 + #[serde(skip_serializing_if = "is_false")] 84 + pub(crate) pending: bool, 85 + } 86 + 87 + fn is_false(b: &bool) -> bool { 88 + !*b 82 89 } 83 90 84 91 #[derive(Debug, Clone, Serialize)] ··· 140 147 141 148 /// All room operations — processed sequentially by the per-room actor. 142 149 pub(crate) enum RoomCommand { 150 + /// Track with already-resolved metadata (legacy path, used by tests). 151 + #[allow(dead_code)] 143 152 QueueTrack(TrackMeta), 153 + /// Raw URL — metadata extraction happens asynchronously in a spawned task. 154 + QueueUrl(String), 155 + /// Async metadata extraction result. 156 + MetadataReady { 157 + item_id: i64, 158 + meta: TrackMeta, 159 + }, 160 + /// Async metadata extraction failed. 161 + MetadataFailed { 162 + item_id: i64, 163 + error: String, 164 + }, 144 165 Skip, 145 166 /// Sent by the pipeline task when it finishes (or errors, or is aborted). 146 167 TrackEnded {
+44 -29
src/web.rs
··· 2 2 3 3 use std::collections::HashMap; 4 4 use std::net::SocketAddr; 5 - use std::path::PathBuf; 6 5 use std::sync::Arc; 7 6 8 7 use askama::Template; ··· 18 17 19 18 use crate::db; 20 19 use crate::room; 21 - use crate::source::SourceRegistry; 22 20 use crate::types::{RoomId, TrackMeta}; 23 21 24 22 /// Per-IP rate limiter for POST /api/ingest (max 1 req/5s per IP). ··· 58 56 pub(crate) struct AppState { 59 57 pub rooms: room::Registry, 60 58 pub pool: Pool<Sqlite>, 61 - pub cache_dir: PathBuf, 62 - pub sources: Arc<SourceRegistry>, 63 59 pub rate_limiter: RateLimiter, 64 60 } 65 61 66 62 pub(crate) fn router( 67 63 rooms: room::Registry, 68 64 pool: Pool<Sqlite>, 69 - cache_dir: PathBuf, 70 - sources: Arc<SourceRegistry>, 71 65 ) -> Router { 72 66 let rate_limiter = RateLimiter::new(); 73 67 ··· 84 78 let state = AppState { 85 79 rooms, 86 80 pool, 87 - cache_dir, 88 - sources, 89 81 rate_limiter, 90 82 }; 91 83 ··· 96 88 .route("/api/ingest", post(ingest_url)) 97 89 .route("/api/skip", post(skip_handler)) 98 90 .route("/ws/{room_id}", get(ws_handler)) 91 + .route("/ws/{room_id}/video", get(video_ws_handler)) 99 92 .with_state(state) 100 93 } 101 94 ··· 188 181 room_id: String, 189 182 } 190 183 191 - #[derive(serde::Serialize)] 192 - struct IngestResponse { 193 - title: String, 194 - duration: String, 195 - thumbnail: Option<String>, 196 - } 197 - 198 184 fn err_response(status: StatusCode, msg: &str) -> axum::response::Response { 199 185 (status, Json(serde_json::json!({ "error": msg }))).into_response() 200 186 } ··· 203 189 State(state): State<AppState>, 204 190 ConnectInfo(addr): ConnectInfo<SocketAddr>, 205 191 axum::Json(req): axum::Json<IngestRequest>, 206 - ) -> Result<Json<IngestResponse>, axum::response::Response> { 192 + ) -> Result<Json<serde_json::Value>, axum::response::Response> { 207 193 // Input validation — URL max 2048 characters. 208 194 if req.url.len() > 2048 { 209 195 return Err(err_response(StatusCode::BAD_REQUEST, "URL too long")); ··· 226 212 227 213 let room_id = RoomId(req.room_id); 228 214 215 + let t0 = std::time::Instant::now(); 216 + 229 217 // Validate the room exists. 230 218 if !state.rooms.exists(&room_id).await { 231 219 return Err(err_response(StatusCode::NOT_FOUND, "room not found")); 232 220 } 221 + let t1 = t0.elapsed(); 233 222 234 - // Extract metadata via the source registry. 235 - let meta = state 236 - .sources 237 - .extract(&req.url, &state.cache_dir) 238 - .await 239 - .map_err(|e| err_response(StatusCode::BAD_REQUEST, &e.to_string()))?; 223 + // Push URL to room — metadata extraction runs async, no client wait. 224 + state.rooms.push_url(&room_id, req.url).await; 225 + let t2 = t0.elapsed(); 240 226 241 - // Push to room queue. 242 - state.rooms.push(&room_id, meta.clone()).await; 227 + tracing::info!( 228 + "ingest_url timing: exists={:.0?} push_url={:.0?} total={:.0?}", 229 + t1, t2 - t1, t2 230 + ); 243 231 244 - Ok(Json(IngestResponse { 245 - title: meta.title, 246 - duration: meta.duration, 247 - thumbnail: meta.thumbnail, 248 - })) 232 + Ok(Json(serde_json::json!({ "ok": true }))) 249 233 } 250 234 251 235 #[derive(serde::Deserialize)] ··· 321 305 .await; 322 306 })) 323 307 } 308 + 309 + /// WebSocket upgrade endpoint for video-only delivery: `/ws/{room_id}/video`. 310 + /// 311 + /// No MoQ protocol — server auto-subscribes to the video broadcast and 312 + /// forwards raw fMP4 payloads. The client identifies init segments by 313 + /// checking for the ftyp box in the data. 314 + async fn video_ws_handler( 315 + ws: WebSocketUpgrade, 316 + Path(room_id): Path<String>, 317 + State(state): State<AppState>, 318 + ) -> Result<impl IntoResponse, (StatusCode, &'static str)> { 319 + if !room_id.chars().all(|c| c.is_ascii_digit()) { 320 + return Err((StatusCode::BAD_REQUEST, "invalid room ID")); 321 + } 322 + 323 + let rid = RoomId(room_id); 324 + 325 + if !state.rooms.exists(&rid).await { 326 + return Err((StatusCode::NOT_FOUND, "room not found")); 327 + } 328 + 329 + let handle = state 330 + .rooms 331 + .handle(&rid) 332 + .await 333 + .ok_or((StatusCode::NOT_FOUND, "room not found"))?; 334 + 335 + Ok(ws.on_upgrade(move |socket| async move { 336 + crate::transport::handle_video_session(socket, handle.publishers).await; 337 + })) 338 + }
+142 -100
templates/room.html
··· 435 435 transition: 436 436 background 150ms ease-out, 437 437 box-shadow 150ms ease-out; 438 - animation: queueFadeIn 0.3s ease-out both; 438 + animation: queueFadeIn 0.12s ease-out both; 439 439 } 440 440 .queue li:hover { 441 441 background: var(--surface1); ··· 666 666 box-shadow: var(--shadow-card-hover); 667 667 } 668 668 669 + /* Skeleton loading state for pending queue items */ 670 + @keyframes shimmer { 671 + 0% { 672 + background-position: -200% 0; 673 + } 674 + 100% { 675 + background-position: 200% 0; 676 + } 677 + } 678 + .queue li.pending { 679 + position: relative; 680 + } 681 + .queue li.pending .title, 682 + .queue li.pending .duration { 683 + display: inline-block; 684 + border-radius: var(--radius-sm); 685 + background: linear-gradient( 686 + 90deg, 687 + var(--surface1) 25%, 688 + var(--surface2) 37%, 689 + var(--surface1) 63% 690 + ); 691 + background-size: 200% 100%; 692 + animation: shimmer 1.5s ease-in-out infinite; 693 + color: transparent !important; 694 + user-select: none; 695 + } 696 + .queue li.pending .title { 697 + min-width: 16ch; 698 + max-width: 24ch; 699 + } 700 + .queue li.pending .duration { 701 + min-width: 5ch; 702 + } 703 + .queue li.pending .source-link { 704 + visibility: hidden; 705 + } 706 + 669 707 /* Stagger animation */ 670 708 @keyframes queueFadeIn { 671 709 from { 672 - opacity: 0; 673 - transform: translateY(8px); 710 + opacity: 0.3; 711 + transform: translateY(4px); 674 712 } 675 713 to { 676 714 opacity: 1; 677 715 transform: translateY(0); 678 716 } 679 717 } 680 - .queue li:nth-child(1) { 681 - animation-delay: 0.02s; 682 - } 683 - .queue li:nth-child(2) { 684 - animation-delay: 0.04s; 685 - } 686 - .queue li:nth-child(3) { 687 - animation-delay: 0.06s; 688 - } 689 - .queue li:nth-child(4) { 690 - animation-delay: 0.08s; 691 - } 692 - .queue li:nth-child(5) { 693 - animation-delay: 0.1s; 694 - } 695 - .queue li:nth-child(6) { 696 - animation-delay: 0.12s; 697 - } 698 - .queue li:nth-child(7) { 699 - animation-delay: 0.14s; 700 - } 701 - .queue li:nth-child(8) { 702 - animation-delay: 0.16s; 703 - } 704 - .queue li:nth-child(9) { 705 - animation-delay: 0.18s; 706 - } 707 - .queue li:nth-child(10) { 708 - animation-delay: 0.2s; 709 - } 710 - .queue li:nth-child(11) { 711 - animation-delay: 0.22s; 712 - } 713 - .queue li:nth-child(12) { 714 - animation-delay: 0.24s; 715 - } 718 + .queue li:nth-child(1) { animation-delay: 0s; } 719 + .queue li:nth-child(2) { animation-delay: 0.01s; } 720 + .queue li:nth-child(3) { animation-delay: 0.02s; } 721 + .queue li:nth-child(4) { animation-delay: 0.03s; } 722 + .queue li:nth-child(5) { animation-delay: 0.04s; } 723 + .queue li:nth-child(6) { animation-delay: 0.05s; } 724 + .queue li:nth-child(n+7) { animation-delay: 0.05s; } 716 725 717 726 /* Mobile responsive */ 718 727 @media (max-width: 768px) { ··· 1115 1124 1116 1125 function appendVideo(data) { 1117 1126 if (sourceBuffer && !sourceBuffer.updating) { 1118 - sourceBuffer.appendBuffer(data); 1127 + try { 1128 + sourceBuffer.appendBuffer(data); 1129 + } catch (e) { 1130 + console.warn("SourceBuffer append failed:", e); 1131 + } 1119 1132 } else { 1120 1133 pendingBoxes.push(data); 1121 1134 } ··· 1128 1141 encVarint(0x01), 1129 1142 encString("moqbox/room/" + roomId + "/chat"), 1130 1143 encString("chat"), 1131 - ]), 1132 - ); 1133 - // Subscribe to video track for MediaSource playback. 1134 - ws.send( 1135 - concat([ 1136 - encVarint(0x01), 1137 - encString("moqbox/room/" + roomId + "/video"), 1138 - encString("video"), 1139 1144 ]), 1140 1145 ); 1141 1146 // Subscribe to state track for now-playing updates. ··· 1172 1177 debug("WS connected"); 1173 1178 updateConnStatus(true); 1174 1179 reconnectDelay = 1000; 1175 - awaitingInit = true; 1176 - pendingBoxes.length = 0; 1177 1180 currentTrackId = null; 1178 - seekInitDone = false; 1179 1181 subscribeTracks(); 1180 1182 }; 1181 1183 ··· 1188 1190 const [trackId, o2] = decVarint(buf, o1); 1189 1191 const [groupId, o3] = decVarint(buf, o2); 1190 1192 const [objectId, o4] = decVarint(buf, o3); 1191 - if (trackId === 1) { 1192 - const videoData = new Uint8Array(buf.slice(o4)); 1193 - 1194 - if (awaitingInit) { 1195 - const isInit = 1196 - videoData.length >= 8 && 1197 - String.fromCharCode( 1198 - videoData[4], 1199 - videoData[5], 1200 - videoData[6], 1201 - videoData[7], 1202 - ) === "ftyp"; 1203 - 1204 - if (isInit) { 1205 - debug("init segment received"); 1206 - awaitingInit = false; 1207 - pendingBoxes.length = 0; 1208 - if (sourceBuffer && !sourceBuffer.updating) { 1209 - try { 1210 - sourceBuffer.abort(); 1211 - if (sourceBuffer.buffered.length > 0) { 1212 - const end = sourceBuffer.buffered.end( 1213 - sourceBuffer.buffered.length - 1, 1214 - ); 1215 - sourceBuffer.remove(0, end); 1216 - } 1217 - } catch (_) {} 1218 - } 1219 - } else { 1220 - pendingBoxes.push(videoData); 1221 - off = buf.length; 1222 - return; 1223 - } 1224 - } 1225 - 1226 - appendVideo(videoData); 1227 - off = buf.length; 1228 - } else if (trackId === 2) { 1193 + if (trackId === 2) { 1229 1194 const payload = new TextDecoder().decode(buf.slice(o4)); 1230 1195 try { 1231 1196 const chat = JSON.parse(payload); ··· 1268 1233 1269 1234 connect(); 1270 1235 1236 + // ── Video WebSocket (separate connection, no MoQ framing) ────────── 1237 + let videoWs = null; 1238 + let videoReconnectDelay = 1000; 1239 + 1240 + function connectVideoWs() { 1241 + videoWs = new WebSocket( 1242 + proto + "//" + location.host + "/ws/" + roomId + "/video", 1243 + ); 1244 + videoWs.binaryType = "arraybuffer"; 1245 + 1246 + videoWs.onopen = () => { 1247 + debug("video WS connected"); 1248 + videoReconnectDelay = 1000; 1249 + awaitingInit = true; 1250 + pendingBoxes.length = 0; 1251 + seekInitDone = false; 1252 + }; 1253 + 1254 + videoWs.onmessage = (event) => { 1255 + const data = new Uint8Array(event.data); 1256 + 1257 + // Always detect init segments, even when not awaitingInit. 1258 + // The video WS and control WS are independent connections with 1259 + // no ordering guarantee — init can arrive before the state update. 1260 + const isInit = 1261 + data.length >= 8 && 1262 + String.fromCharCode(data[4], data[5], data[6], data[7]) === "ftyp"; 1263 + 1264 + if (isInit) { 1265 + debug("init segment received (video WS)"); 1266 + awaitingInit = false; 1267 + pendingBoxes.length = 0; 1268 + if (sourceBuffer && !sourceBuffer.updating) { 1269 + try { 1270 + sourceBuffer.abort(); 1271 + if (sourceBuffer.buffered.length > 0) { 1272 + const end = sourceBuffer.buffered.end( 1273 + sourceBuffer.buffered.length - 1, 1274 + ); 1275 + sourceBuffer.remove(0, end); 1276 + } 1277 + } catch (_) {} 1278 + } 1279 + appendVideo(data); 1280 + return; 1281 + } 1282 + 1283 + if (awaitingInit) { 1284 + pendingBoxes.push(data); 1285 + return; 1286 + } 1287 + 1288 + appendVideo(data); 1289 + }; 1290 + 1291 + videoWs.onclose = () => { 1292 + setTimeout(() => { 1293 + videoReconnectDelay = Math.min(videoReconnectDelay * 2, 30000); 1294 + connectVideoWs(); 1295 + }, videoReconnectDelay); 1296 + }; 1297 + } 1298 + 1299 + // Open video connection after a short delay to let the control WS 1300 + // establish the room subscription first. 1301 + setTimeout(connectVideoWs, 100); 1302 + 1271 1303 function copyRoomLink() { 1272 1304 const url = window.location.href; 1273 1305 navigator.clipboard ··· 1355 1387 pill.textContent = c === 1 ? "1 viewer" : c + " viewers"; 1356 1388 } 1357 1389 1358 - // Clear old interval when track changes. 1359 - if (elapsedInterval) { 1360 - clearInterval(elapsedInterval); 1361 - elapsedInterval = null; 1362 - } 1363 - 1364 1390 if (state.current_track) { 1365 1391 const trackId = state.current_track.id; 1392 + const trackChanged = currentTrackId !== trackId; 1366 1393 1367 - // Detect track change — set awaitingInit so the next video object 1368 - // is treated as the new init segment. 1369 - if (currentTrackId !== null && currentTrackId !== trackId) { 1394 + // Detect track change — reset video state. 1395 + if (currentTrackId !== null && trackChanged) { 1370 1396 debug("track change", currentTrackId, "->", trackId); 1371 1397 awaitingInit = true; 1372 1398 pendingBoxes.length = 0; 1373 1399 seekInitDone = false; 1400 + if (elapsedInterval) { 1401 + clearInterval(elapsedInterval); 1402 + elapsedInterval = null; 1403 + } 1374 1404 } 1375 1405 currentTrackId = trackId; 1406 + 1407 + // Skip overlay DOM work when the track hasn't changed. 1408 + // Queue display is still updated below. 1409 + if (!trackChanged) { 1410 + updateQueueDisplay(state); 1411 + return; 1412 + } 1376 1413 1377 1414 const startedAt = state.current_track.started_at; 1378 1415 const sourceUrl = state.current_track.url || ""; ··· 1455 1492 } 1456 1493 } 1457 1494 } else { 1495 + if (elapsedInterval) { 1496 + clearInterval(elapsedInterval); 1497 + elapsedInterval = null; 1498 + } 1458 1499 currentTrackId = null; 1459 1500 container.classList.add("idle"); 1460 1501 } 1461 1502 1462 - // Update queue section from state. 1503 + // Update queue section from state — inline, not deferred. 1463 1504 updateQueueDisplay(state); 1464 1505 } 1465 1506 ··· 1476 1517 esc(item.thumbnail) + 1477 1518 '" class="thumb-img" alt="">' 1478 1519 : '<div class="thumb"></div>'; 1520 + const pendingClass = item.pending ? ' class="pending"' : ''; 1479 1521 html += 1480 - "<li>" + 1522 + '<li' + pendingClass + '>' + 1481 1523 thumb + 1482 1524 '<div class="title">' + 1483 1525 esc(item.title) +