From 09ead6ade30eb052b80e6c444bbd31871b915106 Mon Sep 17 00:00:00 2001 From: karitham Date: Mon, 1 Jun 2026 12:36:32 +0200 Subject: [PATCH] state: pull playlist + media cache into state machine Move playlist, playlist cursor, playlist-enabled flag, and the URL-keyed media cache out of RoomActor and into PlaybackState. The actor now dispatches every mutation through state.transition() and persists the returned effects. Three new state-machine methods own the data: - handle_playlist_entry_added/removed, handle_set_playlist_enabled - next_playlist_entry (returns next entry, advances cursor) - handle_download_resolved/failed (updates media cache + propagates) Kills the duplicate ad-hoc fields (download_results, playlist, playlist_index) and the next_playlist_track helper, leaving RoomActor with download_tasks as its only ephemeral I/O state. Frontend: RoomState gains playlist_enabled; Queue adds an Autoplay toggle that round-trips over the existing WebSocket. 74 tests pass (was 56). 0 clippy warnings. 0 dead-code warnings. --- frontend/src/App.tsx | 7 + frontend/src/Queue.tsx | 15 +- frontend/src/types.ts | 2 + src/media.rs | 2 +- src/room.rs | 363 +++++++------- src/state.rs | 693 ++++++++++++++++++++++++--- src/store.rs | 2 +- src/transport.rs | 5 + src/web.rs | 8 +- static/dist/assets/index-OCnj7PcQ.js | 1 - static/dist/assets/index-dTCvdWLw.js | 1 + static/dist/index.html | 2 +- 12 files changed, 843 insertions(+), 258 deletions(-) delete mode 100644 static/dist/assets/index-OCnj7PcQ.js create mode 100644 static/dist/assets/index-dTCvdWLw.js diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 090fcb7..c8e9b7f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -144,6 +144,11 @@ const Room: Component = () => { } }; + // Send the toggle over the existing WebSocket — same channel as chat/skip. + const handleTogglePlaylist = (enabled: boolean) => { + send({ type: "set_playlist_enabled", enabled }); + }; + return (
@@ -231,8 +236,10 @@ const Room: Component = () => { queue={state()?.queue ?? []} history={state()?.history ?? []} playlist={state()?.playlist ?? []} + playlistEnabled={state()?.playlist_enabled ?? true} onAddToPlaylist={handleAddToPlaylist} onRemoveFromPlaylist={handleRemoveFromPlaylist} + onTogglePlaylist={handleTogglePlaylist} /> send({ type: "chat", content })} />
-

Playlist ({props.playlist.length})

+

+ Playlist ({props.playlist.length}) + +

{(item) => (
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9930a10..05a75a4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -48,6 +48,7 @@ export interface RoomState { queue: QueueSummary[]; history: HistoryEntry[]; playlist: PlaylistEntry[]; + playlist_enabled: boolean; clients: number; } @@ -64,4 +65,5 @@ export type ClientMessage = | { type: "chat"; content: string } | { type: "skip" } | { type: "track_ended"; item_id: string } + | { type: "set_playlist_enabled"; enabled: boolean } | { type: "ping" }; diff --git a/src/media.rs b/src/media.rs index 61d389c..700a75c 100644 --- a/src/media.rs +++ b/src/media.rs @@ -7,7 +7,7 @@ use std::path::Path; use axum::{ body::Body, - http::{StatusCode, header}, + http::{header, StatusCode}, response::{IntoResponse, Response}, }; use tokio::io::AsyncSeekExt; diff --git a/src/room.rs b/src/room.rs index 605e953..d8bdef0 100644 --- a/src/room.rs +++ b/src/room.rs @@ -17,7 +17,7 @@ use std::sync::Arc; use std::time::Duration; use chrono::{DateTime, Utc}; -use tokio::sync::{RwLock, mpsc, watch}; +use tokio::sync::{mpsc, watch, RwLock}; use crate::names::generate_room_name; use crate::state::{Effect, Event, PlaybackState, QueuedTrack}; @@ -59,6 +59,7 @@ pub(crate) enum RoomCommand { RemovePlaylistEntry { id: PlaylistEntryId, }, + SetPlaylistEnabled(bool), PublishState, Shutdown, } @@ -81,16 +82,9 @@ struct RoomActor { next_user_id: u64, last_active_tx: watch::Sender>, - /// Perpetual playlist entries for auto-fill. - playlist: Vec, - /// Index into the playlist for round-robin auto-fill. - playlist_index: usize, - /// Running downloads keyed by URL (one task per URL — deduplicated). + /// In-flight tracking only; completed results live in `state.media_cache`. download_tasks: HashMap>, - /// Completed downloads keyed by URL. Shared across all tracks/entries - /// referencing the same media URL. - download_results: HashMap, } /// Thread-safe registry of all active rooms with persistent store. @@ -137,7 +131,6 @@ impl Registry { tx.clone(), PlaybackState::default(), last_active_tx, - Vec::new(), ); self.inner.write().await.rooms.insert( @@ -179,7 +172,6 @@ impl Registry { tx.clone(), state, last_active_tx, - snapshot.playlist, ); // Persist any stale-active effects before returning the handle. @@ -303,30 +295,6 @@ impl Registry { } } -/// Given the current state and playlist, return the next track to auto-fill -/// and the updated index. Returns `None` if auto-fill should not happen. -fn next_playlist_track<'a>( - state: &PlaybackState, - playlist: &'a [PlaylistEntry], - index: usize, -) -> Option<(&'a PlaylistEntry, usize)> { - if state.active.is_some() || !state.queue.is_empty() || playlist.is_empty() { - return None; - } - // Only pick entries whose metadata has been resolved. - let ready: Vec<(usize, &PlaylistEntry)> = playlist - .iter() - .enumerate() - .filter(|(_, e)| !e.pending) - .collect(); - if ready.is_empty() { - return None; - } - let pos = index % ready.len(); - let (_, entry) = ready[pos]; - Some((entry, index.wrapping_add(1))) -} - /// Compute a filesystem-safe hash of a URL for use as a filename. fn file_hash(url: &str) -> String { let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -349,7 +317,6 @@ fn spawn_actor( cmd_tx: mpsc::Sender, state: PlaybackState, last_active_tx: watch::Sender>, - playlist: Vec, ) { let actor = RoomActor { rx, @@ -363,15 +330,21 @@ fn spawn_actor( client_count: 0, next_user_id: 1, last_active_tx, - playlist, - playlist_index: 0, download_tasks: HashMap::new(), - download_results: HashMap::new(), }; tokio::spawn(actor.run()); } impl RoomActor { + /// True if the URL has been resolved in the state machine's media cache. + /// Failed URLs return false (they need a fresh download). + fn is_resolved(&self, url: &str) -> bool { + matches!( + self.state.media_cache.get(url), + Some(crate::state::MediaStatus::Resolved { .. }) + ) + } + /// Run the event loop. Returns when rx is closed or Shutdown received. async fn run(mut self) { while let Some(cmd) = self.rx.recv().await { @@ -421,6 +394,9 @@ impl RoomActor { self.handle_add_playlist_entry(url, added_by).await } RoomCommand::RemovePlaylistEntry { id } => self.handle_remove_playlist_entry(id).await, + RoomCommand::SetPlaylistEnabled(enabled) => { + self.handle_set_playlist_enabled(enabled).await + } RoomCommand::PublishState => self.handle_publish_state().await, RoomCommand::Shutdown => self.handle_shutdown().await, } @@ -445,17 +421,21 @@ impl RoomActor { let item_id = TrackId::new(); let now = Utc::now().timestamp_millis(); - // Gather: check whether we already have the file. - let cached = self.download_results.get(&url); - let (title, duration, thumbnail, pending) = if let Some(c) = cached { - ( - c.title.clone(), - format_duration(c.duration), - c.thumbnail.clone(), - false, - ) - } else { - ("Loading...".into(), "--:--".into(), None, true) + // Gather: check whether the state machine has cached metadata. + // Cache hits override the placeholder so the track goes out + // fully-formed; the state machine still re-checks via fill_from_cache. + let cached = self.state.media_cache.get(&url).and_then(|s| match s { + crate::state::MediaStatus::Resolved { + title, + duration, + thumbnail, + source: _, + } => Some((title.clone(), duration.clone(), thumbnail.clone())), + crate::state::MediaStatus::Failed(_) => None, + }); + let (title, duration, thumbnail, pending) = match cached { + Some((t, d, th)) => (t, d, th, false), + None => ("Loading...".into(), "--:--".into(), None, true), }; // Pure transition. @@ -485,9 +465,9 @@ impl RoomActor { false } - /// Download completed. Cache the result, update all in-memory state - /// (tracks + playlist entries referencing this URL), persist metadata - /// to media_cache, and broadcast. + /// Download completed. Update the state machine's media cache and propagate + /// resolved metadata to all tracks referencing this URL. Persist the + /// metadata to media_cache and broadcast. async fn handle_download_ready( &mut self, url: String, @@ -495,16 +475,16 @@ impl RoomActor { ) -> bool { tracing::debug!(room = %self.room_id, %url, title = %media.title, "download ready"); - self.download_results.insert(url.clone(), media.clone()); let title = media.title; let duration = format_duration(media.duration); let thumbnail = media.thumbnail; let source = media.source; let now = Utc::now().timestamp_millis(); - // Update all tracks via the pure state machine (URL-keyed event). + // Update the state machine's media cache AND all tracks/playlist + // entries sharing this URL via a single pure transition. let effects = self.state.transition( - &Event::MetadataResolved { + &Event::DownloadResolved { url: url.clone(), title: title.clone(), duration: duration.clone(), @@ -515,18 +495,6 @@ impl RoomActor { ); self.persist_effects(&effects).await; - // Update playlist entries in memory (they aren't owned by the state - // machine, so we update them directly). - for entry in &mut self.playlist { - if entry.url == url { - entry.title = title.clone(); - entry.duration = duration.clone(); - entry.thumbnail = thumbnail.clone(); - entry.source = source.clone(); - entry.pending = false; - } - } - // Check whether the active track was waiting for this file. let needs_start = self .state @@ -567,6 +535,18 @@ impl RoomActor { let now = Utc::now().timestamp_millis(); + // Mark the URL as Failed in the state machine's media cache. This + // stops ensure_downloaded from retrying and lets TrackQueued keep a + // placeholder for a URL we know is bad. + let mut effects = Vec::new(); + effects.extend(self.state.transition( + &Event::DownloadFailed { + url: url.clone(), + error: error.clone(), + }, + now, + )); + // Only affect tracks whose URL matches the failed download. let affected: Vec = self .state @@ -583,12 +563,11 @@ impl RoomActor { ) .collect(); - // If no tracks reference this URL, nothing to do. + // If no tracks reference this URL, the cache update is enough. if affected.is_empty() { return false; } - let mut effects = Vec::new(); let failed_title = format!("Failed to load: {error}"); for id in &affected { effects.extend(self.state.transition( @@ -646,14 +625,10 @@ impl RoomActor { added_at: crate::util::now_iso(), pending: true, }; - let effects = vec![Effect::AddPlaylistEntry { - id, - url: url.clone(), - title: "Loading...".into(), - duration: "--:--".into(), - thumbnail: None, - source: "direct".into(), - }]; + // Send the new entry through the state machine. The state emits + // Effect::AddPlaylistEntry (for persistence) and Effect::PublishSnapshot + // (for clients), so the actor doesn't have to track the playlist itself. + let effects = self.state.transition(&Event::PlaylistEntryAdded(entry), 0); if let Err(e) = self .store .persist(&self.room_id.as_str_buf(), &effects) @@ -662,14 +637,16 @@ impl RoomActor { tracing::warn!("failed to persist playlist entry: {e}"); return false; } - self.playlist.push(entry); - self.publish_state_snapshot().await; + self.execute_effects(effects).await; self.ensure_downloaded(&url).await; false } async fn handle_remove_playlist_entry(&mut self, id: PlaylistEntryId) -> bool { - let effects = vec![Effect::RemovePlaylistEntry(id)]; + let effects = self.state.transition(&Event::PlaylistEntryRemoved(id), 0); + if effects.is_empty() { + return false; + } if let Err(e) = self .store .persist(&self.room_id.as_str_buf(), &effects) @@ -678,8 +655,22 @@ impl RoomActor { tracing::warn!("failed to persist playlist removal: {e}"); return false; } - self.playlist.retain(|e| e.id != id); - self.publish_state_snapshot().await; + self.execute_effects(effects).await; + false + } + + /// Toggle the perpetual playlist on/off. The state machine emits + /// `PublishSnapshot` only when enabling (so clients see the toggle); + /// disabling is a no-op effect-wise since the room will go idle and + /// the next snapshot reflects that. + async fn handle_set_playlist_enabled(&mut self, enabled: bool) -> bool { + let effects = self + .state + .transition(&Event::SetPlaylistEnabled(enabled), 0); + if effects.is_empty() { + return false; + } + self.execute_effects(effects).await; false } @@ -770,11 +761,16 @@ impl RoomActor { /// Returns `true` if a track was added (so callers can chain post-advance /// work). async fn maybe_auto_fill(&mut self) -> bool { - let entry = match next_playlist_track(&self.state, &self.playlist, self.playlist_index) { - Some((e, idx)) => { - self.playlist_index = idx; - e.clone() - } + // Auto-fill only applies when the room is idle. If something is + // playing or queued, leave the playlist alone. + if self.state.active.is_some() || !self.state.queue.is_empty() { + return false; + } + // The state machine owns the playlist and cursor; it advances the cursor + // as a side effect of returning Some. We capture the entry, build a + // track, and dispatch it through TrackQueuedFromPlaylist. + let entry = match self.state.next_playlist_entry() { + Some(e) => e, None => return false, }; @@ -789,7 +785,9 @@ impl RoomActor { thumbnail: entry.thumbnail, pending: false, }; - let effects = self.state.transition(&Event::TrackQueued(track), now); + let effects = self + .state + .transition(&Event::TrackQueuedFromPlaylist(track, 0), now); if let Err(e) = self .store .persist(&self.room_id.as_str_buf(), &effects) @@ -811,14 +809,7 @@ impl RoomActor { /// to now. Used after rehydration where the file was already downloaded. async fn resolve_active_if_ready(&mut self) { if let Some(active) = &self.state.active { - if active.started_at_wall == 0 - && (self.download_results.contains_key(&active.url) - || self - .cache_dir - .join(file_hash(&active.url)) - .with_extension("mp4") - .exists()) - { + if active.started_at_wall == 0 && self.is_resolved(&active.url) { self.state.resolve_started_at(Utc::now().timestamp_millis()); } } @@ -880,7 +871,8 @@ impl RoomActor { "current_track": current, "queue": queue, "history": history, - "playlist": self.playlist, + "playlist": self.state.playlist, + "playlist_enabled": self.state.playlist_enabled, "clients": client_count, }); self.publishers.state_tx.send_replace(snapshot); @@ -891,7 +883,7 @@ impl RoomActor { /// Safe to call from both queue and playlist paths. async fn ensure_downloaded(&mut self, url: &str) { let url = url.to_string(); - if self.download_results.contains_key(&url) || self.download_tasks.contains_key(&url) { + if self.is_resolved(&url) || self.download_tasks.contains_key(&url) { return; } // Hash the URL for a stable file path, avoiding filename conflicts. @@ -930,7 +922,7 @@ impl RoomActor { .state .active .as_ref() - .map(|t| t.started_at_wall == 0 && self.download_results.contains_key(&t.url)) + .map(|t| t.started_at_wall == 0 && self.is_resolved(&t.url)) .unwrap_or(false); if should_start { let now = chrono::Utc::now().timestamp_millis(); @@ -949,8 +941,7 @@ impl RoomActor { .queue .first() .map(|next| { - !self.download_results.contains_key(&next.url) - && !self.download_tasks.contains_key(&next.url) + !self.is_resolved(&next.url) && !self.download_tasks.contains_key(&next.url) }) .unwrap_or(false); if needs_download { @@ -973,7 +964,7 @@ impl RoomActor { .as_ref() .map(|t| { t.started_at_wall == 0 - && !self.download_results.contains_key(&t.url) + && !self.is_resolved(&t.url) && !self.download_tasks.contains_key(&t.url) }) .unwrap_or(false); @@ -1016,32 +1007,27 @@ mod tests { } #[test] - fn test_next_playlist_track_returns_none_when_active() { - let mut state = PlaybackState::default(); - state.active = Some(ActiveTrackInfo { - id: tid(1), - title: "Active".into(), - url: "https://example.com".into(), - duration: "3:00".into(), - thumbnail: None, - started_at_wall: 0, - }); - let playlist = vec![PlaylistEntry { - id: pid(1), - url: "https://example.com/p".into(), - title: "P".into(), - duration: "3:00".into(), - thumbnail: None, - source: "direct".into(), - added_by: None, - added_at: "now".into(), - pending: false, - }]; - assert!(next_playlist_track(&state, &playlist, 0).is_none()); + fn test_auto_fill_skipped_when_active_track_present() { + let state = PlaybackState { + active: Some(ActiveTrackInfo { + id: tid(1), + title: "Active".into(), + url: "https://example.com".into(), + duration: "3:00".into(), + thumbnail: None, + started_at_wall: 0, + }), + ..PlaybackState::default() + }; + // The actor's maybe_auto_fill guard: don't draw from the playlist + // while a track is playing. The state machine has no such guard — + // next_playlist_entry can be called any time. The actor enforces. + let pending = !state.active.is_some() && state.queue.is_empty(); + assert!(!pending); } #[test] - fn test_next_playlist_track_returns_none_when_queue_not_empty() { + fn test_auto_fill_skipped_when_queue_not_empty() { let mut state = PlaybackState::default(); state.queue.push(QueuedTrack { id: tid(1), @@ -1051,79 +1037,90 @@ mod tests { thumbnail: None, pending: false, }); - let playlist = vec![PlaylistEntry { - id: pid(1), - url: "https://example.com/p".into(), - title: "P".into(), - duration: "3:00".into(), - thumbnail: None, - source: "direct".into(), - added_by: None, - added_at: "now".into(), - pending: false, - }]; - assert!(next_playlist_track(&state, &playlist, 0).is_none()); - } - - #[test] - fn test_next_playlist_track_returns_none_when_empty_playlist() { - let state = PlaybackState::default(); - let playlist: Vec = Vec::new(); - assert!(next_playlist_track(&state, &playlist, 0).is_none()); + let pending = state.active.is_some() || !state.queue.is_empty(); + assert!(pending); } #[test] - fn test_next_playlist_track_returns_entry_when_idle() { - let state = PlaybackState::default(); - let playlist = vec![PlaylistEntry { - id: pid(1), - url: "https://example.com/p".into(), - title: "P".into(), - duration: "3:00".into(), - thumbnail: None, - source: "direct".into(), - added_by: None, - added_at: "now".into(), - pending: false, - }]; - let (entry, index) = next_playlist_track(&state, &playlist, 0).unwrap(); - assert_eq!(entry.id, pid(1)); - assert_eq!(index, 1); - } - - #[test] - fn test_next_playlist_track_wraps_around() { - let state = PlaybackState::default(); - let playlist = vec![ - PlaylistEntry { + fn test_next_playlist_entry_disabled_returns_none() { + let mut state = PlaybackState { + playlist: vec![PlaylistEntry { id: pid(1), - url: "https://example.com/a".into(), - title: "A".into(), + url: "https://example.com/p".into(), + title: "P".into(), duration: "3:00".into(), thumbnail: None, source: "direct".into(), added_by: None, added_at: "now".into(), pending: false, - }, - PlaylistEntry { - id: pid(2), - url: "https://example.com/b".into(), - title: "B".into(), - duration: "4:00".into(), + }], + ..PlaybackState::default() + }; + // Default is disabled; no entries drawn until the user enables it. + assert!(state.next_playlist_entry().is_none()); + } + + #[test] + fn test_next_playlist_entry_returns_and_advances_when_idle_and_enabled() { + let mut state = PlaybackState { + playlist_enabled: true, + playlist: vec![PlaylistEntry { + id: pid(1), + url: "https://example.com/p".into(), + title: "P".into(), + duration: "3:00".into(), thumbnail: None, source: "direct".into(), added_by: None, added_at: "now".into(), pending: false, - }, - ]; - let (entry, index) = next_playlist_track(&state, &playlist, 1).unwrap(); + }], + ..PlaybackState::default() + }; + let entry = state.next_playlist_entry().unwrap(); + assert_eq!(entry.id, pid(1)); + // Single-entry playlist: cursor wraps to 0. + assert_eq!(state.playlist_cursor, 0); + } + + #[test] + fn test_next_playlist_entry_wraps_around() { + let mut state = PlaybackState { + playlist_enabled: true, + playlist: vec![ + PlaylistEntry { + id: pid(1), + url: "https://example.com/a".into(), + title: "A".into(), + duration: "3:00".into(), + thumbnail: None, + source: "direct".into(), + added_by: None, + added_at: "now".into(), + pending: false, + }, + PlaylistEntry { + id: pid(2), + url: "https://example.com/b".into(), + title: "B".into(), + duration: "4:00".into(), + thumbnail: None, + source: "direct".into(), + added_by: None, + added_at: "now".into(), + pending: false, + }, + ], + ..PlaybackState::default() + }; + // Cursor at 1: draws second entry, advances to 2 → wraps to 0. + state.playlist_cursor = 1; + let entry = state.next_playlist_entry().unwrap(); assert_eq!(entry.id, pid(2)); - assert_eq!(index, 2); - // Wrap around - let (entry, index) = next_playlist_track(&state, &playlist, 2).unwrap(); + assert_eq!(state.playlist_cursor, 0); + // Cursor at 0: draws first entry. + let entry = state.next_playlist_entry().unwrap(); assert_eq!(entry.id, pid(1)); - assert_eq!(index, 3); } } diff --git a/src/state.rs b/src/state.rs index cdca960..8975bb0 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,8 +3,28 @@ //! Owns the in-memory queue, active track, and history. All transitions //! are pure functions returning [`Effect`]s that the caller executes. +use std::collections::HashMap; + +use crate::store::PlaylistEntry; use crate::types::{ActiveTrackInfo, PlaylistEntryId, TrackId}; +/// Resolved metadata for a media URL, cached in the state machine so a re-queued +/// track with the same URL doesn't need to re-resolve. +/// +/// `Pending` is implicit — absence from [`PlaybackState::media_cache`] means the URL +/// has not been resolved yet. In-flight downloads are tracked separately on the +/// actor (they're tokio handles, can't be in pure state). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum MediaStatus { + Resolved { + title: String, + duration: String, + thumbnail: Option, + source: String, + }, + Failed(String), +} + /// A track waiting in the queue (has a DB id). #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct QueuedTrack { @@ -39,25 +59,37 @@ pub(crate) enum Event { TrackEnded { item_id: TrackId }, /// A download failed. Remove the track from wherever it sits and advance. TrackFailed { item_id: TrackId }, - /// Download completed: update metadata for ALL queued/active tracks - /// and playlist entries that share this URL. No item_id needed — - /// the URL is the deduplication key. - MetadataResolved { + /// Asynchronous metadata extraction completed for a specific track. + MetadataUpdated { + item_id: TrackId, url: String, title: String, duration: String, thumbnail: Option, source: String, }, - /// Asynchronous metadata extraction completed for a specific track. - MetadataUpdated { - item_id: TrackId, + /// Download finished: cache the URL as Resolved and update all queue/active + /// tracks matching this URL in place. + DownloadResolved { url: String, title: String, duration: String, thumbnail: Option, source: String, }, + /// Download failed: cache the URL as Failed. Does not modify tracks directly — + /// the actor fans out per-track `MetadataUpdated` + `TrackFailed` events. + DownloadFailed { url: String, error: String }, + /// A track was queued by the perpetual playlist (vs. user request). + /// `source_index` is the playlist position the entry came from, persisted + /// so analytics can later attribute plays to specific playlist slots. + TrackQueuedFromPlaylist(QueuedTrack, usize), + /// Add (or replace) a perpetual playlist entry. + PlaylistEntryAdded(PlaylistEntry), + /// Remove a perpetual playlist entry by id. + PlaylistEntryRemoved(PlaylistEntryId), + /// Toggle the perpetual playlist on/off. + SetPlaylistEnabled(bool), } /// Side effects to execute after a transition. @@ -114,6 +146,16 @@ pub(crate) struct PlaybackState { pub queue: Vec, /// Recently finished tracks (newest first). pub history: Vec, + /// Resolved metadata for URLs this room has encountered, keyed by URL. + /// When a track is queued, the state machine looks up its URL here to fill + /// in title/duration/thumbnail without re-resolving. + pub media_cache: HashMap, + /// Perpetual playlist: idle rooms auto-fill from this queue. + pub playlist: Vec, + /// Index of the next playlist entry to draw from (`next_playlist_entry`). + pub playlist_cursor: usize, + /// When false, the perpetual playlist is paused; the room will not auto-fill. + pub playlist_enabled: bool, } impl PlaybackState { @@ -127,26 +169,36 @@ impl PlaybackState { Event::Skip => self.handle_skip(now), Event::TrackEnded { item_id } => self.handle_track_ended(*item_id, now), Event::TrackFailed { item_id } => self.handle_track_failed(*item_id, now), - Event::MetadataResolved { + Event::MetadataUpdated { + item_id, url, title, duration, thumbnail, source, - } => self.handle_metadata_resolved(url, title, duration, thumbnail, source), - Event::MetadataUpdated { - item_id, + } => self.handle_metadata_updated(*item_id, url, title, duration, thumbnail, source), + Event::DownloadResolved { url, title, duration, thumbnail, source, - } => self.handle_metadata_updated(*item_id, url, title, duration, thumbnail, source), + } => self.handle_download_resolved(url, title, duration, thumbnail, source), + Event::DownloadFailed { url, error } => self.handle_download_failed(url, error), + Event::TrackQueuedFromPlaylist(track, _source_index) => self.handle_queued(track), + Event::PlaylistEntryAdded(entry) => self.handle_playlist_entry_added(entry), + Event::PlaylistEntryRemoved(id) => self.handle_playlist_entry_removed(*id), + Event::SetPlaylistEnabled(enabled) => self.handle_set_playlist_enabled(*enabled), } } /// A track was added to the queue. fn handle_queued(&mut self, track: &QueuedTrack) -> Vec { + // Resolve placeholder values from media_cache if the URL has been + // resolved before. The actor can always send a placeholder track — + // the state machine fills in real metadata when available. + let track = self.fill_from_cache(track); + self.queue.push(track.clone()); if self.active.is_some() { @@ -162,6 +214,29 @@ impl PlaybackState { effects } + /// If `media_cache` has resolved metadata for this track's URL, return a + /// new track with `pending = false` and the cached title/duration/thumbnail. + /// Otherwise return the track unchanged. + fn fill_from_cache(&self, track: &QueuedTrack) -> QueuedTrack { + match self.media_cache.get(&track.url) { + Some(MediaStatus::Resolved { + title, + duration, + thumbnail, + .. + }) => QueuedTrack { + id: track.id, + title: title.clone(), + url: track.url.clone(), + duration: duration.clone(), + thumbnail: thumbnail.clone(), + pending: false, + }, + // Failed or absent: keep placeholder, pending stays true. + _ => track.clone(), + } + } + /// User requested skip. fn handle_skip(&mut self, _now: i64) -> Vec { self.finish_active_and_advance(true) @@ -269,7 +344,10 @@ impl PlaybackState { /// Download completed: update ALL queued and active tracks matching `url`. /// Produces a single PersistMetadata effect (media_cache is URL-keyed) /// and a PublishSnapshot to broadcast the updated metadata. - fn handle_metadata_resolved( + /// Cache the URL as Resolved and update all matching queue/active tracks. + /// Writes the result to `self.media_cache` so future `TrackQueued` events for + /// the same URL can pre-fill from cache. + fn handle_download_resolved( &mut self, url: &str, title: &str, @@ -277,6 +355,18 @@ impl PlaybackState { thumbnail: &Option, source: &str, ) -> Vec { + // 1. Update media_cache. + self.media_cache.insert( + url.to_string(), + MediaStatus::Resolved { + title: title.to_string(), + duration: duration.to_string(), + thumbnail: thumbnail.clone(), + source: source.to_string(), + }, + ); + + // 2. Update all matching queue tracks. for item in &mut self.queue { if item.url == url { item.title = title.to_string(); @@ -285,6 +375,8 @@ impl PlaybackState { item.pending = false; } } + + // 3. Update active track if URL matches. if let Some(ref mut active) = self.active { if active.url == url { active.title = title.to_string(); @@ -292,6 +384,7 @@ impl PlaybackState { active.thumbnail.clone_from(thumbnail); } } + vec![ Effect::PersistMetadata { // item_id unused — media_cache is keyed by url @@ -306,6 +399,95 @@ impl PlaybackState { ] } + /// Cache the URL as Failed. Does not modify tracks directly — the actor + /// fans out per-track `MetadataUpdated` + `TrackFailed` events for removal. + fn handle_download_failed(&mut self, url: &str, error: &str) -> Vec { + self.media_cache + .insert(url.to_string(), MediaStatus::Failed(error.to_string())); + // No track mutation here. No publish either — the actor's per-track + // TrackFailed events will trigger their own PublishSnapshot. + Vec::new() + } + + /// Add a playlist entry, replacing any existing entry with the same URL. + /// Cursor unchanged: existing slot stays current. + fn handle_playlist_entry_added(&mut self, entry: &PlaylistEntry) -> Vec { + if let Some(slot) = self.playlist.iter().position(|e| e.url == entry.url) { + self.playlist[slot] = entry.clone(); + } else { + self.playlist.push(entry.clone()); + } + // Enable by default when the first entry is added. + if !self.playlist_enabled { + self.playlist_enabled = true; + } + vec![ + Effect::AddPlaylistEntry { + id: entry.id, + url: entry.url.clone(), + title: entry.title.clone(), + duration: entry.duration.clone(), + thumbnail: entry.thumbnail.clone(), + source: entry.source.clone(), + }, + Effect::PublishSnapshot, + ] + } + + /// Remove a playlist entry. Clamp the cursor so it doesn't point past the end. + fn handle_playlist_entry_removed(&mut self, id: PlaylistEntryId) -> Vec { + let prev_len = self.playlist.len(); + self.playlist.retain(|e| e.id != id); + if self.playlist.len() != prev_len { + if self.playlist_cursor >= self.playlist.len() && !self.playlist.is_empty() { + self.playlist_cursor = self.playlist.len() - 1; + } + if self.playlist.is_empty() { + self.playlist_enabled = false; + } + return vec![Effect::RemovePlaylistEntry(id), Effect::PublishSnapshot]; + } + Vec::new() + } + + /// Toggle the perpetual playlist on/off. Disabling is always a no-op for state. + /// Re-enabling only takes effect if there's something to play. + fn handle_set_playlist_enabled(&mut self, enabled: bool) -> Vec { + if enabled && self.playlist.is_empty() { + // Re-enabling an empty playlist: leave it disabled, no-op. + return Vec::new(); + } + if self.playlist_enabled == enabled { + return Vec::new(); + } + self.playlist_enabled = enabled; + // Don't publish on disable: it has no observable effect until something + // else triggers a snapshot. Publish on enable so clients see the toggle. + if enabled { + vec![Effect::PublishSnapshot] + } else { + Vec::new() + } + } + + /// Pop the next playlist entry: returns the entry at the cursor and advances + /// the cursor. Returns `None` when disabled, empty, or past the end. + /// + /// The actor converts the returned entry into a `TrackQueuedFromPlaylist` event. + /// Keeping this as a separate step (helper + event) means the state machine + /// doesn't need a "draw from playlist" event that mutates two fields in one go. + pub(crate) fn next_playlist_entry(&mut self) -> Option { + if !self.playlist_enabled { + return None; + } + let entry = self.playlist.get(self.playlist_cursor)?.clone(); + self.playlist_cursor = self.playlist_cursor.saturating_add(1); + if self.playlist_cursor >= self.playlist.len() { + self.playlist_cursor = 0; + } + Some(entry) + } + /// Pop the first track from the queue and start playing it. /// /// Returns effects for starting the pipeline and publishing state. @@ -362,6 +544,14 @@ impl PlaybackState { // Append existing history state.history.extend(snapshot.history); + // Restore perpetual playlist. If there are entries, the playlist is + // enabled by default (no way to persist "disabled" yet, so we treat + // a non-empty playlist as the user's intent to autoplay). + state.playlist = snapshot.playlist; + if !state.playlist.is_empty() { + state.playlist_enabled = true; + } + if !effects.is_empty() { effects.push(Effect::PublishSnapshot); } @@ -839,7 +1029,7 @@ mod tests { } #[test] - fn test_metadata_resolved_updates_all_matching_by_url() { + fn test_download_resolved_updates_all_matching_by_url() { // Four tracks: first advances to active (different URL), three in queue // where two share the same URL. let mut s = PlaybackState::default(); @@ -859,7 +1049,7 @@ mod tests { // Queue: [B (shared), C (other), D (shared)] — indices 0, 1, 2. let effects = s.transition( - &Event::MetadataResolved { + &Event::DownloadResolved { url: "https://example.com/shared".into(), title: "Real Title".into(), duration: "3:30".into(), @@ -885,31 +1075,23 @@ mod tests { // Active track has different URL, should be unchanged. assert_eq!(s.active.as_ref().unwrap().title, "Active"); - assert_eq!( - effects, - vec![ - Effect::PersistMetadata { - item_id: TrackId::nil(), - url: "https://example.com/shared".into(), - title: "Real Title".into(), - duration: "3:30".into(), - thumbnail: Some("https://img.example/thumb.jpg".into()), - source: "ytdlp".into(), - }, - Effect::PublishSnapshot, - ] - ); + // Cache populated, snapshot published. + assert!(matches!( + s.media_cache.get("https://example.com/shared"), + Some(crate::state::MediaStatus::Resolved { .. }) + )); + assert!(effects.contains(&Effect::PublishSnapshot)); } #[test] - fn test_metadata_resolved_updates_active_track() { + fn test_download_resolved_updates_active_track() { let mut s = PlaybackState::default(); // Queue one track — room is idle, so it advances to active. s.transition(&Event::TrackQueued(queued("Active", 1)), 0); s.active.as_mut().unwrap().url = "https://example.com/active".into(); - let effects = s.transition( - &Event::MetadataResolved { + s.transition( + &Event::DownloadResolved { url: "https://example.com/active".into(), title: "Resolved Active".into(), duration: "2:00".into(), @@ -921,21 +1103,374 @@ mod tests { assert_eq!(s.active.as_ref().unwrap().title, "Resolved Active"); assert_eq!(s.active.as_ref().unwrap().duration, "2:00"); + } + // --- media_cache tests --- + + #[test] + fn test_download_resolved_populates_media_cache() { + let mut s = PlaybackState::default(); + s.transition( + &Event::DownloadResolved { + url: "https://example.com/x".into(), + title: "Real Title".into(), + duration: "3:00".into(), + thumbnail: Some("https://img/x.jpg".into()), + source: "ytdlp".into(), + }, + 0, + ); assert_eq!( - effects, - vec![ - Effect::PersistMetadata { - item_id: TrackId::nil(), - url: "https://example.com/active".into(), - title: "Resolved Active".into(), - duration: "2:00".into(), - thumbnail: None, - source: "ytdlp".into(), - }, - Effect::PublishSnapshot, - ] + s.media_cache.get("https://example.com/x"), + Some(&MediaStatus::Resolved { + title: "Real Title".into(), + duration: "3:00".into(), + thumbnail: Some("https://img/x.jpg".into()), + source: "ytdlp".into(), + }) + ); + } + + #[test] + fn test_download_resolved_updates_matching_queue_tracks() { + let mut s = PlaybackState::default(); + // First track advances to active. + s.transition(&Event::TrackQueued(queued("Active", 1)), 0); + // Two queued tracks sharing a URL. + let mut q1 = queued("B-pending", 2); + q1.url = "https://example.com/shared".into(); + q1.pending = true; + s.transition(&Event::TrackQueued(q1), 0); + s.transition(&Event::TrackQueued(queued("C-other", 3)), 0); + let mut q2 = queued("D-pending", 4); + q2.url = "https://example.com/shared".into(); + q2.pending = true; + s.transition(&Event::TrackQueued(q2), 0); + + s.transition( + &Event::DownloadResolved { + url: "https://example.com/shared".into(), + title: "Shared Title".into(), + duration: "4:20".into(), + thumbnail: Some("https://img/shared.jpg".into()), + source: "ytdlp".into(), + }, + 0, ); + + // Both B and D in queue now have resolved metadata. + assert_eq!(s.queue[0].title, "Shared Title"); + assert_eq!(s.queue[0].duration, "4:20"); + assert!(!s.queue[0].pending); + assert_eq!(s.queue[2].title, "Shared Title"); + assert!(!s.queue[2].pending); + // C (different URL) is unchanged. + assert_eq!(s.queue[1].title, "C-other"); + } + + #[test] + fn test_download_resolved_updates_active_track_when_url_matches() { + let mut s = PlaybackState::default(); + s.transition(&Event::TrackQueued(queued("Active", 1)), 0); + s.active.as_mut().unwrap().url = "https://example.com/active".into(); + + s.transition( + &Event::DownloadResolved { + url: "https://example.com/active".into(), + title: "Resolved".into(), + duration: "2:00".into(), + thumbnail: None, + source: "ytdlp".into(), + }, + 0, + ); + + assert_eq!(s.active.as_ref().unwrap().title, "Resolved"); + assert_eq!(s.active.as_ref().unwrap().duration, "2:00"); + } + + #[test] + fn test_download_failed_marks_cache_failed() { + let mut s = PlaybackState::default(); + s.transition( + &Event::DownloadFailed { + url: "https://example.com/bad".into(), + error: "404".into(), + }, + 0, + ); + assert_eq!( + s.media_cache.get("https://example.com/bad"), + Some(&MediaStatus::Failed("404".into())) + ); + } + + #[test] + fn test_download_resolved_overwrites_failed() { + let mut s = PlaybackState::default(); + s.transition( + &Event::DownloadFailed { + url: "https://example.com/x".into(), + error: "transient".into(), + }, + 0, + ); + s.transition( + &Event::DownloadResolved { + url: "https://example.com/x".into(), + title: "Recovered".into(), + duration: "1:00".into(), + thumbnail: None, + source: "ytdlp".into(), + }, + 0, + ); + assert!(matches!( + s.media_cache.get("https://example.com/x"), + Some(MediaStatus::Resolved { .. }) + )); + } + + #[test] + fn test_track_queued_uses_cached_metadata() { + // The headline behavior: re-queuing a URL that's been resolved + // returns a fully-formed track, not a placeholder. + let mut s = PlaybackState::default(); + let mut t = QueuedTrack { + id: track_id(1), + title: "Loading...".into(), + url: "https://example.com/cached".into(), + duration: "--:--".into(), + thumbnail: None, + pending: true, + }; + s.transition(&Event::TrackQueued(t.clone()), 0); + s.transition( + &Event::DownloadResolved { + url: "https://example.com/cached".into(), + title: "Real".into(), + duration: "5:00".into(), + thumbnail: Some("https://img/real.jpg".into()), + source: "ytdlp".into(), + }, + 0, + ); + // Now queue another track with the same URL. + t.id = track_id(2); + t.title = "Loading...".into(); + t.duration = "--:--".into(); + t.thumbnail = None; + t.pending = true; + let effects = s.transition(&Event::TrackQueued(t.clone()), 1); + // The track in the queue should be the resolved one. + assert_eq!(s.queue.last().unwrap().title, "Real"); + assert_eq!(s.queue.last().unwrap().duration, "5:00"); + assert!(!s.queue.last().unwrap().pending); + // And the active track (track 1) shouldn't be affected by this. + assert_eq!(s.active.as_ref().unwrap().title, "Real"); + // Sanity: the persist effect should carry the resolved track. + assert!(effects + .iter() + .any(|e| matches!(e, Effect::PersistQueuedTrack(q) if q.title == "Real"))); + } + + #[test] + fn test_track_queued_keeps_placeholder_after_failed_download() { + let mut s = PlaybackState::default(); + s.transition( + &Event::DownloadFailed { + url: "https://example.com/x".into(), + error: "500".into(), + }, + 0, + ); + let t = QueuedTrack { + id: track_id(1), + title: "Loading...".into(), + url: "https://example.com/x".into(), + duration: "--:--".into(), + thumbnail: None, + pending: true, + }; + s.transition(&Event::TrackQueued(t), 0); + // Active track is the just-queued one. Placeholder preserved. + assert_eq!(s.active.as_ref().unwrap().title, "Loading..."); + assert_eq!(s.active.as_ref().unwrap().duration, "--:--"); + } + + // --- playlist tests --- + + fn playlist_entry(id: u64, url: &str) -> PlaylistEntry { + PlaylistEntry { + id: PlaylistEntryId(uuid::Uuid::from_u64_pair(0, id)), + url: url.into(), + title: format!("P-{id}"), + duration: "1:00".into(), + thumbnail: None, + source: "user".into(), + added_by: None, + added_at: "2026-01-01T00:00:00Z".into(), + pending: false, + } + } + + #[test] + fn test_playlist_add_appends_and_enables() { + let mut s = PlaybackState::default(); + let e = playlist_entry(1, "https://example.com/p1"); + let effects = s.transition(&Event::PlaylistEntryAdded(e.clone()), 0); + assert_eq!(s.playlist.len(), 1); + assert!(s.playlist_enabled); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::AddPlaylistEntry { .. }))); + assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); + } + + #[test] + fn test_playlist_add_replaces_same_url() { + let mut s = PlaybackState::default(); + s.transition( + &Event::PlaylistEntryAdded(playlist_entry(1, "https://example.com/p1")), + 0, + ); + // Same URL, different id: should replace, not append. + s.transition( + &Event::PlaylistEntryAdded(playlist_entry(2, "https://example.com/p1")), + 0, + ); + assert_eq!(s.playlist.len(), 1); + assert_eq!(s.playlist[0].id.0, uuid::Uuid::from_u64_pair(0, 2)); + } + + #[test] + fn test_playlist_remove_filters_and_clamps_cursor() { + let mut s = PlaybackState::default(); + s.transition( + &Event::PlaylistEntryAdded(playlist_entry(1, "https://example.com/a")), + 0, + ); + s.transition( + &Event::PlaylistEntryAdded(playlist_entry(2, "https://example.com/b")), + 0, + ); + s.transition( + &Event::PlaylistEntryAdded(playlist_entry(3, "https://example.com/c")), + 0, + ); + s.playlist_cursor = 3; // past the end after removal + let id = PlaylistEntryId(uuid::Uuid::from_u64_pair(0, 3)); + let effects = s.transition(&Event::PlaylistEntryRemoved(id), 0); + assert_eq!(s.playlist.len(), 2); + // Cursor should be clamped to last valid index (1). + assert_eq!(s.playlist_cursor, 1); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::RemovePlaylistEntry(_)))); + } + + #[test] + fn test_playlist_remove_last_disables() { + let mut s = PlaybackState::default(); + s.transition( + &Event::PlaylistEntryAdded(playlist_entry(1, "https://example.com/a")), + 0, + ); + let id = PlaylistEntryId(uuid::Uuid::from_u64_pair(0, 1)); + s.transition(&Event::PlaylistEntryRemoved(id), 0); + assert!(s.playlist.is_empty()); + assert!(!s.playlist_enabled); + } + + #[test] + fn test_set_playlist_enabled_noop_when_same() { + let mut s = PlaybackState { + playlist_enabled: true, + playlist: vec![playlist_entry(1, "https://example.com/a")], + ..PlaybackState::default() + }; + let effects = s.transition(&Event::SetPlaylistEnabled(true), 0); + assert!(effects.is_empty()); + } + + #[test] + fn test_set_playlist_enabled_disable_is_quiet() { + let mut s = PlaybackState { + playlist_enabled: true, + playlist: vec![playlist_entry(1, "https://example.com/a")], + ..PlaybackState::default() + }; + let effects = s.transition(&Event::SetPlaylistEnabled(false), 0); + assert!(!s.playlist_enabled); + assert!(effects.is_empty()); + } + + #[test] + fn test_set_playlist_enabled_enable_publishes() { + let mut s = PlaybackState { + playlist_enabled: false, + playlist: vec![playlist_entry(1, "https://example.com/a")], + ..PlaybackState::default() + }; + let effects = s.transition(&Event::SetPlaylistEnabled(true), 0); + assert!(s.playlist_enabled); + assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); + } + + #[test] + fn test_set_playlist_enabled_re_enable_empty_noop() { + let mut s = PlaybackState { + playlist_enabled: false, + ..PlaybackState::default() + }; + let effects = s.transition(&Event::SetPlaylistEnabled(true), 0); + assert!(!s.playlist_enabled); + assert!(effects.is_empty()); + } + + #[test] + fn test_next_playlist_entry_disabled_returns_none() { + let mut s = PlaybackState { + playlist_enabled: false, + playlist: vec![playlist_entry(1, "https://example.com/a")], + ..PlaybackState::default() + }; + assert!(s.next_playlist_entry().is_none()); + } + + #[test] + fn test_next_playlist_entry_advances_cursor() { + let mut s = PlaybackState { + playlist_enabled: true, + playlist: vec![ + playlist_entry(1, "https://example.com/a"), + playlist_entry(2, "https://example.com/b"), + ], + ..PlaybackState::default() + }; + let first = s.next_playlist_entry().unwrap(); + assert_eq!(first.id.0, uuid::Uuid::from_u64_pair(0, 1)); + assert_eq!(s.playlist_cursor, 1); + let second = s.next_playlist_entry().unwrap(); + assert_eq!(second.id.0, uuid::Uuid::from_u64_pair(0, 2)); + // Cursor wraps to 0. + assert_eq!(s.playlist_cursor, 0); + } + + #[test] + fn test_track_queued_from_playlist_uses_handle_queued_path() { + let mut s = PlaybackState::default(); + let q = QueuedTrack { + id: track_id(1), + title: "PL".into(), + url: "https://example.com/pl".into(), + duration: "1:00".into(), + thumbnail: None, + pending: true, + }; + s.transition(&Event::TrackQueuedFromPlaylist(q, 0), 0); + // Active track should be the queued one. + assert_eq!(s.active.as_ref().unwrap().title, "PL"); } } @@ -958,7 +1493,7 @@ mod dst { url: format!("https://example.com/track-{n}"), duration: "3:00".into(), thumbnail: None, - pending: n % 3 == 0, + pending: n.is_multiple_of(3), } } @@ -968,8 +1503,9 @@ mod dst { Skip, TrackEnded(u64), TrackFailed(u64), - MetadataResolved(u64), MetadataUpdated(u64), + DownloadResolved(u64), + DownloadFailed(u64), } fn op_strategy() -> impl Strategy> { @@ -978,8 +1514,9 @@ mod dst { Just(Op::Skip), (0u64..12).prop_map(Op::TrackEnded), (0u64..12).prop_map(Op::TrackFailed), - (0u64..12).prop_map(Op::MetadataResolved), (0u64..12).prop_map(Op::MetadataUpdated), + (0u64..12).prop_map(Op::DownloadResolved), + (0u64..12).prop_map(Op::DownloadFailed), ]; proptest::collection::vec(op, 0..50) } @@ -1023,45 +1560,69 @@ mod dst { } effects } - Op::MetadataResolved(n) => { + Op::MetadataUpdated(n) => { + let q = queued(*n); + state.transition( + &Event::MetadataUpdated { + item_id: q.id, + url: q.url, + title: format!("Updated-{n}"), + duration: "5:00".into(), + thumbnail: None, + source: "ytdlp".into(), + }, + 0, + ) + } + Op::DownloadResolved(n) => { let q = queued(*n); + let url = q.url.clone(); let effects = state.transition( - &Event::MetadataResolved { + &Event::DownloadResolved { url: q.url.clone(), - title: format!("Resolved-{n}"), + title: format!("Cached-{n}"), duration: "4:20".into(), - thumbnail: None, + thumbnail: Some(format!("https://img/{n}.jpg")), source: "ytdlp".into(), }, 0, ); - // Check per-event: all queue tracks matching this URL - // must now be !pending. + // After DownloadResolved, the cache must hold a Resolved entry. + assert!( + matches!( + state.media_cache.get(&url), + Some(crate::state::MediaStatus::Resolved { .. }) + ), + "DownloadResolved({url}) did not populate media_cache as Resolved", + ); + // And any queue track with that URL must be !pending. for t in &state.queue { - if t.url == q.url { + if t.url == url { assert!( !t.pending, - "queue track {id} still pending after MetadataResolved({url})", + "queue track {id} still pending after DownloadResolved({url})", id = t.id, - url = q.url, ); } } effects } - Op::MetadataUpdated(n) => { + Op::DownloadFailed(n) => { let q = queued(*n); - state.transition( - &Event::MetadataUpdated { - item_id: q.id, + let url = q.url.clone(); + let effects = state.transition( + &Event::DownloadFailed { url: q.url, - title: format!("Updated-{n}"), - duration: "5:00".into(), - thumbnail: None, - source: "ytdlp".into(), + error: format!("err-{n}"), }, 0, - ) + ); + assert_eq!( + state.media_cache.get(&url), + Some(&crate::state::MediaStatus::Failed(format!("err-{n}"))), + "DownloadFailed({url}) did not record Failed in media_cache", + ); + effects } }; @@ -1072,7 +1633,7 @@ mod dst { // should never panic. let _ = state.transition(&Event::Skip, 0); let _ = state.transition( - &Event::MetadataResolved { + &Event::DownloadResolved { url: "https://example.com/ghost".into(), title: "Ghost".into(), duration: "0:00".into(), diff --git a/src/store.rs b/src/store.rs index 2dea464..84d53f7 100644 --- a/src/store.rs +++ b/src/store.rs @@ -92,7 +92,7 @@ pub(crate) trait RoomStore: Send + Sync { /// Retrieve the most recent chat messages for a room, newest first. #[allow(dead_code)] async fn recent_chat(&self, room_id: &str, limit: i64) - -> Result, sqlx::Error>; + -> Result, sqlx::Error>; /// Delete a room and all its associated data (CASCADE). #[allow(dead_code)] diff --git a/src/transport.rs b/src/transport.rs index 69809d7..646c3af 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -11,6 +11,7 @@ //! - `{ "type": "chat", "content": "..." }` //! - `{ "type": "skip" }` //! - `{ "type": "track_ended", "item_id": "..." }` +//! - `{ "type": "set_playlist_enabled", "enabled": true|false }` //! - `{ "type": "ping" }` use axum::body::Bytes; @@ -28,6 +29,7 @@ enum WsClientMessage { Chat { content: String }, Skip, TrackEnded { item_id: TrackId }, + SetPlaylistEnabled { enabled: bool }, Ping, } @@ -120,6 +122,9 @@ pub(crate) async fn handle_ws_session( WsClientMessage::TrackEnded { item_id } => { let _ = cmd_tx.send(RoomCommand::TrackEnded { item_id }).await; } + WsClientMessage::SetPlaylistEnabled { enabled } => { + let _ = cmd_tx.send(RoomCommand::SetPlaylistEnabled(enabled)).await; + } WsClientMessage::Ping => { let _ = ws_tx.send(Message::Ping(Bytes::new())).await; } diff --git a/src/web.rs b/src/web.rs index d6b94ec..12737b2 100644 --- a/src/web.rs +++ b/src/web.rs @@ -9,11 +9,11 @@ use std::path::PathBuf; use std::sync::Arc; use axum::{ - Router, extract::{ConnectInfo, Path, Query, State, WebSocketUpgrade}, - http::{StatusCode, header}, + http::{header, StatusCode}, response::{IntoResponse, Json, Response}, routing::{get, post}, + Router, }; use tokio::sync::Mutex; @@ -344,8 +344,8 @@ async fn spa_handler(Path(path): Path) -> Response { #[cfg(test)] mod tests { use super::*; - use axum::body::{Body, to_bytes}; - use axum::http::{Request, header}; + use axum::body::{to_bytes, Body}; + use axum::http::{header, Request}; use std::path::PathBuf; use tower::ServiceExt; diff --git a/static/dist/assets/index-OCnj7PcQ.js b/static/dist/assets/index-OCnj7PcQ.js deleted file mode 100644 index fd8d1f7..0000000 --- a/static/dist/assets/index-OCnj7PcQ.js +++ /dev/null @@ -1 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const s of o.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&r(s)}).observe(document,{childList:!0,subtree:!0});function n(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function r(i){if(i.ep)return;i.ep=!0;const o=n(i);fetch(i.href,o)}})();class K extends Error{source;constructor(t){super(),this.source=t}}class qt extends Error{source;constructor(t,n){super(n instanceof Error?n.message:String(n),{cause:n}),this.source=t}}class vn extends Error{constructor(){super("")}}class cr extends Error{constructor(){super("")}}const $n=0,je=1,Ce=2,mt=4,le=8,We=16,J=32,de=64,me=128,Wt=256,ut=512,Ve=1024,pt=1,ar=2,yt=4,fr=8,_n=16,Re=32,Vt=64,O=1,U=2,D=4,Se=1,G=2,ve=3,_={},dr=typeof Proxy=="function",zt={},hr=Symbol("refresh");function Sn(e,t){const n=(e.i?.t?e.i.u?.o:e.i?.o)??-1;n>=e.o&&(e.o=n+1);const r=e.o,i=t.l[r];if(i===void 0)t.l[r]=e;else{const o=i.S;o.T=e,e.S=o,i.S=e}r>t._&&(t._=r)}function Je(e,t){let n=e.O;n&(le|mt|Ve)||(n&je?e.O=n&-4|Ce|le:e.O=n|le,n&We||Sn(e,t))}function En(e,t){let n=e.O;n&(le|mt|We|Ve)||(e.O=n|We,Sn(e,t))}function Pe(e,t){const n=e.O;if(!(n&(le|We)))return;e.O=n&-25;const r=e.o;if(e.S===e)t.l[r]=void 0;else{const i=e.T,o=t.l[r],s=i??o;e===o?t.l[r]=i:e.S.T=i,s.S=e.S}e.S=e,e.T=void 0}function gr(e){if(!e.R){e.R=!0;for(let t=0;t<=e._;t++)for(let n=e.l[t];n!==void 0;n=n.T)n.O&le&&ct(n)}}function ct(e,t=Ce){const n=e.O;if(!((n&(je|Ce))>=t)){e.O=n&-4|t;for(let r=e.I;r!==null;r=r.p)ct(r.h,je);if(e.N!==null)for(let r=e.N;r!==null;r=r.A)for(let i=r.I;i!==null;i=i.p)ct(i.h,je)}}function Ne(e,t){for(e.R=!1,e.C=0;e.C<=e._;e.C++){let n=e.l[e.C];for(;n!==void 0;)n.O&le?t(n):mr(n,e),n=e.l[e.C]}e._=0}function mr(e,t){Pe(e,t);let n=e.o;for(let r=e.P;r;r=r.D){const i=r.m,o=i.V||i;o.L&&o.o>=n&&(n=o.o+1)}if(e.o!==n){e.o=n;for(let r=e.I;r!==null;r=r.p)En(r.h,t)}}const at=new WeakMap,te=new Set;function pr(e){let t=at.get(e);if(t)return W(t);const n=e.U,r=n?.G?W(n.G):null;return t={k:e,F:new Set,W:[[],[]],H:null,M:b,j:r},at.set(e,t),te.add(t),e.$=!1,t}function W(e){for(;e.H;)e=e.H;return e}function Cn(e,t){if(e=W(e),t=W(t),e===t)return e;t.H=e;for(const n of t.F)e.F.add(n);return e.W[0].push(...t.W[0]),e.W[1].push(...t.W[1]),e}function Ee(e){const t=e.G;if(!t)return;const n=W(t);if(te.has(n))return n;e.G=void 0}function qe(e){return Ee(e)?.M??e.M}function wt(e){return e.K!==void 0&&e.K!==_}function Gt(e,t){const n=W(t),r=e.G;if(r){if(r.H){e.G=t;return}const i=W(r);if(te.has(i)){i!==n&&!wt(e)&&(n.j&&W(n.j)===i?e.G=t:i.j&&W(i.j)===n||Cn(n,i));return}}e.G=t}const Me=new Set,x={l:new Array(2e3).fill(void 0),R:!1,C:0,_:0},H={l:new Array(2e3).fill(void 0),R:!1,C:0,_:0};let M=0,b=null,ze=!1,Mt=0,ot=null;const De=new Set;function yr(e){return Me.size===0&&te.size===0&&e.Y.length===0&&e.Z.length===0&&e.q.size===0&&De.size===0}function wr(){if(De.size!==0)for(const e of De){if(e.I!==null){De.delete(e);continue}e.B===_&&(e.K!==void 0&&e.K!==_||(De.delete(e),e.X?.()))}}function br(e){return!!ot?.has(e)}function et(e){for(const t of te){if(t.H||t.F.size>0)continue;const n=t.W[e-1];n.length&&(t.W[e-1]=[],dt(n,e))}}function vr(e){for(let t=e.I;t!==null;t=t.p){const n=t.h;if(!n.J)continue;if(n.J===ve){n.ee||(n.ee=!0,n.te.enqueue(G,n.ne));continue}const r=n.O&J?H:x;r.C>n.o&&(r.C=n.o),Je(n,r)}}function $r(e,t){t.ie=e,e.re.push(...t.re);for(const n of te)n.M===t&&(n.M=e);e.Z.push(...t.Z);for(const n of t.q)e.q.add(n);for(const[n,r]of t.oe){let i=e.oe.get(n);i||e.oe.set(n,i=new Set);for(const o of r)i.add(o)}for(const n of t.se)e.se.add(n)}function _r(e){for(let t=0;t=0&&(this.Y.splice(n,1),t.i=null)}notify(t,n,r,i){return this.i?this.i.notify(t,n,r,i):!1}run(t){if(this.le[t-1].length){const n=this.le[t-1];this.le[t-1]=[],dt(n,t)}for(let n=0;n=x.C,sn(r.fe),b=null,!r.re.length&&!r.oe.size&&r.Z.length){ot=new Set;for(let i=0;i=x.C&&(Ne(x,Q.Ee),ft())):(Me.size&&Ne(H,Q.Ee),At());M++,ze=x._>=x.C,te.size&&et(Se),this.run(Se),te.size&&et(G),this.run(G)}finally{this.ce=!1}}}notify(t,n,r,i){if(n&O){if(r&O){const o=i!==void 0?i:t.Re;if(b&&o){const s=o.source;let u=b.oe.get(s);u||b.oe.set(s,u=new Set);const l=u.size;u.add(t),u.size!==l&&we()}}return!0}return!1}initTransition(t){if(t&&(t=Tn(t)),!(t&&t===b)&&!(!t&&b&&b.Ie===M)){if(!b)b=t??{Ie:M,fe:[],oe:new Map,Z:[],q:new Set,re:[],_e:{le:[[],[]],Y:[]},ie:!1,se:new Set};else if(t){const n=b;$r(t,n),Me.delete(n),b=t}if(Me.add(b),b.Ie=M,this.ae!==null&&(this.ae.M=b,b.fe.push(this.ae),this.ae=null),this.fe!==b.fe){for(let n=0;ni.h.o&&(s.C=i.h.o),Je(i.h,s)}}function on(e){const t=e;if(!t.L){e.B!==_&&(e.ue=e.B,e.B=_);return}e.B!==_&&(e.ue=e.B,e.B=_,e.J&&e.J!==ve&&(e.ee=!0)),t.O&=~Ve,t.he&O||(t.he&=~D),(t.Ne!==null||t.Ae!==null)&&Q.Se(t,!1,!0)}function ft(){E.ae!==null&&(on(E.ae),E.ae=null);const e=E.fe;for(let t=0;t=x.C;if(r&&Ne(x,Q.Ee),n){if(r&&ft(),_r(e?e.Z:E.Z),e&&e.se.size){for(const i of e.se){if(i.O&de)continue;if(i.J===ve){i.ee||(i.ee=!0,i.te.enqueue(G,i.ne));continue}const o=i.O&J?H:x;o.C>i.o&&(o.C=i.o),Je(i,o)}e.se.clear()}e?e.q:E.q,wr(),Sr(e)}}function On(e){for(const t of e.Y)t.checkSources?.(),On(t)}function sn(e){for(let t=0;te(()=>n.dispose()))}function Yt(e){const t=e.m,n=e.D,r=e.p,i=e.Le;if(r!==null?r.Le=i:t.Ue=i,i!==null)i.p=r;else if(t.I=r,r===null){t.X?.();const o=t;o.L&&o.Oe&Re&&!(o.O&J)&&Pn(o)}return n}function Rn(e){const t=e.ye;let n=t!==null?t.D:e.P;if(n!==null){do n=Yt(n);while(n!==null);t!==null?t.D=null:e.P=null}}function Pn(e){Pe(e,e.O&J?H:x);let t=e.P;for(;t!==null;)t=Yt(t);e.P=null,e.ye=null,He(e,!0)}function Le(e,t){const n=t.ye;if(n!==null&&n.m===e)return;let r=null;const i=t.O&mt;if(i&&(r=n!==null?n.D:t.P,r!==null&&r.m===e)){t.ye=r;return}const o=e.Ue;if(o!==null&&o.h===t&&(!i||Ir(o,t)))return;const s=t.ye=e.Ue={m:e,h:t,D:r,Le:o,p:null};n!==null?n.D=s:t.P=s,o!==null?o.p=s:e.I=s}function Ir(e,t){const n=t.ye;if(n!==null){let r=t.P;do{if(r===e)return!0;if(r===n)break;r=r.D}while(r!==null)}return!1}function xr(e,t){return e.Ce===t||e.Pe?.has(t)?!1:e.Ce?(e.Pe?e.Pe.add(t):e.Pe=new Set([e.Ce,t]),e.Ce=void 0,!0):(e.Ce=t,!0)}function Lr(e,t){return e.Ce?e.Ce!==t?!1:(e.Ce=void 0,!0):e.Pe?.delete(t)?(e.Pe.size===1?(e.Ce=e.Pe.values().next().value,e.Pe=void 0):e.Pe.size===0&&(e.Pe=void 0),!0):!1}function kn(e){e.Ce=void 0,e.Pe?.clear(),e.Pe=void 0}function ht(e,t,n){if(!t){e.Re=null;return}if(n instanceof K&&n.source===t){e.Re=n;return}const r=e.Re;(!(r instanceof K)||r.source!==t)&&(e.Re=new K(t))}function Dt(e,t){for(let n=e.I;n!==null;n=n.p)t(n.h);for(let n=e.N;n!==null;n=n.A)for(let r=n.I;r!==null;r=r.p)t(r.h)}function Nr(e){let t=!1;const n=new Set,r=i=>{if(n.has(i)||!Lr(i,e))return;n.add(i),i.Ie=M;const o=i.Ce??i.Pe?.values().next().value;if(o)ht(i,o),be(i);else{if(i.he&=~O,ht(i),be(i),i.Ge){if(i.J===ve){const s=i;s.ee||(s.ee=!0,s.te.enqueue(G,s.ne))}else{const s=i.O&J?H:x;s.C>i.o&&(s.C=i.o),Je(i,s)}t=!0}i.Ge=!1}Dt(i,r)};Dt(e,r),t&&we()}function qr(e,t,n){let r=!1,i=!1;if(typeof t=="object"&&t!==null&&X(()=>{r=t[Symbol.asyncIterator],i=!r&&typeof t.then=="function"}),!i&&!r)return e.ve=null,t;e.ve=t;let o;const s=l=>{e.ve===t&&(E.initTransition(qe(e)),Zt(e,l instanceof K?O:U,l),e.Ie=M)},u=(l,f)=>{if(e.ve!==t||e.O&(Ce|me))return;E.initTransition(qe(e));const g=!!(e.he&D);Rn(e),In(e);const c=Ee(e);if(c&&c.F.delete(e),e.K!==void 0)e.K!==void 0&&e.K!==_?e.B=l:(e.ue=l,Oe(e)),e.Ie=M;else if(c){const d=e.J,p=e.ue,m=e.ke;(!d&&g||!m||!m(l,p))&&(e.ue=l,e.Ie=M,e.Fe&&ce(e.Fe,l),Oe(e,!0))}else ce(e,()=>l);Nr(e),we(),Te(),f?.()};if(i){let l=!1,f=!0;if(t.then(g=>{f?(o=g,l=!0):u(g)},g=>{f||s(g)}),f=!1,!l)throw E.initTransition(qe(e)),new K($)}if(r){const l=t[Symbol.asyncIterator]();let f=!1,g=!1;vt(()=>{if(!g){g=!0;try{const p=l.return?.();p&&typeof p.then=="function"&&p.then(void 0,()=>{})}catch{}}});const c=()=>{let p,m=!1,a=!0;return l.next().then(h=>{if(a)p=h,m=!0,h.done&&(g=!0);else{if(e.ve!==t)return;h.done?(g=!0,we(),Te()):u(h.value,c)}},h=>{!a&&e.ve===t&&(g=!0,s(h))}),a=!1,m&&!p.done?(o=p.value,f=!0,c()):m&&p.done},d=c();if(!f&&!d)throw E.initTransition(qe(e)),new K($)}return o}function In(e,t=!1){(e.Ce||e.Pe)&&kn(e),e.Ge&&(e.Ge=!1),e.he=t?0:e.he&D,e.Re&&ht(e),e.We&&be(e),e.xe&&e.xe()}function Zt(e,t,n,r,i){t===U&&!(n instanceof qt)&&!(n instanceof K)&&(n=new qt(e,n));const o=t===O&&n instanceof K?n.source:void 0,s=o===e,u=t===O&&e.K!==void 0&&!s,l=u&&wt(e);r||(t===O&&o?(xr(e,o),e.he=O|e.he&D,ht(e,o,n)):(kn(e),e.he=t|(t!==U?e.he&D:0),e.Re=n),be(e)),i&&!r&&Gt(e,i);const f=r||l,g=r||u?void 0:i;if(e.xe){if(r&&t===O)return;f?e.xe(t,n):e.xe();return}Dt(e,c=>{c.Ie=M,(t===O&&o&&c.Ce!==o&&!c.Pe?.has(o)||t!==O&&(c.Re!==n||c.Ce||c.Pe))&&(!f&&!c.M&&bt(c),Zt(c,t,n,f,g))})}let Mr=null;Q.Ee=ue;Q.Se=He;let B=!1,re=!1,$=null,z=null;function ue(e,t=!1){const n=e.J;t||(e.M&&(!n||b)&&b!==e.M&&E.initTransition(e.M),Pe(e,e.O&J?H:x),e.ve=null,e.M||n===ve?He(e):(e.ge!==null||e.be!==null)&&(An(e),e.Ae=e.be,e.Ne=e.ge,e.be=null,e.ge=null,e.me=0));let r=!!(e.O&me);const i=e.K!==void 0&&e.K!==_,o=!!(e.he&O),s=!!(e.he&D),u=$;$=e,e.ye=null,e.O=mt,e.Ie=M;let l=e.B===_?e.ue:e.B,f=e.o,g=B,c=z;if(B=!0,r){const a=Ee(e);a&&(z=a)}else if(b&&!t&&b.Z.length)for(let a=e.P;a;a=a.D){const h=a.m;if(h.O&me){const y=Ee(h);if(y){r=!0,z=y,e.O|=me,Gt(e,y);break}}}const d=n&&n!==G,p=re;d&&(re=!0);try{if(e.Oe&Vt)l=e.L(l),e.ve=null;else{const a=e.ve,h=e.L(l),y=typeof h=="object"&&h!==null,w=e.ve!==a;l=w||!y?h:qr(e,h),!w&&!y&&(e.ve=null)}if(In(e,t),e.G){const a=Ee(e);a&&(a.F.delete(e),be(a.k))}}catch(a){if(a instanceof K&&z){const h=W(z);h.k!==e&&(h.F.add(e),e.G=h,be(h.k))}a instanceof K&&(e.Ge=!0),Zt(e,a instanceof K?O:U,a,void 0,a instanceof K?e.G:void 0)}finally{B=g,d&&(re=p),e.O=$n|(t?e.O&Wt:0),$=u}if(!e.Re){Rn(e);const a=i?e.K:e.B===_?e.ue:e.B,h=!n&&s||!e.ke||!e.ke(a,l);if(n&&h&&(e.ee=!e.Re,t||e.te.enqueue(n,Q.Te.bind(null,e))),h){const y=i?e.K:void 0;t||n&&b!==e.M||r?(e.ue=l,i&&r&&(e.K=l,e.B=l)):e.B=l,i&&!r&&o&&!e.$&&(e.K=l),(!i||r||e.K!==y)&&Oe(e,r||i)}else if(i)e.B=l;else if(e.o!=f)for(let y=e.I;y!==null;y=y.p)En(y.h,y.h.O&J?H:x)}z=c,(e.B!==_||e.Ne!==null||e.Ae!==null||!!(e.he&(O|D)))&&(!t||e.he&O)&&!e.M&&!(b&&i)&&bt(e),e.M&&n&&b!==e.M&&Tr(e.M,()=>ue(e))}function xn(e){if(e.O&je)for(let t=e.P;t;t=t.D){const n=t.m,r=n.V||n;if(r.L&&xn(r),e.O&Ce)break}(e.O&(Ce|me)||e.Re&&e.Ie=(o?H.C:x.C)&&(ct(t),gr(o?H:x),xn(r));const s=r.o;s>=t.o&&e.i!==t&&(t.o=s+1)}if(r.he&O)if(t&&!(re&&r.M&&b!==r.M))if(z){const o=r.G,s=W(z);if(o&&W(o)===s&&!wt(r))throw!B&&e!==t&&Le(e,t),r.Re}else throw!B&&e!==t&&Le(e,t),r.Re;else{if(t&&r!==e&&r.he&D)throw!B&&e!==t&&Le(e,t),r.Re;if(!t&&r.he&D)throw r.Re}if(e.L&&e.he&U){if(e.Ie0)}return!0}return e.K!==void 0&&e.K===_&&!e.U?!1:e.B!==_&&!(t.he&D)?!0:!!(t.he&O&&!(t.he&D))}function be(e){if(e.We){const t=Kr(e),n=e.We;if(ce(n,t),!t&&n.G){const r=Ee(e);if(r&&r.F.size>0){const i=W(n.G);i!==r&&Cn(r,i)}at.delete(n),n.G=void 0}}}function Ur(e,t=!0){const n=re;re=t;try{return e()}finally{re=n}}function Wr(e,t=Ye()){if(!t)throw new vn;const n=zr(e,t)?t.Ve[e.id]:e.defaultValue;if(Qt(n))throw new cr;return n}function Vr(e,t,n=Ye()){if(!n)throw new vn;n.Ve={...n.Ve,[e.id]:Qt(t)?e.defaultValue:t}}function zr(e,t){return!Qt(t?.Ve[e.id])}function Qt(e){return typeof e>"u"}function Mn(e,t,n,r){const i=!!r?.user,o=Dr(e,t,n,i?G:Se,Gr,r);ue(o,!0),!r?.defer&&(o.J===G||r?.schedule?o.te.enqueue(o.J,Ft.bind(null,o)):Ft(o))}function Gr(e,t){const n=e!==void 0?e:this.he,r=t!==void 0?t:this.Re;if(n&U){let i=r;if(this.te.notify(this,O,0),this.J===G)try{return this.je?this.je(i,()=>{this.$e?.(),this.$e=void 0}):console.error(i)}catch(o){i=o}if(!this.te.notify(this,U,U))throw i}else this.J===Se&&this.te.notify(this,O|U,n,r)}function Ft(e){if(!(!e.ee||e.O&de)){e.$e?.(),e.$e=void 0;try{const t=e.Qe(e.ue,e.Me);e.$e=t,e.$e&&!e.Ke&&(e.Ke=!0,se(e.i,()=>vt(()=>e.$e?.())))}catch(t){if(e.Re=new qt(e,t),e.he|=U,!e.te.notify(e,U,U))throw t}finally{e.Me=e.ue,e.ee=!1}}}Q.Te=Ft;function Jr(e,t){const n=()=>{if(!(!r.ee||r.O&de))try{r.ee=!1,ue(r)}finally{}},r=$t(()=>{r.$e?.(),r.$e=void 0;const i=Ur(e);r.$e=i},{...t,lazy:!0});r.$e=void 0,r.Oe=r.Oe&~Re|_n,r.ee=!0,r.J=ve,r.xe=(i,o)=>{if((i!==void 0?i:r.he)&U){r.te.notify(r,O,0);const u=o!==void 0?o:r.Re;if(!r.te.notify(r,U,U))throw u}},r.ne=n,r.te.enqueue(G,n),vt(()=>r.$e?.())}function Dn(e){return vt(e)}function fe(e){const t=qn.bind(null,e);return t[hr]=e,t}function Hr(e,t){if(typeof e=="function"){const r=$t(e,t);return r.Oe&=~Re,[fe(r),jr.bind(null,r)]}const n=Fe(e,t);return[fe(n),ce.bind(null,n)]}function pe(e,t){return fe($t(e,t))}function Yr(e,t,n){Mn(e,t.effect||t,t.error,{user:!0,...n})}function Zr(e,t,n){Mn(e,t,void 0,n)}function Qr(e,t){Jr(e,t)}function Xr(e){const t=Ye();t&&!(t.Oe&_n)?Qr(()=>X(e),void 0):E.enqueue(G,()=>{e()?.()})}const ei=Symbol(0),Bt=Symbol(0);function ln(e){return e==null||typeof e!="object"||Object.isFrozen(e)?!1:typeof Node>"u"||!(e instanceof Node)}const Fn=Symbol(0);function un(e){return e==="__proto__"||e==="constructor"||e==="prototype"}function Be(e,t,n=0){let r,i=e;if(n{Be(n,t)}},{DELETE:Fn});function tt(){return!0}const ti={get(e,t,n){return t===Bt?n:e.get(t)},has(e,t){return t===Bt?!0:e.has(t)},set:tt,deleteProperty:tt,getOwnPropertyDescriptor(e,t){return{configurable:!0,enumerable:!0,get(){return e.get(t)},set:tt,deleteProperty:tt}},ownKeys(e){return e.keys()}};function Rt(e){return(e=typeof e=="function"?e():e)?e:{}}const Pt=Symbol(0);function ni(...e){if(e.length===1&&typeof e[0]!="function")return e[0];let t=!1;const n=[];for(let l=0;l=0;f--){const g=Rt(n[f]);if(l in g)return g[l]}},has(l){for(let f=n.length-1;f>=0;f--)if(l in Rt(n[f]))return!0;return!1},keys(){const l=new Set;for(let f=0;f=0;l--){const f=n[l];if(!f){l===o&&o--;continue}const g=Object.getOwnPropertyNames(f);for(let c=g.length-1;c>=0;c--){const d=g[c];if(!(d==="__proto__"||d==="constructor")&&!r[d]){i=i||l!==o;const p=Object.getOwnPropertyDescriptor(f,d);r[d]=p.get?{enumerable:!0,configurable:!0,get:p.get.bind(f)}:p}}}if(!i)return n[o];const s={},u=Object.keys(r);for(let l=u.length-1;l>=0;l--){const f=u[l],g=r[f];g.get?Object.defineProperty(s,f,g):s[f]=g.value}return s[Pt]=n,s}function ri(e,t,n){const r=typeof n?.keyed=="function"?n.keyed:void 0,i=t.length>1,o=t,s={Ze:Ke(),qe:0,Be:e,ze:[],Xe:o,Je:[],et:[],tt:r,nt:r||n?.keyed===!1?[]:void 0,it:i&&n?.keyed!==!1?[]:void 0,rt:n?.keyed===!1,ot:n?.fallback},u=$t(ii.bind(s));return s.Ze.u=u,u.Oe&=~Re,fe(u)}const nt={ownedWrite:!0};function ii(){const e=this.Be()||[],t=e.length;return e[ei],se(this.Ze,()=>{let n,r,i=this.nt?this.rt?()=>(this.nt[r]=Fe(e[r],nt),this.Xe(fe(this.nt[r]),r)):()=>(this.nt[r]=Fe(e[r],nt),this.it&&(this.it[r]=Fe(r,nt)),this.Xe(fe(this.nt[r]),this.it?fe(this.it[r]):void 0)):this.it?()=>{const o=e[r];return this.it[r]=Fe(r,nt),this.Xe(o,fe(this.it[r]))}:()=>{const o=e[r];return this.Xe(o)};if(t===0)this.qe!==0&&(this.Ze.dispose(!1),this.et=[],this.ze=[],this.Je=[],this.qe=0,this.nt&&(this.nt=[]),this.it&&(this.it=[])),this.ot&&!this.Je[0]&&(this.Je[0]=se(this.et[0]=Ke(),this.ot));else if(this.qe===0){for(this.et[0]&&this.et[0].dispose(),this.Je=new Array(t),r=0;r=o&&u>=o&&(this.ze[s]===e[u]||this.nt&&cn(this.tt,this.ze[s],e[u]));s--,u--)d[u]=this.Je[s],p[u]=this.et[s],m&&(m[u]=this.nt[s]),a&&(a[u]=this.it[s]);for(g=new Map,c=new Array(u+1),r=u;r>=o;r--)l=e[r],f=this.tt?this.tt(l):l,n=g.get(f),c[r]=n===void 0?-1:n,g.set(f,r);for(n=o;n<=s;n++)l=this.ze[n],f=this.tt?this.tt(l):l,r=g.get(f),r!==void 0&&r!==-1?(d[r]=this.Je[n],p[r]=this.et[n],m&&(m[r]=this.nt[n]),a&&(a[r]=this.it[n]),r=c[r],g.set(f,r)):this.et[n].dispose();for(r=o;r{let r=[];return jt(n,r,{...t,doNotUnwrap:!1}),r}:n}return e}}function jt(e,t=[],n){let r=null,i=!1;for(let o=0;o(Vr(r,i.value),en(()=>i.children)))}return r.id=n,r.defaultValue=e,r}function jn(e){return Wr(e)}function en(e){const t=pe(e,{lazy:!0}),n=pe(()=>Xt(t()),{lazy:!0,sync:!0});return n.toArray=()=>{const r=n();return Array.isArray(r)?r:r!=null?[r]:[]},n}class _e{static{for(const t of["all","allSettled","any","race","reject","resolve"])_e[t]=()=>new _e}catch(){return new _e}then(){return new _e}finally(){return new _e}}const Z=(...e)=>pe(...e),q=(...e)=>Hr(...e),oi=(...e)=>Zr(...e),_t=(...e)=>Yr(...e);function L(e,t){return X(()=>e(t||{}))}const si=e=>`Stale read from <${e}>.`;function st(e){const t="fallback"in e?{keyed:e.keyed,fallback:()=>e.fallback}:{keyed:e.keyed};return ri(()=>e.each,e.children,t)}function Kn(e){const t=e.keyed,n=pe(()=>e.when,void 0),r=t?n:pe(n,{equals:(i,o)=>!i==!o,sync:!0});return pe(()=>{const i=r();if(i){const o=e.children;return typeof o=="function"&&o.length>0?X(t?()=>o(i):()=>o(()=>{if(!X(r))throw si("Show");return n()})):o}return e.fallback},{sync:!0})}const F=Symbol("slot"),li={transparent:!0,sync:!0},ui={sync:!0},ee=(e,t,n)=>oi(e,t,n?{transparent:!0,sync:!0,...n}:li),j=e=>Z(()=>e(),ui);function ci(e,t,n,r){let i=n.length,o=t.length,s=i,u=0,l=0,f=t[o-1],g=f[F],c=f.parentNode===e&&(!g||g===r)?f.nextSibling:r||null,d=null,p,m;for(;u=o-1||l>=s)break}while(t[u]===n[s-1]&&n[l]===t[o-1]);else do if(e.insertBefore(t[--o],p),l++,u>=o-1||l>=s)break;while(t[u]===n[s-1]&&n[l]===t[o-1]);else{if(!d){d=new Map;let h=l;for(;ha-l){const T=t[u],I=T[F],N=T.parentNode===e&&(!I||I===r)?T:c;for(;l{if(i=o,t===document){const s=e();ee(()=>Xt(s),()=>{})}else{const s=e();v(t,()=>s,t.firstChild?null:void 0,n,r.insertOptions)}},{id:r.renderId})}catch(o){throw i&&i(),dn(t),o}return()=>{i(),dn(t),t.textContent=""}}function fi(e,t,n){const r=document.createElement("template");return r.innerHTML=e,n===2?r.content.firstChild.firstChild:r.content.firstChild}function A(e,t){let n;return i=>(n||(n=fi(e,i,t))).cloneNode(!0)}function Ze(e){for(let t=0,n=e.length;tUn(r,o,i)))}}function di(e){const t=hi(e,e);t&&(t.roots=(t.roots||0)+1)}function dn(e){const t=Ae.get(e);t&&(t.roots>1?t.roots--:delete t.roots),gi(e,e)}function hi(e,t=e){if(!e||!t)return;let n=Ae.get(e);return n||Ae.set(e,n={owners:new Map,handlers:new Map}),n.owners.set(t,(n.owners.get(t)||0)+1),Kt.forEach(r=>Un(r,e,n)),n}function gi(e,t=e){const n=Ae.get(e);if(!n)return;const r=n.owners.get(t);r>1?n.owners.set(t,r-1):n.owners.delete(t),!n.owners.size&&(n.handlers.forEach((i,o)=>e.removeEventListener(o,i)),Ae.delete(e))}function Un(e,t,n){if(n.handlers.has(e))return;const r=i=>yi(i,t,n);n.handlers.set(e,r),t.addEventListener(e,r)}function mi(e,t){let n=e,r=0;for(;n;){if(t.owners.has(n))return{owner:n,distance:r};r++,n=n._$host||n.parentNode||n.host}}function ye(e,t,n){n==null||n===!1?e.removeAttribute(t):e.setAttribute(t,n===!0?"":n)}function Ge(e,t,n){if(t==null||t===!1){n&&e.removeAttribute("class");return}if(typeof t=="string"){t!==n&&e.setAttribute("class",t);return}typeof n=="string"?(n={},e.removeAttribute("class")):n=gn(n||{}),t=gn(t);const r=Object.keys(t||{}),i=Object.keys(n);let o,s;for(o=0,s=i.length;oi.call(e,n[1],o))}else e.addEventListener(t,n,typeof n!="function"&&n)}function pi(e,t){Array.isArray(e)?e.flat(1/0).forEach(n=>n&&n(t)):e(t)}function gt(e,t){const n=X(e);se(null,()=>pi(n,t))}function v(e,t,n,r,i){const o=n!==void 0;if(o&&!r&&(r=[]),typeof t!="function"&&(t=It(t,r,o,!0),typeof t!="function"))return kt(e,t,r,n);if(o&&r.length===0){const u=document.createTextNode("");e.insertBefore(u,n),r=[u]}let s=r;ee(u=>{const l=It(t(),s,o,!0);return typeof l!="function"?l:(ee(()=>It(l,s,o),f=>{kt(e,f,s,n),s=f},u!==void 0&&!(i&&i.schedule)?{...i,schedule:!0}:i),fn)},u=>{u!==fn&&(kt(e,u,s,n),s=u)},i)}function gn(e){if(Array.isArray(e)){const t={};Wn(e,t),e=t}if(e&&typeof e=="object"){const t={},n=Object.keys(e);for(let r=0,i=n.length;rObject.defineProperty(e,"target",{configurable:!0,value:c}),f=()=>{const c=i[o];if(c&&!i.disabled){const d=i[`${o}Data`];if(d!==void 0?c.call(i,d,e):c.call(i,e),e.cancelBubble)return}return i.host&&typeof i.host!="string"&&!i.host._$host&&i.contains(e.target)&&l(i.host),!0},g=()=>{for(;f()&&!(i===u||i.parentNode===u);)i=i._$host||i.parentNode||i.host};if(Object.defineProperty(e,"currentTarget",{configurable:!0,get(){return i||u||document}}),e.composedPath){const c=e.composedPath();if(c.length){l(c[0]);for(let d=0;d=0;o--){const s=t[o];if(r!==s){const u=s[F],l=s.parentNode===e&&(!u||u===n);r&&!i&&!o?l?e.replaceChild(r,s):e.insertBefore(r,n):l&&s.remove()}else i=!0}}else r&&e.insertBefore(r,n);r&&n&&(r[F]=n)}const wi=!1;function bi(e,t,n,r={}){try{const i=ai(e,t,n,{...r,insertOptions:{schedule:!0}});return Te(),i}finally{}}function Vn(){let e=new Set;function t(i){return e.add(i),()=>e.delete(i)}let n=!1;function r(i,o){if(n)return!(n=!1);const s={to:i,options:o,defaultPrevented:!1,preventDefault:()=>s.defaultPrevented=!0};for(const u of e)u.listener({...s,from:u.location,retry:l=>{l&&(n=!0),u.navigate(i,{...o,resolve:!1})}});return!s.defaultPrevented}return{subscribe:t,confirm:r}}let Ut;function tn(){(!window.history.state||window.history.state._depth==null)&&window.history.replaceState({...window.history.state,_depth:window.history.length-1},""),Ut=window.history.state._depth}tn();function vi(e){return{...e,_depth:window.history.state&&window.history.state._depth}}function $i(e,t){let n=!1;return()=>{const r=Ut;tn();const i=r==null?null:Ut-r;if(n){n=!1;return}i&&t(i)?(n=!0,window.history.go(-i)):e()}}const _i=/^(?:[a-z0-9]+:)?\/\//i,Si=/^\/+|(\/)\/+$/g,zn="http://sr";function Ue(e,t=!1){const n=e.replace(Si,"$1");return n?t||/^[?#]/.test(n)?n:"/"+n:""}function lt(e,t,n){if(_i.test(t))return;const r=Ue(e),i=n&&Ue(n);let o="";return!i||t.startsWith("/")?o=r:i.toLowerCase().indexOf(r.toLowerCase())!==0?o=r+i:o=i,(o||"/")+Ue(t,!o)}function Ei(e,t){if(e==null)throw new Error(t);return e}function Ci(e,t){return Ue(e).replace(/\/*(\*.*)?$/g,"")+Ue(t)}function Gn(e){const t={};return e.searchParams.forEach((n,r)=>{r in t?Array.isArray(t[r])?t[r].push(n):t[r]=[t[r],n]:t[r]=n}),t}function Oi(e,t,n){const[r,i]=e.split("/*",2),o=r.split("/").filter(Boolean),s=o.length;return u=>{const l=u.split("/").filter(Boolean),f=l.length-s;if(f<0||f>0&&i===void 0&&!t)return null;const g={path:s?"":"/",params:{}},c=d=>n===void 0?void 0:n[d];for(let d=0;dr===e;return t===void 0?!0:typeof t=="string"?n(t):typeof t=="function"?t(e):Array.isArray(t)?t.some(n):t instanceof RegExp?t.test(e):!1}function Ti(e){const[t,n]=e.pattern.split("/*",2),r=t.split("/").filter(Boolean);return r.reduce((i,o)=>i+(o.startsWith(":")?2:3),r.length-(n===void 0?0:1))}function Jn(e){const t=new Map,n=Ye();return new Proxy({},{get(r,i){return t.has(i)||se(n,()=>t.set(i,Z(()=>e()[i]))),t.get(i)()},getOwnPropertyDescriptor(){return{enumerable:!0,configurable:!0}},ownKeys(){return Reflect.ownKeys(e())},has(r,i){return i in e()}})}function Hn(e){let t=/(\/?\:[^\/]+)\?/.exec(e);if(!t)return[e];let n=e.slice(0,t.index),r=e.slice(t.index+t[0].length);const i=[n,n+=t[1]];for(;t=/^(\/\:[^\/]+)\?/.exec(r);)i.push(n+=t[1]),r=r.slice(t[0].length);return Hn(r).reduce((o,s)=>[...o,...i.map(u=>u+s)],[])}const Ai=100,Yn=Bn(),Zn=Bn();function Ri(e){try{return jn(e)}catch{return}}const Qn=()=>Ei(jn(Yn)," and 'use' router primitives can be only used inside a Route."),Xn=()=>Qn().navigatorFactory(),Pi=()=>Qn().params;function ki(e,t=""){const{component:n,preload:r,children:i,info:o}=e,s=!i||Array.isArray(i)&&!i.length,u={key:e,component:n,preload:r,info:o};return er(e.path).reduce((l,f)=>{for(const g of Hn(f)){const c=Ci(t,g);let d=s?c:c.split("/*",1)[0];d=d.split("/").map(p=>p.startsWith(":")||p.startsWith("*")?p:encodeURIComponent(p)).join("/"),l.push({...u,originalPath:f,pattern:d,matcher:Oi(d,!s,e.matchFilters)})}return l},[])}function Ii(e,t=0){return{routes:e,score:Ti(e[e.length-1])*1e4-t,matcher(n){const r=[];for(let i=e.length-1;i>=0;i--){const o=e[i],s=o.matcher(n);if(!s)return null;r.unshift({...s,route:o})}return r}}}function er(e){return Array.isArray(e)?e:[e]}function tr(e,t="",n=[],r=[]){const i=er(e);for(let o=0,s=i.length;os.score-o.score)}function Lt(e,t){for(let n=0,r=e.length;n{const c=e();try{return new URL(c,r)}catch{return console.error(`Invalid path ${c}`),g}},{equals:(g,c)=>g.href===c.href}),o=Z(()=>i().pathname),s=Z(()=>i().search),u=Z(()=>i().hash),l=()=>"",f=Z(()=>Gn(i()));return{get pathname(){return o()},get search(){return s()},get hash(){return u()},get state(){return t()},get key(){return l()},query:n?n(f):Jn(f)}}let ge;function Li(){return ge}function Ni(e,t,n,r={}){const{signal:[i,o],utils:s={}}=e,u=s.parsePath||(R=>R),l=s.renderPath||(R=>R),f=s.beforeLeave||Vn(),g=lt("",r.base||""),c=X(i);if(g===void 0)throw new Error(`${g} is not a valid base path`);g&&!c.value&&o({value:g,replace:!0,scroll:!1});const[d,p]=q(!1,{ownedWrite:!0}),[m,a]=q(void 0,{ownedWrite:!0});let h;const y=Z(()=>m()??i()),w=xi(()=>y().value,()=>y().state,s.queryWrapper),T=[],I=q([],{ownedWrite:!0}),N=Z(()=>typeof r.transformUrl=="function"?Lt(t(),r.transformUrl(w.pathname)):Lt(t(),w.pathname)),Y=()=>{const R=N(),P={};for(let V=0;Vg,outlet:()=>null,resolvePath(R){return lt(g,R)}};return{base:Qe,location:w,params:St,isRouting:d,renderPath:l,parsePath:u,navigatorFactory:Ie,matches:N,beforeLeave:f,preloadRoute:Et,singleFlight:r.singleFlight===void 0?!0:r.singleFlight,submissions:I};function ke(R,P,V){X(()=>{if(typeof P=="number"){P&&(s.go?s.go(P):console.warn("Router integration does not support relative routing"));return}const ae=!P||P[0]==="?",{replace:$e,resolve:ne,scroll:he,state:ie}={replace:!1,resolve:!ae,scroll:!0,...V},S=ne?R.resolvePath(P):lt(ae&&w.pathname||"",P);if(S===void 0)throw new Error(`Path '${P}' is not a routable path`);if(T.length>=Ai)throw new Error("Too many redirects");const C=y();if((S!==C.value||ie!==C.state)&&!wi){if(f.confirm(S,V)){T.push({value:C.value,replace:$e,scroll:he,state:C.state});const k={value:S,state:ie};h===void 0&&(p(!0),Te()),ge="navigate",h=k,h===k&&(a({...h}),queueMicrotask(()=>{h===k&&(ge=void 0,Xe(h),a(void 0),p(!1),h=void 0)}))}}})}function Ie(R){return R=R||Ri(Zn)||Qe,(P,V)=>ke(R,P,V)}function Xe(R){const P=T[0];P&&(o({...R,replace:P.replace,scroll:P.scroll}),T.length=0)}function Et(R,P){const V=Lt(t(),R.pathname),ae=ge;ge="preload";for(let $e in V){const{route:ne,params:he}=V[$e];ne.component&&ne.component.preload&&ne.component.preload();const{preload:ie}=ne;P&&ie&&se(n(),()=>ie({params:he,location:{pathname:R.pathname,search:R.search,hash:R.hash,query:Gn(R),state:null,key:""},intent:"preload"}))}ge=ae}}function qi(e,t,n,r){const{base:i,location:o,params:s}=e,{pattern:u,component:l,preload:f}=r().route,g=Z(()=>r().path);l&&l.preload&&l.preload();const c=f?f({params:s,location:o,intent:ge||"initial"}):void 0;return{parent:t,pattern:u,path:g,outlet:()=>l?L(l,{params:s,location:o,data:c,get children(){return n()}}):n(),resolvePath(p){return lt(i.path(),p,g())}}}const Mi=e=>function(n){const{base:r,singleFlight:i,transformUrl:o,root:s,rootPreload:u,routeChildren:l}=X(()=>({base:n.base,singleFlight:n.singleFlight,transformUrl:n.transformUrl,root:n.root,rootPreload:n.rootPreload,routeChildren:n.children})),f=en(()=>l),g=Z(()=>tr(f(),r||""));let c;const d=Ni(e,g,()=>c,{base:r,singleFlight:i,transformUrl:o});return e.create&&e.create(d),L(Yn,{value:d,get children(){return L(Di,{routerState:d,root:s,preload:u,get children(){return[j(()=>(c=Ye())&&null),L(Fi,{routerState:d,get branches(){return g()}})]}})}})};function Di(e){const t=e.routerState.location,n=e.routerState.params,r=Z(()=>e.preload&&X(()=>{e.preload({params:n,location:t,intent:Li()||"initial"})})),i=e.root;return i?L(i,{params:n,location:t,get data(){return r()},get children(){return e.children}}):e.children}function Fi(e){const t=[];let n,r;const i=Z(s=>{const u=e.routerState.matches(),l=r;let f=l&&u.length===l.length;const g=[];for(let c=0,d=u.length;c{t[c]=a,g[c]=qi(e.routerState,g[c-1]||e.routerState.base,pn(()=>i()?.[c+1]),()=>{const h=e.routerState.matches();return h[c]??h[0]})}))}return t.splice(u.length).forEach(c=>c()),s&&f?(r=u,s):(n=g[0],r=u,g)}),o=pn(()=>i()&&n);return j(o)}const pn=e=>()=>{const t=e();if(t)return L(Zn,{value:t,get children(){return t.outlet()}})},yn=e=>{const t=en(()=>e.children);return ni(e,{get children(){return t()}})};function Bi(e){let t=!1;const n=s=>typeof s=="string"?{value:s}:s,[r,i]=q(n(e.get()),{equals:(s,u)=>s.value===u.value&&s.state===u.state,ownedWrite:!0}),o=[r,s=>{!t&&e.set(s),i(s)}];return e.init&&Dn(e.init((s=e.get())=>{t=!0,o[1](n(s)),t=!1})),Mi({signal:o,create:e.create,utils:e.utils})}function ji(e,t,n){return e.addEventListener(t,n),()=>e.removeEventListener(t,n)}function Ki(e,t){const n=e&&document.getElementById(e);n?n.scrollIntoView():t&&window.scrollTo(0,0)}const Ui=new Map;function Wi({preload:e=!0,explicitLinks:t=!1,actionBase:n="/_server",transformUrl:r}={}){return i=>{const o=i.base.path(),s=i.navigatorFactory(i.base);let u,l;function f(a){return a.namespaceURI==="http://www.w3.org/2000/svg"}function g(a){if(a.defaultPrevented||a.button!==0||a.metaKey||a.altKey||a.ctrlKey||a.shiftKey)return;const h=a.composedPath().find(Y=>Y instanceof Node&&Y.nodeName.toUpperCase()==="A");if(!h||t&&!h.hasAttribute("link"))return;const y=f(h),w=y?h.href.baseVal:h.href;if((y?h.target.baseVal:h.target)||!w&&!h.hasAttribute("state"))return;const I=(h.getAttribute("rel")||"").split(/\s+/);if(h.hasAttribute("download")||I&&I.includes("external"))return;const N=y?new URL(w,document.baseURI):new URL(w);if(!(N.origin!==window.location.origin||o&&N.pathname&&!N.pathname.toLowerCase().startsWith(o.toLowerCase())))return[h,N]}function c(a){const h=g(a);if(!h)return;const[y,w]=h,T=i.parsePath(w.pathname+w.search+w.hash),I=y.getAttribute("state");a.preventDefault(),s(T,{resolve:!1,replace:y.hasAttribute("replace"),scroll:!y.hasAttribute("noscroll"),state:I?JSON.parse(I):void 0})}function d(a){const h=g(a);if(!h)return;const[y,w]=h;r&&(w.pathname=r(w.pathname)),i.preloadRoute(w,y.getAttribute("preload")!=="false")}function p(a){clearTimeout(u);const h=g(a);if(!h)return l=null;const[y,w]=h;l!==y&&(r&&(w.pathname=r(w.pathname)),u=setTimeout(()=>{i.preloadRoute(w,y.getAttribute("preload")!=="false"),l=y},20))}function m(a){if(a.defaultPrevented)return;let h=a.submitter&&a.submitter.hasAttribute("formaction")?a.submitter.getAttribute("formaction"):a.target.getAttribute("action");if(!h)return;if(!h.startsWith("https://action/")){const w=new URL(h,zn);if(h=i.parsePath(w.pathname+w.search),!h.startsWith(n))return}if(a.target.method.toUpperCase()!=="POST")throw new Error("Only POST forms are supported for Actions");const y=Ui.get(h);if(y){a.preventDefault();const w=new FormData(a.target,a.submitter);y.call({r:i,f:a.target},a.target.enctype==="multipart/form-data"?w:new URLSearchParams(w))}}Ze(["click","submit"]),document.addEventListener("click",c),e&&(document.addEventListener("mousemove",p,{passive:!0}),document.addEventListener("focusin",d,{passive:!0}),document.addEventListener("touchstart",d,{passive:!0})),document.addEventListener("submit",m),Dn(()=>{document.removeEventListener("click",c),e&&(document.removeEventListener("mousemove",p),document.removeEventListener("focusin",d),document.removeEventListener("touchstart",d)),document.removeEventListener("submit",m)})}}function Vi(e){const t=()=>{const r=window.location.pathname.replace(/^\/+/,"/")+window.location.search,i=window.history.state&&window.history.state._depth&&Object.keys(window.history.state).length===1?void 0:window.history.state;return{value:r+window.location.hash,state:i}},n=Vn();return Bi({get:t,set({value:r,replace:i,scroll:o,state:s}){i?window.history.replaceState(vi(s),"",r):window.history.pushState(s,"",r),Ki(decodeURIComponent(window.location.hash.slice(1)),o),tn()},init:r=>ji(window,"popstate",$i(r,i=>{if(i)return!n.confirm(i);{const o=t();return!n.confirm(o.value,{state:o.state})}})),create:Wi({preload:e.preload,explicitLinks:e.explicitLinks,actionBase:e.actionBase,transformUrl:e.transformUrl}),utils:{go:r=>window.history.go(r),beforeLeave:n}})(e)}var zi=A('