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
3 changes: 3 additions & 0 deletions changelog/v0.1.0-beta3.txt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ MUSIC
- A song paused while it was still loading stays paused, and a recovery never starts a song you paused.
- A song whose link expires during long pauses recovers each time, waits for the connection to come back when the network drops, and tries another audio format when one cannot be decoded. Errors now say which of these went wrong.
- Media keys and the system's Now Playing controls follow whichever player started last, show the song's progress, and seek forward and back. Starting a video pauses the music and starting music pauses the video.
- Flow reopens the music player on the song you stopped at, paused at the same point, with its queue, shuffle order and radio. "Restore the last queue" in Settings turns this off.
- History and recommendations count the time a song actually played, so skipping or dragging to the end no longer counts as a full listen, and a short replay no longer replaces a longer one.
- A new "Resume long tracks" setting, off by default, starts mixes, podcasts and episodes of 10 minutes or more where you left them.

AUDIO
- Equalizer boosts no longer distort: the music equalizer turns the whole curve down by its highest boost before the filters, and a soft limiter bends any peak that still goes past full scale.
Expand Down
17 changes: 17 additions & 0 deletions src-tauri/migrations/0019_music_queue_state.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- The music queue as the player last left it, so a restart reopens on the same song (Flow for
-- Android's QueuePersistence). One row. state: JSON written and read by the player (queue, index,
-- position, source, shuffle order, radio tracks). Device-local, never synced.
CREATE TABLE IF NOT EXISTS music_queue_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
state TEXT NOT NULL,
saved_at INTEGER NOT NULL
);

-- Where long tracks (ten minutes or more: mixes, podcasts, episodes) were left, so they resume
-- there (Android's MusicResumeTracker). Device-local, never synced.
CREATE TABLE IF NOT EXISTS music_resume_positions (
video_id TEXT PRIMARY KEY,
position_seconds REAL NOT NULL,
duration_seconds REAL NOT NULL,
updated_at INTEGER NOT NULL
);
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod files;
pub mod home_feed;
pub mod music;
pub mod music_brain;
pub mod music_queue;
pub mod notifications;
pub mod pip;
pub mod recommendation;
Expand Down
81 changes: 81 additions & 0 deletions src-tauri/src/commands/music_queue.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//! Saves and restores the music queue between launches, and where long tracks were left.

use sqlx::SqlitePool;
use tauri::State;

use crate::db::music_queue;
use crate::errors::{AppError, ErrorResponse};
use crate::security::validation::validate_video_id;

type CmdResult<T> = Result<T, ErrorResponse>;

/// A saved queue is a few hundred songs at most; anything far larger is not one.
const MAX_QUEUE_STATE_BYTES: usize = 4 * 1024 * 1024;

fn unix_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| {
i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX)
})
}

#[tauri::command]
pub async fn save_music_queue_state(state: String, pool: State<'_, SqlitePool>) -> CmdResult<()> {
if state.len() > MAX_QUEUE_STATE_BYTES {
return Err(AppError::Validation("Music queue state is too large".into()).into());
}
serde_json::from_str::<serde_json::Value>(&state).map_err(|_| {
ErrorResponse::from(AppError::Validation("Music queue state is not JSON".into()))
})?;
music_queue::save_queue_state(&pool, &state, unix_ms())
.await
.map_err(ErrorResponse::from)
}

#[tauri::command]
pub async fn load_music_queue_state(pool: State<'_, SqlitePool>) -> CmdResult<Option<String>> {
music_queue::load_queue_state(&pool)
.await
.map_err(ErrorResponse::from)
}

#[tauri::command]
pub async fn clear_music_queue_state(pool: State<'_, SqlitePool>) -> CmdResult<()> {
music_queue::clear_queue_state(&pool)
.await
.map_err(ErrorResponse::from)
}

#[tauri::command]
pub async fn save_music_resume_position(
video_id: String,
position_seconds: f64,
duration_seconds: f64,
pool: State<'_, SqlitePool>,
) -> CmdResult<()> {
validate_video_id(&video_id).map_err(ErrorResponse::from)?;
if !position_seconds.is_finite() || !duration_seconds.is_finite() || duration_seconds <= 0.0 {
return Err(AppError::Validation("Invalid resume position".into()).into());
}
music_queue::save_resume_position(
&pool,
video_id.trim(),
position_seconds.clamp(0.0, duration_seconds),
duration_seconds,
unix_ms(),
)
.await
.map_err(ErrorResponse::from)
}

#[tauri::command]
pub async fn get_music_resume_position(
video_id: String,
pool: State<'_, SqlitePool>,
) -> CmdResult<Option<f64>> {
validate_video_id(&video_id).map_err(ErrorResponse::from)?;
music_queue::get_resume_position(&pool, video_id.trim())
.await
.map_err(ErrorResponse::from)
}
1 change: 1 addition & 0 deletions src-tauri/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod downloads;
pub mod library;
pub mod music_content;
pub mod music_home;
pub mod music_queue;
pub mod neuro_stores;
pub mod notifications;
pub mod recommendations;
Expand Down
112 changes: 112 additions & 0 deletions src-tauri/src/db/music_queue.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//! The music player's saved queue and long-track positions (migration 0018).

use crate::errors::{AppError, AppResult};
use sqlx::{Row, SqlitePool};

pub async fn save_queue_state(pool: &SqlitePool, state: &str, saved_at: i64) -> AppResult<()> {
sqlx::query(
"INSERT INTO music_queue_state (id, state, saved_at) VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET state = excluded.state, saved_at = excluded.saved_at",
)
.bind(state)
.bind(saved_at)
.execute(pool)
.await
.map_err(AppError::from)?;
Ok(())
}

pub async fn load_queue_state(pool: &SqlitePool) -> AppResult<Option<String>> {
let row = sqlx::query("SELECT state FROM music_queue_state WHERE id = 1")
.fetch_optional(pool)
.await
.map_err(AppError::from)?;
Ok(row.map(|row| row.get::<String, _>(0)))
}

pub async fn clear_queue_state(pool: &SqlitePool) -> AppResult<()> {
sqlx::query("DELETE FROM music_queue_state")
.execute(pool)
.await
.map_err(AppError::from)?;
Ok(())
}

pub async fn save_resume_position(
pool: &SqlitePool,
video_id: &str,
position_seconds: f64,
duration_seconds: f64,
updated_at: i64,
) -> AppResult<()> {
sqlx::query(
"INSERT INTO music_resume_positions (video_id, position_seconds, duration_seconds, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(video_id) DO UPDATE SET
position_seconds = excluded.position_seconds,
duration_seconds = excluded.duration_seconds,
updated_at = excluded.updated_at",
)
.bind(video_id)
.bind(position_seconds)
.bind(duration_seconds)
.bind(updated_at)
.execute(pool)
.await
.map_err(AppError::from)?;
Ok(())
}

pub async fn get_resume_position(pool: &SqlitePool, video_id: &str) -> AppResult<Option<f64>> {
let row = sqlx::query("SELECT position_seconds FROM music_resume_positions WHERE video_id = ?")
.bind(video_id)
.fetch_optional(pool)
.await
.map_err(AppError::from)?;
Ok(row.map(|row| row.get::<f64, _>(0)))
}

#[cfg(test)]
mod tests {
use super::*;

async fn pool() -> SqlitePool {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
pool
}

#[tokio::test]
async fn the_queue_keeps_only_its_latest_state() {
let pool = pool().await;
assert_eq!(load_queue_state(&pool).await.unwrap(), None);
save_queue_state(&pool, r#"{"v":1}"#, 1).await.unwrap();
save_queue_state(&pool, r#"{"v":2}"#, 2).await.unwrap();
assert_eq!(
load_queue_state(&pool).await.unwrap().as_deref(),
Some(r#"{"v":2}"#)
);
clear_queue_state(&pool).await.unwrap();
assert_eq!(load_queue_state(&pool).await.unwrap(), None);
}

#[tokio::test]
async fn a_long_track_remembers_where_it_was_left() {
let pool = pool().await;
assert_eq!(get_resume_position(&pool, "mix").await.unwrap(), None);
save_resume_position(&pool, "mix", 600.0, 3600.0, 1)
.await
.unwrap();
save_resume_position(&pool, "mix", 1200.5, 3600.0, 2)
.await
.unwrap();
assert_eq!(
get_resume_position(&pool, "mix").await.unwrap(),
Some(1200.5)
);
}
}
40 changes: 38 additions & 2 deletions src-tauri/src/db/watch_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,16 @@ pub async fn upsert_watch_record(pool: &SqlitePool, record: &WatchHistoryRecord)
if let Some(row) = existing {
let id: i64 = sqlx::Row::get(&row, 0);
// Clearing the sync stamp makes it follow the new watch time, so a re-watch beats a
// deletion synced from another device before it.
// deletion synced from another device before it. A song's row keeps its longest listen:
// a short replay or a skip must not wipe out a full play recorded earlier.
sqlx::query(
"UPDATE watch_history SET
title = ?,
channel_name = ?,
channel_id = COALESCE(?, channel_id),
watch_date = ?,
watch_duration_seconds = ?,
watch_duration_seconds = CASE WHEN ? AND is_music
THEN MAX(watch_duration_seconds, ?) ELSE ? END,
total_duration_seconds = ?,
is_music = ?,
is_short = is_short OR ?,
Expand All @@ -63,6 +65,8 @@ pub async fn upsert_watch_record(pool: &SqlitePool, record: &WatchHistoryRecord)
.bind(&record.channel_name)
.bind(&record.channel_id)
.bind(&record.watch_date)
.bind(record.is_music)
.bind(record.watch_duration_seconds)
.bind(record.watch_duration_seconds)
.bind(record.total_duration_seconds)
.bind(record.is_music)
Expand Down Expand Up @@ -300,6 +304,38 @@ mod channel_tests {
assert_eq!(history[0].watch_duration_seconds, 20);
}

#[tokio::test]
async fn a_song_keeps_its_longest_listen_and_a_video_its_latest_watch() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
let song = |seconds| WatchHistoryRecord {
video_id: "song".into(),
is_music: true,
..record(None, seconds)
};
upsert_watch_record(&pool, &song(190)).await.unwrap();
upsert_watch_record(&pool, &song(2)).await.unwrap();
upsert_watch_record(&pool, &record(None, 300))
.await
.unwrap();
upsert_watch_record(&pool, &record(None, 40)).await.unwrap();

let history = get_watch_history(&pool, 10, 0).await.unwrap();
let seconds = |id: &str| {
history
.iter()
.find(|row| row.video_id == id)
.unwrap()
.watch_duration_seconds
};
assert_eq!(seconds("song"), 190);
assert_eq!(seconds("v1"), 40);
}

#[tokio::test]
async fn a_short_stays_a_short_whatever_writes_it_next() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
Expand Down
10 changes: 10 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ use commands::music_brain::{
get_music_time_rotation, rank_music_candidates, record_music_interaction, reset_music_brain,
unblock_music_artist,
};
use commands::music_queue::{
clear_music_queue_state, get_music_resume_position, load_music_queue_state,
save_music_queue_state, save_music_resume_position,
};
use commands::notifications::{
check_subscriptions_now, clear_notifications, delete_notification, get_notifications,
get_unread_notification_count, mark_notifications_read,
Expand Down Expand Up @@ -617,6 +621,12 @@ pub fn run() {
get_music_brain_snapshot,
get_music_taste_profile,
reset_music_brain,
// --- Music queue restore and long-track resume ---
save_music_queue_state,
load_music_queue_state,
clear_music_queue_state,
save_music_resume_position,
get_music_resume_position,
// --- Flow Local Sync (P2P LAN) ---
sync_device_info,
sync_status,
Expand Down
Loading
Loading