Added nix files. revorked queue append, added library migration.

This commit is contained in:
Aleksandr Bogomiakov
2026-08-02 00:40:50 +01:00
parent cced546cd5
commit f73e3e7b95
23 changed files with 1707 additions and 69 deletions
+32
View File
@@ -0,0 +1,32 @@
# Changelog
All notable changes to Furumi are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.2.5] - 2026-08-02
### Added
- Command history navigation with the Up and Down arrow keys.
- A copy-friendly plain terminal view for connection tickets, device and Jam
invites, and track-sharing links.
- A configurable permanent music directory with write validation and an
optional safe migration of existing Furumi-managed files.
- Queue reordering for one track or a `Shift+V` selection with `Alt+K` and
`Alt+J`.
- Reproducible Nix/devenv tooling with Rust 1.97 and ALSA development files,
plus automatic Rust 1.97 selection through `rust-toolchain.toml`.
### Changed
- Sequential `a` actions now build one ordered play-next block instead of
reversing independently inserted tracks.
- Federated artist images and release covers are stored beside permanent music
in an `Artist/Release` directory tree.
[Unreleased]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.5...HEAD
[0.2.5]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.4...v0.2.5
Generated
+1 -1
View File
@@ -1565,7 +1565,7 @@ dependencies = [
[[package]] [[package]]
name = "furumi_tui" name = "furumi_tui"
version = "0.2.4" version = "0.2.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"blake3", "blake3",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumi_tui" name = "furumi_tui"
version = "0.2.4" version = "0.2.5"
edition = "2024" edition = "2024"
rust-version = "1.97" rust-version = "1.97"
description = "A federated P2P player for personal music libraries" description = "A federated P2P player for personal music libraries"
+23
View File
@@ -0,0 +1,23 @@
{ pkgs, ... }:
{
languages.rust = {
enable = true;
channel = "stable";
version = "1.97.0";
components = [
"cargo"
"clippy"
"rust-src"
"rustfmt"
];
};
packages = [
pkgs.pkg-config
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
pkgs.alsa-lib
];
env.RUST_BACKTRACE = "1";
}
Generated
+362
View File
@@ -0,0 +1,362 @@
{
"nodes": {
"cachix": {
"inputs": {
"devenv": [
"devenv"
],
"flake-compat": [
"devenv",
"flake-compat"
],
"git-hooks": [
"devenv",
"git-hooks"
],
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1777487137,
"narHash": "sha256-TuvKVBX60mqyMT6OB5JqVEh1YIWtFMR/igLCaCdC9tw=",
"owner": "cachix",
"repo": "cachix",
"rev": "a66a440c321d35f7193472c317f42a55ccd1cb93",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"crate2nix": {
"flake": false,
"locked": {
"lastModified": 1772186516,
"narHash": "sha256-8s28pzmQ6TOIUzznwFibtW1CMieMUl1rYJIxoQYor58=",
"owner": "rossng",
"repo": "crate2nix",
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
"type": "github"
},
"original": {
"owner": "rossng",
"repo": "crate2nix",
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"crate2nix": "crate2nix",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"ghostty": "ghostty",
"git-hooks": "git-hooks",
"nix": "nix",
"nixd": "nixd",
"nixpkgs": [
"nixpkgs"
],
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1785616853,
"narHash": "sha256-WKA53CfbSwXh/QuGHc3gsWoLNHYaKw4xgvkc9ceRgUA=",
"owner": "cachix",
"repo": "devenv",
"rev": "a3feed9ca96c66548496e8af2804d2a3a045d22e",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"ghostty": {
"flake": false,
"locked": {
"lastModified": 1784602798,
"narHash": "sha256-298x90knBUWX5GHGXh2SKsAKvStjU2ri9UgOGoF79/8=",
"owner": "ghostty-org",
"repo": "ghostty",
"rev": "88b4cd047fa627cdca6781bc7e7dc8b75a2cecb9",
"type": "github"
},
"original": {
"owner": "ghostty-org",
"repo": "ghostty",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1782908218,
"narHash": "sha256-wLMOrPgVyeF3XmP+qfYcLqnVdTxikdcSvbIY7rA9jTA=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "9f7e99119ece7705299595299f3b031f39356de1",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"nix": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"flake-parts": [
"devenv",
"flake-parts"
],
"git-hooks-nix": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-23-11": [
"devenv"
],
"nixpkgs-regression": [
"devenv"
]
},
"locked": {
"lastModified": 1785349663,
"narHash": "sha256-JSD8lPe5kalvPKx5X+inX8ZZdGLeXkAbd3Jiv7UDf+I=",
"owner": "cachix",
"repo": "nix",
"rev": "f33db89fd6db6edc337d93212f6628ab6d25f407",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "devenv-2.34",
"repo": "nix",
"type": "github"
}
},
"nixd": {
"inputs": {
"flake-parts": [
"devenv",
"flake-parts"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1783935112,
"narHash": "sha256-IAQ14nteIKXAz4cd75UcZrsHGEVJ7QNrkUwX9rmeZ/Y=",
"owner": "nix-community",
"repo": "nixd",
"rev": "a64cd33e53b316b6b092ea0a966640cd2309bf3d",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixd",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1772624091,
"narHash": "sha256-QKyJ0QGWBn6r0invrMAK8dmJoBYWoOWy7lN+UHzW1jc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "80bdc1e5ce51f56b19791b52b2901187931f5353",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1782614948,
"narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1785571196,
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"flake-parts": "flake-parts_2",
"nixpkgs": "nixpkgs_2",
"rust-overlay": "rust-overlay_2"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1782875958,
"narHash": "sha256-5eqDcnBjb1424HRQdnhuhNOBZguq1Z2tqSa2OMF/m2c=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "13139aefa973f3d96c60c0fbab801de058ae25ca",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"rust-overlay_2": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1785562362,
"narHash": "sha256-J15aBa3d6B1SUUAQydQ06wFjPzrrcVceb6jkdfwfGls=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "5f29c219a7655519f8a9f8c6968064b82c17cc93",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"devenv",
"nixd",
"nixpkgs"
]
},
"locked": {
"lastModified": 1780220602,
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+30
View File
@@ -0,0 +1,30 @@
{
description = "Furumi TUI development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
devenv.url = "github:cachix/devenv";
devenv.inputs.nixpkgs.follows = "nixpkgs";
rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
imports = [ inputs.devenv.flakeModule ];
perSystem = { ... }: {
devenv.shells.default = {
imports = [ ./devenv.nix ];
};
};
};
}
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "1.97.0"
profile = "minimal"
components = ["clippy", "rust-src", "rustfmt"]
+6
View File
@@ -38,6 +38,8 @@ pub enum Action {
OpenCurrentTrackInfo, OpenCurrentTrackInfo,
QueueAddNext, QueueAddNext,
QueueAddLast, QueueAddLast,
MoveQueueUp,
MoveQueueDown,
/// Download the selected federated track(s) into the local library. /// Download the selected federated track(s) into the local library.
DownloadSelected, DownloadSelected,
RemoveFromQueue, RemoveFromQueue,
@@ -107,6 +109,8 @@ impl Action {
| Action::OpenListenHistory => Category::Playback, | Action::OpenListenHistory => Category::Playback,
Action::QueueAddNext Action::QueueAddNext
| Action::QueueAddLast | Action::QueueAddLast
| Action::MoveQueueUp
| Action::MoveQueueDown
| Action::DownloadSelected | Action::DownloadSelected
| Action::RemoveFromQueue | Action::RemoveFromQueue
| Action::ClearQueue | Action::ClearQueue
@@ -193,6 +197,8 @@ impl Action {
Action::OpenCurrentTrackInfo => "Current track info".into(), Action::OpenCurrentTrackInfo => "Current track info".into(),
Action::QueueAddNext => "Queue: add next".into(), Action::QueueAddNext => "Queue: add next".into(),
Action::QueueAddLast => "Queue: add to end".into(), Action::QueueAddLast => "Queue: add to end".into(),
Action::MoveQueueUp => "Queue: move track/selection up".into(),
Action::MoveQueueDown => "Queue: move track/selection down".into(),
Action::DownloadSelected => "Federation: download to library".into(), Action::DownloadSelected => "Federation: download to library".into(),
Action::RemoveFromQueue => "Queue: remove selected".into(), Action::RemoveFromQueue => "Queue: remove selected".into(),
Action::ClearQueue => "Queue: clear".into(), Action::ClearQueue => "Queue: clear".into(),
+13
View File
@@ -17,6 +17,16 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
match key.code { match key.code {
KeyCode::Esc => cancel(state), KeyCode::Esc => cancel(state),
KeyCode::Enter => commit(state, runtime), KeyCode::Enter => commit(state, runtime),
KeyCode::Up => {
if state.cmdline.history_previous() {
after_change(state, runtime);
}
}
KeyCode::Down => {
if state.cmdline.history_next() {
after_change(state, runtime);
}
}
// Backspace on an empty line closes it, like vim. // Backspace on an empty line closes it, like vim.
KeyCode::Backspace if state.cmdline.input.is_empty() => cancel(state), KeyCode::Backspace if state.cmdline.input.is_empty() => cancel(state),
_ => { _ => {
@@ -162,7 +172,9 @@ pub(super) fn refresh_local_search(state: &mut AppState, runtime: &Runtime) {
/// Enter: close the line. Live commands already took effect (their view /// Enter: close the line. Live commands already took effect (their view
/// stays open); one-shot commands execute here. /// stays open); one-shot commands execute here.
fn commit(state: &mut AppState, runtime: &mut Runtime) { fn commit(state: &mut AppState, runtime: &mut Runtime) {
let remembered = state.cmdline.input.as_str().to_string();
let parsed = command::parse(&state.cmdline.input); let parsed = command::parse(&state.cmdline.input);
state.cmdline.remember(&remembered);
close(state); close(state);
match parsed { match parsed {
Parsed::Empty => {} Parsed::Empty => {}
@@ -272,6 +284,7 @@ fn close(state: &mut AppState) {
state.cmdline.active = false; state.cmdline.active = false;
state.cmdline.input.clear(); state.cmdline.input.clear();
state.cmdline.live = false; state.cmdline.live = false;
state.cmdline.begin_history_navigation();
} }
/// Pop the live search view if this command-line session opened it. /// Pop the live search view if this command-line session opened it.
+2
View File
@@ -56,6 +56,8 @@ pub enum AppEvent {
LocalContentIdsLoaded(Result<Vec<String>, String>), LocalContentIdsLoaded(Result<Vec<String>, String>),
/// Counts and storage footprint of the local library/database. /// Counts and storage footprint of the local library/database.
LocalLibraryStatsLoaded(Result<crate::library::LocalLibraryStats, String>), LocalLibraryStatsLoaded(Result<crate::library::LocalLibraryStats, String>),
MusicDirectoryValidated(Result<std::path::PathBuf, String>),
MusicDirectoryChanged(Result<crate::library::MusicRelocationStats, String>),
ListenHistoryLoaded(Result<Vec<crate::library::ListenHistoryEntry>, String>), ListenHistoryLoaded(Result<Vec<crate::library::ListenHistoryEntry>, String>),
/// One content id became available locally while the UI is open. /// One content id became available locally while the UI is open.
LocalContentAvailable { LocalContentAvailable {
+155 -7
View File
@@ -8,6 +8,7 @@ pub mod state;
pub mod update; pub mod update;
use std::io; use std::io;
use std::io::Write as _;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::sync::Arc; use std::sync::Arc;
@@ -61,6 +62,8 @@ pub struct Runtime {
pub art_semaphore: Arc<tokio::sync::Semaphore>, pub art_semaphore: Arc<tokio::sync::Semaphore>,
/// The terminal screen was externally disturbed and needs a full repaint. /// The terminal screen was externally disturbed and needs a full repaint.
pub force_redraw: bool, pub force_redraw: bool,
/// The alternate screen is temporarily suspended for copy-friendly text.
pub plain_text_mode: bool,
/// Monotonic sequence for live search; stale responses are dropped. /// Monotonic sequence for live search; stale responses are dropped.
pub search_seq: Arc<std::sync::atomic::AtomicU64>, pub search_seq: Arc<std::sync::atomic::AtomicU64>,
pub player: player::Controller, pub player: player::Controller,
@@ -106,6 +109,15 @@ fn refresh_local_library_stats(runtime: &Runtime) {
}); });
} }
pub(super) fn validate_music_directory(state: &mut AppState, runtime: &Runtime, path: PathBuf) {
state.status_message = Some("checking music directory write access…".into());
let tx = runtime.event_tx.clone();
tokio::task::spawn_blocking(move || {
let result = Library::validate_music_directory(&path).map_err(err_string);
let _ = tx.send(AppEvent::MusicDirectoryValidated(result));
});
}
fn spawn_artist_federation_enrichment(runtime: &Runtime, id: i64, name: String) { fn spawn_artist_federation_enrichment(runtime: &Runtime, id: i64, name: String) {
let fed = Arc::clone(&runtime.federation); let fed = Arc::clone(&runtime.federation);
let tx = runtime.event_tx.clone(); let tx = runtime.event_tx.clone();
@@ -244,6 +256,7 @@ pub async fn run(
}; };
state.player.volume = settings.volume; state.player.volume = settings.volume;
state.global.filters = settings.library; state.global.filters = settings.library;
state.music_dir = settings.music_dir.clone();
if let Err(err) = state.visualizer.load_library() { if let Err(err) = state.visualizer.load_library() {
state.status_message = Some(format!("visualizations disabled: {err:#}")); state.status_message = Some(format!("visualizations disabled: {err:#}"));
} }
@@ -255,6 +268,7 @@ pub async fn run(
Arc::clone(&library), Arc::clone(&library),
Arc::clone(&devices), Arc::clone(&devices),
Arc::clone(&jam), Arc::clone(&jam),
settings.music_dir.clone(),
); );
state.federation.settings = federation.settings(); state.federation.settings = federation.settings();
state.federation.devices = Some(devices.status()); state.federation.devices = Some(devices.status());
@@ -288,6 +302,7 @@ pub async fn run(
fed_streaming: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), fed_streaming: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)), art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
force_redraw: false, force_redraw: false,
plain_text_mode: false,
search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)), search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
player: player::spawn(move |event| { player: player::spawn(move |event| {
let _ = player_events.send(AppEvent::Player(event)); let _ = player_events.send(AppEvent::Player(event));
@@ -318,11 +333,30 @@ pub async fn run(
visual_tick.set_missed_tick_behavior(MissedTickBehavior::Skip); visual_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop { loop {
if runtime.force_redraw { let plain_text = match state.popup.as_ref() {
terminal.clear()?; Some(state::Popup::PlainText { text }) => Some(text.as_str()),
runtime.force_redraw = false; _ => None,
};
match (runtime.plain_text_mode, plain_text) {
(false, Some(text)) => {
enter_plain_text_mode(text)?;
runtime.plain_text_mode = true;
}
(true, None) => {
leave_plain_text_mode()?;
runtime.plain_text_mode = false;
runtime.force_redraw = true;
}
_ => {}
}
if !runtime.plain_text_mode {
if runtime.force_redraw {
terminal.clear()?;
runtime.force_redraw = false;
}
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
} }
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
tokio::select! { tokio::select! {
maybe_event = input.next() => match maybe_event { maybe_event = input.next() => match maybe_event {
@@ -347,6 +381,10 @@ pub async fn run(
} }
if state.should_quit { if state.should_quit {
if runtime.plain_text_mode {
leave_plain_text_mode()?;
runtime.plain_text_mode = false;
}
state.shutting_down = true; state.shutting_down = true;
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?; terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
runtime.federation.shutdown().await; runtime.federation.shutdown().await;
@@ -356,6 +394,32 @@ pub async fn run(
} }
} }
fn enter_plain_text_mode(text: &str) -> Result<()> {
let text: String = text
.chars()
.filter(|character| !character.is_control())
.collect();
crossterm::execute!(
io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableBracketedPaste,
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::style::Print(text)
)?;
io::stdout().flush()?;
Ok(())
}
fn leave_plain_text_mode() -> Result<()> {
crossterm::execute!(
io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableBracketedPaste
)?;
Ok(())
}
fn sync_player_shared(state: &mut AppState, runtime: &Runtime) { fn sync_player_shared(state: &mut AppState, runtime: &Runtime) {
if state.device_playback.is_control() { if state.device_playback.is_control() {
extrapolate_control_position(state); extrapolate_control_position(state);
@@ -453,6 +517,7 @@ fn apply_playback_state_to_ui(
.filter(|track| update::track_allowed_by_source_mode(state, track)) .filter(|track| update::track_allowed_by_source_mode(state, track))
.collect(); .collect();
state.player.queue_pos = queue_pos.min(state.player.queue.len().saturating_sub(1)); state.player.queue_pos = queue_pos.min(state.player.queue.len().saturating_sub(1));
state.player.play_next_end = None;
state.player.playing = wire.playing && !state.player.queue.is_empty(); state.player.playing = wire.playing && !state.player.queue.is_empty();
state.player.paused = wire.paused; state.player.paused = wire.paused;
state.device_playback.local_idle_since_ms = if state.player.playing && !state.player.paused { state.device_playback.local_idle_since_ms = if state.player.playing && !state.player.paused {
@@ -1331,6 +1396,14 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
} }
Effect::SetOptions => {} Effect::SetOptions => {}
Effect::PlaybackQueueChanged => {} Effect::PlaybackQueueChanged => {}
Effect::QueueOrderChanged { restart_current } => {
if restart_current && state.player.playing && state.player.current.is_some() {
let paused = state.player.paused;
start_current_audio(state, runtime, state.player.position_secs, paused);
push_media_metadata(state, runtime);
push_media_update(state, runtime, true);
}
}
Effect::SourceModeChanged => { Effect::SourceModeChanged => {
runtime.library_network_refresh_at = None; runtime.library_network_refresh_at = None;
if let Ok(mut cursors) = runtime.library_network_cursors.lock() { if let Ok(mut cursors) = runtime.library_network_cursors.lock() {
@@ -1349,6 +1422,30 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
perform_effect(state, runtime, effect); perform_effect(state, runtime, effect);
} }
} }
Effect::ChangeMusicDirectory {
path,
move_existing,
} => {
if state.music_dir_changing {
state.status_message = Some("music directory change is already running".into());
return;
}
state.music_dir_changing = true;
state.status_message = Some(if move_existing {
"moving saved music to the new directory…".into()
} else {
"changing music save directory…".into()
});
let federation = Arc::clone(&runtime.federation);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = federation
.change_media_dir(path, move_existing)
.await
.map_err(err_string);
let _ = tx.send(AppEvent::MusicDirectoryChanged(result));
});
}
Effect::EnqueueRelease { id, next } => { Effect::EnqueueRelease { id, next } => {
let library = Arc::clone(&runtime.library); let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone(); let tx = runtime.event_tx.clone();
@@ -1733,6 +1830,7 @@ fn is_controlled_playback_effect(effect: &Effect) -> bool {
| Effect::SetOptions | Effect::SetOptions
| Effect::RemoveQueueIndices { .. } | Effect::RemoveQueueIndices { .. }
| Effect::PlaybackQueueChanged | Effect::PlaybackQueueChanged
| Effect::QueueOrderChanged { .. }
) )
} }
@@ -1766,7 +1864,9 @@ fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime,
Effect::SetOptions Effect::SetOptions
| Effect::RemoveQueueIndices { .. } | Effect::RemoveQueueIndices { .. }
| Effect::PlaybackQueueChanged | Effect::PlaybackQueueChanged
| Effect::LoadListenHistory => {} | Effect::QueueOrderChanged { .. }
| Effect::LoadListenHistory
| Effect::ChangeMusicDirectory { .. } => {}
_ => {} _ => {}
} }
if local_only_volume { if local_only_volume {
@@ -2678,6 +2778,7 @@ fn save_app_settings(state: &AppState) {
let settings = crate::config::settings::AppSettings { let settings = crate::config::settings::AppSettings {
volume: state.player.volume, volume: state.player.volume,
library: state.global.filters, library: state.global.filters,
music_dir: state.music_dir.clone(),
}; };
if let Err(err) = crate::config::settings::save(&settings) { if let Err(err) = crate::config::settings::save(&settings) {
tracing::warn!(%err, "saving app settings failed"); tracing::warn!(%err, "saving app settings failed");
@@ -2939,6 +3040,44 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
Err(err) => state::Loadable::Failed(err), Err(err) => state::Loadable::Failed(err),
}); });
} }
AppEvent::MusicDirectoryValidated(result) => match result {
Ok(path) => {
let current = std::fs::canonicalize(&state.music_dir)
.unwrap_or_else(|_| state.music_dir.clone());
if path == current {
state.status_message = Some("this is already the music save directory".into());
} else {
state.popup = Some(state::Popup::ConfirmMusicDirectory { path });
state.status_message = None;
}
}
Err(message) => {
state.status_message = Some(format!(
"music directory is not writable; nothing changed: {message}"
));
}
},
AppEvent::MusicDirectoryChanged(result) => {
state.music_dir_changing = false;
match result {
Ok(stats) => {
state.music_dir = runtime.federation.media_dir();
save_app_settings(state);
state.status_message = Some(format!(
"music directory changed · moved {} track(s), {} image(s)",
stats.tracks, stats.images
));
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
message: state.status_message.clone(),
});
}
Err(message) => {
state.status_message = Some(format!(
"music directory change failed; old library kept: {message}"
));
}
}
}
AppEvent::FederationStatus(status) => { AppEvent::FederationStatus(status) => {
state.federation.status = Some(status); state.federation.status = Some(status);
} }
@@ -3018,6 +3157,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
title: "Device invite".to_string(), title: "Device invite".to_string(),
text: invite, text: invite,
help: "Use this invite on another client within 10 minutes to pair it with this device group.".to_string(), help: "Use this invite on another client within 10 minutes to pair it with this device group.".to_string(),
cursor: 0,
}); });
state.status_message = Some("device invite generated".to_string()); state.status_message = Some("device invite generated".to_string());
} }
@@ -3117,6 +3257,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
title: "Jam invite".to_string(), title: "Jam invite".to_string(),
text: invite, text: invite,
help: "Copied capability lets federation peers control this host player until restart or regeneration.".to_string(), help: "Copied capability lets federation peers control this host player until restart or regeneration.".to_string(),
cursor: 0,
}); });
state.status_message = Some("Jam started".into()); state.status_message = Some("Jam started".into());
} }
@@ -3273,6 +3414,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
// Skip the failed track instead of stalling the queue. // Skip the failed track instead of stalling the queue.
if state.player.queue_pos + 1 < state.player.queue.len() { if state.player.queue_pos + 1 < state.player.queue.len() {
state.player.queue_pos += 1; state.player.queue_pos += 1;
update::normalize_play_next_block(&mut state.player);
start_current_audio(state, runtime, 0.0, state.player.paused); start_current_audio(state, runtime, 0.0, state.player.paused);
push_media_metadata(state, runtime); push_media_metadata(state, runtime);
push_media_update(state, runtime, true); push_media_update(state, runtime, true);
@@ -3374,6 +3516,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
text: ticket, text: ticket,
help: "Copy this ticket and paste it into Connect to a peer on another client." help: "Copy this ticket and paste it into Connect to a peer on another client."
.to_string(), .to_string(),
cursor: 0,
}); });
} }
Err(message) => state.status_message = Some(message), Err(message) => state.status_message = Some(message),
@@ -3516,6 +3659,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
.take() .take()
.unwrap_or(state.player.queue_pos + 1); .unwrap_or(state.player.queue_pos + 1);
state.player.queue_pos = next_pos.min(state.player.queue.len().saturating_sub(1)); state.player.queue_pos = next_pos.min(state.player.queue.len().saturating_sub(1));
update::normalize_play_next_block(&mut state.player);
state.player.current = state.player.queue.get(state.player.queue_pos).cloned(); state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
state.player.position_secs = 0.0; state.player.position_secs = 0.0;
state.player.track_started_at = Some(now_epoch_seconds()); state.player.track_started_at = Some(now_epoch_seconds());
@@ -3648,9 +3792,13 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
} }
AppEvent::EnqueueTracks { tracks, next } => { AppEvent::EnqueueTracks { tracks, next } => {
let previous_len = state.player.queue.len(); let previous_len = state.player.queue.len();
update::enqueue_tracks(state, tracks, next); let restart_current = update::enqueue_tracks(state, tracks, next);
let count = state.player.queue.len().saturating_sub(previous_len); let count = state.player.queue.len().saturating_sub(previous_len);
record_control_playback_state(state, runtime, false); perform_effect(
state,
runtime,
Effect::QueueOrderChanged { restart_current },
);
state.status_message = Some(if count == 0 { state.status_message = Some(if count == 0 {
"no tracks available in the current source mode".to_string() "no tracks available in the current source mode".to_string()
} else if next { } else if next {
+79 -7
View File
@@ -136,17 +136,52 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {} KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
_ => state.popup = Some(Popup::FedText { title, text }), _ => state.popup = Some(Popup::FedText { title, text }),
}, },
Popup::FedCopyText { title, text, help } => match key.code { Popup::FedCopyText {
title,
text,
help,
mut cursor,
} => match key.code {
KeyCode::Esc | KeyCode::Char('q') => {} KeyCode::Esc | KeyCode::Char('q') => {}
KeyCode::Left | KeyCode::Right | KeyCode::Tab | KeyCode::BackTab => {
cursor = usize::from(cursor == 0);
state.popup = Some(Popup::FedCopyText {
title,
text,
help,
cursor,
});
}
KeyCode::Enter if cursor == 1 => state.popup = Some(Popup::PlainText { text }),
KeyCode::Char('p') => state.popup = Some(Popup::PlainText { text }),
KeyCode::Enter | KeyCode::Char('c') => match copy_to_clipboard(&text) { KeyCode::Enter | KeyCode::Char('c') => match copy_to_clipboard(&text) {
Ok(()) => state.status_message = Some("copied to clipboard".to_string()), Ok(()) => state.status_message = Some("copied to clipboard".to_string()),
Err(err) => { Err(err) => {
state.status_message = Some(format!("copy failed: {err}")); state.status_message = Some(format!("copy failed: {err}"));
state.popup = Some(Popup::FedCopyText { title, text, help }); state.popup = Some(Popup::FedCopyText {
title,
text,
help,
cursor,
});
} }
}, },
_ => state.popup = Some(Popup::FedCopyText { title, text, help }), _ => {
state.popup = Some(Popup::FedCopyText {
title,
text,
help,
cursor,
})
}
}, },
Popup::PlainText { text } => match key.code {
KeyCode::Esc => {}
_ => state.popup = Some(Popup::PlainText { text }),
},
Popup::ConfirmMusicDirectory { path } => {
handle_music_directory_confirmation(state, runtime, path, key)
}
Popup::FederationStatusDetails { Popup::FederationStatusDetails {
focus, focus,
status_cursor, status_cursor,
@@ -502,6 +537,13 @@ fn handle_fed_input(
!state.federation.settings.network_id.is_empty(); !state.federation.settings.network_id.is_empty();
super::fed_apply_settings(state, runtime); super::fed_apply_settings(state, runtime);
} }
FedInputField::MusicDirectory => {
if value.is_empty() {
state.status_message = Some("music directory is empty".into());
} else {
super::validate_music_directory(state, runtime, value.into());
}
}
FedInputField::ConnectTicket => { FedInputField::ConnectTicket => {
if value.is_empty() { if value.is_empty() {
state.status_message = Some("ticket is empty".into()); state.status_message = Some("ticket is empty".into());
@@ -551,6 +593,31 @@ fn handle_fed_input(
} }
} }
fn handle_music_directory_confirmation(
state: &mut AppState,
runtime: &mut Runtime,
path: std::path::PathBuf,
key: KeyEvent,
) {
let move_existing = match key.code {
KeyCode::Esc | KeyCode::Char('q') => return,
KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => true,
KeyCode::Char('n') | KeyCode::Char('N') => false,
_ => {
state.popup = Some(Popup::ConfirmMusicDirectory { path });
return;
}
};
super::perform_effect(
state,
runtime,
crate::app::update::Effect::ChangeMusicDirectory {
path,
move_existing,
},
);
}
fn handle_device_pairing( fn handle_device_pairing(
state: &mut AppState, state: &mut AppState,
runtime: &Runtime, runtime: &Runtime,
@@ -934,10 +1001,15 @@ fn handle_track_info(
KeyCode::Char('c') => { KeyCode::Char('c') => {
if let Some(track) = tracks.get(cursor.min(len.saturating_sub(1))) { if let Some(track) = tracks.get(cursor.min(len.saturating_sub(1))) {
match crate::share::track_share_link(track) { match crate::share::track_share_link(track) {
Some(link) => match copy_to_clipboard(&link) { Some(link) => {
Ok(()) => state.status_message = Some("frid link copied".into()), state.popup = Some(Popup::FedCopyText {
Err(err) => state.status_message = Some(format!("copy failed: {err}")), title: "Track share link".into(),
}, text: link,
help: "Copy the link with a clipboard helper, or show it as one clean terminal line for mouse selection.".into(),
cursor: 0,
});
return;
}
None => state.status_message = Some("no content id for this track yet".into()), None => state.status_message = Some("no content id for this track yet".into()),
} }
} }
+116 -1
View File
@@ -389,6 +389,7 @@ mod tests {
featured_tracks: Vec::new(), featured_tracks: Vec::new(),
}; };
let mut state = AppState::default(); let mut state = AppState::default();
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
state.artist_fed_views.insert( state.artist_fed_views.insert(
detail.id, detail.id,
Loadable::Ready(crate::federation::FedArtistCard { Loadable::Ready(crate::federation::FedArtistCard {
@@ -716,7 +717,16 @@ pub enum Popup {
title: String, title: String,
text: String, text: String,
help: String, help: String,
cursor: usize,
}, },
/// A copy-friendly terminal screen containing exactly one logical line.
/// The app loop temporarily leaves the alternate screen while this is
/// active so terminal selection does not acquire TUI borders or hard
/// line breaks.
PlainText { text: String },
/// The destination is already write-tested; Enter/y migrates managed
/// files, while n changes only the destination for future downloads.
ConfirmMusicDirectory { path: std::path::PathBuf },
/// Incoming trusted-device pairing request. /// Incoming trusted-device pairing request.
DevicePairing { DevicePairing {
request_id: String, request_id: String,
@@ -801,6 +811,7 @@ impl StatusDetailFocus {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedInputField { pub enum FedInputField {
MusicDirectory,
NetworkId, NetworkId,
ConnectTicket, ConnectTicket,
DeviceName, DeviceName,
@@ -811,6 +822,7 @@ pub enum FedInputField {
impl FedInputField { impl FedInputField {
pub fn title(self) -> &'static str { pub fn title(self) -> &'static str {
match self { match self {
FedInputField::MusicDirectory => "Music save directory",
FedInputField::NetworkId => "Network ID", FedInputField::NetworkId => "Network ID",
FedInputField::ConnectTicket => "Connect to peer (paste ticket)", FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
FedInputField::DeviceName => "Device name", FedInputField::DeviceName => "Device name",
@@ -821,6 +833,9 @@ impl FedInputField {
pub fn help(self) -> &'static str { pub fn help(self) -> &'static str {
match self { match self {
FedInputField::MusicDirectory => {
"Federated tracks saved to your library use this directory. The directory is checked for write access before anything changes."
}
FedInputField::NetworkId => { FedInputField::NetworkId => {
"A unique network id. It must match exactly on every client that should see and connect to the same peers." "A unique network id. It must match exactly on every client that should see and connect to the same peers."
} }
@@ -867,6 +882,7 @@ impl FedRow {
/// the config directory. /// the config directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow { pub enum SettingsRow {
MusicDirectory,
Federation(FedRow), Federation(FedRow),
StatusDetails, StatusDetails,
DeviceName, DeviceName,
@@ -1004,7 +1020,7 @@ pub fn device_status_order(state: &AppState) -> Vec<usize> {
} }
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> { pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
let mut rows = Vec::new(); let mut rows = vec![SettingsRow::MusicDirectory];
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation)); rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
rows.push(SettingsRow::DeviceName); rows.push(SettingsRow::DeviceName);
rows.push(SettingsRow::DeviceInvite); rows.push(SettingsRow::DeviceInvite);
@@ -1064,6 +1080,98 @@ pub struct Cmdline {
/// A live command (search) applied effects during this session; Esc /// A live command (search) applied effects during this session; Esc
/// undoes them, Enter keeps them. /// undoes them, Enter keeps them.
pub live: bool, pub live: bool,
/// Commands committed during this process, oldest first.
pub history: Vec<String>,
/// Entry currently recalled with Up/Down. `None` is the editable draft
/// after the newest history entry.
history_index: Option<usize>,
history_draft: String,
}
impl Cmdline {
const HISTORY_LIMIT: usize = 100;
pub fn begin_history_navigation(&mut self) {
self.history_index = None;
self.history_draft.clear();
}
pub fn remember(&mut self, value: &str) {
let value = value.trim();
if value.is_empty() {
return;
}
if self.history.last().is_none_or(|last| last != value) {
self.history.push(value.to_string());
if self.history.len() > Self::HISTORY_LIMIT {
self.history.remove(0);
}
}
self.begin_history_navigation();
}
pub fn history_previous(&mut self) -> bool {
if self.history.is_empty() {
return false;
}
let index = match self.history_index {
Some(index) => index.saturating_sub(1),
None => {
self.history_draft = self.input.as_str().to_string();
self.history.len() - 1
}
};
self.history_index = Some(index);
self.input = LineEdit::new(self.history[index].clone());
true
}
pub fn history_next(&mut self) -> bool {
let Some(index) = self.history_index else {
return false;
};
if index + 1 < self.history.len() {
self.history_index = Some(index + 1);
self.input = LineEdit::new(self.history[index + 1].clone());
} else {
self.history_index = None;
self.input = LineEdit::new(std::mem::take(&mut self.history_draft));
}
true
}
}
#[cfg(test)]
mod cmdline_history_tests {
use super::Cmdline;
use crate::app::input::LineEdit;
#[test]
fn history_walks_oldest_and_restores_the_draft() {
let mut cmdline = Cmdline::default();
cmdline.remember("volume 20");
cmdline.remember("/ambient");
cmdline.input = LineEdit::new("unfinished");
assert!(cmdline.history_previous());
assert_eq!(cmdline.input.as_str(), "/ambient");
assert!(cmdline.history_previous());
assert_eq!(cmdline.input.as_str(), "volume 20");
assert!(cmdline.history_previous());
assert_eq!(cmdline.input.as_str(), "volume 20");
assert!(cmdline.history_next());
assert_eq!(cmdline.input.as_str(), "/ambient");
assert!(cmdline.history_next());
assert_eq!(cmdline.input.as_str(), "unfinished");
}
#[test]
fn history_deduplicates_consecutive_commands() {
let mut cmdline = Cmdline::default();
cmdline.remember("q");
cmdline.remember(" q ");
assert_eq!(cmdline.history, vec!["q"]);
}
} }
/// Live search state driven by the `:/query` command. /// Live search state driven by the `:/query` command.
@@ -1169,6 +1277,9 @@ impl RepeatMode {
pub struct PlayerBar { pub struct PlayerBar {
pub queue: Vec<TrackItem>, pub queue: Vec<TrackItem>,
pub queue_pos: usize, pub queue_pos: usize,
/// Exclusive end of the contiguous "play next" block built by
/// sequential `a` actions. New `a` additions are inserted here.
pub play_next_end: Option<usize>,
pub current: Option<TrackItem>, pub current: Option<TrackItem>,
/// A track is loaded (playing or paused); false = stopped. /// A track is loaded (playing or paused); false = stopped.
pub playing: bool, pub playing: bool,
@@ -1194,6 +1305,7 @@ impl Default for PlayerBar {
Self { Self {
queue: Vec::new(), queue: Vec::new(),
queue_pos: 0, queue_pos: 0,
play_next_end: None,
current: None, current: None,
playing: false, playing: false,
paused: false, paused: false,
@@ -1283,6 +1395,9 @@ pub struct AppState {
pub status_message: Option<String>, pub status_message: Option<String>,
pub spinner_frame: usize, pub spinner_frame: usize,
pub settings_cursor: usize, pub settings_cursor: usize,
/// Root for music permanently downloaded from federation peers.
pub music_dir: std::path::PathBuf,
pub music_dir_changing: bool,
pub player: PlayerBar, pub player: PlayerBar,
pub device_playback: DevicePlaybackState, pub device_playback: DevicePlaybackState,
pub jam: crate::jam::JamStatus, pub jam: crate::jam::JamStatus,
+131 -11
View File
@@ -48,8 +48,19 @@ pub enum Effect {
}, },
/// Queue/options changed without a direct audio engine action. /// Queue/options changed without a direct audio engine action.
PlaybackQueueChanged, PlaybackQueueChanged,
/// Queue order changed. A decoded gapless-next source must be discarded
/// by restarting the current source at its current position.
QueueOrderChanged {
restart_current: bool,
},
/// Persist and apply a Local / My / Global source-mode change. /// Persist and apply a Local / My / Global source-mode change.
SourceModeChanged, SourceModeChanged,
/// Switch the permanent federation-download root, optionally relocating
/// files managed under the previous root first.
ChangeMusicDirectory {
path: std::path::PathBuf,
move_existing: bool,
},
/// Persist the federation settings and start/stop the node. /// Persist the federation settings and start/stop the node.
FedApplySettings, FedApplySettings,
/// Force an immediate library publish into the DHT. /// Force an immediate library publish into the DHT.
@@ -180,6 +191,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
} }
Action::ToggleShuffle => { Action::ToggleShuffle => {
state.player.shuffle = !state.player.shuffle; state.player.shuffle = !state.player.shuffle;
state.player.play_next_end = None;
// Shuffle physically reorders the unplayed tail, so the Queue // Shuffle physically reorders the unplayed tail, so the Queue
// tab always shows the real upcoming order; turning it off // tab always shows the real upcoming order; turning it off
// restores the original ordering. // restores the original ordering.
@@ -275,6 +287,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
Action::OpenCommandLine => { Action::OpenCommandLine => {
state.cmdline.active = true; state.cmdline.active = true;
state.cmdline.input.clear(); state.cmdline.input.clear();
state.cmdline.begin_history_navigation();
None None
} }
Action::OpenSearch => { Action::OpenSearch => {
@@ -283,6 +296,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
state.cmdline.active = true; state.cmdline.active = true;
state.cmdline.input = crate::app::input::LineEdit::new("/"); state.cmdline.input = crate::app::input::LineEdit::new("/");
state.cmdline.live = true; state.cmdline.live = true;
state.cmdline.begin_history_navigation();
state.search = SearchState::default(); state.search = SearchState::default();
state.active_tab = Tab::Global; state.active_tab = Tab::Global;
if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) { if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) {
@@ -371,6 +385,8 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
Action::RemoveFromQueue => remove_selected_from_queue(state), Action::RemoveFromQueue => remove_selected_from_queue(state),
Action::QueueAddNext => queue_add(state, true), Action::QueueAddNext => queue_add(state, true),
Action::QueueAddLast => queue_add(state, false), Action::QueueAddLast => queue_add(state, false),
Action::MoveQueueUp => move_selected_queue(state, true),
Action::MoveQueueDown => move_selected_queue(state, false),
Action::GoToRelease => { Action::GoToRelease => {
let track = selected_track(state).or_else(|| state.player.current.clone()); let track = selected_track(state).or_else(|| state.player.current.clone());
match track { match track {
@@ -418,6 +434,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
let had_tracks = !state.player.queue.is_empty(); let had_tracks = !state.player.queue.is_empty();
state.player.queue.clear(); state.player.queue.clear();
state.player.queue_pos = 0; state.player.queue_pos = 0;
state.player.play_next_end = None;
state.player.current = None; state.player.current = None;
state.player.playing = false; state.player.playing = false;
state.player.paused = false; state.player.paused = false;
@@ -1030,6 +1047,68 @@ fn selected_queue_indices(state: &AppState) -> Vec<usize> {
.unwrap_or_else(|| vec![state.queue_tab.cursor.min(state.player.queue.len() - 1)]) .unwrap_or_else(|| vec![state.queue_tab.cursor.min(state.player.queue.len() - 1)])
} }
fn move_selected_queue(state: &mut AppState, up: bool) -> Option<Effect> {
let indices = selected_queue_indices(state);
let Some(&start) = indices.first() else {
state.status_message = Some("queue is empty".into());
return None;
};
let end = *indices.last().unwrap_or(&start);
let len = state.player.queue.len();
if (up && start == 0) || (!up && end + 1 >= len) {
state.status_message = Some(if up {
"selection is already at the top of the queue".into()
} else {
"selection is already at the bottom of the queue".into()
});
return None;
}
let old_current = state.player.queue_pos;
let new_current = if up {
let displaced = state.player.queue.remove(start - 1);
state.player.queue.insert(end, displaced);
if old_current == start - 1 {
end
} else if (start..=end).contains(&old_current) {
old_current - 1
} else {
old_current
}
} else {
let displaced = state.player.queue.remove(end + 1);
state.player.queue.insert(start, displaced);
if old_current == end + 1 {
start
} else if (start..=end).contains(&old_current) {
old_current + 1
} else {
old_current
}
};
let delta: isize = if up { -1 } else { 1 };
state.player.queue_pos = new_current;
state.queue_tab.cursor = (state.queue_tab.cursor as isize + delta) as usize;
if state
.track_selection
.is_active_for(&TrackSelectionScope::Queue)
{
state.track_selection.anchor = (state.track_selection.anchor as isize + delta) as usize;
state.track_selection.cursor = (state.track_selection.cursor as isize + delta) as usize;
}
// An explicit manual order supersedes both the temporary play-next block
// and a saved pre-shuffle order.
state.player.play_next_end = None;
state.player.original_order = None;
let restart_current = state.player.prefetched_pos.take().is_some();
state.status_message = Some(format!(
"moved {} track(s) {}",
indices.len(),
if up { "up" } else { "down" }
));
Some(Effect::QueueOrderChanged { restart_current })
}
fn remove_selected_from_queue(state: &mut AppState) -> Option<Effect> { fn remove_selected_from_queue(state: &mut AppState) -> Option<Effect> {
let indices = selected_queue_indices(state); let indices = selected_queue_indices(state);
if indices.is_empty() { if indices.is_empty() {
@@ -1081,6 +1160,10 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
.iter() .iter()
.filter(|index| **index < old_queue_pos) .filter(|index| **index < old_queue_pos)
.count(); .count();
if let Some(end) = state.player.play_next_end {
let removed_before_end = unique.iter().filter(|index| **index < end).count();
state.player.play_next_end = Some(end.saturating_sub(removed_before_end));
}
let was_loaded = state.player.playing; let was_loaded = state.player.playing;
let was_paused = state.player.paused; let was_paused = state.player.paused;
@@ -1107,6 +1190,7 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
state.player.track_started_at = None; state.player.track_started_at = None;
state.player.listen_id = None; state.player.listen_id = None;
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1); state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
normalize_play_next_block(&mut state.player);
return QueueRemovalOutcome { return QueueRemovalOutcome {
restart_paused: was_loaded.then_some(was_paused), restart_paused: was_loaded.then_some(was_paused),
stop: false, stop: false,
@@ -1134,6 +1218,7 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
.cloned() .cloned()
}); });
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1); state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
normalize_play_next_block(&mut state.player);
QueueRemovalOutcome { QueueRemovalOutcome {
restart_paused: None, restart_paused: None,
stop: false, stop: false,
@@ -1267,7 +1352,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
if !tracks.is_empty() { if !tracks.is_empty() {
let count = tracks.len(); let count = tracks.len();
let title = tracks[0].title.clone(); let title = tracks[0].title.clone();
enqueue_tracks(state, tracks, next); let restart_current = enqueue_tracks(state, tracks, next);
state.track_selection.clear(); state.track_selection.clear();
state.status_message = Some(if count == 1 && next { state.status_message = Some(if count == 1 && next {
format!("queued next: {title}") format!("queued next: {title}")
@@ -1278,7 +1363,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
} else { } else {
format!("queued: {count} tracks") format!("queued: {count} tracks")
}); });
return Some(Effect::PlaybackQueueChanged); return Some(Effect::QueueOrderChanged { restart_current });
} }
if let Some(id) = selected_release_id(state) { if let Some(id) = selected_release_id(state) {
return Some(Effect::EnqueueRelease { id, next }); return Some(Effect::EnqueueRelease { id, next });
@@ -1372,19 +1457,22 @@ pub(crate) fn track_artist_refs(track: &TrackItem) -> Vec<crate::library::models
refs refs
} }
/// Insert tracks after the playing one (`next`) or at the end. Keeps the /// Insert tracks into the stable "play next" block (`next`) or at the end.
/// gapless prefetch index pointing at the same track if items shift. /// Returns whether a decoded gapless-next source became stale.
pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool) { pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool) -> bool {
let tracks: Vec<_> = tracks let tracks: Vec<_> = tracks
.into_iter() .into_iter()
.filter(|track| track_allowed_by_source_mode(state, track)) .filter(|track| track_allowed_by_source_mode(state, track))
.collect(); .collect();
let player = &mut state.player; let player = &mut state.player;
if tracks.is_empty() { if tracks.is_empty() {
return; return false;
} }
let insert_at = if next && !player.queue.is_empty() { let insert_at = if next && !player.queue.is_empty() {
(player.queue_pos + 1).min(player.queue.len()) player
.play_next_end
.filter(|end| *end >= player.queue_pos.saturating_add(1) && *end <= player.queue.len())
.unwrap_or_else(|| (player.queue_pos + 1).min(player.queue.len()))
} else if next { } else if next {
0 0
} else { } else {
@@ -1394,14 +1482,28 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool)
for (offset, track) in tracks.into_iter().enumerate() { for (offset, track) in tracks.into_iter().enumerate() {
player.queue.insert(insert_at + offset, track); player.queue.insert(insert_at + offset, track);
} }
if let Some(prefetched) = &mut player.prefetched_pos let restart_current = player
&& insert_at <= *prefetched .prefetched_pos
{ .is_some_and(|prefetched| insert_at <= prefetched);
*prefetched += count; if restart_current {
player.prefetched_pos = None;
} }
if insert_at <= player.queue_pos && player.current.is_some() { if insert_at <= player.queue_pos && player.current.is_some() {
player.queue_pos += count; player.queue_pos += count;
} }
if next {
player.play_next_end = Some(insert_at + count);
}
restart_current
}
pub(super) fn normalize_play_next_block(player: &mut super::state::PlayerBar) {
if player
.play_next_end
.is_some_and(|end| end <= player.queue_pos || end > player.queue.len())
{
player.play_next_end = None;
}
} }
/// Manual queue navigation (n / p); the tail is pre-shuffled when shuffle /// Manual queue navigation (n / p); the tail is pre-shuffled when shuffle
@@ -1426,6 +1528,7 @@ fn queue_step(state: &mut AppState, direction: isize) -> Option<Effect> {
} else { } else {
player.queue_pos = next as usize; player.queue_pos = next as usize;
} }
normalize_play_next_block(player);
Some(Effect::PlayCurrent) Some(Effect::PlayCurrent)
} }
@@ -1464,9 +1567,11 @@ pub fn advance_after_finish(state: &mut AppState) -> Option<Effect> {
repeat => { repeat => {
if player.queue_pos + 1 < player.queue.len() { if player.queue_pos + 1 < player.queue.len() {
player.queue_pos += 1; player.queue_pos += 1;
normalize_play_next_block(player);
Some(Effect::PlayCurrent) Some(Effect::PlayCurrent)
} else if repeat == super::state::RepeatMode::All { } else if repeat == super::state::RepeatMode::All {
player.queue_pos = 0; player.queue_pos = 0;
player.play_next_end = None;
Some(Effect::PlayCurrent) Some(Effect::PlayCurrent)
} else { } else {
player.playing = false; player.playing = false;
@@ -2076,6 +2181,7 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
return None; return None;
} }
state.player.queue_pos = state.queue_tab.cursor.min(state.player.queue.len() - 1); state.player.queue_pos = state.queue_tab.cursor.min(state.player.queue.len() - 1);
normalize_play_next_block(&mut state.player);
return Some(Effect::PlayCurrent); return Some(Effect::PlayCurrent);
} }
if state.active_tab != Tab::Global { if state.active_tab != Tab::Global {
@@ -2616,6 +2722,19 @@ fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Ve
fn federation_select(state: &mut AppState) -> Option<Effect> { fn federation_select(state: &mut AppState) -> Option<Effect> {
use super::state::{FedInputField, FedRow, Popup, SettingsRow}; use super::state::{FedInputField, FedRow, Popup, SettingsRow};
match settings_rows(state).get(state.settings_cursor).copied()? { match settings_rows(state).get(state.settings_cursor).copied()? {
SettingsRow::MusicDirectory => {
if state.music_dir_changing {
state.status_message = Some("music directory change is already running".into());
return None;
}
state.popup = Some(Popup::FedInput {
field: FedInputField::MusicDirectory,
input: crate::app::input::LineEdit::new(
state.music_dir.to_string_lossy().into_owned(),
),
});
None
}
SettingsRow::Federation(FedRow::Toggle) => { SettingsRow::Federation(FedRow::Toggle) => {
let settings = &mut state.federation.settings; let settings = &mut state.federation.settings;
if !settings.enabled && settings.network_id.trim().is_empty() { if !settings.enabled && settings.network_id.trim().is_empty() {
@@ -2789,6 +2908,7 @@ fn require_connected_devices_enabled(state: &mut AppState) -> bool {
pub(super) fn on_new_queue(state: &mut AppState) { pub(super) fn on_new_queue(state: &mut AppState) {
let player = &mut state.player; let player = &mut state.player;
player.original_order = None; player.original_order = None;
player.play_next_end = None;
if player.shuffle && !player.queue.is_empty() { if player.shuffle && !player.queue.is_empty() {
player.original_order = Some(player.queue.iter().map(track_key).collect()); player.original_order = Some(player.queue.iter().map(track_key).collect());
shuffle_range(player, (player.queue_pos + 1).min(player.queue.len())); shuffle_range(player, (player.queue_pos + 1).min(player.queue.len()));
+68 -1
View File
@@ -457,6 +457,7 @@ fn local_mode_hides_pending_federation_tracks_from_playlists_and_playback() {
active_tab: Tab::Playlists, active_tab: Tab::Playlists,
..AppState::default() ..AppState::default()
}; };
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
state.playlists.opened = Some(OpenedPlaylist { id: 7, cursor: 1 }); state.playlists.opened = Some(OpenedPlaylist { id: 7, cursor: 1 });
state.playlist_views.insert( state.playlist_views.insert(
7, 7,
@@ -512,6 +513,7 @@ fn network_modes_show_pending_federation_playlist_tracks() {
#[test] #[test]
fn local_mode_rejects_async_federation_queue_additions() { fn local_mode_rejects_async_federation_queue_additions() {
let mut state = AppState::default(); let mut state = AppState::default();
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
enqueue_tracks( enqueue_tracks(
&mut state, &mut state,
@@ -732,13 +734,78 @@ fn artist_top_track_selection_queues_all_selected_tracks() {
update(&mut state, Action::MoveDown); update(&mut state, Action::MoveDown);
assert_eq!( assert_eq!(
update(&mut state, Action::QueueAddLast), update(&mut state, Action::QueueAddLast),
Some(Effect::PlaybackQueueChanged), Some(Effect::QueueOrderChanged {
restart_current: false,
}),
); );
let queued: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect(); let queued: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
assert_eq!(queued, vec![1, 2]); assert_eq!(queued, vec![1, 2]);
assert!(!state.track_selection.is_active()); assert!(!state.track_selection.is_active());
} }
#[test]
fn sequential_queue_next_additions_keep_their_order_as_one_block() {
let mut state = AppState::default();
state.player.queue = (10..=13).map(test_track).collect();
state.player.queue_pos = 0;
state.player.current = Some(test_track(10));
assert!(!enqueue_tracks(&mut state, vec![test_track(1)], true));
assert!(!enqueue_tracks(&mut state, vec![test_track(2)], true));
assert!(!enqueue_tracks(&mut state, vec![test_track(3)], true));
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![10, 1, 2, 3, 11, 12, 13]
);
assert_eq!(state.player.play_next_end, Some(4));
}
#[test]
fn queue_selection_moves_as_a_group_and_preserves_current_track() {
let mut state = AppState {
active_tab: Tab::Queue,
..AppState::default()
};
state.player.queue = (1..=5).map(test_track).collect();
state.player.queue_pos = 0;
state.player.current = Some(test_track(1));
state.queue_tab.cursor = 2;
state.track_selection.start(TrackSelectionScope::Queue, 2);
state
.track_selection
.set_cursor(TrackSelectionScope::Queue, 3);
assert_eq!(
update(&mut state, Action::MoveQueueUp),
Some(Effect::QueueOrderChanged {
restart_current: false,
})
);
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3, 4, 2, 5]
);
assert_eq!(state.player.queue_pos, 0);
assert_eq!(state.queue_tab.cursor, 1);
assert_eq!(
state
.track_selection
.indices(&TrackSelectionScope::Queue, 5),
Some(vec![1, 2])
);
}
#[test] #[test]
fn removing_current_queue_track_requests_paused_restart() { fn removing_current_queue_track_requests_paused_restart() {
let mut state = AppState { let mut state = AppState {
+10
View File
@@ -63,6 +63,16 @@ command = "QueueAddNext"
key_sequence = "shift-a" key_sequence = "shift-a"
command = "QueueAddLast" command = "QueueAddLast"
[[keymaps]]
key_sequence = "alt-k"
command = "MoveQueueUp"
context = "queue"
[[keymaps]]
key_sequence = "alt-j"
command = "MoveQueueDown"
context = "queue"
[[keymaps]] [[keymaps]]
key_sequence = "shift-c" key_sequence = "shift-c"
command = "OpenConnectedDevices" command = "OpenConnectedDevices"
+17
View File
@@ -1,5 +1,6 @@
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -50,6 +51,9 @@ pub struct AppSettings {
pub volume: u8, pub volume: u8,
#[serde(default)] #[serde(default)]
pub library: LibraryFilters, pub library: LibraryFilters,
/// Root used for music materialized from federation peers.
#[serde(default = "default_music_dir")]
pub music_dir: PathBuf,
} }
impl Default for AppSettings { impl Default for AppSettings {
@@ -57,6 +61,7 @@ impl Default for AppSettings {
Self { Self {
volume: default_volume(), volume: default_volume(),
library: LibraryFilters::default(), library: LibraryFilters::default(),
music_dir: default_music_dir(),
} }
} }
} }
@@ -64,6 +69,9 @@ impl Default for AppSettings {
impl AppSettings { impl AppSettings {
pub fn normalized(mut self) -> Self { pub fn normalized(mut self) -> Self {
self.volume = self.volume.min(100); self.volume = self.volume.min(100);
if self.music_dir.as_os_str().is_empty() {
self.music_dir = default_music_dir();
}
self self
} }
} }
@@ -72,6 +80,14 @@ fn default_volume() -> u8 {
80 80
} }
/// The historical permanent-download location, kept as the default for
/// backward compatibility with existing installations.
pub fn default_music_dir() -> PathBuf {
crate::config::project_dirs()
.map(|dirs| dirs.data_dir().join("federation-media"))
.unwrap_or_else(|| PathBuf::from("federation-media"))
}
pub fn load() -> (AppSettings, Option<String>) { pub fn load() -> (AppSettings, Option<String>) {
let Some(path) = settings_path() else { let Some(path) = settings_path() else {
return (AppSettings::default(), None); return (AppSettings::default(), None);
@@ -129,5 +145,6 @@ hide_featured_only = true
assert_eq!(settings.volume, 100); assert_eq!(settings.volume, 100);
assert!(settings.library.hide_featured_only); assert!(settings.library.hide_featured_only);
assert_eq!(settings.library.source_mode, LibrarySourceMode::Global); assert_eq!(settings.library.source_mode, LibrarySourceMode::Global);
assert_eq!(settings.music_dir, default_music_dir());
} }
} }
+114 -30
View File
@@ -422,7 +422,9 @@ pub struct Federation {
jam: Arc<crate::jam::JamManager>, jam: Arc<crate::jam::JamManager>,
data_dir: PathBuf, data_dir: PathBuf,
cache_dir: PathBuf, cache_dir: PathBuf,
media_dir: PathBuf, media_dir: std::sync::Mutex<PathBuf>,
/// Serializes permanent downloads/card writes with a directory change.
media_change: tokio::sync::Mutex<()>,
metadata_cache: std::sync::Mutex<std::collections::HashMap<String, CachedTrackMetadata>>, metadata_cache: std::sync::Mutex<std::collections::HashMap<String, CachedTrackMetadata>>,
settings: std::sync::Mutex<FedSettings>, settings: std::sync::Mutex<FedSettings>,
running: tokio::sync::Mutex<Option<Running>>, running: tokio::sync::Mutex<Option<Running>>,
@@ -524,6 +526,7 @@ impl Federation {
library: Arc<Library>, library: Arc<Library>,
devices: Arc<crate::devices::DeviceSync>, devices: Arc<crate::devices::DeviceSync>,
jam: Arc<crate::jam::JamManager>, jam: Arc<crate::jam::JamManager>,
media_dir: PathBuf,
) -> Arc<Self> { ) -> Arc<Self> {
let dirs = crate::config::project_dirs(); let dirs = crate::config::project_dirs();
let data_dir = dirs let data_dir = dirs
@@ -534,10 +537,6 @@ impl Federation {
.as_ref() .as_ref()
.map(|d| d.cache_dir().join("fedcache")) .map(|d| d.cache_dir().join("fedcache"))
.unwrap_or_else(|| PathBuf::from("fedcache")); .unwrap_or_else(|| PathBuf::from("fedcache"));
let media_dir = dirs
.as_ref()
.map(|d| d.data_dir().join("federation-media"))
.unwrap_or_else(|| PathBuf::from("federation-media"));
let initial_error = [&data_dir, &cache_dir, &media_dir] let initial_error = [&data_dir, &cache_dir, &media_dir]
.into_iter() .into_iter()
.find_map(|dir| { .find_map(|dir| {
@@ -551,7 +550,8 @@ impl Federation {
jam, jam,
data_dir, data_dir,
cache_dir, cache_dir,
media_dir, media_dir: std::sync::Mutex::new(media_dir),
media_change: tokio::sync::Mutex::new(()),
metadata_cache: std::sync::Mutex::new(Default::default()), metadata_cache: std::sync::Mutex::new(Default::default()),
settings: std::sync::Mutex::new(load_settings()), settings: std::sync::Mutex::new(load_settings()),
running: tokio::sync::Mutex::new(None), running: tokio::sync::Mutex::new(None),
@@ -566,6 +566,40 @@ impl Federation {
lock(&self.settings).clone() lock(&self.settings).clone()
} }
pub fn media_dir(&self) -> PathBuf {
lock(&self.media_dir).clone()
}
/// Switches the permanent-download root while excluding concurrent
/// downloads. Validation runs again here because permissions may have
/// changed after the confirmation popup was shown.
pub async fn change_media_dir(
self: &Arc<Self>,
requested: PathBuf,
move_existing: bool,
) -> Result<crate::library::MusicRelocationStats> {
let _guard = self.media_change.lock().await;
let old = self.media_dir();
let library = Arc::clone(&self.library);
let requested_for_task = requested.clone();
let (path, stats) = tokio::task::spawn_blocking(move || -> Result<_> {
let path = Library::validate_music_directory(&requested_for_task)?;
if path == std::fs::canonicalize(&old).unwrap_or(old.clone()) {
return Ok((path, crate::library::MusicRelocationStats::default()));
}
let stats = if move_existing && old.exists() {
library.relocate_managed_music(&old, &path)?
} else {
crate::library::MusicRelocationStats::default()
};
Ok((path, stats))
})
.await
.context("music directory task failed")??;
*lock(&self.media_dir) = path;
Ok(stats)
}
pub async fn create_jam(&self) -> Result<String> { pub async fn create_jam(&self) -> Result<String> {
let service = { let service = {
let running = self.running.lock().await; let running = self.running.lock().await;
@@ -1403,11 +1437,6 @@ impl Federation {
Ok(peer.to_string()) Ok(peer.to_string())
} }
/// Directory for streamed (never library-imported) card artwork.
fn art_cache_dir(&self) -> PathBuf {
self.cache_dir.join("art")
}
/// Returns a cached-or-streamed image for the card: the artist image /// Returns a cached-or-streamed image for the card: the artist image
/// (`release: None`) or a release cover. Peers are tried in order until /// (`release: None`) or a release cover. Peers are tried in order until
/// one answers with an image; the result lands in the art cache and its /// one answers with an image; the result lands in the art cache and its
@@ -1418,14 +1447,11 @@ impl Federation {
artist: &str, artist: &str,
release: Option<&str>, release: Option<&str>,
) -> Option<String> { ) -> Option<String> {
let dir = self.art_cache_dir(); let _guard = self.media_change.lock().await;
let dir = music_art_dir(&self.media_dir(), artist, release);
let stem = match release { let stem = match release {
Some(release) => format!( Some(_) => "cover".to_string(),
"cover-{}-{}", None => "artist".to_string(),
sanitize_file_stem(artist),
sanitize_file_stem(release)
),
None => format!("artist-{}", sanitize_file_stem(artist)),
}; };
// Reuse a previously streamed copy of any known image type. // Reuse a previously streamed copy of any known image type.
for extension in ["jpg", "png", "webp", "gif", "bmp"] { for extension in ["jpg", "png", "webp", "gif", "bmp"] {
@@ -1489,7 +1515,7 @@ impl Federation {
F: FnMut(DownloadProgress) + Send, F: FnMut(DownloadProgress) + Send,
{ {
let save = self.settings().save_on_listen; let save = self.settings().save_on_listen;
self.fetch_playable_with_progress(fed, save, false, progress, None) self.fetch_playable_with_progress(fed, save, save, progress, None)
.await .await
} }
@@ -1504,7 +1530,7 @@ impl Federation {
S: FnMut(StreamingStart) + Send, S: FnMut(StreamingStart) + Send,
{ {
let save = self.settings().save_on_listen; let save = self.settings().save_on_listen;
self.fetch_playable_with_progress(fed, save, false, progress, Some(&mut stream_start)) self.fetch_playable_with_progress(fed, save, save, progress, Some(&mut stream_start))
.await .await
} }
@@ -1558,6 +1584,11 @@ impl Federation {
where where
F: FnMut(DownloadProgress) + Send, F: FnMut(DownloadProgress) + Send,
{ {
let _media_guard = if save {
Some(self.media_change.lock().await)
} else {
None
};
let mut fed = fed.clone(); let mut fed = fed.clone();
let mut tried_content_lookup = false; let mut tried_content_lookup = false;
@@ -1636,8 +1667,10 @@ impl Federation {
} }
Err(_) => anyhow::bail!("malformed owner id '{}'", fed.owner), Err(_) => anyhow::bail!("malformed owner id '{}'", fed.owner),
}; };
let media_dir;
let dir = if save { let dir = if save {
&self.media_dir media_dir = music_release_dir(&self.media_dir(), &fed);
&media_dir
} else { } else {
&self.cache_dir &self.cache_dir
}; };
@@ -1698,8 +1731,14 @@ impl Federation {
} }
// Same for the cover: the peer's library cover wins over an // Same for the cover: the peer's library cover wins over an
// embedded picture; embedded art stays as the fallback. // embedded picture; embedded art stays as the fallback.
if import_cover.is_some() { if let Some((bytes, extension)) = &import_cover {
import.cover = import_cover; match save_release_cover(&import_path, bytes, extension) {
Ok(()) => import.cover = None,
Err(err) => {
tracing::warn!(%err, "saving the release cover beside its music failed");
import.cover = import_cover;
}
}
} }
let (track_id, _) = crate::library::import::upsert_track(&library, &import)?; let (track_id, _) = crate::library::import::upsert_track(&library, &import)?;
// A like that referenced the federated track moves onto the // A like that referenced the federated track moves onto the
@@ -1711,7 +1750,13 @@ impl Federation {
// created (or still image-less) main artist. // created (or still image-less) main artist.
if let (Some((bytes, extension)), Some(artist_name)) = if let (Some((bytes, extension)), Some(artist_name)) =
(&artist_image, import.artists.first()) (&artist_image, import.artists.first())
&& let Err(err) = save_artist_image(&library, artist_name, bytes, extension) && let Err(err) = save_artist_image(
&library,
artist_name,
import_path.parent().and_then(Path::parent),
bytes,
extension,
)
{ {
tracing::warn!(%err, "saving the artist image failed"); tracing::warn!(%err, "saving the artist image failed");
} }
@@ -1943,15 +1988,13 @@ impl Federation {
fn save_artist_image( fn save_artist_image(
library: &Library, library: &Library,
artist_name: &str, artist_name: &str,
artist_dir: Option<&Path>,
bytes: &[u8], bytes: &[u8],
extension: &str, extension: &str,
) -> Result<()> { ) -> Result<()> {
let covers_dir = library.covers_dir(); let artist_dir = artist_dir.context("downloaded track has no artist directory")?;
std::fs::create_dir_all(covers_dir)?; std::fs::create_dir_all(artist_dir)?;
let path = covers_dir.join(format!( let path = artist_dir.join(format!("artist.{extension}"));
"artist-{}.{extension}",
sanitize_file_stem(artist_name)
));
// Write only if the artist actually lacks an image, to avoid litter. // Write only if the artist actually lacks an image, to avoid litter.
if library.artist_image_missing(artist_name)? { if library.artist_image_missing(artist_name)? {
std::fs::write(&path, bytes)?; std::fs::write(&path, bytes)?;
@@ -1960,6 +2003,16 @@ fn save_artist_image(
Ok(()) Ok(())
} }
fn save_release_cover(audio_path: &Path, bytes: &[u8], extension: &str) -> Result<()> {
let directory = audio_path
.parent()
.context("downloaded track has no release directory")?;
std::fs::create_dir_all(directory)?;
let path = directory.join(format!("cover.{extension}"));
std::fs::write(&path, bytes).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
/// Overlays the peer-supplied metadata onto tag-derived import data. Every /// Overlays the peer-supplied metadata onto tag-derived import data. Every
/// non-empty peer field wins; file tags only fill the gaps. /// non-empty peer field wins; file tags only fill the gaps.
fn apply_remote_metadata(import: &mut crate::library::import::TrackImport, meta: &TrackMetadata) { fn apply_remote_metadata(import: &mut crate::library::import::TrackImport, meta: &TrackMetadata) {
@@ -2563,6 +2616,37 @@ fn sanitize_file_stem(value: &str) -> String {
stem stem
} }
fn music_artist_dir(root: &Path, artist: &str) -> PathBuf {
let artist = if artist.trim().is_empty() {
"Unknown Artist"
} else {
artist
};
root.join(sanitize_file_stem(artist))
}
fn music_art_dir(root: &Path, artist: &str, release: Option<&str>) -> PathBuf {
let artist_dir = music_artist_dir(root, artist);
match release.filter(|release| !release.trim().is_empty()) {
Some(release) => artist_dir.join(sanitize_file_stem(release)),
None => artist_dir,
}
}
fn music_release_dir(root: &Path, fed: &FedTrack) -> PathBuf {
let artist = fed
.artist_names
.first()
.map(String::as_str)
.unwrap_or("Unknown Artist");
let release = fed
.release_title
.as_deref()
.filter(|release| !release.trim().is_empty())
.unwrap_or("Unknown Release");
music_art_dir(root, artist, Some(release))
}
fn download_stem(fed: &FedTrack) -> String { fn download_stem(fed: &FedTrack) -> String {
let artists = fed.artist_line(); let artists = fed.artist_line();
if artists.is_empty() { if artists.is_empty() {
+337
View File
@@ -11,6 +11,7 @@ pub mod import;
pub mod models; pub mod models;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::io::Write as _;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex; use std::sync::Mutex;
@@ -288,6 +289,12 @@ pub struct LocalLibraryStats {
pub database_bytes: u64, pub database_bytes: u64,
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MusicRelocationStats {
pub tracks: usize,
pub images: usize,
}
impl LocalLibraryStats { impl LocalLibraryStats {
pub fn total_bytes(&self) -> u64 { pub fn total_bytes(&self) -> u64 {
self.audio_bytes self.audio_bytes
@@ -330,6 +337,258 @@ impl Library {
&self.covers_dir &self.covers_dir
} }
/// Verifies that `path` can actually be used for durable downloads. The
/// returned path is absolute/canonical, so the persisted setting does not
/// later depend on Furumi's working directory.
pub fn validate_music_directory(path: &Path) -> Result<PathBuf> {
anyhow::ensure!(!path.as_os_str().is_empty(), "music directory is empty");
std::fs::create_dir_all(path)
.with_context(|| format!("creating music directory {}", path.display()))?;
let path = std::fs::canonicalize(path)
.with_context(|| format!("resolving music directory {}", path.display()))?;
anyhow::ensure!(path.is_dir(), "{} is not a directory", path.display());
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let probe = path.join(format!(
".furumi-write-test-{}-{unique}",
std::process::id()
));
let result = (|| -> Result<()> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe)
.with_context(|| format!("no write access to {}", path.display()))?;
file.write_all(b"furumi")?;
file.sync_all()?;
Ok(())
})();
let _ = std::fs::remove_file(&probe);
result?;
Ok(path)
}
/// Copies Furumi-managed audio and its managed artwork into a portable
/// `Artist/Release` tree, atomically switches SQLite paths, then removes
/// the old copies. User-imported audio outside `old_root` is untouched.
pub fn relocate_managed_music(
&self,
old_root: &Path,
new_root: &Path,
) -> Result<MusicRelocationStats> {
let new_root = Self::validate_music_directory(new_root)?;
let old_root = std::fs::canonicalize(old_root)
.with_context(|| format!("resolving old music directory {}", old_root.display()))?;
anyhow::ensure!(
old_root != new_root,
"the new music directory is the current directory"
);
anyhow::ensure!(
!old_root.starts_with(&new_root) && !new_root.starts_with(&old_root),
"choose a directory outside the current music directory"
);
#[derive(Debug)]
struct Row {
track_id: i64,
track_path: String,
release_id: i64,
release_title: String,
cover_path: Option<String>,
artist_id: Option<i64>,
artist_name: String,
image_path: Option<String>,
}
let rows = {
let conn = self.lock();
let mut statement = conn.prepare(
"SELECT t.id, t.file_path, r.id, r.title, r.cover_path,
a.id, COALESCE(a.name, 'Unknown Artist'), a.image_path
FROM tracks t
JOIN releases r ON r.id = t.release_id
LEFT JOIN track_artists ta
ON ta.track_id = t.id AND ta.role = 'main' AND ta.position = 0
LEFT JOIN artists a ON a.id = ta.artist_id
ORDER BY t.id",
)?;
statement
.query_map([], |row| {
Ok(Row {
track_id: row.get(0)?,
track_path: row.get(1)?,
release_id: row.get(2)?,
release_title: row.get(3)?,
cover_path: row.get(4)?,
artist_id: row.get(5)?,
artist_name: row.get(6)?,
image_path: row.get(7)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
#[derive(Debug, Clone)]
struct CopyOp {
source: PathBuf,
destination: PathBuf,
}
let managed_art =
|path: &Path| path.starts_with(&old_root) || path.starts_with(&self.covers_dir);
let mut reserved = HashSet::<PathBuf>::new();
let mut copies = Vec::<CopyOp>::new();
let mut track_updates = Vec::<(i64, String, String)>::new();
let mut release_updates = HashMap::<i64, (String, String)>::new();
let mut artist_updates = HashMap::<i64, (String, String)>::new();
for row in rows {
let source = PathBuf::from(&row.track_path);
if !source.is_file() || !source.starts_with(&old_root) {
continue;
}
let artist_dir = new_root.join(storage_name(&row.artist_name, "Unknown Artist"));
let release_dir = artist_dir.join(storage_name(&row.release_title, "Unknown Release"));
let filename = source
.file_name()
.filter(|name| !name.is_empty())
.unwrap_or_else(|| std::ffi::OsStr::new("track"));
let destination =
unique_destination(release_dir.join(filename), row.track_id, &mut reserved);
copies.push(CopyOp {
source: source.clone(),
destination: destination.clone(),
});
track_updates.push((
row.track_id,
row.track_path,
destination.to_string_lossy().into_owned(),
));
if let Some(cover) = row.cover_path {
let cover_source = PathBuf::from(&cover);
if cover_source.is_file()
&& managed_art(&cover_source)
&& !release_updates.contains_key(&row.release_id)
{
let extension = cover_source.extension().unwrap_or_default();
let mut destination = release_dir.join("cover");
destination.set_extension(extension);
let destination =
unique_destination(destination, row.release_id, &mut reserved);
copies.push(CopyOp {
source: cover_source,
destination: destination.clone(),
});
release_updates.insert(
row.release_id,
(cover, destination.to_string_lossy().into_owned()),
);
}
}
if let (Some(artist_id), Some(image)) = (row.artist_id, row.image_path) {
let image_source = PathBuf::from(&image);
if image_source.is_file()
&& managed_art(&image_source)
&& !artist_updates.contains_key(&artist_id)
{
let extension = image_source.extension().unwrap_or_default();
let mut destination = artist_dir.join("artist");
destination.set_extension(extension);
let destination = unique_destination(destination, artist_id, &mut reserved);
copies.push(CopyOp {
source: image_source,
destination: destination.clone(),
});
artist_updates.insert(
artist_id,
(image, destination.to_string_lossy().into_owned()),
);
}
}
}
let mut created = Vec::<PathBuf>::new();
let copy_result = (|| -> Result<()> {
for op in &copies {
let parent = op
.destination
.parent()
.context("music destination has no parent")?;
std::fs::create_dir_all(parent)?;
copy_file_exclusive(&op.source, &op.destination)?;
created.push(op.destination.clone());
}
Ok(())
})();
if let Err(error) = copy_result {
for path in created.iter().rev() {
let _ = std::fs::remove_file(path);
}
return Err(error.context("copying the existing music library"));
}
let database_result = (|| -> Result<()> {
let mut conn = self.lock();
let transaction = conn.transaction()?;
for (id, old, new) in &track_updates {
anyhow::ensure!(
transaction.execute(
"UPDATE tracks SET file_path = ?3 WHERE id = ?1 AND file_path = ?2",
params![id, old, new],
)? == 1,
"track {id} changed while the music library was moving"
);
}
for (id, (old, new)) in &release_updates {
anyhow::ensure!(
transaction.execute(
"UPDATE releases SET cover_path = ?3 WHERE id = ?1 AND cover_path = ?2",
params![id, old, new],
)? == 1,
"release {id} changed while the music library was moving"
);
}
for (id, (old, new)) in &artist_updates {
anyhow::ensure!(
transaction.execute(
"UPDATE artists SET image_path = ?3 WHERE id = ?1 AND image_path = ?2",
params![id, old, new],
)? == 1,
"artist {id} changed while the music library was moving"
);
}
transaction.commit()?;
Ok(())
})();
if let Err(error) = database_result {
for path in created.iter().rev() {
let _ = std::fs::remove_file(path);
}
return Err(error.context("updating music paths in the library"));
}
let mut removed = HashSet::new();
for op in &copies {
if removed.insert(op.source.clone())
&& let Err(error) = std::fs::remove_file(&op.source)
{
// The committed destination is authoritative. A failed old
// delete only leaves a recoverable duplicate.
tracing::warn!(path = %op.source.display(), %error, "old music copy was not removed");
}
}
remove_empty_directories(&old_root);
Ok(MusicRelocationStats {
tracks: track_updates.len(),
images: release_updates.len() + artist_updates.len(),
})
}
pub fn local_stats(&self) -> Result<LocalLibraryStats> { pub fn local_stats(&self) -> Result<LocalLibraryStats> {
let (artist_count, release_count, track_count, audio_bytes, tracks_without_size) = { let (artist_count, release_count, track_count, audio_bytes, tracks_without_size) = {
let conn = self.lock(); let conn = self.lock();
@@ -2498,6 +2757,84 @@ impl Library {
} }
} }
fn storage_name(value: &str, fallback: &str) -> String {
let cleaned: String = value
.chars()
.map(|character| match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
character if character.is_control() => '_',
character => character,
})
.collect();
let cleaned = cleaned.trim().trim_matches('.');
let shortened: String = cleaned.chars().take(120).collect();
if shortened.is_empty() {
fallback.to_string()
} else {
shortened
}
}
fn unique_destination(requested: PathBuf, id: i64, reserved: &mut HashSet<PathBuf>) -> PathBuf {
if !requested.exists() && reserved.insert(requested.clone()) {
return requested;
}
let stem = requested
.file_stem()
.unwrap_or_else(|| std::ffi::OsStr::new("file"))
.to_string_lossy();
let extension = requested.extension().map(|value| value.to_os_string());
let mut candidate = requested.with_file_name(format!("{stem}-{id}"));
if let Some(extension) = extension {
candidate.set_extension(extension);
}
let mut suffix = 2usize;
while candidate.exists() || !reserved.insert(candidate.clone()) {
candidate = requested.with_file_name(format!("{stem}-{id}-{suffix}"));
if let Some(extension) = requested.extension() {
candidate.set_extension(extension);
}
suffix += 1;
}
candidate
}
fn copy_file_exclusive(source: &Path, destination: &Path) -> Result<()> {
let mut input =
std::fs::File::open(source).with_context(|| format!("opening {}", source.display()))?;
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(destination)
.with_context(|| format!("creating {}", destination.display()))?;
let result = std::io::copy(&mut input, &mut output)
.with_context(|| format!("copying {}", source.display()))
.and_then(|_| {
output
.sync_all()
.with_context(|| format!("syncing {}", destination.display()))
});
if let Err(error) = result {
drop(output);
let _ = std::fs::remove_file(destination);
return Err(error);
}
Ok(())
}
fn remove_empty_directories(root: &Path) {
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
remove_empty_directories(&path);
}
}
let _ = std::fs::remove_dir(root);
}
fn cleanup_empty_releases(tx: &rusqlite::Transaction) -> rusqlite::Result<()> { fn cleanup_empty_releases(tx: &rusqlite::Transaction) -> rusqlite::Result<()> {
tx.execute( tx.execute(
"DELETE FROM releases WHERE id NOT IN (SELECT release_id FROM tracks)", "DELETE FROM releases WHERE id NOT IN (SELECT release_id FROM tracks)",
+124
View File
@@ -60,6 +60,130 @@ fn artist_filters(hide_featured_only: bool) -> crate::config::settings::LibraryF
} }
} }
fn unique_test_dir(label: &str) -> std::path::PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("furumi-{label}-{}-{unique}", std::process::id()))
}
#[test]
fn managed_music_relocation_builds_artist_release_tree_and_updates_paths() {
let root = unique_test_dir("music-relocation");
let old = root.join("old");
let new = root.join("new");
let covers = root.join("covers");
std::fs::create_dir_all(&old).unwrap();
std::fs::create_dir_all(&covers).unwrap();
let audio = old.join("legacy.flac");
let cover = covers.join("release.jpg");
let artist_image = covers.join("artist.png");
std::fs::write(&audio, b"audio").unwrap();
std::fs::write(&cover, b"cover").unwrap();
std::fs::write(&artist_image, b"artist").unwrap();
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
register_norm_function(&conn).unwrap();
conn.execute_batch(SCHEMA).unwrap();
let library = Library {
conn: Mutex::new(conn),
db_path: root.join("library.db"),
covers_dir: covers,
};
let track_id = import::upsert_track(
&library,
&import::TrackImport {
file_path: audio.to_string_lossy().into_owned(),
title: "Song".into(),
artists: vec!["Artist".into()],
featured_artists: vec![],
album_artists: vec!["Artist".into()],
release_title: "Release".into(),
release_type: Some("album".into()),
year: Some(2026),
track_number: Some(1),
disc_number: Some(1),
duration_seconds: 1.0,
audio_format: Some("flac".into()),
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: Some(5),
cover: None,
},
)
.unwrap()
.0;
let (release_id, artist_id): (i64, i64) = library
.lock()
.query_row(
"SELECT t.release_id, ta.artist_id
FROM tracks t JOIN track_artists ta ON ta.track_id = t.id
WHERE t.id = ?1 AND ta.role = 'main'",
[track_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
library
.lock()
.execute(
"UPDATE releases SET cover_path = ?2 WHERE id = ?1",
params![release_id, cover.to_string_lossy()],
)
.unwrap();
library
.lock()
.execute(
"UPDATE artists SET image_path = ?2 WHERE id = ?1",
params![artist_id, artist_image.to_string_lossy()],
)
.unwrap();
let stats = library.relocate_managed_music(&old, &new).unwrap();
assert_eq!(stats.tracks, 1);
assert_eq!(stats.images, 2);
let track = library.tracks_by_ids(&[track_id]).unwrap().remove(0);
assert_eq!(
track.file_path,
new.join("Artist/Release/legacy.flac").to_string_lossy()
);
assert_eq!(
track.cover_path.as_deref(),
Some(
new.join("Artist/Release/cover.jpg")
.to_string_lossy()
.as_ref()
)
);
let image: String = library
.lock()
.query_row(
"SELECT image_path FROM artists WHERE id = ?1",
[artist_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(image, new.join("Artist/artist.png").to_string_lossy());
assert!(!audio.exists());
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn music_directory_validation_rejects_a_file_without_touching_it() {
let root = unique_test_dir("music-validation");
std::fs::create_dir_all(&root).unwrap();
let file = root.join("not-a-directory");
std::fs::write(&file, b"keep").unwrap();
assert!(Library::validate_music_directory(&file).is_err());
assert_eq!(std::fs::read(&file).unwrap(), b"keep");
std::fs::remove_dir_all(root).unwrap();
}
#[test] #[test]
fn local_stats_counts_library_rows_and_audio_bytes() { fn local_stats_counts_library_rows_and_audio_bytes() {
let lib = test_library(); let lib = test_library();
+19
View File
@@ -69,6 +69,25 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
let mut y = area.y; let mut y = area.y;
let mut cursor = 0usize; let mut cursor = 0usize;
draw_section(frame, area, state, &mut y, "Library");
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Music save directory",
if state.music_dir_changing {
format!("{} moving…", state.spinner())
} else {
state.music_dir.to_string_lossy().into_owned()
},
);
cursor += 1;
y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Federation"); draw_section(frame, area, state, &mut y, "Federation");
for row in FedRow::ALL { for row in FedRow::ALL {
let (label, value) = match row { let (label, value) = match row {
+1 -1
View File
@@ -258,7 +258,7 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
let player = &state.player; let player = &state.player;
let block = Block::bordered() let block = Block::bordered()
.title(format!( .title(format!(
" Queue — {} tracks · Mode: {} · enter: play · d: remove · shift-v: select · :clear ", " Queue — {} tracks · Mode: {} · enter: play · alt-j/k: move · d: remove · shift-v: select · :clear ",
player.queue.len(), player.queue.len(),
state.global.filters.source_mode.label() state.global.filters.source_mode.label()
)) ))
+62 -9
View File
@@ -42,8 +42,15 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
draw_fed_input(frame, state, field.title(), field.help(), input) draw_fed_input(frame, state, field.title(), field.help(), input)
} }
Some(Popup::FedText { title, text }) => draw_fed_text(frame, state, title, text), Some(Popup::FedText { title, text }) => draw_fed_text(frame, state, title, text),
Some(Popup::FedCopyText { title, text, help }) => { Some(Popup::FedCopyText {
draw_fed_copy_text(frame, state, title, text, help) title,
text,
help,
cursor,
}) => draw_fed_copy_text(frame, state, title, text, help, *cursor),
Some(Popup::PlainText { .. }) => {}
Some(Popup::ConfirmMusicDirectory { path }) => {
draw_music_directory_confirmation(frame, state, path)
} }
Some(Popup::FederationStatusDetails { Some(Popup::FederationStatusDetails {
focus, focus,
@@ -93,6 +100,34 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
} }
} }
fn draw_music_directory_confirmation(frame: &mut Frame, state: &AppState, path: &std::path::Path) {
let area = centered(frame.area(), 76, 9);
let block = Block::bordered()
.title(" Move existing music? ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(vec![
Line::raw("The destination is writable:"),
Line::styled(
path.to_string_lossy().into_owned(),
theme::accent_for(state),
),
Line::raw(""),
Line::raw("Move music and artwork previously saved by Furumi there?"),
Line::styled(
"enter/y move · n keep existing files where they are · esc cancel",
theme::dim(),
),
])
.wrap(Wrap { trim: false }),
inner,
);
}
fn draw_listen_history(frame: &mut Frame, state: &AppState, cursor: usize) { fn draw_listen_history(frame: &mut Frame, state: &AppState, cursor: usize) {
let area = centered( let area = centered(
frame.area(), frame.area(),
@@ -862,7 +897,14 @@ fn draw_fed_text(frame: &mut Frame, state: &AppState, title: &str, text: &str) {
} }
/// Wrapped text with an explicit copy-and-close action. /// Wrapped text with an explicit copy-and-close action.
fn draw_fed_copy_text(frame: &mut Frame, state: &AppState, title: &str, text: &str, help: &str) { fn draw_fed_copy_text(
frame: &mut Frame,
state: &AppState,
title: &str,
text: &str,
help: &str,
cursor: usize,
) {
let width = frame.area().width.saturating_sub(8).clamp(36, 96); let width = frame.area().width.saturating_sub(8).clamp(36, 96);
let text_width = usize::from(width.saturating_sub(2)); let text_width = usize::from(width.saturating_sub(2));
let text_lines = (text.chars().count() / text_width.max(1) + 1) as u16; let text_lines = (text.chars().count() / text_width.max(1) + 1) as u16;
@@ -894,17 +936,28 @@ fn draw_fed_copy_text(frame: &mut Frame, state: &AppState, title: &str, text: &s
Paragraph::new(text.to_string()).wrap(Wrap { trim: false }), Paragraph::new(text.to_string()).wrap(Wrap { trim: false }),
body_area, body_area,
); );
let button = |label: &str, selected: bool| {
if selected {
Span::styled(format!(" {label} "), theme::tab_active_for(state))
} else {
Span::styled(format!(" {label} "), theme::dim())
}
};
frame.render_widget( frame.render_widget(
Paragraph::new(Line::styled( Paragraph::new(Line::from(vec![
" Copy to clipboard and close ", button("Copy to clipboard", cursor == 0),
theme::tab_active_for(state), Span::raw(" "),
)) button("Show as plain terminal line", cursor == 1),
]))
.alignment(Alignment::Center), .alignment(Alignment::Center),
button_area, button_area,
); );
frame.render_widget( frame.render_widget(
Paragraph::new(Line::styled("enter/c copy · esc close", theme::dim())) Paragraph::new(Line::styled(
.alignment(Alignment::Center), "left/right choose · enter apply · c copy · p plain line · esc close",
theme::dim(),
))
.alignment(Alignment::Center),
footer_area, footer_area,
); );
} }