Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ These bite you if you ignore them — they're the contract the rest of the codeb
- **Modal accessibility**: every modal calls [`useModalA11y(isOpen, onClose)`](src/hooks/useModalA11y.ts) — Escape-close, Tab focus trap, focus restoration. Container gets `role="dialog"` + `aria-modal="true"` + `aria-labelledby` (stable heading id) or `aria-label` (conditional heading). Don't roll bespoke `useEffect` Escape handlers.
- **Right panels are flex siblings, not overlays**: `NowPlayingPanel` / `QueuePanel` / `LyricsPanel` are mounted as flex children of the outer row in `AppLayout`. The center column has `min-w-0` so wide tables collapse instead of pushing the panel off-screen.
- **Process-wide offline mode**: every outbound HTTP path (Deezer, Last.fm, similar, LRCLIB) checks `offline::is_offline()` first and short-circuits to an empty payload or cache. Persisted in `app_setting['network.offline_mode']`. Treat new HTTP code paths the same way.
- **Outbound `playlist + field: "tracks"` ops carry a snapshot map (Phase 1.j.b)**: every command in [`commands/playlist.rs`](src-tauri/crates/app/src/commands/playlist.rs) that inserts tracks (`add_track_to_playlist`, `add_tracks_to_playlist`, `add_source_to_playlist`) calls [`sync::track_snapshots::build_snapshots(conn, &track_ids)`](src-tauri/crates/app/src/sync/track_snapshots.rs) inside the same SQLite transaction and folds the result into the outbound payload as `snapshots: { "<id_str>": { title, artist?, duration_ms? } }`. The server stores the snapshot in `playlist_track.snapshot_*` and filters its public share preview on `snapshot_title IS NOT NULL`, so tracks without one stay invisible to the wider web. **Don't shadow this** — emitting a `tracks` insert op without `snapshots` regresses the share preview for that playlist until any other client re-syncs it. `delete` + `set` (reorder) ops don't need snapshots (no display change on the receiving side).
- **Adding a new player-bar action**: default it into the overflow ("⋯") menu via [`MoreActionsMenu`](src/components/player/MoreActionsMenu.tsx) first; promote to primary only when usage warrants it; add a Settings pin toggle if both modes make sense. See [`docs/features/ui.md`](docs/features/ui.md#player-bar-layout).

## Feature catalogue
Expand Down
22 changes: 20 additions & 2 deletions src-tauri/crates/app/src/commands/playlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,14 +363,22 @@ pub async fn add_track_to_playlist(
let mut tx = pool.begin().await?;
append_track_conn(&mut tx, playlist_id, track_id, now).await?;
let entity_id = crate::sync::canonical::ensure_local_playlist(&mut tx, playlist_id).await?;
// Phase 1.j.b — fold per-track snapshots into the outbound
// payload so the server's `playlist_track.snapshot_*` columns
// land populated and the public share preview can render the
// track without resolving the local-i64 id cross-device.
let snapshots = crate::sync::track_snapshots::build_snapshots(&mut tx, &[track_id]).await?;
crate::sync::hooks::enqueue_op_in_tx(
&mut tx,
&crate::sync::hooks::PendingOpDraft {
entity: "playlist".into(),
entity_id,
field: Some("tracks".into()),
op: "insert".into(),
payload: Some(serde_json::json!({ "track_ids": [track_id] })),
payload: Some(serde_json::json!({
"track_ids": [track_id],
"snapshots": snapshots,
})),
},
)
.await?;
Expand Down Expand Up @@ -403,6 +411,9 @@ pub async fn add_tracks_to_playlist(
let mut tx = pool.begin().await?;
let inserted = append_tracks_conn(&mut tx, playlist_id, &track_ids, now).await?;
let entity_id = crate::sync::canonical::ensure_local_playlist(&mut tx, playlist_id).await?;
// Phase 1.j.b — per-track snapshots for the public share
// preview. See [`add_track_to_playlist`] for the rationale.
let snapshots = crate::sync::track_snapshots::build_snapshots(&mut tx, &track_ids).await?;
// One coalesced op for the whole batch — emitting N ops would
// cost N Lamport draws and bloat the queue without giving the
// server side any extra signal.
Expand All @@ -413,7 +424,10 @@ pub async fn add_tracks_to_playlist(
entity_id,
field: Some("tracks".into()),
op: "insert".into(),
payload: Some(serde_json::json!({ "track_ids": track_ids })),
payload: Some(serde_json::json!({
"track_ids": track_ids,
"snapshots": snapshots,
})),
},
)
.await?;
Expand Down Expand Up @@ -560,6 +574,9 @@ pub async fn add_source_to_playlist(
let mut tx = pool.begin().await?;
let inserted = append_tracks_conn(&mut tx, playlist_id, &track_ids, now_millis()).await?;
let entity_id = crate::sync::canonical::ensure_local_playlist(&mut tx, playlist_id).await?;
// Phase 1.j.b — per-track snapshots for the public share
// preview.
let snapshots = crate::sync::track_snapshots::build_snapshots(&mut tx, &track_ids).await?;
crate::sync::hooks::enqueue_op_in_tx(
&mut tx,
&crate::sync::hooks::PendingOpDraft {
Expand All @@ -569,6 +586,7 @@ pub async fn add_source_to_playlist(
op: "insert".into(),
payload: Some(serde_json::json!({
"track_ids": track_ids,
"snapshots": snapshots,
"via_source": { "type": source_type, "id": source_id },
})),
},
Expand Down
21 changes: 16 additions & 5 deletions src-tauri/crates/app/src/commands/share.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ pub async fn share_link_mint(
url,
})
}
reqwest::StatusCode::NOT_FOUND => Err(AppError::Other("playlist not found or not owned by the active profile".into())),
reqwest::StatusCode::NOT_FOUND => Err(AppError::Other(
"playlist not found or not owned by the active profile".into(),
)),
other => Err(AppError::Other(format!(
"share mint returned {other} ({})",
resp.text().await.unwrap_or_default()
Expand Down Expand Up @@ -150,7 +152,9 @@ pub async fn share_link_revoke(
write_cached_token(&pool, &playlist_canonical, None).await?;
Ok(())
}
reqwest::StatusCode::NOT_FOUND => Err(AppError::Other("playlist not found or not owned by the active profile".into())),
reqwest::StatusCode::NOT_FOUND => Err(AppError::Other(
"playlist not found or not owned by the active profile".into(),
)),
other => Err(AppError::Other(format!(
"share revoke returned {other} ({})",
resp.text().await.unwrap_or_default()
Expand All @@ -172,7 +176,9 @@ pub async fn share_link_status(
let playlist_canonical =
canonical::canonical_for_local(&mut conn, canonical::ENTITY_PLAYLIST, playlist_id)
.await?
.ok_or(AppError::Other("playlist not found or not owned by the active profile".into()))?;
.ok_or(AppError::Other(
"playlist not found or not owned by the active profile".into(),
))?;
drop(conn);

let token = read_cached_token(&pool, &playlist_canonical).await?;
Expand Down Expand Up @@ -208,7 +214,9 @@ async fn resolve_canonicals(
let playlist_canonical =
canonical::canonical_for_local(&mut conn, canonical::ENTITY_PLAYLIST, playlist_id)
.await?
.ok_or(AppError::Other("playlist not found or not owned by the active profile".into()))?;
.ok_or(AppError::Other(
"playlist not found or not owned by the active profile".into(),
))?;
drop(conn);

let profile_canonical = crate::db::profile_meta::canonical_id_for(&state.app_db, profile_id)
Expand Down Expand Up @@ -240,7 +248,10 @@ async fn build_share_url(app_db: &SqlitePool, token: &str) -> AppResult<String>

const CACHE_KEY_PREFIX: &str = "share.token.";

async fn read_cached_token(pool: &SqlitePool, playlist_canonical: &str) -> AppResult<Option<String>> {
async fn read_cached_token(
pool: &SqlitePool,
playlist_canonical: &str,
) -> AppResult<Option<String>> {
let key = format!("{CACHE_KEY_PREFIX}{playlist_canonical}");
let value: Option<String> =
sqlx::query_scalar("SELECT value FROM profile_setting WHERE key = ?")
Expand Down
26 changes: 10 additions & 16 deletions src-tauri/crates/app/src/db/profile_meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,10 @@ use crate::error::AppResult;
/// boot after the first post-migration boot) hits zero rows and
/// commits an empty transaction, which is essentially free.
pub async fn backfill_canonical_ids(pool: &SqlitePool) -> AppResult<usize> {
let needs_uuid: Vec<i64> = sqlx::query_scalar(
"SELECT id FROM profile WHERE canonical_id IS NULL ORDER BY id",
)
.fetch_all(pool)
.await?;
let needs_uuid: Vec<i64> =
sqlx::query_scalar("SELECT id FROM profile WHERE canonical_id IS NULL ORDER BY id")
.fetch_all(pool)
.await?;

if needs_uuid.is_empty() {
return Ok(0);
Expand Down Expand Up @@ -80,21 +79,16 @@ pub async fn ensure_canonical_id(pool: &SqlitePool, profile_id: i64) -> AppResul
// Re-read — either we wrote `candidate` or a racing caller wrote
// its own value first. In both cases the row now has a non-NULL
// canonical, and the read returns the winning value.
canonical_id_for(pool, profile_id)
.await?
.ok_or_else(|| {
crate::error::AppError::Other(format!(
"profile {profile_id} disappeared mid-ensure_canonical_id"
))
})
canonical_id_for(pool, profile_id).await?.ok_or_else(|| {
crate::error::AppError::Other(format!(
"profile {profile_id} disappeared mid-ensure_canonical_id"
))
})
}

/// Look up the canonical id of a given profile. `None` for rows that
/// don't exist (deleted) or haven't been backfilled yet.
pub async fn canonical_id_for(
pool: &SqlitePool,
profile_id: i64,
) -> AppResult<Option<String>> {
pub async fn canonical_id_for(pool: &SqlitePool, profile_id: i64) -> AppResult<Option<String>> {
let row: Option<Option<String>> =
sqlx::query_scalar("SELECT canonical_id FROM profile WHERE id = ?")
.bind(profile_id)
Expand Down
4 changes: 1 addition & 3 deletions src-tauri/crates/app/src/sync/drain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,9 +390,7 @@ mod tests {
field: Some("name".into()),
op: "set".into(),
payload: Some(serde_json::json!({ "value": "Soirée" })),
profile_canonical_id: Some(
"11111111-2222-4333-8444-555555555555".into(),
),
profile_canonical_id: Some("11111111-2222-4333-8444-555555555555".into()),
}],
};
let v = serde_json::to_value(&body).unwrap();
Expand Down
1 change: 1 addition & 0 deletions src-tauri/crates/app/src/sync/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,5 @@ pub mod hooks;
pub mod lamport;
pub mod mode;
pub mod queue;
pub mod track_snapshots;
pub mod ws;
Loading
Loading