diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1eb1765..a96606d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -92,7 +92,14 @@ jobs:
for variant in spotify ytmusic full; do
archive="cloofy-$variant-$TARGET.tar.gz"
cp LICENSE README.md "dist/$variant/"
- tar -czf "$archive" -C "dist/$variant" cloofy LICENSE README.md
+ files="cloofy LICENSE README.md"
+ # The app menu's entry and its icon, which the Linux packages install.
+ if [ "$RUNNER_OS" = Linux ]; then
+ cp integrations/linux/cloofy.desktop integrations/omarchy/cloofy.svg "dist/$variant/"
+ files="$files cloofy.desktop cloofy.svg"
+ fi
+ # shellcheck disable=SC2086
+ tar -czf "$archive" -C "dist/$variant" $files
shasum -a 256 "$archive" > "$archive.sha256"
done
# cloofy 0.4 and older update themselves from this name, and play Spotify.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52ec47b..6dbb430 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,20 @@ All notable changes to cloofy. The format follows [Keep a Changelog](https://kee
## [Unreleased]
+## [0.5.3] - 2026-11-12
+
+What you asked for: every song at the same loudness, shelves you can hide, cloofy in the app menu on Linux, and a YouTube Music sign-in that lasts.
+
+### Added
+
+- **Normalize volume.** A new setting under Playback plays every song at about the same loudness, on Spotify with the loudness Spotify measured for each track, on YouTube Music with mpv's loudness filter, at the level Spotify and YouTube aim for. It applies at once, to the song that plays, and to `cloofy play` and `cloofy connect` ([#32](https://github.com/parda21/cloofy/issues/32)).
+- **Hide a shelf on Home.** The menu of anything on a shelf (. or a right-click) has **Hide this shelf**, on both services; the shelf stays away after a restart. **Hidden shelves** on the Settings page shows them all again ([#31](https://github.com/parda21/cloofy/issues/31)).
+- **cloofy in the app menu on Linux.** The installer and the Arch packages add a desktop entry and cloofy's icon, so GNOME, KDE Plasma, rofi or Walker find it; it opens in the desktop's terminal. The installer's entry starts cloofy by its path, so it works where the desktop doesn't look in `~/.local/bin`. `cloofy launcher install` and `cloofy launcher remove` do the same by hand, and `--uninstall` takes it out again. On Omarchy the launcher stays the one of `cloofy omarchy install` ([#30](https://github.com/parda21/cloofy/issues/30)).
+
+### Fixed
+
+- **Signed out of YouTube Music after a day or two.** A browser that goes on using its sign-in gets new cookies for it, and the ones cloofy copied stop working. When YouTube no longer takes them, cloofy now reads them from the same browser again, once per start, and keeps them only if they are still the same account's, told by its channel or handle; otherwise it asks you to sign in as before. Sign-ins made with 0.5.2 or older, and pasted ones, don't say which browser they came from: run `cloofy login ytmusic --force` once ([#26](https://github.com/parda21/cloofy/issues/26)).
+
## [0.5.2] - 2026-11-10
YouTube Music's Home looks and works as in its own apps, and cloofy asks once for a star.
@@ -166,7 +180,8 @@ The first release.
- **Session restore.** The page, the selection and the last track come back after a restart.
- **Installer.** It sets everything up step by step.
-[Unreleased]: https://github.com/parda21/cloofy/compare/v0.5.2...HEAD
+[Unreleased]: https://github.com/parda21/cloofy/compare/v0.5.3...HEAD
+[0.5.3]: https://github.com/parda21/cloofy/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/parda21/cloofy/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/parda21/cloofy/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/parda21/cloofy/compare/v0.4.0...v0.5.0
diff --git a/Cargo.lock b/Cargo.lock
index eec6678..d0b4a75 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -436,7 +436,7 @@ checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "cloofy"
-version = "0.5.2"
+version = "0.5.3"
dependencies = [
"anyhow",
"clap",
@@ -461,7 +461,7 @@ dependencies = [
[[package]]
name = "cloofy-core"
-version = "0.5.2"
+version = "0.5.3"
dependencies = [
"anyhow",
"cloofy-os",
@@ -480,7 +480,7 @@ dependencies = [
[[package]]
name = "cloofy-os"
-version = "0.5.2"
+version = "0.5.3"
dependencies = [
"block2",
"dispatch2",
@@ -501,7 +501,7 @@ dependencies = [
[[package]]
name = "cloofy-player"
-version = "0.5.2"
+version = "0.5.3"
dependencies = [
"anyhow",
"cloofy-core",
@@ -515,7 +515,7 @@ dependencies = [
[[package]]
name = "cloofy-spotify"
-version = "0.5.2"
+version = "0.5.3"
dependencies = [
"anyhow",
"base64 0.23.1",
@@ -538,7 +538,7 @@ dependencies = [
[[package]]
name = "cloofy-tui"
-version = "0.5.2"
+version = "0.5.3"
dependencies = [
"anyhow",
"base64 0.23.1",
@@ -572,7 +572,7 @@ dependencies = [
[[package]]
name = "cloofy-ytmusic"
-version = "0.5.2"
+version = "0.5.3"
dependencies = [
"anyhow",
"cloofy-core",
diff --git a/Cargo.toml b/Cargo.toml
index 142f8de..9901a05 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ members = ["crates/*"]
exclude = ["vendor"]
[workspace.package]
-version = "0.5.2"
+version = "0.5.3"
edition = "2024"
rust-version = "1.90"
license = "MIT"
diff --git a/README.md b/README.md
index f4d70d4..b9d53aa 100644
--- a/README.md
+++ b/README.md
@@ -87,7 +87,7 @@ The demo opens the full interface with a sample library: real albums and artists
curl -fsSL https://cloofy.app/install.sh | bash
```
-The installer first asks what you listen to: Spotify, YouTube Music, or both. Each comes as a build of its own, so a YouTube Music install carries no Spotify code, and the other way around. Then it checks your system, puts the latest release in `~/.local/bin`, and walks you through the [setup](#setup). For Spotify that's your Spotify app, its client ID and signing in. For YouTube Music it's mpv, yt-dlp and deno, which it offers to install with your package manager, and an optional sign-in. On [Omarchy](https://omarchy.org) it also offers cloofy's [bar widget, theme, menu entries and launcher](#omarchy). Run it again to switch; cloofy offers new releases itself, see [updates](#updates).
+The installer first asks what you listen to: Spotify, YouTube Music, or both. Each comes as a build of its own, so a YouTube Music install carries no Spotify code, and the other way around. Then it checks your system, puts the latest release in `~/.local/bin`, and walks you through the [setup](#setup). For Spotify that's your Spotify app, its client ID and signing in. For YouTube Music it's mpv, yt-dlp and deno, which it offers to install with your package manager, and an optional sign-in. On [Omarchy](https://omarchy.org) it also offers cloofy's [bar widget, theme, menu entries and launcher](#omarchy). On other Linux desktops it adds cloofy with its icon to the app menu. Run it again to switch; cloofy offers new releases itself, see [updates](#updates).
Package managers have the same three builds:
@@ -115,7 +115,7 @@ Homebrew's and Arch's YouTube Music packages bring mpv, yt-dlp and deno along; w
- `bash -s -- --service ytmusic` installs YouTube Music without asking about it: `spotify`, `ytmusic` or `both`.
- `bash -s -- --yes` installs without asking anything: the services you have, or Spotify the first time.
-- `bash -s -- --uninstall` removes cloofy, and its Omarchy setup.
+- `bash -s -- --uninstall` removes cloofy, its entry in the app menu and its Omarchy setup.
On platforms without a prebuilt binary, the installer builds cloofy with cargo, with only the services you picked. To do that yourself (Rust 1.90 or newer), name them as features; without any, you get both:
@@ -221,12 +221,14 @@ Installed with Homebrew, mise, a system package or Nix, cloofy leaves its update
## Settings
-,, the command palette, or a click on your name in the top bar opens the Settings page: crossfade, cover art and how covers are drawn, the colors, the accent color, icons, power saving, song notifications and update checks. Changes apply at once and cloofy saves them in `~/.config/cloofy/config.toml`, so there's no need to edit the file. You still can; it's read at start:
+,, the command palette, or a click on your name in the top bar opens the Settings page: crossfade, volume normalization, the shelves hidden from Home, cover art and how covers are drawn, the colors, the accent color, icons, power saving, song notifications and update checks. Changes apply at once and cloofy saves them in `~/.config/cloofy/config.toml`, so there's no need to edit the file. You still can; it's read at start:
```toml
client_id = "your client id"
services = ["ytmusic"] # "spotify", "ytmusic" or both, in the switch's order; every one built in when missing
crossfade = 6 # seconds tracks overlap, 0 to 12; off when missing
+normalize_volume = true # every song at about the same loudness; off when missing
+hidden_shelves = ["Covers and remixes"] # shelves Home leaves out, by title
covers = false # no cover art, for slower computers; on when missing
colors = "terminal" # "cloofy" or "terminal"; Omarchy's theme there, cloofy's own elsewhere, when missing
accent = "violet" # "theme", "green", "violet", "pink", "blue" or "orange"; follows the cover when missing
@@ -301,7 +303,7 @@ YouTube protects its audio streams with challenges that only a JavaScript runtim
Is signing in to YouTube Music with my browser's cookies safe?
-`cloofy login ytmusic` has yt-dlp export the browser's cookies into a private folder, keeps only youtube.com's, and deletes the export right away. They stay in `~/.local/share/cloofy/ytmusic/cookies.txt`, which only you can read, or in the macOS Keychain with `credentials = "keychain"`. cloofy sends them only to music.youtube.com, with its own requests there; streams, covers and lyrics from LRCLIB load without them. They are a signed-in session like the browser's, so treat the file like a password. `cloofy logout ytmusic` deletes it.
+`cloofy login ytmusic` has yt-dlp export the browser's cookies into a private folder, keeps only youtube.com's, and deletes the export right away. They stay in `~/.local/share/cloofy/ytmusic/cookies.txt`, which only you can read, or in the macOS Keychain with `credentials = "keychain"`. cloofy sends them only to music.youtube.com, with its own requests there; streams, covers and lyrics from LRCLIB load without them. They are a signed-in session like the browser's, so treat the file like a password. `cloofy logout ytmusic` deletes it. A browser that goes on using its sign-in gets new cookies for it, so after a day or two YouTube may stop taking cloofy's copy: cloofy then reads them from the same browser again, the same way, and keeps them only when they still sign in the same account. A sign-in made before cloofy 0.5.3, or from a pasted Cookie header, doesn't say which browser it came from: run `cloofy login ytmusic --force` once to sign in with a browser.
diff --git a/crates/cloofy-core/src/config.rs b/crates/cloofy-core/src/config.rs
index 19f0520..f58abda 100644
--- a/crates/cloofy-core/src/config.rs
+++ b/crates/cloofy-core/src/config.rs
@@ -14,6 +14,10 @@ pub struct Config {
pub client_id: Option,
/// Seconds consecutive tracks overlap (0–12); off when missing.
pub crossfade: Option,
+ /// Plays every song at about the same loudness; off when missing.
+ pub normalize_volume: Option,
+ /// Titles of the shelves Home leaves out, such as `["Covers and remixes"]`.
+ pub hidden_shelves: Option>,
/// `"nerd"` or `"text"`; detected from the terminal when missing.
pub icons: Option,
/// `"keychain"` keeps sign-ins in the system's keychain; files when missing.
diff --git a/crates/cloofy-core/src/home.rs b/crates/cloofy-core/src/home.rs
index efe3744..5a6a7f0 100644
--- a/crates/cloofy-core/src/home.rs
+++ b/crates/cloofy-core/src/home.rs
@@ -13,6 +13,14 @@ pub struct Home {
}
impl Home {
+ /// This Home without the shelves titled as one of `hidden`, whatever the case. The
+ /// shortcuts at the top have no title to hide them by.
+ pub fn without_shelves(mut self, hidden: &[String]) -> Self {
+ let hidden: Vec = hidden.iter().map(|title| title.trim().to_lowercase()).collect();
+ self.shelves.retain(|shelf| shelf.shortcuts || !hidden.contains(&shelf.title.trim().to_lowercase()));
+ self
+ }
+
/// The chip Home is narrowed to, if any.
pub fn chip(&self) -> Option<&Chip> {
self.chips.iter().find(|chip| chip.selected)
@@ -79,3 +87,21 @@ impl HomeItem {
self.kind == ItemKind::Playlist && self.context_uri != self.uri
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn hidden_shelves_go_by_title_and_the_shortcuts_stay() {
+ let shelf = |title: &str, shortcuts| Shelf { title: title.into(), shortcuts, ..Shelf::default() };
+ let home = Home {
+ shelves: vec![shelf("", true), shelf("Covers and remixes", false), shelf("Quick picks", false)],
+ ..Home::default()
+ };
+ let shown = home.clone().without_shelves(&["covers and Remixes ".into(), String::new()]);
+ let titles: Vec<&str> = shown.shelves.iter().map(|shelf| shelf.title.as_str()).collect();
+ assert_eq!(titles, ["", "Quick picks"]);
+ assert_eq!(home.without_shelves(&[]).shelves.len(), 3);
+ }
+}
diff --git a/crates/cloofy-player/src/lib.rs b/crates/cloofy-player/src/lib.rs
index 981d8eb..5172819 100644
--- a/crates/cloofy-player/src/lib.rs
+++ b/crates/cloofy-player/src/lib.rs
@@ -35,17 +35,23 @@ pub struct Engine {
impl Engine {
/// `crossfade` overlaps consecutive tracks by that long; zero plays them back to back.
+ /// `normalize` plays every track at about the same loudness, as Spotify's apps do.
///
/// The receiver gets the name of each audio output that goes away during playback, such as
/// headphones being unplugged; playback should pause then.
- pub fn new(session: &Session, crossfade: Duration) -> Result<(Self, UnboundedReceiver)> {
+ pub fn new(session: &Session, crossfade: Duration, normalize: bool) -> Result<(Self, UnboundedReceiver)> {
let backend = PREFERRED_BACKENDS
.iter()
.find_map(|name| audio_backend::find(Some((*name).to_owned())))
.or_else(|| audio_backend::find(None))
.context("No audio backend available")?;
let mixer = (mixer::find(None).context("No mixer available")?)(MixerConfig::default())?;
- let config = PlayerConfig { bitrate: Bitrate::Bitrate320, crossfade, ..PlayerConfig::default() };
+ let config = PlayerConfig {
+ bitrate: Bitrate::Bitrate320,
+ crossfade,
+ normalisation: normalize,
+ ..PlayerConfig::default()
+ };
let (lost_tx, lost_rx) = mpsc::unbounded_channel();
let player =
Player::new(config, session.clone(), mixer.get_soft_volume(), move || open_output(backend, lost_tx));
diff --git a/crates/cloofy-tui/src/app/browsing.rs b/crates/cloofy-tui/src/app/browsing.rs
index eb05271..46dd51e 100644
--- a/crates/cloofy-tui/src/app/browsing.rs
+++ b/crates/cloofy-tui/src/app/browsing.rs
@@ -80,6 +80,19 @@ impl Browsing {
std::iter::once(&mut self.view).chain(self.history.iter_mut())
}
+ /// Shows this service's Home as it was loaded, on its pages that show the whole Home: for
+ /// when no shelf is hidden any more.
+ pub(super) fn show_whole_home(&mut self) {
+ let Some(home) = self.home_cache.clone() else { return };
+ for view in std::iter::once(&mut self.view).chain(self.history.iter_mut()) {
+ if let View::Home(shown) = view
+ && shown.is_whole()
+ {
+ shown.set_home(home.clone());
+ }
+ }
+ }
+
/// Where its pages were left, for the next start.
fn page(&self) -> Page {
super::persist::page(&self.view, &self.history, self.focus, &self.nav, self.library_filter)
diff --git a/crates/cloofy-tui/src/app/menu.rs b/crates/cloofy-tui/src/app/menu.rs
index b6bd10d..9c6008d 100644
--- a/crates/cloofy-tui/src/app/menu.rs
+++ b/crates/cloofy-tui/src/app/menu.rs
@@ -64,7 +64,9 @@ impl App {
SearchEntry::Artist(a) => Some(target(&a.uri, &a.name, TargetKind::Artist, a.image_url.clone())),
SearchEntry::Header(_) => None,
},
- View::Home(v) => v.selected().map(|item| self.home_target(item)),
+ View::Home(v) => v
+ .selected()
+ .map(|item| ContextTarget { shelf: v.shelf_title().map(str::to_owned), ..self.home_target(item) }),
View::Artist(v) => match v.selected()? {
ArtistSelection::Track(t) => Some(track_target(&t.track, Some(v.uri.clone()))),
ArtistSelection::Item(item) => Some(self.home_target(item)),
@@ -197,6 +199,11 @@ impl App {
copy_to_clipboard(&backend::web_link(&target.uri));
self.notice("Link copied".into());
}
+ MenuAction::HideShelf => {
+ if let Some(title) = target.shelf {
+ self.hide_shelf(title);
+ }
+ }
}
}
@@ -294,6 +301,7 @@ fn target(uri: &str, name: &str, kind: TargetKind, image_url: Option) ->
owned: false,
liked_songs: false,
upcoming: None,
+ shelf: None,
}
}
@@ -383,6 +391,9 @@ fn menu_items(target: &ContextTarget, saved: bool) -> Vec<(MenuAction, String)>
if !target.liked_songs {
add(MenuAction::CopyLink, i.link, "Copy link");
}
+ if target.shelf.is_some() {
+ add(MenuAction::HideShelf, i.remove, "Hide this shelf");
+ }
items
}
diff --git a/crates/cloofy-tui/src/app/mod.rs b/crates/cloofy-tui/src/app/mod.rs
index a36286b..69467a8 100644
--- a/crates/cloofy-tui/src/app/mod.rs
+++ b/crates/cloofy-tui/src/app/mod.rs
@@ -1031,6 +1031,8 @@ impl App {
let service = player.service();
self.player_errors.remove(&service);
player.set_crossfade(Duration::from_secs(self.prefs.crossfade));
+ // As the setting is now: it may have changed since the player was started with it.
+ player.set_normalize(self.prefs.normalize);
self.players.insert(service, player);
// Until something plays, the player of the service shown is the one that would.
if self.active.is_none() || (self.now.track.is_none() && self.loader.service() == Some(service)) {
@@ -1475,12 +1477,13 @@ impl App {
self.home_fetched_at = Some(Instant::now());
self.data_dirty = true;
}
+ let hidden = self.prefs.hidden_shelves.clone();
let Some(View::Home(v)) = self.view_mut(view) else { return false };
if !v.wants(chip.as_deref()) {
return false;
}
match result {
- Ok(home) => v.set_home(home),
+ Ok(home) => v.set_home(home.without_shelves(&hidden)),
// The Home before the chip stays, so the failure is said at the bottom.
Err(err) => match v.chip_failed() {
Some(failed) => self.error(format!("Couldn't load {failed}: {err}")),
@@ -3245,7 +3248,8 @@ impl App {
fn open_home(&mut self) {
let id = self.next_id();
- let mut view = HomeView::new(id, self.home_cache.clone());
+ let cached = self.home_cache.clone().map(|home| home.without_shelves(&self.prefs.hidden_shelves));
+ let mut view = HomeView::new(id, cached);
if self.home_fetched_at.is_some_and(|at| at.elapsed() < HOME_FRESH) {
view.loading = false;
} else {
diff --git a/crates/cloofy-tui/src/app/settings.rs b/crates/cloofy-tui/src/app/settings.rs
index 0c46d62..a1c74b1 100644
--- a/crates/cloofy-tui/src/app/settings.rs
+++ b/crates/cloofy-tui/src/app/settings.rs
@@ -82,6 +82,12 @@ impl App {
player.set_crossfade(Duration::from_secs(self.prefs.crossfade));
}
}
+ Setting::Normalize => {
+ for player in self.players.values() {
+ player.set_normalize(self.prefs.normalize);
+ }
+ }
+ Setting::HiddenShelves => self.show_hidden_shelves(),
Setting::Covers if self.prefs.covers => {
if let Some(url) = self.now.track.as_ref().and_then(|t| t.cover_url.clone()) {
self.load_cover(url);
@@ -135,6 +141,39 @@ impl App {
self.colors.as_ref().is_some_and(Colors::knows_terminal)
}
+ /// Leaves the shelf titled `title` out of Home from now on, here and after a restart.
+ pub(super) fn hide_shelf(&mut self, title: String) {
+ if !self.prefs.hidden_shelves.iter().any(|hidden| hidden.eq_ignore_ascii_case(&title)) {
+ self.prefs.hidden_shelves.push(title.clone());
+ }
+ let hidden = self.prefs.hidden_shelves.clone();
+ // The other services' pages too: a title may be on more than one Home.
+ let parked = self.parked.values_mut().flat_map(super::browsing::Browsing::views_mut);
+ for view in std::iter::once(&mut self.view).chain(self.history.iter_mut()).chain(parked) {
+ if let View::Home(home) = view {
+ home.hide_shelves(&hidden);
+ }
+ }
+ self.save_setting(Setting::HiddenShelves);
+ self.notice(format!("Hid “{title}” · Settings brings it back"));
+ }
+
+ /// With no shelf hidden any more: Home shows what it was loaded with. A chip's Home has
+ /// them back the next time it loads.
+ fn show_hidden_shelves(&mut self) {
+ for parked in self.parked.values_mut() {
+ parked.show_whole_home();
+ }
+ let Some(home) = self.home_cache.clone() else { return };
+ for view in std::iter::once(&mut self.view).chain(self.history.iter_mut()) {
+ if let View::Home(shown) = view
+ && shown.is_whole()
+ {
+ shown.set_home(home.clone());
+ }
+ }
+ }
+
/// Writes `setting` to the config file. The demo keeps changes until it closes.
pub(super) fn save_setting(&mut self, setting: Setting) {
if self.demo {
diff --git a/crates/cloofy-tui/src/backend/fake.rs b/crates/cloofy-tui/src/backend/fake.rs
index 0f31193..e6c9813 100644
--- a/crates/cloofy-tui/src/backend/fake.rs
+++ b/crates/cloofy-tui/src/backend/fake.rs
@@ -40,6 +40,8 @@ struct State {
playing: Option,
/// Home fails to load from now on.
home_down: bool,
+ /// Whether loudness is evened out.
+ normalize: bool,
}
struct Playing {
@@ -268,6 +270,13 @@ impl Fake {
Ok(())
}
+ /// Recorded when it changes something: a player hears the setting whenever it starts.
+ pub fn set_normalize(&self, normalize: bool) {
+ if std::mem::replace(&mut self.state().normalize, normalize) != normalize {
+ self.record(format!("normalize {normalize}"));
+ }
+ }
+
pub fn shuffle(&self, on: bool) -> Result<()> {
self.record(format!("shuffle {on}"));
self.send(PlayerEvent::ShuffleChanged { shuffle: on });
diff --git a/crates/cloofy-tui/src/backend/mod.rs b/crates/cloofy-tui/src/backend/mod.rs
index 59bc8ff..6ec6ff9 100644
--- a/crates/cloofy-tui/src/backend/mod.rs
+++ b/crates/cloofy-tui/src/backend/mod.rs
@@ -180,6 +180,8 @@ pub enum Account {
pub struct Launch {
pub paths: Paths,
pub crossfade: Duration,
+ /// Every song at about the same loudness.
+ pub normalize: bool,
/// Where the last session left the volume, if anywhere.
pub volume: Option,
/// Cuts a wait between attempts to connect short, as when the computer wakes up.
@@ -334,6 +336,11 @@ impl Player {
}
}
+ /// Plays every song at about the same loudness, or as loud as it is.
+ pub fn set_normalize(&self, normalize: bool) {
+ dispatch!(self, Player, player => player.set_normalize(normalize))
+ }
+
/// Elsewhere songs follow each other as they are.
pub fn set_crossfade(&self, crossfade: Duration) {
match self {
diff --git a/crates/cloofy-tui/src/backend/spotify/mod.rs b/crates/cloofy-tui/src/backend/spotify/mod.rs
index b2fd3c4..627502f 100644
--- a/crates/cloofy-tui/src/backend/spotify/mod.rs
+++ b/crates/cloofy-tui/src/backend/spotify/mod.rs
@@ -75,7 +75,15 @@ impl Account {
let state = Arc::new(watch::Sender::new(SessionState::Connecting));
let volume = launch.volume.unwrap_or(cloofy_player::DEFAULT_VOLUME);
let hashes = launch.paths.pathfinder_hashes();
- player::spawn(launch.paths, launch.crossfade, volume, launch.retry_now, launch.tx.clone(), state.clone());
+ player::spawn(
+ launch.paths,
+ launch.crossfade,
+ launch.normalize,
+ volume,
+ launch.retry_now,
+ launch.tx.clone(),
+ state.clone(),
+ );
Catalog::new(self.api, state, hashes, launch.tx)
}
}
diff --git a/crates/cloofy-tui/src/backend/spotify/player.rs b/crates/cloofy-tui/src/backend/spotify/player.rs
index a9aca3d..28936c1 100644
--- a/crates/cloofy-tui/src/backend/spotify/player.rs
+++ b/crates/cloofy-tui/src/backend/spotify/player.rs
@@ -165,6 +165,10 @@ impl Controller {
self.engine.player.set_crossfade(crossfade);
}
+ pub fn set_normalize(&self, normalize: bool) {
+ self.engine.player.set_normalisation(normalize);
+ }
+
/// cloofy's Spotify Connect device ID.
pub fn device_id(&self) -> String {
self.session.device_id().to_owned()
@@ -196,6 +200,7 @@ impl Controller {
pub fn spawn(
paths: Paths,
crossfade: Duration,
+ normalize: bool,
volume: u16,
retry_now: Arc,
tx: UnboundedSender,
@@ -211,7 +216,7 @@ pub fn spawn(
tokio::spawn(async move {
let started = async {
let pending = session::prepare(&paths)?;
- let (engine, lost_outputs) = Engine::new(&pending.session, crossfade)?;
+ let (engine, lost_outputs) = Engine::new(&pending.session, crossfade, normalize)?;
forward_events(&engine, lost_outputs, &tx, &state);
anyhow::Ok((pending, Arc::new(engine)))
};
diff --git a/crates/cloofy-tui/src/backend/ytmusic/mod.rs b/crates/cloofy-tui/src/backend/ytmusic/mod.rs
index ad68fcd..9319a84 100644
--- a/crates/cloofy-tui/src/backend/ytmusic/mod.rs
+++ b/crates/cloofy-tui/src/backend/ytmusic/mod.rs
@@ -111,6 +111,10 @@ impl Controller {
self.0.set_volume(volume)
}
+ pub fn set_normalize(&self, normalize: bool) {
+ let _ = self.0.set_normalize(normalize);
+ }
+
pub fn shuffle(&self, on: bool) -> Result<()> {
self.0.shuffle(on)
}
diff --git a/crates/cloofy-tui/src/lib.rs b/crates/cloofy-tui/src/lib.rs
index ce5288a..d309ac2 100644
--- a/crates/cloofy-tui/src/lib.rs
+++ b/crates/cloofy-tui/src/lib.rs
@@ -214,12 +214,18 @@ async fn run_app(
None => {
let saved = state::load(&paths.state_file());
let crossfade = Duration::from_secs(prefs.crossfade);
+ let normalize = prefs.normalize;
let started: Vec<(Service, Result)> = accounts
.into_iter()
.zip(retries)
.map(|((service, account), retry_now)| {
let (paths, tx, volume) = (paths.clone(), tx.clone(), saved.volume);
- (service, account.map(|account| account.start(Launch { paths, crossfade, volume, retry_now, tx })))
+ (
+ service,
+ account.map(|account| {
+ account.start(Launch { paths, crossfade, normalize, volume, retry_now, tx })
+ }),
+ )
})
.collect();
(app::Start::Restore(Box::new(saved)), started)
diff --git a/crates/cloofy-tui/src/overlay.rs b/crates/cloofy-tui/src/overlay.rs
index 5603d91..801595c 100644
--- a/crates/cloofy-tui/src/overlay.rs
+++ b/crates/cloofy-tui/src/overlay.rs
@@ -74,6 +74,7 @@ pub enum MenuAction {
GoToAlbum,
GoToArtist,
CopyLink,
+ HideShelf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -104,6 +105,8 @@ pub struct ContextTarget {
pub liked_songs: bool,
/// The queue entry a track of the queue page stands for: playing it skips ahead to it.
pub upcoming: Option,
+ /// The title of the Home shelf it is on, which the menu can hide.
+ pub shelf: Option,
}
impl Overlay {
diff --git a/crates/cloofy-tui/src/preferences.rs b/crates/cloofy-tui/src/preferences.rs
index 5b91db1..8250d18 100644
--- a/crates/cloofy-tui/src/preferences.rs
+++ b/crates/cloofy-tui/src/preferences.rs
@@ -15,6 +15,10 @@ const CROSSFADE_STEPS: [u64; 5] = [0, 3, 6, 9, 12];
pub struct Preferences {
/// Seconds consecutive tracks overlap.
pub crossfade: u64,
+ /// Every song at about the same loudness.
+ pub normalize: bool,
+ /// Titles of the shelves Home leaves out.
+ pub hidden_shelves: Vec,
pub covers: bool,
pub images: ImageProtocol,
pub colors: ColorChoice,
@@ -35,6 +39,8 @@ impl Preferences {
pub fn from_config(config: &Config) -> Self {
Self {
crossfade: config.crossfade.unwrap_or(0).min(12),
+ normalize: config.normalize_volume == Some(true),
+ hidden_shelves: config.hidden_shelves.clone().unwrap_or_default(),
covers: config.covers != Some(false),
images: config.images.as_deref().and_then(ImageProtocol::parse).unwrap_or_default(),
colors: match config.colors.as_deref() {
@@ -74,6 +80,9 @@ impl Preferences {
match setting {
Setting::Crossfade if self.crossfade == 0 => "Off".to_owned(),
Setting::Crossfade => format!("{} s", self.crossfade),
+ Setting::Normalize => on_off(self.normalize),
+ Setting::HiddenShelves if self.hidden_shelves.is_empty() => "None".to_owned(),
+ Setting::HiddenShelves => format!("{} hidden", self.hidden_shelves.len()),
Setting::Covers => on_off(self.covers),
Setting::Images => self.images.label().to_owned(),
Setting::Colors => self.colors.label().to_owned(),
@@ -88,10 +97,12 @@ impl Preferences {
/// For settings that are switched rather than chosen: whether it's on.
pub fn switch(&self, setting: Setting) -> Option {
match setting {
+ Setting::Normalize => Some(self.normalize),
Setting::Covers => Some(self.covers),
Setting::Notifications => Some(self.notifications),
Setting::UpdateCheck => Some(self.update_check),
Setting::Crossfade
+ | Setting::HiddenShelves
| Setting::Images
| Setting::Colors
| Setting::Accent
@@ -104,6 +115,9 @@ impl Preferences {
pub fn step(&mut self, setting: Setting, forward: bool) {
match setting {
Setting::Crossfade => self.crossfade = cycle(&CROSSFADE_STEPS, &self.crossfade, forward),
+ Setting::Normalize => self.normalize = !self.normalize,
+ // Its one choice: every shelf shown again.
+ Setting::HiddenShelves => self.hidden_shelves.clear(),
Setting::Covers => self.covers = !self.covers,
Setting::Images => self.images = cycle(&ImageProtocol::ALL, &self.images, forward),
Setting::Colors => self.colors = cycle(&ColorChoice::ALL, &self.colors, forward),
@@ -121,6 +135,12 @@ impl Preferences {
let off = |on: bool| (!on).then(|| Value::from(false));
match setting {
Setting::Crossfade => ("crossfade", (self.crossfade > 0).then(|| Value::from(self.crossfade as i64))),
+ Setting::Normalize => ("normalize_volume", self.normalize.then(|| Value::from(true))),
+ Setting::HiddenShelves => (
+ "hidden_shelves",
+ (!self.hidden_shelves.is_empty())
+ .then(|| self.hidden_shelves.iter().map(String::as_str).collect::()),
+ ),
Setting::Covers => ("covers", off(self.covers)),
Setting::Images => (
"images",
@@ -147,6 +167,8 @@ fn cycle(items: &[T], current: &T, forward: bool) -> T {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Setting {
Crossfade,
+ Normalize,
+ HiddenShelves,
Covers,
Images,
Colors,
@@ -158,8 +180,9 @@ pub enum Setting {
}
/// The Settings page: section titles with their settings, in order.
-pub const SECTIONS: [(&str, &[Setting]); 4] = [
- ("Playback", &[Setting::Crossfade]),
+pub const SECTIONS: [(&str, &[Setting]); 5] = [
+ ("Playback", &[Setting::Crossfade, Setting::Normalize]),
+ ("Home", &[Setting::HiddenShelves]),
("Display", &[Setting::Covers, Setting::Images, Setting::Colors, Setting::Accent, Setting::Icons]),
("Power", &[Setting::PowerSaving]),
("Notifications and updates", &[Setting::Notifications, Setting::UpdateCheck]),
@@ -174,6 +197,8 @@ impl Setting {
pub fn label(self) -> &'static str {
match self {
Setting::Crossfade => "Crossfade",
+ Setting::Normalize => "Normalize volume",
+ Setting::HiddenShelves => "Hidden shelves",
Setting::Covers => "Cover art",
Setting::Images => "Cover images",
Setting::Colors => "Colors",
@@ -188,6 +213,8 @@ impl Setting {
pub fn description(self) -> &'static str {
match self {
Setting::Crossfade => "Let songs fade into each other.",
+ Setting::Normalize => "Play every song at about the same loudness.",
+ Setting::HiddenShelves => "Shelves you hid from Home with its menu. Enter shows them all again.",
Setting::Covers => "Turn off to save memory and processor time on slower computers.",
Setting::Images => "How covers reach the terminal. Blocks work in every terminal.",
Setting::Colors => "Auto follows Omarchy's theme; Terminal takes the terminal's background and text.",
@@ -404,6 +431,7 @@ mod tests {
assert!(Setting::all().all(|setting| preferences.entry(setting).1.is_none()));
assert!(preferences.covers && preferences.notifications && preferences.update_check);
assert_eq!(preferences.crossfade, 0);
+ assert!(!preferences.normalize);
}
#[test]
diff --git a/crates/cloofy-tui/src/scenarios/playback.rs b/crates/cloofy-tui/src/scenarios/playback.rs
index cb09d77..cd6ce77 100644
--- a/crates/cloofy-tui/src/scenarios/playback.rs
+++ b/crates/cloofy-tui/src/scenarios/playback.rs
@@ -4,7 +4,10 @@
use std::time::Duration;
use cloofy_core::{player::PlayerEvent, service::Service};
-use crossterm::event::KeyCode::{Char, Down, Enter};
+use crossterm::event::{
+ KeyCode,
+ KeyCode::{Char, Down, Enter},
+};
use crate::{backend::fake::SONG, harness::Harness, views::View};
@@ -264,3 +267,41 @@ async fn the_bot_check_says_why_nothing_plays() {
h.settle().await;
assert!(!h.shows("prove it isn't a bot"), "{}", h.screen());
}
+
+/// Normalize volume (#32) reaches every player at once, and the players that start later.
+#[tokio::test]
+async fn normalizing_the_volume_reaches_every_player() {
+ let mut h = Harness::fakes(&[SPOTIFY, YOUTUBE]).await;
+ h.press(Char(',')).await.click_on("Normalize volume").await;
+ assert!(h.shows("Play every song at about the same loudness."), "{}", h.screen());
+ h.press(Enter).await;
+ for service in [SPOTIFY, YOUTUBE] {
+ assert_eq!(h.fake(service).commands(), ["normalize true"], "{}", service.name());
+ }
+ h.press(Enter).await;
+ assert_eq!(h.fake(SPOTIFY).commands(), ["normalize true", "normalize false"]);
+}
+
+/// A shelf hidden from its menu (#31) is gone from Home until Settings shows them all again.
+#[tokio::test]
+async fn a_hidden_shelf_stays_away_until_settings_brings_it_back() {
+ let mut h = Harness::fakes(&[YOUTUBE]).await;
+ // The notice names the shelf, so its Play all tells whether the shelf itself is there.
+ assert!(h.shows("Play all") && h.shows("For you"), "{}", h.screen());
+ h.press(Char('.')).await.click_on("Hide this shelf").await;
+ assert!(!h.shows("Play all") && !h.shows("YouTube Music Song 4") && h.shows("For you"), "{}", h.screen());
+ assert!(h.shows("Hid “Quick picks” · Settings brings it back"), "{}", h.screen());
+
+ // Home loaded again leaves it out too.
+ h.press(Char('S')).await.press(Char('S')).await;
+ h.wait(Duration::from_secs(600)).await.press(Char('1')).await;
+ assert!(!h.shows("Play all"), "{}", h.screen());
+
+ h.press(Char(',')).await.click_on("Hidden shelves").await;
+ assert!(h.shows("1 hidden"), "{}", h.screen());
+ h.press(Enter).await;
+ assert!(!h.shows("1 hidden"), "{}", h.screen());
+ // Back on Home the shelf is there again, above the one that stayed selected.
+ h.press(KeyCode::Esc).await;
+ assert!(h.shows("YouTube Music Song 4") && h.shows("For you"), "{}", h.screen());
+}
diff --git a/crates/cloofy-tui/src/views.rs b/crates/cloofy-tui/src/views.rs
index e2924a9..7992666 100644
--- a/crates/cloofy-tui/src/views.rs
+++ b/crates/cloofy-tui/src/views.rs
@@ -447,6 +447,27 @@ impl HomeView {
}
}
+ /// The title of the selected shelf, if it has one to hide it by.
+ pub fn shelf_title(&self) -> Option<&str> {
+ let shelf = self.home.as_ref()?.shelves.get(self.shelf).filter(|_| self.chip.is_none())?;
+ Some(shelf.title.trim()).filter(|title| !shelf.shortcuts && !title.is_empty())
+ }
+
+ /// Whether this view shows the whole Home rather than a chip's.
+ pub fn is_whole(&self) -> bool {
+ self.filter.is_none()
+ }
+
+ /// Takes the shelves titled as one of `hidden` out of the Home shown.
+ pub fn hide_shelves(&mut self, hidden: &[String]) {
+ if let Some(home) = self.home.clone() {
+ // A chip's Home may still be on its way: what it falls back to and the wait stay.
+ let (fallback, loading) = (self.fallback.clone(), self.loading);
+ self.set_home(home.without_shelves(hidden));
+ (self.fallback, self.loading) = (fallback, loading);
+ }
+ }
+
/// Shows a newer Home. Shelves and cards are found again by URI, so the selection and
/// how far each shelf was paged survive the update.
pub fn set_home(&mut self, home: Home) {
diff --git a/crates/cloofy-ytmusic/src/account.rs b/crates/cloofy-ytmusic/src/account.rs
index 95f06ac..e15dddf 100644
--- a/crates/cloofy-ytmusic/src/account.rs
+++ b/crates/cloofy-ytmusic/src/account.rs
@@ -17,6 +17,8 @@ const MAX_ACCOUNTS: u32 = 10;
/// The comments in the sign-in's `cookies.txt` that say which account it acts as.
const INDEX_NOTE: &str = "# cloofy-authuser: ";
const PAGE_NOTE: &str = "# cloofy-pageid: ";
+const BROWSER_NOTE: &str = "# cloofy-browser: ";
+const USER_NOTE: &str = "# cloofy-user: ";
/// The account requests act as.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -52,6 +54,36 @@ impl Identity {
}
}
+/// Where a sign-in came from, kept with it so that cloofy can read it from there again when
+/// YouTube stops taking the cookies: a browser that goes on using its sign-in gets new cookies
+/// for it, and the ones cloofy copied stop working after a day or two.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub(crate) struct Origin {
+ /// The browser as yt-dlp's `--cookies-from-browser` takes it; `None` for a pasted header.
+ pub browser: Option,
+ /// YouTube's ID of the account signed in as. Cookies read again are only kept when they
+ /// are this account's: the browser may have signed in to another one since.
+ pub user: Option,
+}
+
+impl Origin {
+ /// Reads it from the sign-in's `cookies.txt`; empty where the file says nothing, as files
+ /// from before cloofy 0.5.3 do.
+ pub(crate) fn parse(text: &str) -> Self {
+ let note = |prefix: &str| {
+ let lines = text.lines().map(|line| line.trim_end_matches('\r'));
+ lines.filter_map(|line| line.strip_prefix(prefix)).map(str::trim).rfind(|value| !value.is_empty())
+ };
+ Self { browser: note(BROWSER_NOTE).map(str::to_owned), user: note(USER_NOTE).map(str::to_owned) }
+ }
+
+ /// The lines that keep it in a `cookies.txt`, as comments other readers skip.
+ pub(crate) fn notes(&self) -> String {
+ let note = |prefix: &str, value: &Option| value.as_ref().map(|value| format!("{prefix}{value}\n"));
+ [note(BROWSER_NOTE, &self.browser), note(USER_NOTE, &self.user)].into_iter().flatten().collect()
+ }
+}
+
/// An account the cookies can act as.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Account {
@@ -166,6 +198,17 @@ fn parse(page: &Value, index: u32) -> Vec {
mod tests {
use super::*;
+ #[test]
+ fn where_a_sign_in_came_from_survives_its_file() {
+ let origin = Origin { browser: Some("brave+gnomekeyring".into()), user: Some("UCabc".into()) };
+ let text = format!("# Netscape HTTP Cookie File\n{}{}", Identity::default().notes(), origin.notes());
+ assert_eq!(Origin::parse(&text), origin);
+ assert_eq!(Identity::parse(&text), Identity::default(), "next to the account's own notes");
+ // A file from before 0.5.3, and a pasted header's.
+ assert_eq!(Origin::parse("# cloofy-authuser: 1\n"), Origin::default());
+ assert_eq!(Origin::default().notes(), "");
+ }
+
fn fixture() -> Value {
serde_json::from_str(include_str!("../tests/fixtures/accounts_list.json")).unwrap()
}
diff --git a/crates/cloofy-ytmusic/src/auth.rs b/crates/cloofy-ytmusic/src/auth.rs
index 949b13d..8d2f835 100644
--- a/crates/cloofy-ytmusic/src/auth.rs
+++ b/crates/cloofy-ytmusic/src/auth.rs
@@ -17,9 +17,10 @@ use tokio::process::Command;
use crate::{
YtMusic,
- account::{self, Account},
+ account::{self, Account, Origin},
cookies::Jar,
- innertube, library,
+ innertube::{self, InnerTube},
+ library,
};
/// A browser yt-dlp can read cookies from.
@@ -192,8 +193,11 @@ pub struct SignIn {
}
impl SignIn {
- fn new(jar: Jar) -> Result {
- Ok(Self { client: YtMusic::unsaved(jar)? })
+ /// `browser` is where the cookies were read, as yt-dlp names it; `None` for pasted ones.
+ fn new(jar: Jar, browser: Option) -> Result {
+ let client = YtMusic::unsaved(jar)?;
+ client.api().set_origin(Origin { browser, user: None });
+ Ok(Self { client })
}
/// The accounts it can act as: each Google account signed in to the browser, in Google's
@@ -207,9 +211,13 @@ impl SignIn {
self.client.api().set_identity(account.identity.clone());
}
- /// Asks YouTube Music whose sign-in it is; fails when it turns the cookies down.
+ /// Asks YouTube Music whose sign-in it is; fails when it turns the cookies down. The
+ /// account is kept with the sign-in, for when cloofy reads the browser's cookies again.
pub async fn check(&self) -> Result {
- library::me(self.client.api()).await
+ let api = self.client.api();
+ let user = library::me(api).await?;
+ api.set_origin(Origin { user: Some(user.id.clone()), ..api.origin().unwrap_or_default() });
+ Ok(user)
}
/// Keeps it in place of the sign-in before, with the account chosen and the cookies
@@ -241,7 +249,7 @@ pub async fn from_browser(paths: &Paths, browser: &mut Browser) -> Result