Added nix files. revorked queue append, added library migration.
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.97.0"
|
||||||
|
profile = "minimal"
|
||||||
|
components = ["clippy", "rust-src", "rustfmt"]
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user