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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ echo is a native desktop music player and Spotify client written in Rust. echo b
- **Full playback control**: Play/pause, next/previous, seek, shuffle, repeat, volume, queue, and device switching from the now-playing bar.
- **Synced lyrics**: Time-synced lyrics inline in the player bar or as a full-screen view.
- **What's New**: A feed of recent albums and singles from the artists you follow, refreshed at most every 6 hours.
- **Blazing fast Liked Songs**: Your entire Liked Songs library is cached locally (`~/.config/echo/cache.json`) for zero-latency, rate-limit-free scrolling, even with thousands of saved tracks.
- **Blazing fast Liked Songs**: Your entire Liked Songs library is cached locally (`~/.config/echo/liked_songs.json`) for zero-latency scrolling, even with thousands of saved tracks. Keeping it current usually takes a single request; a full re-read is paced, resumable, and backs off when Spotify rate-limits.
- **Library management**: Create, rename, delete, and organize playlists into folders; reorder tracks in your own playlists.
- **Local music support**: Scan a local music folder, play local files, and create local playlists that can also reference Spotify tracks.
- **Search**: Fast global search (`ctrl-k`) across the Spotify catalog and your scanned local tracks.
Expand Down Expand Up @@ -296,7 +296,7 @@ Embedded artwork is used when available. If a track has no embedded artwork, ech
## Troubleshooting
- **Theme color rendering issues (Windows)**: Disable "Adjust indistinguishable text" in the Appearance settings of the Defaults profile.
- **Images not rendering**: Cover art is drawn with half-block cells and needs nothing from the terminal beyond truecolor support, which every modern terminal has.
- **Cache desync**: If your Liked Songs are out of sync with other devices, simply restart echo. It eagerly syncs your library in the background on startup.
- **Cache desync**: Likes from other devices show up on startup or when you open Liked Songs (checked at most every 15 minutes). Songs unliked elsewhere are caught at the same point and trigger a background re-read of the library; a rate-limited re-read picks up where it stopped. Deleting `~/.config/echo/liked_songs.json` while echo is closed forces a full re-read.
- **Local file missing**: If a file was deleted or moved after scanning, run `:rescanlocal` to refresh the local library.
- **Audio sounds mono or muffled (Bluetooth headsets)**: Windows exposes a Bluetooth headset as two output devices — a stereo "Headphones" (A2DP) endpoint, and a mono "Hands-Free" (HFP) endpoint capped at 16 kHz. Windows switches to Hands-Free whenever an application opens the microphone. Check `echo-debug-audio-spotify.log`: if it reports `channels=1`, quit whatever is holding the mic and select the stereo endpoint as your default output device.
- **Configuration Path**: `~/.config/echo/config.toml` (holds tokens and preferences), `~/.config/echo/cache.json` (holds liked tracks), `~/.config/echo/local_library.json`, and `~/.config/echo/local_playlists.json`.
- **Configuration Path**: `~/.config/echo/config.toml` (holds tokens and preferences), `~/.config/echo/cache.json` (holds library caches and liked-state hearts), `~/.config/echo/liked_songs.json` (holds the Liked Songs list), `~/.config/echo/local_library.json`, and `~/.config/echo/local_playlists.json`.
6 changes: 3 additions & 3 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ echo 是一款用 Rust 编写的原生桌面音乐播放器和 Spotify 客户端
- **全面的播放控制**:在正在播放栏中即可播放/暂停、上一曲/下一曲、跳转进度、随机播放、重复、音量、队列和设备切换。
- **同步歌词**:时间同步的歌词可直接显示在播放栏中,或以全屏视图呈现。
- **最新动态**:来自您关注艺术家的近期专辑与单曲信息流,最多每 6 小时刷新一次。
- **极速喜欢的歌曲**:您整个喜欢的歌曲库会缓存在本地(`~/.config/echo/cache.json`),实现零延迟、无速率限制的滚动浏览,即使有数千首保存的曲目也毫无压力。
- **极速喜欢的歌曲**:您整个喜欢的歌曲库会缓存在本地(`~/.config/echo/liked_songs.json`),实现零延迟的滚动浏览,即使有数千首保存的曲目也毫无压力。保持同步通常只需一次请求;完整重读会限速进行、可断点续传,并在 Spotify 限流时自动退避。
- **库管理**:创建、重命名、删除播放列表并将其组织到文件夹中;在自己的播放列表中重新排列曲目顺序。
- **本地音乐支持**:扫描本地音乐文件夹,播放本地文件,创建也可引用 Spotify 曲目的本地播放列表。
- **搜索**:快速全局搜索(`ctrl-k`),覆盖 Spotify 目录和已扫描的本地曲目。
Expand Down Expand Up @@ -294,7 +294,7 @@ device=Headphones (WH-1000XM5) channels=2 sample_rate=48000 format=F32

- **主题颜色渲染问题 (Windows)**:在“默认值”配置文件的“外观”设置中,禁用“调整难以区分的文本”。
- **图像无法渲染**:封面使用半格单元格绘制,只需要终端支持真彩色——所有现代终端都已具备。
- **缓存不同步**:如果您喜欢的歌曲与其他设备不同步,只需重启 echo。它会在启动时在后台急切地同步您的库。
- **缓存不同步**:其他设备上喜欢的歌曲会在启动时或打开喜欢的歌曲时出现(最多每 15 分钟检查一次)。在其他设备上取消喜欢的歌曲也会在同一时机被发现,并触发后台重读整个库;被限流的重读会从中断处继续。在 echo 关闭时删除 `~/.config/echo/liked_songs.json` 可强制完整重读。
- **本地文件丢失**:如果文件在扫描后被删除或移动,运行 `:rescanlocal` 以刷新本地库。
- **音频听起来单声道或沉闷(蓝牙耳机)**:Windows 将蓝牙耳机暴露为两个输出设备——立体声的“耳机”(A2DP)端点,以及限制在 16 kHz 的单声道“免提”(HFP)端点。每当有应用程序打开麦克风时,Windows 就会切换到免提模式。检查 `echo-debug-audio-spotify.log`:如果报告 `channels=1`,请退出占用麦克风的程序,并将立体声端点设为默认输出设备。
- **配置文件路径**:`~/.config/echo/config.toml`(保存令牌和偏好设置)、`~/.config/echo/cache.json`(保存喜欢的曲目)、`~/.config/echo/local_library.json` 和 `~/.config/echo/local_playlists.json`。
- **配置文件路径**:`~/.config/echo/config.toml`(保存令牌和偏好设置)、`~/.config/echo/cache.json`(保存库缓存和喜欢状态)、`~/.config/echo/liked_songs.json`(保存喜欢的歌曲列表)、`~/.config/echo/local_library.json` 和 `~/.config/echo/local_playlists.json`。
6 changes: 3 additions & 3 deletions README.zht.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ echo 是一款用 Rust 編寫的原生桌面音樂播放器和 Spotify 用戶端
- **全面的播放控制**:在正在播放列中即可播放/暫停、上一曲/下一曲、跳轉進度、隨機播放、重複、音量、佇列和裝置切換。
- **同步歌詞**:時間同步的歌詞可直接顯示在播放列中,或以全螢幕檢視呈現。
- **最新動態**:來自您追蹤藝術家的近期專輯與單曲資訊流,最多每 6 小時重新整理一次。
- **極速喜歡的歌曲**:採用全域快取架構。您整個喜歡的歌曲庫會快取在本地(`~/.config/echo/cache.json`),實現零延遲、無速率限制的捲動瀏覽,即使有數千首儲存的曲目也毫無壓力。
- **極速喜歡的歌曲**:採用全域快取架構。您整個喜歡的歌曲庫會快取在本地(`~/.config/echo/liked_songs.json`),實現零延遲的捲動瀏覽,即使有數千首儲存的曲目也毫無壓力。保持同步通常只需一次請求;完整重讀會限速進行、可斷點續傳,並在 Spotify 限流時自動退避。
- **庫管理**:建立、重新命名、刪除播放清單並將其組織到資料夾中;在自己的播放清單中重新排列曲目順序。
- **本地音樂支援**:掃描本地音樂資料夾,播放本地檔案,建立也可引用 Spotify 曲目的本地播放清單。
- **搜尋**:快速全域搜尋(`ctrl-k`),涵蓋 Spotify 目錄和已掃描的本地曲目。
Expand Down Expand Up @@ -294,7 +294,7 @@ device=Headphones (WH-1000XM5) channels=2 sample_rate=48000 format=F32

- **主題顏色渲染問題 (Windows)**:在「預設值」設定檔的「外觀」設定中,停用「調整難以分辨的文字」。
- **影像無法渲染**:封面使用半格儲存格繪製,只需要終端機支援全彩——所有現代終端機都已具備。
- **快取不同步**:如果您喜歡的歌曲與其他裝置不同步,只需重新啟動 echo。它會在啟動時在背景積極地同步您的庫。
- **快取不同步**:其他裝置上喜歡的歌曲會在啟動時或開啟喜歡的歌曲時出現(最多每 15 分鐘檢查一次)。在其他裝置上取消喜歡的歌曲也會在同一時機被發現,並觸發背景重讀整個庫;被限流的重讀會從中斷處繼續。在 echo 關閉時刪除 `~/.config/echo/liked_songs.json` 可強制完整重讀。
- **本地檔案遺失**:如果檔案在掃描後被刪除或移動,執行 `:rescanlocal` 以重新整理本地庫。
- **音訊聽起來像單聲道或悶悶的(藍牙耳機)**:Windows 將藍牙耳機暴露為兩個輸出裝置——立體聲的「耳機」(A2DP)端點,以及限制在 16 kHz 的單聲道「免持」(HFP)端點。每當有應用程式開啟麥克風時,Windows 就會切換到免持模式。檢查 `echo-debug-audio-spotify.log`:如果報告 `channels=1`,請結束占用麥克風的程式,並將立體聲端點設為預設輸出裝置。
- **設定檔路徑**:`~/.config/echo/config.toml`(儲存權杖和偏好設定)、`~/.config/echo/cache.json`(儲存喜歡的曲目)、`~/.config/echo/local_library.json` 和 `~/.config/echo/local_playlists.json`。
- **設定檔路徑**:`~/.config/echo/config.toml`(儲存權杖和偏好設定)、`~/.config/echo/cache.json`(儲存庫快取和喜歡狀態)、`~/.config/echo/liked_songs.json`(儲存喜歡的歌曲列表)、`~/.config/echo/local_library.json` 和 `~/.config/echo/local_playlists.json`。
58 changes: 58 additions & 0 deletions crates/echo-core/src/apply_worker_event/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,27 @@ pub fn handle_tracks_loaded(
state.sort_tracks(state.ui.track_sort);
}

/// Refreshes Liked Songs in place when it is the open list; otherwise the stored copy is
/// what the next open shows.
pub fn handle_liked_songs_updated(
state: &mut AppState,
worker_tx: &mpsc::Sender<WorkerEvent>,
tracks: Vec<Track>,
total: Option<u32>,
) {
let Some(context) = state
.data
.active_tracklist_context
.clone()
.filter(|context| context.id == "LIKED_SONGS")
else {
return;
};
let details = crate::context_details::liked_songs(&context, &tracks, total);
handle_tracks_loaded(state, worker_tx, tracks, context);
state.data.active_context_details = Some(details);
}

pub fn handle_tracks_load_failed(state: &mut AppState, message: String) {
set_timed_status(state, format!("Unable to load tracks: {message}"), 5);
}
Expand Down Expand Up @@ -325,6 +346,43 @@ mod tests {
assert_eq!(state.data.tracks.len(), 2);
}

#[test]
fn liked_songs_updates_refresh_only_the_open_liked_list() {
let (tx, _) = mpsc::channel(1);
let mut state = AppState::new();
let album = TrackListContext::album("a".into(), "A".into(), "Artist".into(), None);
state.begin_tracklist_load(album);
handle_liked_songs_updated(&mut state, &tx, vec![sample_track("x")], Some(1));
assert!(state.data.tracks.is_empty());

let liked = TrackListContext::playlist(
"LIKED_SONGS".into(),
"Liked Songs".into(),
String::new(),
"spotify".into(),
None,
);
state.begin_tracklist_load(liked);
handle_liked_songs_updated(
&mut state,
&tx,
vec![sample_track("x"), sample_track("y")],
Some(3),
);
state.ui.selected_track_index = 1;
handle_liked_songs_updated(
&mut state,
&tx,
vec![sample_track("new"), sample_track("x"), sample_track("y")],
Some(4),
);
assert_eq!(state.data.tracks.len(), 3);
assert_eq!(state.data.tracks[state.ui.selected_track_index].id, "y");
let details = state.data.active_context_details.as_ref().unwrap();
assert_eq!(details.track_count, Some(4));
assert_eq!(details.duration_ms, 3000);
}

use super::*;
use crate::models::{BrowseNode, TrackSource};

Expand Down
3 changes: 3 additions & 0 deletions crates/echo-core/src/apply_worker_event/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ pub fn apply_worker_event(
WorkerEvent::LikedStatusUpdate(results) => {
library::handle_liked_status_update(state, results)
}
WorkerEvent::LikedSongsUpdated { tracks, total } => {
data::handle_liked_songs_updated(state, worker_tx, tracks, total)
}
WorkerEvent::Tick => playback::handle_tick(state, app_tx),
WorkerEvent::PlaybackStarted { item } => {
playback::handle_playback_started(state, app_tx, worker_tx, item)
Expand Down
9 changes: 2 additions & 7 deletions crates/echo-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,10 @@ pub struct AppConfig {
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct CacheData {
#[serde(default)]
/// Liked-state hearts: Spotify ids, kept in step by the Liked Songs sync, plus `local:`
/// ids, which only echo knows about.
pub liked_tracks: HashSet<String>,
#[serde(default)]
pub last_liked_sync_time: Option<u64>,
/// When the whole Liked Songs library was last walked. The hourly sync only tops up with
/// the most recent page, which cannot observe an unlike; the full walk rebuilds the set so
/// tracks unliked on another device stop showing a heart.
#[serde(default)]
pub last_liked_full_sync_time: Option<u64>,
#[serde(default)]
pub playlists: Option<CachedEntry<Vec<Playlist>>>,
#[serde(default)]
pub saved_albums: Option<CachedEntry<Vec<Album>>>,
Expand Down
14 changes: 14 additions & 0 deletions crates/echo-core/src/context_details.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ impl ContextDetails {
}
}

/// Liked Songs' header, from the stored list alone.
pub fn liked_songs(
context: &TrackListContext,
tracks: &[Track],
total: Option<u32>,
) -> ContextDetails {
ContextDetails {
owner: context.subtitle.clone(),
track_count: total,
duration_ms: total_duration(tracks),
..Default::default()
}
}

pub fn total_duration(tracks: &[Track]) -> u64 {
tracks
.iter()
Expand Down
6 changes: 6 additions & 0 deletions crates/echo-core/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ pub enum WorkerEvent {
artwork: Option<crate::artwork::SharedArtwork>,
},
LikedStatusUpdate(std::collections::HashMap<String, bool>),
/// The stored Liked Songs list changed: a sync step or an unlike. `total` is Spotify's
/// count, which can run ahead of `tracks` while the first walk is still going.
LikedSongsUpdated {
tracks: Vec<Track>,
total: Option<u32>,
},
DevicesLoaded(Vec<crate::models::Device>),
LyricsLoaded(Option<crate::models::Lyrics>),
TopTracksLoaded(Vec<Track>),
Expand Down
1 change: 1 addition & 0 deletions crates/echo-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub mod home;
pub mod i18n;
pub mod image_tasks;
pub mod intent;
pub mod liked_songs;
pub mod models;
pub mod platform;
pub mod search;
Expand Down
Loading
Loading