diff --git a/CHANGELOG.md b/CHANGELOG.md index b3086f9..4836f37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to ShuffleMuse are documented in this file. The project follows [Semantic Versioning](https://semver.org/). +## [0.1.2] - 2026-07-29 + +### Added + +- A Browse action that moves the current folder's directly contained audio + files to the front of the active queue and starts the first track, without + duplicating those files or discarding the rest of the randomized queue. +- Best-effort Web Audio endpoint activation and a 15-second post-pause + keepalive on supported browsers, without a fixed delay before media playback. + +### Changed + +- Original-file responses now preserve the response writer's optimized + `io.ReaderFrom` copy path through logging and commit-tracking middleware. +- Library status refreshes reuse the initial authentication response, poll every + 2 seconds only during scans, fall back to 30 seconds while idle, and pause + while the page is hidden. +- The Home view now gives the current track, artwork, and tag filter a more + compact layout. + +### Fixed + +- Cancelled stale playback intents after pause, overlapping track selections, + source changes, and player reset so late asynchronous work cannot start old + audio. +- Prevented the stream-mode selector from covering the next-track control + across intermediate viewport widths. + ## [0.1.1] - 2026-07-24 ### Added @@ -44,5 +72,6 @@ The project follows [Semantic Versioning](https://semver.org/). - Multi-architecture GHCR image publication for `linux/amd64` and `linux/arm64`. +[0.1.2]: https://github.com/ColderCoder/ShuffleMuse/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/ColderCoder/ShuffleMuse/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/ColderCoder/ShuffleMuse/tree/v0.1.0 diff --git a/README.md b/README.md index 1a6d55f..b647c35 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ ShuffleMuse 是一个面向个人和小型自托管场景的轻量音乐库播 ## 快速部署 要求 Docker Engine 和 Docker Compose 插件。默认配置拉取公开的 -`ghcr.io/coldercoder/shufflemuse:0.1.1`。把音乐文件放入项目根目录的 +`ghcr.io/coldercoder/shufflemuse:0.1.2`。把音乐文件放入项目根目录的 `music/` 后执行: ```bash @@ -177,7 +177,7 @@ Tags 页的 CSV 用于查看和外部处理,没有对应的导入功能,不 ## 版本与镜像 -- 稳定版本由对应 Git 标签发布;`v0.1.1` 对应镜像标签 `0.1.1`、`0.1`、 +- 稳定版本由对应 Git 标签发布;`v0.1.2` 对应镜像标签 `0.1.2`、`0.1`、 `0` 和 `latest`。 - 支持 `linux/amd64` 与 `linux/arm64`。 - `shufflemuse --version` 输出版本、Git commit 与构建时间。 diff --git a/docker-compose.yml b/docker-compose.yml index 00d15cb..088cc16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ name: shufflemuse services: shufflemuse: - image: ghcr.io/coldercoder/shufflemuse:0.1.1 + image: ghcr.io/coldercoder/shufflemuse:0.1.2 ports: - "127.0.0.1:8080:8080" volumes: diff --git a/docs/API.md b/docs/API.md index e940deb..1c9026f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -278,11 +278,28 @@ HTTP/1.1 202 Accepted 文件已经在队列中时返回原队列、零基 `queueIndex`、所在页及该页。文件在线但不在队列中时,原子创建以该文件为首、保留原剩余顺序的替代队列,并在响应的 `queue.id` 返回新令牌。 +### `POST /api/queues/{id}/prepend-directory` + +```json +{"dir":"Artist/Album"} +``` + +读取目录中直接包含的在线音频文件(不递归),按不区分大小写的文件名 +排序,移到当前队列最前并去重。原队列的其他项目保持原顺序。成功后原子 +替换队列令牌并返回 `200 OK`;响应包含新队列的第一页以及 +`directoryTrackCount`。 + +目录及 symlink 解析后的目标都必须位于 `MUSIC_DIR` 内。空目录或没有在线 +音频的目录返回 `422 NO_AUDIO_FILES`;不存在的目录返回 +`404 NOT_FOUND`。 + ### `DELETE /api/queues/{id}` 幂等返回 `204 No Content`,令牌不存在或格式无效也相同。 -队列稳定错误:`404 QUEUE_NOT_FOUND`、`404 FILE_NOT_FOUND`、`503 QUEUE_BUSY`、`503 QUEUE_CAPACITY`。JSON、分页、认证和内部错误沿用公共错误码。 +队列稳定错误:`404 QUEUE_NOT_FOUND`、`404 FILE_NOT_FOUND`、 +`422 NO_AUDIO_FILES`、`503 QUEUE_BUSY`、`503 QUEUE_CAPACITY`。JSON、 +分页、认证和内部错误沿用公共错误码。 ### `GET /api/search?q=` @@ -363,12 +380,16 @@ HEAD 和能得到 304 的条件请求只发现 descriptor,不启动 FFmpeg。 "playable":false } ], + "audioCount":0, "total":2, "page":1, "generation":3 } ``` +`audioCount` 是该目录全部直接子项中当前可播放的音频文件数,不受当前 +分页影响;子目录中的音频不计入。 + 音频文件还包含 `playable:true`、`audioId` 和不含扩展名的 `trackName`。 错误:`400 INVALID_DIRECTORY`、`400 QUERY_TOO_LONG`、`404 NOT_FOUND`、`500 BROWSE_ERROR`、分页错误。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e1436ec..bbe89d9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -149,11 +149,13 @@ sequenceDiagram 同 generation 的队列引用同一只读 `Index`,不复制 `Files` 或 `ByID`。 标签候选和 pin 只影响 `uint32` 顺序及小型前缀覆盖;带标签的队列只接受 -同样属于该标签的 pin,避免标签外曲目混入。容量核算把不同快照只计一次; -旧 generation 在最后一个队列释放后可回收。队列排列不可变,固定 200 条 -分页只做切片。选取任意歌曲时的线性位置扫描在全局 Manager 锁外执行并 -响应 request cancellation;重新加锁后必须校验原队列身份,才返回页面或 -原子替换队列。 +同样属于该标签的 pin,避免标签外曲目混入。Browse 的目录播放会把目录 +直接包含的在线音频按文件名排序后写入前缀、从原前缀与随机顺序中去重, +并以新令牌原子替换旧队列。容量核算把不同快照只计一次;旧 generation +在最后一个队列释放后可回收。队列排列不可变,固定 200 条分页只做切片。 +选取任意歌曲时的线性位置扫描和目录前缀构建都在全局 Manager 锁外执行 +并响应 request cancellation;重新加锁后必须校验原队列身份,才返回页面 +或原子替换队列。 重扫发布不修改队列:页面 materialize 时用当前 Index 检查 ID/路径,删除项显示 `available:false`,next/previous 跳过;新增项只进入下一次新建或 Randomize。TTL/LRU 淘汰后前端最多自动恢复一次,以相同标签和当前歌曲 pin 创建新队列,同时保留正在播放的 Audio source。 @@ -250,8 +252,8 @@ HEAD/304 只读取 descriptor,不启动 FFmpeg。未转换外置图使用 `Ope | Store | 主要责任 | | --- | --- | | `auth` | 四态认证状态机、登录/退出和显式重试 | -| `library` | 2 秒状态轮询、首次扫描、重扫和 generation | -| `player` | Audio 元素、队列描述/全局位置、最多 5 页 LRU、播放意图、模式和 metadata | +| `library` | 扫描时 2 秒/空闲时 30 秒的可见页状态轮询、首次扫描、重扫和 generation | +| `player` | Audio 元素、端点保温、队列描述/全局位置、最多 5 页 LRU、播放意图、模式和 metadata | | `tags` | 标签云、当前 200 条标签文件页和 selection | ### 异步一致性 @@ -263,6 +265,7 @@ HEAD/304 只读取 descriptor,不启动 FFmpeg。未转换外置图使用 `Ope - stop 后状态与重扫请求被取消且不能回写; - 文本 Preview 卸载时取消仍在进行的内容请求; - 用户显式选歌后,旧 playlist 请求不能替换它; +- pause、重叠选曲和 reset 会使旧播放意图失效,迟到任务不能启动旧音频; - 标签文件只保留当前服务端分页;播放列表页面要求同一个 generation; - logout 和 Session 过期会统一重置播放器、曲库和标签状态。 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8143c6e..551f09b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -252,7 +252,7 @@ Tags 文件行播放按钮已覆盖 hover、按钮 `:focus-visible` 和行 `:foc ## 当前测试范围 -后端测试覆盖配置、认证、代理、严格请求、扫描、重扫、Browse、stream、media queue、tags、Graveyard、CSV 和静态资源缓存。前端当前有 17 个测试文件、64 个用例,覆盖主要 stores、分页/取消竞态、登录封禁、路由、Search 语义、Playlist 分块、Tags 二级导航与导出、Graveyard、Modal 焦点和 metadata 标题切换。 +后端测试覆盖配置、认证、代理、严格请求、扫描、重扫、Browse、stream、media queue、tags、Graveyard、CSV 和静态资源缓存。前端当前有 17 个测试文件、82 个用例,覆盖主要 stores、分页/取消竞态、登录封禁、路由、Search 语义、Playlist 分块、目录入队、播放意图、Tags 二级导航与导出、Graveyard、Modal 焦点和 metadata 标题切换。 当前明确缺口: @@ -268,7 +268,7 @@ Tags 文件行播放按钮已覆盖 hover、按钮 `:focus-visible` 和行 `:foc ## 依赖与项目元数据 - Go module 为 `github.com/ColderCoder/ShuffleMuse`。 -- 前端和后端发布版本均为 `0.1.1`;容器构建通过 linker flags 注入版本、commit +- 前端和后端发布版本均为 `0.1.2`;容器构建通过 linker flags 注入版本、commit 和构建时间,`shufflemuse --version` 可直接读取。 - 发布与治理入口包括 LICENSE、CHANGELOG、CONTRIBUTING、SECURITY policy、 完整 CI 和仅 GHCR 的标签发布 workflow。 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 59de061..709d9b7 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -71,6 +71,11 @@ HTTP 服务会先开始监听,再在后台完成首次扫描: 切换模式时保留当前绝对播放位置和播放/暂停状态。Original 不占 FFmpeg 并发;Opus 会占一个共享媒体槽。 +支持 Web Audio 时,播放器会在播放手势中尽力激活系统音频端点,并在暂停 +后继续输出 15 秒零信号,以缓解部分蓝牙/HDMI 端点休眠后的开头丢音。此 +过程不会固定等待后才调用媒体播放;Web Audio 不可用或启动失败时会直接 +退回普通 HTML 媒体播放。 + 播放器的 Opus 模式会显示服务器当前生效的转码码率;管理员修改 `MUSIC_OPUS_BITRATE` 后,已认证状态接口和界面文案会同步更新。 ### 浏览器保存的设置 @@ -131,6 +136,10 @@ Home 显示: Browse 使用 `/browse?dir=<相对目录>` 表示当前位置,面包屑可返回任意上级目录。目录和文件合计每页 50 项;翻页会替换当前页,进入新目录时先取消旧请求并清空旧内容,避免继续扫描或显示错误目录。 +目录右上角的 `Play folder` 会把当前目录直接包含的音频按文件名顺序移到 +现有随机队列最前,自动播放第一首,并保留队列中其余不重复的曲目。该 +操作不递归加入子目录;当前目录没有可播放音频时按钮不可用。 + ### 文件操作 | 文件类型 | Preview | Download | Play | diff --git a/internal/api/browse.go b/internal/api/browse.go index 881a3ba..eac3f13 100644 --- a/internal/api/browse.go +++ b/internal/api/browse.go @@ -140,6 +140,8 @@ func (a *API) handleBrowse(w http.ResponseWriter, r *http.Request) { initialCapacity := min(retainedLimit, 4096) retainedCandidates := make(browseCandidateHeap, 0, initialCapacity) + idx, generation := a.currentSnapshot(r) + audioCount := 0 total := 0 for { if r.Context().Err() != nil { @@ -157,6 +159,13 @@ func (a *API) handleBrowse(w http.ResponseWriter, r *http.Request) { candidate.isDirectory = true case isRegular: // Files sort after directories. + if index.AudioExtensions[strings.ToLower(filepath.Ext(entry.Name()))] { + relPath := browseRelativePath(dir, entry.Name()) + audio := idx.ByID[index.GenerateID(relPath)] + if audio != nil && filepath.Clean(audio.Filepath) == filepath.Clean(relPath) { + audioCount++ + } + } default: continue } @@ -181,7 +190,6 @@ func (a *API) handleBrowse(w http.ResponseWriter, r *http.Request) { directories := make([]browseDirectory, 0, len(pageCandidates)) files := make([]browseFile, 0, len(pageCandidates)) - idx, generation := a.currentSnapshot(r) for _, candidate := range pageCandidates { if r.Context().Err() != nil { return @@ -204,6 +212,7 @@ func (a *API) handleBrowse(w http.ResponseWriter, r *http.Request) { "directories": directories, "files": files, "total": total, + "audioCount": audioCount, "page": page, "generation": generation, }) diff --git a/internal/api/browse_test.go b/internal/api/browse_test.go index 3d9a06b..f8ab43c 100644 --- a/internal/api/browse_test.go +++ b/internal/api/browse_test.go @@ -54,6 +54,7 @@ func TestBrowseListsMeaningfulFilesAndDirectories(t *testing.T) { Directories []browseDirectory `json:"directories"` Files []browseFile `json:"files"` Total int `json:"total"` + AudioCount int `json:"audioCount"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { t.Fatal(err) @@ -61,6 +62,9 @@ func TestBrowseListsMeaningfulFilesAndDirectories(t *testing.T) { if len(result.Directories) != 1 || result.Directories[0].Path != "artist1/Scans" { t.Fatalf("unexpected directories: %+v", result.Directories) } + if result.AudioCount != 3 { + t.Fatalf("audio count = %d, want 3 direct indexed files", result.AudioCount) + } names := make(map[string]browseFile) for _, file := range result.Files { names[file.Name] = file @@ -708,6 +712,16 @@ func TestBrowseAndMetadataRequireAuthentication(t *testing.T) { t.Errorf("GET %s status = %d, want 401", path, resp.StatusCode) } } + + resp, body := postJSON( + t, + env.server.URL+"/api/queues/forged/prepend-directory", + `{"dir":"artist1"}`, + nil, + ) + if resp.StatusCode != http.StatusUnauthorized || body["code"] != "UNAUTHORIZED" { + t.Errorf("POST prepend directory = %d/%v, want 401/UNAUTHORIZED", resp.StatusCode, body) + } } func mustWriteBrowseFile(t *testing.T, path string, content []byte) { diff --git a/internal/api/handler.go b/internal/api/handler.go index f2f684c..2b331e0 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log" "net/http" "os" @@ -130,6 +131,7 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("POST /api/queues", a.withLibraryReady(a.handleCreateQueue)) mux.HandleFunc("GET /api/queues/{id}/items", a.withLibraryReady(a.handleQueueItems)) mux.HandleFunc("POST /api/queues/{id}/select", a.withLibraryReady(a.handleQueueSelect)) + mux.HandleFunc("POST /api/queues/{id}/prepend-directory", a.withLibraryReady(a.handleQueuePrependDirectory)) mux.HandleFunc("DELETE /api/queues/{id}", a.handleDeleteQueue) mux.HandleFunc("GET /api/files/{id}/metadata", a.withLibraryReady(a.handleFileMetadata)) mux.HandleFunc("GET /api/files/{id}/cover", a.withLibraryReady(a.handleFileCover)) @@ -168,6 +170,7 @@ func (a *API) RoutesWithAuth() http.Handler { mux.HandleFunc("POST /api/queues", a.withAuth(a.withLibraryReady(a.handleCreateQueue))) mux.HandleFunc("GET /api/queues/{id}/items", a.withAuth(a.withLibraryReady(a.handleQueueItems))) mux.HandleFunc("POST /api/queues/{id}/select", a.withAuth(a.withLibraryReady(a.handleQueueSelect))) + mux.HandleFunc("POST /api/queues/{id}/prepend-directory", a.withAuth(a.withLibraryReady(a.handleQueuePrependDirectory))) mux.HandleFunc("DELETE /api/queues/{id}", a.withAuth(a.handleDeleteQueue)) mux.HandleFunc("GET /api/files/{id}/metadata", a.withAuth(a.withLibraryReady(a.handleFileMetadata))) mux.HandleFunc("GET /api/files/{id}/cover", a.withAuth(a.withLibraryReady(a.handleFileCover))) @@ -228,6 +231,16 @@ type commitTrackingWriter struct { func (w *commitTrackingWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } +func (w *commitTrackingWriter) ReadFrom(r io.Reader) (int64, error) { + if !w.committed { + w.WriteHeader(http.StatusOK) + } + if readerFrom, ok := w.ResponseWriter.(io.ReaderFrom); ok { + return readerFrom.ReadFrom(r) + } + return io.Copy(writerOnly{Writer: w}, r) +} + func (w *commitTrackingWriter) Flush() { if !w.committed { w.WriteHeader(http.StatusOK) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 6d2ef0e..85114d3 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -1,6 +1,7 @@ package api import ( + "io" "log" "net" "net/http" @@ -87,6 +88,13 @@ type loggingWriter struct { func (lw *loggingWriter) Unwrap() http.ResponseWriter { return lw.ResponseWriter } +func (lw *loggingWriter) ReadFrom(r io.Reader) (int64, error) { + if readerFrom, ok := lw.ResponseWriter.(io.ReaderFrom); ok { + return readerFrom.ReadFrom(r) + } + return io.Copy(writerOnly{Writer: lw}, r) +} + func (lw *loggingWriter) Flush() { if flusher, ok := lw.ResponseWriter.(http.Flusher); ok { flusher.Flush() @@ -97,3 +105,9 @@ func (lw *loggingWriter) WriteHeader(code int) { lw.statusCode = code lw.ResponseWriter.WriteHeader(code) } + +// writerOnly hides optional interfaces such as io.ReaderFrom so fallback copies +// cannot recurse back into a response writer's ReadFrom method. +type writerOnly struct { + io.Writer +} diff --git a/internal/api/queue.go b/internal/api/queue.go index 1ce8bdc..cc59c09 100644 --- a/internal/api/queue.go +++ b/internal/api/queue.go @@ -3,8 +3,14 @@ package api import ( "context" "errors" + "io" "net/http" + "os" + "path/filepath" + "sort" + "strings" + "github.com/ColderCoder/ShuffleMuse/internal/index" "github.com/ColderCoder/ShuffleMuse/internal/playqueue" ) @@ -68,6 +74,110 @@ func (a *API) handleQueueSelect(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result) } +func (a *API) handleQueuePrependDirectory(w http.ResponseWriter, r *http.Request) { + var body struct { + Dir string `json:"dir"` + } + if !decodeStrictJSON(w, r, &body) { + return + } + if body.Dir == "" || len(body.Dir) > 4096 { + writeError(w, http.StatusBadRequest, "INVALID_DIRECTORY", "dir is required and must not exceed 4096 bytes") + return + } + dir, err := cleanLibraryPath(body.Dir, true) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_DIRECTORY", err.Error()) + return + } + idx, generation := a.currentSnapshot(r) + entries, found, err := a.directAudioEntries(r.Context(), dir, idx) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return + } + writeError(w, http.StatusInternalServerError, "BROWSE_ERROR", "failed to read directory") + return + } + if !found { + writeError(w, http.StatusNotFound, "NOT_FOUND", "directory not found") + return + } + result, err := a.Queues.Prepend(r.Context(), r.PathValue("id"), entries, idx, generation) + if err != nil { + writeQueueError(w, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (a *API) directAudioEntries( + ctx context.Context, + dir string, + idx *index.Index, +) ([]index.FileEntry, bool, error) { + resolver, err := index.NewRootResolver(a.Config.MusicDir) + if err != nil { + return nil, false, err + } + absDir, stat, err := resolver.Stat(dir) + if err != nil || !stat.IsDir() { + return nil, false, nil + } + directory, err := os.Open(absDir) + if err != nil { + return nil, true, err + } + defer directory.Close() + + entries := make([]index.FileEntry, 0) + for { + if err := ctx.Err(); err != nil { + return nil, true, err + } + batch, readErr := directory.ReadDir(256) + for _, entry := range batch { + if isSystemBrowseEntry(entry.Name()) { + continue + } + _, regular := classifyBrowseEntry(resolver, entry, dir) + if !regular { + continue + } + relPath := browseRelativePath(dir, entry.Name()) + _, info, statErr := resolver.Stat(relPath) + if statErr != nil || !info.Mode().IsRegular() { + continue + } + audio := idx.ByID[index.GenerateID(relPath)] + if audio == nil || filepath.Clean(audio.Filepath) != filepath.Clean(relPath) { + continue + } + entries = append(entries, *audio) + } + if readErr == io.EOF { + break + } + if readErr != nil { + return nil, true, readErr + } + } + sort.Slice(entries, func(i, j int) bool { + left := filepath.Base(entries[i].Filepath) + right := filepath.Base(entries[j].Filepath) + leftSort := strings.ToLower(left) + rightSort := strings.ToLower(right) + if leftSort != rightSort { + return leftSort < rightSort + } + if left != right { + return left < right + } + return entries[i].ID < entries[j].ID + }) + return entries, true, nil +} + func (a *API) handleDeleteQueue(w http.ResponseWriter, r *http.Request) { a.Queues.Delete(r.PathValue("id")) w.WriteHeader(http.StatusNoContent) @@ -79,6 +189,8 @@ func writeQueueError(w http.ResponseWriter, err error) { writeError(w, http.StatusNotFound, "QUEUE_NOT_FOUND", "queue not found") case errors.Is(err, playqueue.ErrFileNotFound): writeError(w, http.StatusNotFound, "FILE_NOT_FOUND", "file not found") + case errors.Is(err, playqueue.ErrNoAudioFiles): + writeError(w, http.StatusUnprocessableEntity, "NO_AUDIO_FILES", "directory contains no audio files") case errors.Is(err, playqueue.ErrBusy): writeError(w, http.StatusServiceUnavailable, "QUEUE_BUSY", "queue builder is busy") case errors.Is(err, playqueue.ErrCapacity): diff --git a/internal/api/queue_test.go b/internal/api/queue_test.go index 4310187..c1ec1d4 100644 --- a/internal/api/queue_test.go +++ b/internal/api/queue_test.go @@ -4,9 +4,12 @@ import ( "encoding/json" "io" "net/http" + "os" + "path/filepath" "strings" "testing" + "github.com/ColderCoder/ShuffleMuse/internal/index" "github.com/ColderCoder/ShuffleMuse/internal/playqueue" ) @@ -101,6 +104,113 @@ func TestQueueAPIPinTagReplaceAndStableErrors(t *testing.T) { } } +func TestQueueAPIPrependsDirectDirectoryAudioInFilenameOrder(t *testing.T) { + env := setupTestEnv(t) + defer env.teardown() + nestedDir := filepath.Join(env.tmpDir, "music", "artist1", "Disc 2") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nestedDir, "nested.flac"), []byte("fake audio data"), 0o644); err != nil { + t.Fatal(err) + } + next, err := index.Scan(filepath.Join(env.tmpDir, "music")) + if err != nil { + t.Fatal(err) + } + env.api.UpdateIndex(next) + env.idx = next + + directTagged := next.ByID[index.GenerateID(filepath.Join("artist1", "track2.flac"))] + if directTagged == nil { + t.Fatal("direct tagged track not indexed") + } + if err := env.tagStore.AddTag(directTagged.Filepath, "focus"); err != nil { + t.Fatal(err) + } + createdResponse, created := postJSON(t, env.server.URL+"/api/queues", `{"tag":"focus"}`, nil) + if createdResponse.StatusCode != http.StatusCreated { + t.Fatalf("create = %d/%v", createdResponse.StatusCode, created) + } + oldID := created["queue"].(map[string]interface{})["id"].(string) + + response, result := postJSON( + t, + env.server.URL+"/api/queues/"+oldID+"/prepend-directory", + `{"dir":"artist1"}`, + nil, + ) + if response.StatusCode != http.StatusOK { + t.Fatalf("prepend = %d/%v", response.StatusCode, result) + } + queue := result["queue"].(map[string]interface{}) + if queue["id"] == oldID || queue["tag"] != "focus" || int(queue["total"].(float64)) != 3 { + t.Fatalf("replacement queue = %v", queue) + } + if int(result["directoryTrackCount"].(float64)) != 3 { + t.Fatalf("directory track count = %v", result["directoryTrackCount"]) + } + items := result["items"].([]interface{}) + if len(items) != 3 { + t.Fatalf("items = %v", items) + } + wantNames := []string{"track1", "track2", "track3"} + seen := make(map[string]bool) + for index, raw := range items { + item := raw.(map[string]interface{}) + if item["name"] != wantNames[index] || int(item["queueIndex"].(float64)) != index { + t.Fatalf("item %d = %v, want %q", index, item, wantNames[index]) + } + id := item["id"].(string) + if seen[id] { + t.Fatalf("duplicate directory item %q", id) + } + seen[id] = true + } + oldResponse, old := getJSON(t, env.server.URL+"/api/queues/"+oldID+"/items?page=1", nil) + if oldResponse.StatusCode != http.StatusNotFound || old["code"] != "QUEUE_NOT_FOUND" { + t.Fatalf("old queue after prepend = %d/%v", oldResponse.StatusCode, old) + } +} + +func TestQueueAPIPrependDirectoryValidationAndEmptyRoot(t *testing.T) { + env := setupTestEnv(t) + defer env.teardown() + createdResponse, created := postJSON(t, env.server.URL+"/api/queues", `{}`, nil) + if createdResponse.StatusCode != http.StatusCreated { + t.Fatalf("create = %d/%v", createdResponse.StatusCode, created) + } + queueID := created["queue"].(map[string]interface{})["id"].(string) + + empty, emptyBody := postJSON( + t, + env.server.URL+"/api/queues/"+queueID+"/prepend-directory", + `{"dir":"."}`, + nil, + ) + if empty.StatusCode != http.StatusUnprocessableEntity || emptyBody["code"] != "NO_AUDIO_FILES" { + t.Fatalf("empty root = %d/%v", empty.StatusCode, emptyBody) + } + invalid, invalidBody := postJSON( + t, + env.server.URL+"/api/queues/"+queueID+"/prepend-directory", + `{"dir":"../outside"}`, + nil, + ) + if invalid.StatusCode != http.StatusBadRequest || invalidBody["code"] != "INVALID_DIRECTORY" { + t.Fatalf("invalid dir = %d/%v", invalid.StatusCode, invalidBody) + } + missing, missingBody := postJSON( + t, + env.server.URL+"/api/queues/"+queueID+"/prepend-directory", + `{"dir":"missing"}`, + nil, + ) + if missing.StatusCode != http.StatusNotFound || missingBody["code"] != "NOT_FOUND" { + t.Fatalf("missing dir = %d/%v", missing.StatusCode, missingBody) + } +} + func TestQueueAPIStrictJSONLimit(t *testing.T) { env := setupTestEnv(t) defer env.teardown() diff --git a/internal/api/response_writer_test.go b/internal/api/response_writer_test.go new file mode 100644 index 0000000..dd4dea8 --- /dev/null +++ b/internal/api/response_writer_test.go @@ -0,0 +1,142 @@ +package api + +import ( + "bytes" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type basicResponseWriter struct { + header http.Header + body bytes.Buffer + statusCodes []int +} + +func (w *basicResponseWriter) Header() http.Header { + if w.header == nil { + w.header = make(http.Header) + } + return w.header +} + +func (w *basicResponseWriter) WriteHeader(status int) { + if len(w.statusCodes) != 0 { + return + } + w.statusCodes = append(w.statusCodes, status) +} + +func (w *basicResponseWriter) Write(p []byte) (int, error) { + if len(w.statusCodes) == 0 { + w.WriteHeader(http.StatusOK) + } + return w.body.Write(p) +} + +type readerFromResponseWriter struct { + basicResponseWriter + readFromCalls int + readFromErr error +} + +func (w *readerFromResponseWriter) ReadFrom(r io.Reader) (int64, error) { + w.readFromCalls++ + if len(w.statusCodes) == 0 { + w.WriteHeader(http.StatusOK) + } + n, err := io.Copy(&w.body, r) + if err != nil { + return n, err + } + return n, w.readFromErr +} + +type readerOnly struct { + io.Reader +} + +func TestResponseWritersDelegateReaderFrom(t *testing.T) { + payload := "original audio bytes" + delegatedErr := errors.New("delegated read error") + + for _, status := range []int{http.StatusOK, http.StatusPartialContent} { + t.Run(http.StatusText(status), func(t *testing.T) { + base := &readerFromResponseWriter{readFromErr: delegatedErr} + logged := &loggingWriter{ + ResponseWriter: base, + statusCode: http.StatusOK, + } + tracked := &commitTrackingWriter{ResponseWriter: logged} + if status != http.StatusOK { + tracked.WriteHeader(status) + } + + n, err := io.Copy(tracked, readerOnly{Reader: strings.NewReader(payload)}) + if !errors.Is(err, delegatedErr) { + t.Fatalf("ReadFrom error = %v, want %v", err, delegatedErr) + } + if n != int64(len(payload)) { + t.Fatalf("copied bytes = %d, want %d", n, len(payload)) + } + if got := base.body.String(); got != payload { + t.Fatalf("response body = %q, want %q", got, payload) + } + if base.readFromCalls != 1 { + t.Fatalf("underlying ReadFrom calls = %d, want 1", base.readFromCalls) + } + if !tracked.committed { + t.Fatal("commitTrackingWriter did not record the response commit") + } + if logged.statusCode != status { + t.Fatalf("logged status = %d, want %d", logged.statusCode, status) + } + if len(base.statusCodes) != 1 || base.statusCodes[0] != status { + t.Fatalf("response commits = %v, want [%d]", base.statusCodes, status) + } + }) + } +} + +func TestLoggingWriterReaderFromFallback(t *testing.T) { + payload := "fallback logging bytes" + base := &basicResponseWriter{} + logged := &loggingWriter{ + ResponseWriter: base, + statusCode: http.StatusOK, + } + logged.WriteHeader(http.StatusPartialContent) + + n, err := io.Copy(logged, readerOnly{Reader: strings.NewReader(payload)}) + if err != nil { + t.Fatalf("fallback copy: %v", err) + } + if n != int64(len(payload)) || base.body.String() != payload { + t.Fatalf("fallback response = %d/%q, want %d/%q", n, base.body.String(), len(payload), payload) + } + if logged.statusCode != http.StatusPartialContent { + t.Fatalf("logged status = %d, want %d", logged.statusCode, http.StatusPartialContent) + } +} + +func TestCommitTrackingWriterReaderFromFallback(t *testing.T) { + payload := "fallback tracked bytes" + base := &basicResponseWriter{} + tracked := &commitTrackingWriter{ResponseWriter: base} + + n, err := io.Copy(tracked, readerOnly{Reader: strings.NewReader(payload)}) + if err != nil { + t.Fatalf("fallback copy: %v", err) + } + if n != int64(len(payload)) || base.body.String() != payload { + t.Fatalf("fallback response = %d/%q, want %d/%q", n, base.body.String(), len(payload), payload) + } + if !tracked.committed { + t.Fatal("commitTrackingWriter did not record the response commit") + } + if len(base.statusCodes) != 1 || base.statusCodes[0] != http.StatusOK { + t.Fatalf("response commits = %v, want [%d]", base.statusCodes, http.StatusOK) + } +} diff --git a/internal/playqueue/manager.go b/internal/playqueue/manager.go index 64a334e..2523ac8 100644 --- a/internal/playqueue/manager.go +++ b/internal/playqueue/manager.go @@ -29,6 +29,7 @@ const ( var ( ErrNotFound = errors.New("queue not found") ErrFileNotFound = errors.New("file not found") + ErrNoAudioFiles = errors.New("directory contains no audio files") ErrBusy = errors.New("queue builder is busy") ErrCapacity = errors.New("queue cache capacity exceeded") ) @@ -91,6 +92,11 @@ type SelectResult struct { QueueIndex int `json:"queueIndex"` } +type PrependResult struct { + Page + DirectoryTrackCount int `json:"directoryTrackCount"` +} + type snapshotRef struct { generation uint64 index *index.Index @@ -385,6 +391,130 @@ func (m *Manager) Select(ctx context.Context, token, fileID string, current *ind return SelectResult{Page: m.pageLocked(replacement, newToken, 1, current, generation), QueueIndex: 0}, nil } +func (m *Manager) Prepend( + ctx context.Context, + token string, + entries []index.FileEntry, + current *index.Index, + generation uint64, +) (PrependResult, error) { + if err := ctx.Err(); err != nil { + return PrependResult{}, err + } + digest, ok := decodeToken(token) + if !ok { + return PrependResult{}, ErrNotFound + } + if current == nil { + return PrependResult{}, ErrFileNotFound + } + if err := m.builds.acquire(ctx); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return PrependResult{}, err + } + return PrependResult{}, ErrBusy + } + defer m.builds.release() + + now := m.now() + m.mu.Lock() + q := m.queues[digest] + if q == nil || now.Sub(q.lastAccess) >= m.config.Idle { + if q != nil { + m.removeLocked(q) + } + m.mu.Unlock() + return PrependResult{}, ErrNotFound + } + q.lastAccess = now + m.mu.Unlock() + + selected := make(map[string]struct{}, len(entries)) + prefix := make([]index.FileEntry, 0, len(entries)+len(q.prefix)) + for i := range entries { + if i&1023 == 0 { + if err := ctx.Err(); err != nil { + return PrependResult{}, err + } + } + entry := entries[i] + online := current.ByID[entry.ID] + if online == nil || online.Filepath != entry.Filepath { + continue + } + if _, exists := selected[entry.ID]; exists { + continue + } + selected[entry.ID] = struct{}{} + prefix = append(prefix, entry) + } + if len(prefix) == 0 { + return PrependResult{}, ErrNoAudioFiles + } + directoryTrackCount := len(prefix) + for i := range q.prefix { + if i&1023 == 0 { + if err := ctx.Err(); err != nil { + return PrependResult{}, err + } + } + if _, exists := selected[q.prefix[i].ID]; !exists { + prefix = append(prefix, q.prefix[i]) + } + } + order := make([]uint32, 0, len(q.order)) + for i, position := range q.order { + if i&1023 == 0 { + if err := ctx.Err(); err != nil { + return PrependResult{}, err + } + } + if int(position) >= len(q.snapshot.index.Files) { + continue + } + if _, exists := selected[q.snapshot.index.Files[position].ID]; !exists { + order = append(order, position) + } + } + + newToken, newDigest, err := m.newToken() + if err != nil { + return PrependResult{}, err + } + replacement := &queue{ + digest: newDigest, tag: q.tag, createdGeneration: q.createdGeneration, + snapshot: q.snapshot, order: order, prefix: prefix, + createdAt: now, lastAccess: now, + } + replacement.bytes = estimateQueue(replacement) + + m.mu.Lock() + defer m.mu.Unlock() + if err := ctx.Err(); err != nil { + return PrependResult{}, err + } + if m.queues[digest] != q { + return PrependResult{}, ErrNotFound + } + if _, exists := m.queues[newDigest]; exists { + return PrependResult{}, errors.New("queue token collision") + } + if saturatedAdd(q.snapshot.bytes, replacement.bytes) > m.config.MaxBytes { + return PrependResult{}, ErrCapacity + } + // Transfer the shared snapshot to the immutable replacement. The old queue + // may still be referenced by a concurrent read-only scan, so leave it intact. + m.totalBytes -= q.bytes + delete(m.queues, q.digest) + m.queues[newDigest] = replacement + m.totalBytes = saturatedAdd(m.totalBytes, replacement.bytes) + m.evictLocked(newDigest) + return PrependResult{ + Page: m.pageLocked(replacement, newToken, 1, current, generation), + DirectoryTrackCount: directoryTrackCount, + }, nil +} + func (m *Manager) Delete(token string) { digest, ok := decodeToken(token) if !ok { diff --git a/internal/playqueue/manager_test.go b/internal/playqueue/manager_test.go index fdb7a03..63df631 100644 --- a/internal/playqueue/manager_test.go +++ b/internal/playqueue/manager_test.go @@ -279,6 +279,60 @@ func TestSelectExistingOrAtomicallyReplacesQueue(t *testing.T) { } } +func TestPrependMovesEntriesToFrontWithoutDuplicates(t *testing.T) { + idx := testIndex(5) + tagged := []string{idx.Files[1].Filepath, idx.Files[2].Filepath} + m := NewManager(Config{}, staticTags{"focus": tagged}, Options{Random: randomBytes(8)}) + created, err := m.Create(context.Background(), idx, 3, CreateRequest{Tag: "focus"}) + if err != nil { + t.Fatal(err) + } + + prepended, err := m.Prepend(context.Background(), created.Queue.ID, []index.FileEntry{ + idx.Files[4], idx.Files[2], idx.Files[0], idx.Files[2], + }, idx, 4) + if err != nil { + t.Fatal(err) + } + if prepended.DirectoryTrackCount != 3 || prepended.Queue.Tag != "focus" || prepended.Queue.Total != 4 { + t.Fatalf("unexpected prepend result: %+v", prepended) + } + assertIDs(t, prepended.Items, []string{ + idx.Files[4].ID, idx.Files[2].ID, idx.Files[0].ID, idx.Files[1].ID, + }) + seen := make(map[string]bool) + for _, item := range prepended.Items { + if seen[item.ID] { + t.Fatalf("duplicate item after prepend: %+v", prepended.Items) + } + seen[item.ID] = true + } + if _, err := m.Page(created.Queue.ID, 1, idx, 4); !errors.Is(err, ErrNotFound) { + t.Fatalf("old queue remained after prepend: %v", err) + } +} + +func TestPrependRejectsEmptyOrCanceledWorkWithoutReplacingQueue(t *testing.T) { + idx := testIndex(3) + m := NewManager(Config{}, nil, Options{Random: randomBytes(4)}) + created, err := m.Create(context.Background(), idx, 1, CreateRequest{}) + if err != nil { + t.Fatal(err) + } + + if _, err := m.Prepend(context.Background(), created.Queue.ID, nil, idx, 1); !errors.Is(err, ErrNoAudioFiles) { + t.Fatalf("empty prepend error = %v", err) + } + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := m.Prepend(canceled, created.Queue.ID, []index.FileEntry{idx.Files[0]}, idx, 1); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled prepend error = %v", err) + } + if _, err := m.Page(created.Queue.ID, 1, idx, 1); err != nil { + t.Fatalf("failed prepend replaced original queue: %v", err) + } +} + func TestSelectLargeQueueHonorsCancellationWithoutRemovingQueue(t *testing.T) { idx := testIndex(5_000) tagged := make([]string, 0, len(idx.Files)-1) diff --git a/web/package.json b/web/package.json index aec61a7..9c2a51c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "shufflemuse-web", "private": true, - "version": "0.1.1", + "version": "0.1.2", "packageManager": "bun@1.3.14", "type": "module", "scripts": { diff --git a/web/src/App.vue b/web/src/App.vue index 7ce7e73..4e9b96f 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -106,7 +106,7 @@ watch(() => route.fullPath, async () => { }) watch(() => auth.isLoggedIn, loggedIn => { - if (loggedIn) void library.start() + if (loggedIn) void library.start(auth.statusSnapshot ?? undefined) else library.stop() }, { immediate: true }) diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 67f3461..907fa2f 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -59,6 +59,10 @@ export interface SelectQueueResponse extends QueuePage { queueIndex: number } +export interface PrependDirectoryResponse extends QueuePage { + directoryTrackCount: number +} + export interface DirectoryEntry { name: string path: string @@ -85,6 +89,7 @@ export interface BrowseResponse { directories: DirectoryEntry[] files: BrowseFileEntry[] total: number + audioCount: number page: number generation?: number } @@ -213,6 +218,15 @@ export async function selectQueueItem(id: string, fileId: string, signal?: Abort return res.data } +export async function prependQueueDirectory(id: string, dir: string, signal?: AbortSignal) { + const res = await http.post( + `/queues/${encodeURIComponent(id)}/prepend-directory`, + { dir }, + { signal }, + ) + return res.data +} + export async function deleteQueue(id: string, signal?: AbortSignal) { await http.delete(`/queues/${encodeURIComponent(id)}`, { signal }) } diff --git a/web/src/components/NowPlayingBar.vue b/web/src/components/NowPlayingBar.vue index 21eb4dd..913f085 100644 --- a/web/src/components/NowPlayingBar.vue +++ b/web/src/components/NowPlayingBar.vue @@ -500,7 +500,7 @@ async function toggleFavorite() { } } -@media (min-width: 961px) and (max-width: 1240px) { +@media (min-width: 761px) and (max-width: 1360px) { .now-playing-bar { grid-template-columns: minmax(0, 1fr) auto auto; column-gap: 0.5rem; diff --git a/web/src/stores/auth.test.ts b/web/src/stores/auth.test.ts index cc75cf7..f9621d9 100644 --- a/web/src/stores/auth.test.ts +++ b/web/src/stores/auth.test.ts @@ -11,11 +11,12 @@ vi.mock('../api', () => ({ describe('auth store', () => { beforeEach(() => { + vi.resetAllMocks() setActivePinia(createPinia()) }) - it('initializes from server status only once', async () => { - vi.mocked(api.getStatus).mockResolvedValue({ + it('initializes from one shared status request and returns the full snapshot', async () => { + const snapshot: api.Status = { fileCount: 3, libraryReady: true, libraryGeneration: 1, @@ -25,18 +26,58 @@ describe('auth store', () => { scanError: '', authRequired: true, authenticated: false, - }) + } + vi.mocked(api.getStatus).mockResolvedValue(snapshot) const auth = useAuthStore() - await Promise.all([auth.checkAuth(), auth.checkAuth()]) - await auth.checkAuth() + const [first, second] = await Promise.all([auth.checkAuth(), auth.checkAuth()]) + const cached = await auth.checkAuth() expect(api.getStatus).toHaveBeenCalledTimes(1) + expect(first).toBe(snapshot) + expect(second).toBe(snapshot) + expect(cached).toEqual(snapshot) + expect(auth.statusSnapshot).toEqual(snapshot) expect(auth.initialized).toBe(true) expect(auth.authRequired).toBe(true) expect(auth.isLoggedIn).toBe(false) }) + it('clears stale status snapshots on expiry, login, and logout', async () => { + const anonymous: api.Status = { + authRequired: true, + authenticated: false, + } + const authenticated: api.Status = { + fileCount: 3, + libraryReady: true, + libraryGeneration: 1, + scanStatus: 'idle', + authRequired: true, + authenticated: true, + } + vi.mocked(api.getStatus) + .mockResolvedValueOnce(anonymous) + .mockResolvedValueOnce(authenticated) + vi.mocked(api.login).mockResolvedValue({ status: 'logged in' }) + vi.mocked(api.logout).mockResolvedValue(undefined) + const auth = useAuthStore() + + await auth.checkAuth() + expect(auth.statusSnapshot).toEqual(anonymous) + auth.expire() + expect(auth.statusSnapshot).toBeNull() + + await auth.login('secret', true) + expect(auth.statusSnapshot).toBeNull() + await auth.checkAuth(true) + expect(auth.statusSnapshot).toEqual(authenticated) + + await auth.logout() + expect(auth.statusSnapshot).toBeNull() + expect(auth.state).toBe('anonymous') + }) + it('updates authentication state after login and logout', async () => { vi.mocked(api.login).mockResolvedValue({ status: 'logged in' }) vi.mocked(api.logout).mockResolvedValue(undefined) @@ -68,11 +109,12 @@ describe('auth store', () => { .mockResolvedValueOnce({ authRequired: false, authenticated: true }) const auth = useAuthStore() - await auth.checkAuth() + await expect(auth.checkAuth()).resolves.toBeNull() expect(auth.state).toBe('unavailable') expect(auth.isLoggedIn).toBe(false) + expect(auth.statusSnapshot).toBeNull() - await auth.checkAuth(true) + await expect(auth.checkAuth(true)).resolves.toEqual({ authRequired: false, authenticated: true }) expect(auth.state).toBe('authenticated') expect(auth.isLoggedIn).toBe(true) }) diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 1e0c588..90463c0 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -7,43 +7,51 @@ export type AuthState = 'checking' | 'authenticated' | 'anonymous' | 'unavailabl export const useAuthStore = defineStore('auth', () => { const state = ref('checking') const authRequired = ref(false) - let checking: Promise | null = null + const statusSnapshot = ref(null) + let checking: Promise | null = null const isLoggedIn = computed(() => state.value === 'authenticated') const initialized = computed(() => state.value !== 'checking') async function login(password: string, remember: boolean) { await api.login(password, remember) + statusSnapshot.value = null authRequired.value = true state.value = 'authenticated' } async function logout() { - try { - await api.logout() - } catch { - // Logging out locally must remain available when the server is unreachable. - } finally { - state.value = 'anonymous' - } + try { + await api.logout() + } catch { + // Logging out locally must remain available when the server is unreachable. + } finally { + statusSnapshot.value = null + state.value = 'anonymous' + } } function expire() { + statusSnapshot.value = null authRequired.value = true state.value = 'anonymous' } - async function checkAuth(force = false) { - if (!force && initialized.value) return + async function checkAuth(force = false): Promise { + if (!force && initialized.value) return statusSnapshot.value if (checking) return checking state.value = 'checking' checking = (async () => { try { const status = await api.getStatus() + statusSnapshot.value = status authRequired.value = status.authRequired state.value = !status.authRequired || status.authenticated ? 'authenticated' : 'anonymous' + return status } catch { + statusSnapshot.value = null state.value = 'unavailable' + return null } finally { checking = null } @@ -55,6 +63,7 @@ export const useAuthStore = defineStore('auth', () => { state, isLoggedIn, authRequired, + statusSnapshot, initialized, login, logout, diff --git a/web/src/stores/library.test.ts b/web/src/stores/library.test.ts index 97fe09f..508f183 100644 --- a/web/src/stores/library.test.ts +++ b/web/src/stores/library.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { useLibraryStore } from './library' import { usePlayerStore } from './player' +import { useAuthStore } from './auth' import * as api from '../api' vi.mock('../api', () => ({ @@ -35,12 +36,135 @@ function deferred() { return { promise, resolve } } +function setVisibility(state: DocumentVisibilityState, notify = true) { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: state, + }) + if (notify) document.dispatchEvent(new Event('visibilitychange')) +} + describe('library store', () => { beforeEach(() => { - vi.clearAllMocks() + vi.useFakeTimers() + vi.resetAllMocks() + setVisibility('visible', false) setActivePinia(createPinia()) }) + afterEach(() => { + useLibraryStore().stop() + setVisibility('visible', false) + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('reuses the auth status snapshot and waits 30 seconds while idle', async () => { + const initial = status() + vi.mocked(api.getStatus).mockResolvedValue(initial) + const auth = useAuthStore() + const library = useLibraryStore() + + const snapshot = await auth.checkAuth() + await library.start(snapshot ?? undefined) + + expect(library.libraryReady).toBe(true) + expect(api.getStatus).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(29999) + expect(api.getStatus).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(api.getStatus).toHaveBeenCalledTimes(2) + }) + + it('reschedules between the 30 second idle and 2 second scanning intervals', async () => { + vi.mocked(api.getStatus) + .mockResolvedValueOnce(status()) + .mockResolvedValueOnce(status({ scanStatus: 'scanning' })) + .mockResolvedValueOnce(status()) + .mockResolvedValue(status()) + const library = useLibraryStore() + + await library.start() + expect(api.getStatus).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(29999) + expect(api.getStatus).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(api.getStatus).toHaveBeenCalledTimes(2) + expect(library.scanActive).toBe(true) + + await vi.advanceTimersByTimeAsync(1999) + expect(api.getStatus).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(api.getStatus).toHaveBeenCalledTimes(3) + expect(library.scanActive).toBe(false) + + await vi.advanceTimersByTimeAsync(29999) + expect(api.getStatus).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(1) + expect(api.getStatus).toHaveBeenCalledTimes(4) + }) + + it('pauses while hidden and refreshes immediately when visible again', async () => { + vi.mocked(api.getStatus) + .mockResolvedValueOnce(status()) + .mockResolvedValue(status({ scanStatus: 'scanning' })) + const library = useLibraryStore() + + await library.start() + setVisibility('hidden') + await vi.advanceTimersByTimeAsync(60000) + expect(api.getStatus).toHaveBeenCalledTimes(1) + + setVisibility('visible') + await vi.advanceTimersByTimeAsync(0) + expect(api.getStatus).toHaveBeenCalledTimes(2) + expect(library.scanActive).toBe(true) + + await vi.advanceTimersByTimeAsync(1999) + expect(api.getStatus).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(api.getStatus).toHaveBeenCalledTimes(3) + }) + + it('cancels the idle timer when a rescan starts and enters the 2 second cycle', async () => { + vi.mocked(api.rescan).mockResolvedValue(undefined) + vi.mocked(api.getStatus).mockResolvedValue(status({ scanStatus: 'scanning' })) + const library = useLibraryStore() + + await library.start(status()) + await vi.advanceTimersByTimeAsync(5000) + await library.requestRescan() + + expect(api.rescan).toHaveBeenCalledTimes(1) + expect(api.getStatus).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(1999) + expect(api.getStatus).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(api.getStatus).toHaveBeenCalledTimes(2) + }) + + it('removes its visibility listener and leaves no polling behind after stop', async () => { + const addListener = vi.spyOn(document, 'addEventListener') + const removeListener = vi.spyOn(document, 'removeEventListener') + vi.mocked(api.getStatus).mockResolvedValue(status()) + const library = useLibraryStore() + + await library.start(status()) + const registration = addListener.mock.calls.find(([event]) => event === 'visibilitychange') + expect(registration).toBeDefined() + + library.stop() + + expect(removeListener).toHaveBeenCalledWith('visibilitychange', registration?.[1]) + await vi.advanceTimersByTimeAsync(60000) + setVisibility('hidden') + setVisibility('visible') + await vi.advanceTimersByTimeAsync(0) + expect(api.getStatus).not.toHaveBeenCalled() + }) + it('tracks initialization and becomes ready after a later status update', async () => { vi.mocked(api.getStatus) .mockResolvedValueOnce(status({ diff --git a/web/src/stores/library.ts b/web/src/stores/library.ts index a2f659c..dda5d84 100644 --- a/web/src/stores/library.ts +++ b/web/src/stores/library.ts @@ -3,7 +3,8 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import * as api from '../api' -const STATUS_POLL_MS = 2000 +const ACTIVE_STATUS_POLL_MS = 2000 +const IDLE_STATUS_POLL_MS = 30000 export const useLibraryStore = defineStore('library', () => { const status = ref(null) @@ -23,12 +24,34 @@ export const useLibraryStore = defineStore('library', () => { const scanActive = computed(() => scanStatus.value === 'initializing' || scanStatus.value === 'scanning') const canRescan = computed(() => !loading.value && !scanActive.value) + function pageIsHidden() { + return typeof document !== 'undefined' && document.visibilityState === 'hidden' + } + + function clearPollTimer() { + if (pollTimer) clearTimeout(pollTimer) + pollTimer = null + } + function schedulePoll() { - if (!running || pollTimer) return + if (!running || loading.value || pollTimer || pageIsHidden()) return + const delay = scanActive.value ? ACTIVE_STATUS_POLL_MS : IDLE_STATUS_POLL_MS pollTimer = setTimeout(() => { pollTimer = null - void refreshStatus().finally(schedulePoll) - }, STATUS_POLL_MS) + const epoch = lifecycleEpoch + void refreshStatus().finally(() => { + if (epoch === lifecycleEpoch) schedulePoll() + }) + }, delay) + } + + function handleVisibilityChange() { + clearPollTimer() + if (!running || pageIsHidden()) return + const epoch = lifecycleEpoch + void refreshStatus().finally(() => { + if (epoch === lifecycleEpoch) schedulePoll() + }) } async function refreshStatus() { @@ -57,18 +80,36 @@ export const useLibraryStore = defineStore('library', () => { return request.promise } - async function start() { - if (running) return refreshStatus() + async function start(initialStatus?: api.Status) { + if (running) { + if (initialStatus && !status.value) { + status.value = initialStatus + statusError.value = null + } + await statusRequest?.promise + return + } running = true - await refreshStatus() - schedulePoll() + const epoch = lifecycleEpoch + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibilityChange) + } + if (initialStatus) { + status.value = initialStatus + statusError.value = null + } else if (!pageIsHidden()) { + await refreshStatus() + } + if (epoch === lifecycleEpoch) schedulePoll() } function stop() { running = false lifecycleEpoch += 1 - if (pollTimer) clearTimeout(pollTimer) - pollTimer = null + clearPollTimer() + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } statusRequest?.controller.abort() statusRequest = null rescanController?.abort() @@ -81,6 +122,7 @@ export const useLibraryStore = defineStore('library', () => { async function requestRescan() { if (!canRescan.value) return + clearPollTimer() const epoch = lifecycleEpoch const controller = new AbortController() rescanController = controller @@ -89,6 +131,9 @@ export const useLibraryStore = defineStore('library', () => { try { await api.rescan(controller.signal) if (epoch !== lifecycleEpoch) return + const pendingStatus = statusRequest?.epoch === epoch ? statusRequest.promise : null + if (pendingStatus) await pendingStatus + if (epoch !== lifecycleEpoch) return if (status.value) { status.value = { ...status.value, @@ -97,7 +142,6 @@ export const useLibraryStore = defineStore('library', () => { } } await refreshStatus() - if (epoch === lifecycleEpoch) schedulePoll() } catch (error) { if (epoch !== lifecycleEpoch || axios.isCancel(error)) return if (axios.isAxiosError(error)) { @@ -108,7 +152,10 @@ export const useLibraryStore = defineStore('library', () => { } } finally { if (rescanController === controller) rescanController = null - if (epoch === lifecycleEpoch) loading.value = false + if (epoch === lifecycleEpoch) { + loading.value = false + schedulePoll() + } } } diff --git a/web/src/stores/player.test.ts b/web/src/stores/player.test.ts index 9b0c346..9a042c7 100644 --- a/web/src/stores/player.test.ts +++ b/web/src/stores/player.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { usePlayerStore } from './player' import * as api from '../api' @@ -7,6 +7,7 @@ vi.mock('../api', () => ({ createQueue: vi.fn(), getQueuePage: vi.fn(), selectQueueItem: vi.fn(), + prependQueueDirectory: vi.fn(), deleteQueue: vi.fn(), getFileMetadata: vi.fn(), getFiles: vi.fn(), @@ -21,6 +22,7 @@ class FakeAudio extends EventTarget { currentTime = 0 duration = 240 readyState = 1 + playCalls = 0 constructor() { super() @@ -33,6 +35,7 @@ class FakeAudio extends EventTarget { } async play() { + this.playCalls += 1 this.paused = false this.dispatchEvent(new Event('playing')) } @@ -47,6 +50,97 @@ class FakeAudio extends EventTarget { } } +class FakeConstantSource { + offset = { value: 1 } + startCalls = 0 + stopCalls = 0 + connectCalls = 0 + disconnectCalls = 0 + + connect() { + this.connectCalls += 1 + } + + disconnect() { + this.disconnectCalls += 1 + } + + start() { + this.startCalls += 1 + } + + stop() { + this.stopCalls += 1 + } +} + +class FakeGain { + gain = { value: 1 } + connectCalls = 0 + disconnectCalls = 0 + + connect() { + this.connectCalls += 1 + } + + disconnect() { + this.disconnectCalls += 1 + } +} + +class FakeAudioContext { + static instances: FakeAudioContext[] = [] + static rejectResume = false + static failConstruction = false + + state: AudioContextState = 'suspended' + destination = {} + source = new FakeConstantSource() + gain = new FakeGain() + resumeCalls = 0 + suspendCalls = 0 + closeCalls = 0 + + constructor() { + if (FakeAudioContext.failConstruction) throw new Error('context unavailable') + FakeAudioContext.instances.push(this) + } + + createConstantSource() { + return this.source + } + + createGain() { + return this.gain + } + + async resume() { + this.resumeCalls += 1 + if (FakeAudioContext.rejectResume) throw new Error('resume rejected') + this.state = 'running' + } + + async suspend() { + this.suspendCalls += 1 + this.state = 'suspended' + } + + async close() { + this.closeCalls += 1 + this.state = 'closed' + } + + static reset() { + FakeAudioContext.instances = [] + FakeAudioContext.rejectResume = false + FakeAudioContext.failConstruction = false + } +} + +function enableWebAudio() { + vi.stubGlobal('AudioContext', FakeAudioContext) +} + function item(index: number, id = `track-${index}`): api.QueueItem { return { id, @@ -89,6 +183,9 @@ describe('player store server queues', () => { vi.clearAllMocks() localStorage.clear() FakeAudio.instances = [] + FakeAudioContext.reset() + vi.stubGlobal('AudioContext', undefined) + vi.stubGlobal('webkitAudioContext', undefined) vi.stubGlobal('Audio', FakeAudio) setActivePinia(createPinia()) vi.mocked(api.createQueue).mockResolvedValue(page()) @@ -101,6 +198,11 @@ describe('player store server queues', () => { vi.mocked(api.deleteQueue).mockResolvedValue(undefined) }) + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + it('sanitizes corrupt and out-of-range stored volume before creating audio', () => { localStorage.setItem('shufflemuse-volume', 'not-a-number') let player = usePlayerStore() @@ -197,6 +299,157 @@ describe('player store server queues', () => { expect(player.isPlaying).toBe(true) }) + it('does not delay media while activating a cold audio endpoint', async () => { + vi.useFakeTimers() + enableWebAudio() + const player = usePlayerStore() + await player.preparePlaylist() + + const playback = player.playAt(0) + await vi.advanceTimersByTimeAsync(0) + const audio = FakeAudio.instances[0] + expect(FakeAudioContext.instances).toHaveLength(1) + expect(audio.src).toContain('/api/stream/one') + expect(audio.playCalls).toBe(1) + await playback + + expect(player.isPlaying).toBe(true) + expect(player.isBuffering).toBe(false) + expect(vi.getTimerCount()).toBe(0) + player.reset() + }) + + it('reuses a warm endpoint for track changes, mode changes, and Opus seeks', async () => { + vi.useFakeTimers() + enableWebAudio() + vi.mocked(api.createQueue).mockResolvedValue(page( + 'two-tracks', 1, 2, [item(0, 'one'), item(1, 'two')], + )) + const player = usePlayerStore() + await player.preparePlaylist() + + await player.playAt(0) + const context = FakeAudioContext.instances[0] + const audio = FakeAudio.instances[0] + + await player.playAt(1) + await player.setStreamMode('opus') + await player.seek(45) + + expect(player.currentTrack?.id).toBe('two') + expect(audio.src).toContain('mode=opus') + expect(audio.src).toContain('start=45.000') + expect(audio.playCalls).toBe(4) + expect(FakeAudioContext.instances).toHaveLength(1) + expect(context.resumeCalls).toBe(1) + expect(context.source.startCalls).toBe(1) + expect(vi.getTimerCount()).toBe(0) + player.reset() + }) + + it('keeps the endpoint active for 15 seconds and reactivates it without delaying media', async () => { + vi.useFakeTimers() + enableWebAudio() + const player = usePlayerStore() + await player.preparePlaylist() + await player.playAt(0) + const context = FakeAudioContext.instances[0] + const audio = FakeAudio.instances[0] + + player.pause() + await vi.advanceTimersByTimeAsync(14999) + await player.resume() + expect(audio.playCalls).toBe(2) + expect(context.suspendCalls).toBe(0) + + player.pause() + await vi.advanceTimersByTimeAsync(15000) + expect(context.suspendCalls).toBe(1) + expect(context.state).toBe('suspended') + + await player.resume() + expect(audio.playCalls).toBe(3) + expect(context.resumeCalls).toBe(2) + player.reset() + }) + + it('lets pause cancel playback before pending track selection starts media', async () => { + enableWebAudio() + const player = usePlayerStore() + await player.preparePlaylist() + + const playback = player.playAt(0) + expect(player.isBuffering).toBe(true) + await player.togglePlay() + expect(player.isBuffering).toBe(false) + + await playback + expect(FakeAudio.instances).toHaveLength(0) + expect(player.isPlaying).toBe(false) + player.reset() + }) + + it('only starts the latest track when track selections overlap', async () => { + enableWebAudio() + vi.mocked(api.createQueue).mockResolvedValue(page( + 'two-tracks', 1, 2, [item(0, 'one'), item(1, 'two')], + )) + const player = usePlayerStore() + await player.preparePlaylist() + + const first = player.playAt(0) + const second = player.playAt(1) + await Promise.all([first, second]) + + const audio = FakeAudio.instances[0] + expect(player.currentTrack?.id).toBe('two') + expect(audio.src).toContain('/api/stream/two') + expect(audio.playCalls).toBe(1) + expect(FakeAudioContext.instances).toHaveLength(1) + player.reset() + }) + + it('cancels pending playback and releases endpoint resources on reset', async () => { + vi.useFakeTimers() + enableWebAudio() + const player = usePlayerStore() + await player.preparePlaylist() + + const playback = player.playAt(0) + const context = FakeAudioContext.instances[0] + + player.reset() + expect(context.source.stopCalls).toBe(1) + expect(context.source.disconnectCalls).toBe(1) + expect(context.gain.disconnectCalls).toBe(1) + expect(context.closeCalls).toBe(1) + expect(vi.getTimerCount()).toBe(0) + + await playback + expect(FakeAudio.instances).toHaveLength(0) + }) + + it('falls back immediately when Web Audio is unsupported or cannot start', async () => { + const unsupported = usePlayerStore() + await unsupported.preparePlaylist() + await unsupported.playAt(0) + expect(FakeAudio.instances[0].playCalls).toBe(1) + unsupported.reset() + + setActivePinia(createPinia()) + enableWebAudio() + FakeAudioContext.rejectResume = true + const rejected = usePlayerStore() + await rejected.preparePlaylist() + await rejected.playAt(0) + + expect(FakeAudio.instances[1].playCalls).toBe(1) + expect(FakeAudioContext.instances).toHaveLength(1) + expect(FakeAudioContext.instances[0].closeCalls).toBe(1) + expect(rejected.isPlaying).toBe(true) + rejected.reset() + }) + it('ignores a stale aborted queue creation', async () => { const old = deferred() vi.mocked(api.createQueue) @@ -318,6 +571,91 @@ describe('player store server queues', () => { expect(player.playlistError).toBe('Failed to prepare playlist') }) + it('moves a directory to the queue front and immediately plays its first track', async () => { + const player = usePlayerStore() + await player.preparePlaylist() + player.selectedTag = 'focus' + vi.mocked(api.prependQueueDirectory).mockResolvedValueOnce({ + ...page('folder-queue', 1, 3, [ + item(0, 'folder-a'), + item(1, 'folder-b'), + item(2, 'one'), + ]), + queue: { + id: 'folder-queue', + tag: 'focus', + createdGeneration: 1, + total: 3, + pageSize: 200, + }, + directoryTrackCount: 2, + }) + + const count = await player.prependDirectory('Album') + + expect(api.prependQueueDirectory).toHaveBeenCalledWith('queue-1', 'Album', expect.any(AbortSignal)) + expect(count).toBe(2) + expect(player.queue?.id).toBe('folder-queue') + expect(player.selectedTag).toBe('focus') + expect(player.activeIndex).toBe(0) + expect(player.sidebarPage).toBe(1) + expect(player.currentTrack?.id).toBe('folder-a') + expect(player.currentTime).toBe(0) + expect(player.isPlaying).toBe(true) + expect(FakeAudio.instances[0].src).toContain('/api/stream/folder-a') + }) + + it('rebuilds an expired queue once before prepending the directory', async () => { + const player = usePlayerStore() + await player.preparePlaylist() + player.selectedTag = 'focus' + vi.mocked(api.prependQueueDirectory) + .mockRejectedValueOnce(Object.assign(new Error('expired'), { + isAxiosError: true, + response: { data: { code: 'QUEUE_NOT_FOUND' } }, + })) + .mockResolvedValueOnce({ + ...page('folder-queue', 1, 2, [item(0, 'folder-a'), item(1, 'tagged')]), + queue: { + id: 'folder-queue', + tag: 'focus', + createdGeneration: 1, + total: 2, + pageSize: 200, + }, + directoryTrackCount: 1, + }) + vi.mocked(api.createQueue).mockResolvedValueOnce(page( + 'recovered-base', 1, 1, [item(0, 'tagged')], + )) + + const count = await player.prependDirectory('Album') + + expect(api.createQueue).toHaveBeenLastCalledWith({ tag: 'focus' }, expect.any(AbortSignal)) + expect(api.prependQueueDirectory).toHaveBeenNthCalledWith( + 2, 'recovered-base', 'Album', expect.any(AbortSignal), + ) + expect(count).toBe(1) + expect(player.currentTrack?.id).toBe('folder-a') + }) + + it('keeps the current queue and track when directory prepending fails', async () => { + const player = usePlayerStore() + await player.preparePlaylist() + await player.playAt(0) + const audio = FakeAudio.instances[0] + const source = audio.src + vi.mocked(api.prependQueueDirectory).mockRejectedValueOnce(new Error('busy')) + + await expect(player.prependDirectory('Album')).rejects.toThrow('busy') + + expect(player.queue?.id).toBe('queue-1') + expect(player.currentTrack?.id).toBe('one') + expect(player.isPlaying).toBe(true) + expect(audio.src).toBe(source) + expect(player.playlistLoading).toBe(false) + }) + it('creates a new independent queue after the last track ends', async () => { vi.mocked(api.createQueue) .mockResolvedValueOnce(page('cycle-one', 1, 1, [item(0, 'last')])) diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index b1ab1d8..384c180 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -7,6 +7,7 @@ const STORAGE_KEY_VOLUME = 'shufflemuse-volume' const STORAGE_KEY_MUTED = 'shufflemuse-muted' const STORAGE_KEY_STREAM_MODE = 'shufflemuse-stream-mode' const MAX_CACHED_PAGES = 5 +const ENDPOINT_KEEPALIVE_MS = 15000 export type StreamMode = 'original' | 'opus' @@ -23,6 +24,10 @@ interface CachedPage { libraryGeneration: number } +interface PlaybackIntent { + id: number +} + function loadVolume(): number { try { const saved = localStorage.getItem(STORAGE_KEY_VOLUME) @@ -90,10 +95,19 @@ export const usePlayerStore = defineStore('player', () => { let audio: HTMLAudioElement | null = null let playEpoch = 0 + let playbackIntent = 0 + let activeMediaPlaybackIntent = 0 + let pendingMediaPlaybackIntent = 0 let sourceRequest = 0 let sourceOffset = 0 let loadedTrackID: string | null = null let loadedMode: StreamMode | null = null + let endpointContext: AudioContext | null = null + let endpointSource: ConstantSourceNode | null = null + let endpointGain: GainNode | null = null + let endpointDisabled = false + let endpointGeneration = 0 + let endpointSuspendTimer: ReturnType | null = null let queueController: AbortController | null = null let selectController: AbortController | null = null let metadataController: AbortController | null = null @@ -113,6 +127,142 @@ export const usePlayerStore = defineStore('player', () => { const playlist = computed(() => sidebarItems.value) const cachedPageCount = computed(() => pages.value.size) + function clearEndpointSuspendTimer() { + if (endpointSuspendTimer === null) return + clearTimeout(endpointSuspendTimer) + endpointSuspendTimer = null + } + + function closeEndpoint(disable: boolean) { + endpointGeneration += 1 + clearEndpointSuspendTimer() + + const context = endpointContext + const source = endpointSource + const gain = endpointGain + endpointContext = null + endpointSource = null + endpointGain = null + endpointDisabled = disable + + try { source?.stop() } catch { /* already stopped */ } + try { source?.disconnect() } catch { /* already disconnected */ } + try { gain?.disconnect() } catch { /* already disconnected */ } + if (context && context.state !== 'closed') { + try { void context.close().catch(() => {}) } catch { /* already closed */ } + } + } + + function createEndpointContext(): AudioContext | null { + const scope = globalThis as unknown as { + AudioContext?: new () => AudioContext + webkitAudioContext?: new () => AudioContext + } + const AudioContextConstructor = scope.AudioContext ?? scope.webkitAudioContext + if (!AudioContextConstructor) { + endpointDisabled = true + return null + } + + let context: AudioContext | null = null + let source: ConstantSourceNode | null = null + let gain: GainNode | null = null + try { + context = new AudioContextConstructor() + source = context.createConstantSource() + gain = context.createGain() + source.offset.value = 0 + gain.gain.value = 0 + source.connect(gain) + gain.connect(context.destination) + source.start() + endpointContext = context + endpointSource = source + endpointGain = gain + return context + } catch { + try { source?.stop() } catch { /* not started */ } + try { source?.disconnect() } catch { /* not connected */ } + try { gain?.disconnect() } catch { /* not connected */ } + if (context && context.state !== 'closed') { + try { void context.close().catch(() => {}) } catch { /* ignore */ } + } + endpointDisabled = true + return null + } + } + + function resumeEndpoint(context: AudioContext) { + if (context.state === 'running') return + try { + void context.resume().catch(() => { + if (endpointContext === context) closeEndpoint(true) + }) + } catch { + if (endpointContext === context) closeEndpoint(true) + } + } + + function activateEndpoint() { + endpointGeneration += 1 + clearEndpointSuspendTimer() + if (endpointDisabled) return + + if (endpointContext?.state === 'closed') closeEndpoint(false) + const context = endpointContext ?? createEndpointContext() + if (context) resumeEndpoint(context) + } + + function scheduleEndpointSuspend() { + clearEndpointSuspendTimer() + const context = endpointContext + if (!context || context.state === 'closed') return + const generation = ++endpointGeneration + endpointSuspendTimer = setTimeout(() => { + endpointSuspendTimer = null + if (endpointContext !== context || endpointGeneration !== generation) return + try { + void context.suspend().then( + () => { + if (endpointContext !== context || endpointGeneration === generation) return + resumeEndpoint(context) + }, + () => {}, + ) + } catch { /* keep playback independent of endpoint keepalive failures */ } + }, ENDPOINT_KEEPALIVE_MS) + } + + function beginPlaybackIntent(): PlaybackIntent { + const id = ++playbackIntent + if (audio && !audio.paused && isPlaying.value) { + activeMediaPlaybackIntent = id + } else if (!isPlaying.value) { + pendingMediaPlaybackIntent = id + isBuffering.value = true + } + activateEndpoint() + return { id } + } + + function isCurrentPlaybackIntent(intent: PlaybackIntent): boolean { + return intent.id === playbackIntent + } + + function clearPendingPlayback(intent: PlaybackIntent) { + if (pendingMediaPlaybackIntent !== intent.id) return + pendingMediaPlaybackIntent = 0 + if (!isPlaying.value) isBuffering.value = false + } + + function abandonPlayback(intent: PlaybackIntent | null) { + if (!intent || !isCurrentPlaybackIntent(intent)) return + playbackIntent += 1 + activeMediaPlaybackIntent = audio && !audio.paused && isPlaying.value ? playbackIntent : 0 + clearPendingPlayback(intent) + if (!isPlaying.value) scheduleEndpointSuspend() + } + function apiQueuePageSize(): number { return queue.value?.pageSize ?? 200 } @@ -131,6 +281,9 @@ export const usePlayerStore = defineStore('player', () => { void next() }) audio.addEventListener('playing', () => { + if (activeMediaPlaybackIntent !== playbackIntent || audio?.paused) return + clearEndpointSuspendTimer() + pendingMediaPlaybackIntent = 0 isBuffering.value = false isPlaying.value = true }) @@ -141,8 +294,12 @@ export const usePlayerStore = defineStore('player', () => { audio.addEventListener('waiting', () => { if (!audio?.paused) isBuffering.value = true }) - audio.addEventListener('canplay', () => { isBuffering.value = false }) - audio.addEventListener('seeked', () => { isBuffering.value = false }) + audio.addEventListener('canplay', () => { + if (pendingMediaPlaybackIntent !== playbackIntent) isBuffering.value = false + }) + audio.addEventListener('seeked', () => { + if (pendingMediaPlaybackIntent !== playbackIntent) isBuffering.value = false + }) audio.addEventListener('timeupdate', () => { if (!audio) return const absoluteTime = sourceOffset + audio.currentTime @@ -155,9 +312,11 @@ export const usePlayerStore = defineStore('player', () => { if (Number.isFinite(audio.duration) && audio.duration > 0) duration.value = audio.duration }) audio.addEventListener('error', () => { + pendingMediaPlaybackIntent = 0 error.value = 'Playback error' isBuffering.value = false isPlaying.value = false + scheduleEndpointSuspend() }) } return audio @@ -274,8 +433,15 @@ export const usePlayerStore = defineStore('player', () => { }) } - async function loadCurrentSource(position: number, autoplay: boolean) { + async function loadCurrentSource( + position: number, + autoplay: boolean, + suppliedIntent: PlaybackIntent | null = null, + ) { if (!currentTrack.value) return + const intent = autoplay ? suppliedIntent ?? beginPlaybackIntent() : null + if (intent && !isCurrentPlaybackIntent(intent)) return + const epoch = playEpoch const trackID = currentTrack.value.id const mode = streamMode.value const requestID = ++sourceRequest @@ -289,6 +455,7 @@ export const usePlayerStore = defineStore('player', () => { loadedTrackID = trackID loadedMode = mode isBuffering.value = autoplay + pendingMediaPlaybackIntent = intent?.id ?? 0 error.value = null element.src = url element.load() @@ -299,18 +466,43 @@ export const usePlayerStore = defineStore('player', () => { element.currentTime = target } if (autoplay) { + if (!intent) return + if (requestID !== sourceRequest || epoch !== playEpoch || !isCurrentPlaybackIntent(intent)) { + clearPendingPlayback(intent) + return + } + activeMediaPlaybackIntent = intent.id await element.play() - if (requestID !== sourceRequest) return + if (requestID !== sourceRequest || epoch !== playEpoch || !isCurrentPlaybackIntent(intent)) { + if (activeMediaPlaybackIntent === intent.id) { + activeMediaPlaybackIntent = 0 + element.pause() + } + clearPendingPlayback(intent) + return + } + pendingMediaPlaybackIntent = 0 isPlaying.value = true + isBuffering.value = false } else { + pendingMediaPlaybackIntent = 0 + activeMediaPlaybackIntent = 0 isPlaying.value = false isBuffering.value = false + if (endpointSuspendTimer === null) scheduleEndpointSuspend() } } catch { - if (requestID !== sourceRequest) return + if ( + requestID !== sourceRequest + || epoch !== playEpoch + || (intent && !isCurrentPlaybackIntent(intent)) + ) return + pendingMediaPlaybackIntent = 0 + activeMediaPlaybackIntent = 0 isPlaying.value = false isBuffering.value = false error.value = 'Failed to load track' + scheduleEndpointSuspend() } } @@ -319,6 +511,7 @@ export const usePlayerStore = defineStore('player', () => { epoch: number, preserveCurrent: boolean, autoplay: boolean, + playback: PlaybackIntent | null, ) { if (epoch !== playEpoch) return queue.value = response.queue @@ -335,6 +528,7 @@ export const usePlayerStore = defineStore('player', () => { currentTime.value = 0 duration.value = 0 } + abandonPlayback(playback) playlistError.value = selectedTag.value ? `No tracks tagged ${selectedTag.value}` : 'No tracks available' return } @@ -346,6 +540,7 @@ export const usePlayerStore = defineStore('player', () => { } const first = response.items.find(item => item.available) if (!first) { + abandonPlayback(playback) playlistError.value = 'No tracks available' return } @@ -354,14 +549,20 @@ export const usePlayerStore = defineStore('player', () => { currentTime.value = 0 duration.value = 0 void refreshMetadata(first.id, epoch) - if (autoplay) await loadCurrentSource(0, true) - else if (audio) audio.pause() + if (autoplay) await loadCurrentSource(0, true, playback) + else if (audio) { + audio.pause() + if (!isPlaying.value && endpointSuspendTimer === null) scheduleEndpointSuspend() + } } async function createReplacement( tag: string, options: { pinCurrent: boolean; preserveCurrent: boolean; autoplay: boolean }, + suppliedPlayback: PlaybackIntent | null = null, ) { + const playback = options.autoplay ? suppliedPlayback ?? beginPlaybackIntent() : null + if (playback && !isPlaying.value) isBuffering.value = true const epoch = ++playEpoch abortQueueWork() if (!options.preserveCurrent) metadataController?.abort() @@ -386,12 +587,13 @@ export const usePlayerStore = defineStore('player', () => { if (epoch !== playEpoch || controller.signal.aborted) return selectedTag.value = tag recoveryUsed = false - await applyCreatedQueue(response, epoch, options.preserveCurrent, options.autoplay) + await applyCreatedQueue(response, epoch, options.preserveCurrent, options.autoplay, playback) } catch (requestError) { if (epoch !== playEpoch || axios.isCancel(requestError)) return selectedTag.value = previousTag playlistError.value = 'Failed to prepare playlist' if (currentTrack.value) void refreshMetadata(currentTrack.value.id, epoch, false) + if (!isPlaying.value) abandonPlayback(playback) } finally { if (queueController === controller) queueController = null if (epoch === playEpoch) playlistLoading.value = false @@ -418,6 +620,71 @@ export const usePlayerStore = defineStore('player', () => { }) } + async function prependDirectory(dir: string): Promise { + const playback = beginPlaybackIntent() + if (!isPlaying.value) isBuffering.value = true + const epoch = ++playEpoch + abortQueueWork() + metadataController?.abort() + const controller = new AbortController() + queueController = controller + playlistLoading.value = true + playlistError.value = null + let temporaryQueueID: string | null = null + try { + let response: api.PrependDirectoryResponse | null = null + const existingQueueID = queue.value?.id + if (existingQueueID) { + try { + response = await api.prependQueueDirectory(existingQueueID, dir, controller.signal) + } catch (requestError) { + if (errorCode(requestError) !== 'QUEUE_NOT_FOUND' || controller.signal.aborted) throw requestError + } + } + if (!response) { + const request = selectedTag.value ? { tag: selectedTag.value } : {} + const created = await api.createQueue(request, controller.signal) + if (epoch !== playEpoch || controller.signal.aborted) { + void api.deleteQueue(created.queue.id) + return null + } + temporaryQueueID = created.queue.id + response = await api.prependQueueDirectory(temporaryQueueID, dir, controller.signal) + temporaryQueueID = null + } + if (epoch !== playEpoch || controller.signal.aborted) { + void api.deleteQueue(response.queue.id) + return null + } + + queue.value = response.queue + resetPageCache(response) + activeIndex.value = 0 + sidebarPage.value = 1 + evictPages() + recoveryUsed = false + const first = response.items.find(item => item.queueIndex === 0 && item.available) + if (!first) throw new Error('Directory queue did not return an available first track') + syncCurrentTrack(first) + currentTime.value = 0 + duration.value = 0 + void refreshMetadata(first.id, epoch) + await loadCurrentSource(0, true, playback) + return response.directoryTrackCount + } catch (requestError) { + if (temporaryQueueID) { + try { await api.deleteQueue(temporaryQueueID) } catch { /* best effort */ } + } + if (epoch !== playEpoch || axios.isCancel(requestError)) return null + if (currentTrack.value) void refreshMetadata(currentTrack.value.id, epoch, false) + if (!isPlaying.value) abandonPlayback(playback) + throw requestError + } finally { + if (queueController === controller) queueController = null + if (epoch === playEpoch) playlistLoading.value = false + } + } + async function recoverQueue(epoch: number): Promise { if (recoveryUsed || epoch !== playEpoch) return false recoveryUsed = true @@ -430,7 +697,7 @@ export const usePlayerStore = defineStore('player', () => { try { const response = await api.createQueue(request, controller.signal) if (epoch !== playEpoch || controller.signal.aborted) return false - await applyCreatedQueue(response, epoch, true, false) + await applyCreatedQueue(response, epoch, true, false, null) return true } catch (requestError) { if (!axios.isCancel(requestError) && epoch === playEpoch) playlistError.value = 'Playlist expired and could not be restored' @@ -482,18 +749,23 @@ export const usePlayerStore = defineStore('player', () => { } async function playAt(index: number) { + const playback = beginPlaybackIntent() + if (!isPlaying.value) isBuffering.value = true if (!queue.value) await preparePlaylist() + if (!isCurrentPlaybackIntent(playback)) return if (!queue.value) { error.value = 'No tracks available' + abandonPlayback(playback) return } const epoch = ++playEpoch selectController?.abort() metadataController?.abort() const item = await itemAt(index, epoch) - if (epoch !== playEpoch) return + if (epoch !== playEpoch || !isCurrentPlaybackIntent(playback)) return if (!item || !item.available) { error.value = 'Track is unavailable' + abandonPlayback(playback) return } activeIndex.value = item.queueIndex @@ -502,7 +774,7 @@ export const usePlayerStore = defineStore('player', () => { currentTime.value = 0 duration.value = 0 void refreshMetadata(item.id, epoch) - await loadCurrentSource(0, true) + await loadCurrentSource(0, true, playback) } async function reconcileSelection(fileID: string, epoch: number) { @@ -545,6 +817,8 @@ export const usePlayerStore = defineStore('player', () => { } async function playTrack(file: api.FileEntry) { + const playback = beginPlaybackIntent() + if (!isPlaying.value) isBuffering.value = true const epoch = ++playEpoch selectController?.abort() metadataController?.abort() @@ -552,44 +826,77 @@ export const usePlayerStore = defineStore('player', () => { currentTime.value = 0 duration.value = 0 void refreshMetadata(file.id, epoch) - await loadCurrentSource(0, true) + await loadCurrentSource(0, true, playback) if (epoch === playEpoch) void reconcileSelection(file.id, epoch) } function pause() { - getAudio().pause() + playbackIntent += 1 + activeMediaPlaybackIntent = 0 + pendingMediaPlaybackIntent = 0 + audio?.pause() isBuffering.value = false isPlaying.value = false + scheduleEndpointSuspend() } async function resume() { + const playback = beginPlaybackIntent() + isBuffering.value = true if (!currentTrack.value) { if (!queue.value) await preparePlaylist() - if (!queue.value || queue.value.total === 0) return + if (!isCurrentPlaybackIntent(playback)) return + if (!queue.value || queue.value.total === 0) { + abandonPlayback(playback) + return + } const item = await itemAt(activeIndex.value, playEpoch) + if (!isCurrentPlaybackIntent(playback)) return if (item?.available) syncCurrentTrack(item) } - if (!currentTrack.value) return + if (!currentTrack.value) { + abandonPlayback(playback) + return + } if (loadedTrackID !== currentTrack.value.id || loadedMode !== streamMode.value) { - await loadCurrentSource(currentTime.value, true) + await loadCurrentSource(currentTime.value, true, playback) return } const element = getAudio() - isBuffering.value = true + const epoch = playEpoch try { + pendingMediaPlaybackIntent = playback.id + if (epoch !== playEpoch || !isCurrentPlaybackIntent(playback)) { + clearPendingPlayback(playback) + return + } + activeMediaPlaybackIntent = playback.id await element.play() + if (epoch !== playEpoch || !isCurrentPlaybackIntent(playback)) { + if (activeMediaPlaybackIntent === playback.id) { + activeMediaPlaybackIntent = 0 + element.pause() + } + clearPendingPlayback(playback) + return + } + pendingMediaPlaybackIntent = 0 isPlaying.value = true isBuffering.value = false error.value = null } catch { + if (epoch !== playEpoch || !isCurrentPlaybackIntent(playback)) return + pendingMediaPlaybackIntent = 0 + activeMediaPlaybackIntent = 0 isPlaying.value = false isBuffering.value = false error.value = 'Failed to resume playback' + scheduleEndpointSuspend() } } async function togglePlay() { - if (isPlaying.value) pause() + if (isPlaying.value || isBuffering.value) pause() else await resume() } @@ -605,21 +912,29 @@ export const usePlayerStore = defineStore('player', () => { } async function next() { + const playback = beginPlaybackIntent() + if (!isPlaying.value) isBuffering.value = true if (!queue.value) await preparePlaylist() + if (!isCurrentPlaybackIntent(playback)) return if (!queue.value || queue.value.total === 0) { error.value = 'No tracks available' + abandonPlayback(playback) return } const epoch = ++playEpoch const originalQueue = queue.value.id const item = await findAvailable(activeIndex.value + 1, 1, epoch) - if (epoch !== playEpoch) return + if (epoch !== playEpoch || !isCurrentPlaybackIntent(playback)) return if (queue.value?.id !== originalQueue) { await next() return } if (!item) { - await createReplacement(selectedTag.value, { pinCurrent: false, preserveCurrent: false, autoplay: true }) + await createReplacement( + selectedTag.value, + { pinCurrent: false, preserveCurrent: false, autoplay: true }, + playback, + ) return } activeIndex.value = item.queueIndex @@ -628,7 +943,7 @@ export const usePlayerStore = defineStore('player', () => { duration.value = 0 evictPages() void refreshMetadata(item.id, epoch) - await loadCurrentSource(0, true) + await loadCurrentSource(0, true, playback) } async function previous() { @@ -636,15 +951,18 @@ export const usePlayerStore = defineStore('player', () => { await seek(0) return } + const playback = beginPlaybackIntent() + if (!isPlaying.value) isBuffering.value = true const epoch = ++playEpoch const originalQueue = queue.value.id const item = await findAvailable(activeIndex.value - 1, -1, epoch) - if (epoch !== playEpoch) return + if (epoch !== playEpoch || !isCurrentPlaybackIntent(playback)) return if (queue.value?.id !== originalQueue) { await previous() return } if (!item) { + abandonPlayback(playback) await seek(0) return } @@ -654,7 +972,7 @@ export const usePlayerStore = defineStore('player', () => { duration.value = 0 evictPages() void refreshMetadata(item.id, epoch) - await loadCurrentSource(0, true) + await loadCurrentSource(0, true, playback) } async function showQueuePage(page: number) { @@ -685,7 +1003,8 @@ export const usePlayerStore = defineStore('player', () => { const target = duration.value > 0 ? clamp(seconds, 0, duration.value) : Math.max(seconds, 0) const shouldResume = isPlaying.value if (streamMode.value === 'opus') { - await loadCurrentSource(target, shouldResume) + const playback = shouldResume ? beginPlaybackIntent() : null + await loadCurrentSource(target, shouldResume, playback) return } const element = getAudio() @@ -694,17 +1013,19 @@ export const usePlayerStore = defineStore('player', () => { try { element.currentTime = target } catch { - await loadCurrentSource(target, shouldResume) + const playback = shouldResume ? beginPlaybackIntent() : null + await loadCurrentSource(target, shouldResume, playback) } } async function setStreamMode(mode: StreamMode) { if (streamMode.value === mode) return const shouldResume = isPlaying.value + const playback = shouldResume ? beginPlaybackIntent() : null const position = currentTime.value streamMode.value = mode try { localStorage.setItem(STORAGE_KEY_STREAM_MODE, mode) } catch { /* ignore */ } - if (currentTrack.value) await loadCurrentSource(position, shouldResume) + if (currentTrack.value) await loadCurrentSource(position, shouldResume, playback) } function setVolume(value: number) { @@ -726,7 +1047,11 @@ export const usePlayerStore = defineStore('player', () => { function reset() { playEpoch += 1 + playbackIntent += 1 + activeMediaPlaybackIntent = 0 + pendingMediaPlaybackIntent = 0 sourceRequest += 1 + closeEndpoint(false) abortQueueWork() metadataController?.abort() metadataController = null @@ -794,6 +1119,7 @@ export const usePlayerStore = defineStore('player', () => { preparePlaylist, filterPlaylistByTag, randomizePlaylist, + prependDirectory, playAt, playTrack, pause, diff --git a/web/src/views/BrowseView.test.ts b/web/src/views/BrowseView.test.ts index 22e0486..d52f4f7 100644 --- a/web/src/views/BrowseView.test.ts +++ b/web/src/views/BrowseView.test.ts @@ -5,6 +5,7 @@ import { createMemoryHistory, createRouter } from 'vue-router' import BrowseView from './BrowseView.vue' import * as api from '../api' import { useLibraryStore } from '../stores/library' +import { usePlayerStore } from '../stores/player' function deferred() { let resolve!: (value: T) => void @@ -34,6 +35,7 @@ describe('BrowseView', () => { .mockResolvedValueOnce({ files: [], total: 0, + audioCount: 0, page: 1, directories: [{ name: 'Artist', path: 'Artist' }], }) @@ -53,6 +55,7 @@ describe('BrowseView', () => { trackName: 'Track', }], total: 1, + audioCount: 1, page: 1, directories: [], }) @@ -108,6 +111,7 @@ describe('BrowseView', () => { audioId: 'new', }], total: 1, + audioCount: 1, page: 1, directories: [], }) @@ -129,6 +133,7 @@ describe('BrowseView', () => { audioId: 'old', }], total: 1, + audioCount: 1, page: 1, directories: [], }) @@ -138,7 +143,7 @@ describe('BrowseView', () => { expect(wrapper.text()).not.toContain('old.flac') }) - it('starts a rescan from the browse toolbar', async () => { + it('starts a rescan from the browse toolbar', async () => { const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/browse', name: 'browse', component: BrowseView }], @@ -170,16 +175,115 @@ describe('BrowseView', () => { expect(library.scanStatus).toBe('scanning') }) + it('plays the current folder from the queue front', async () => { + vi.mocked(api.getBrowse).mockReset() + vi.mocked(api.getBrowse).mockResolvedValue({ + files: [{ + id: 'one', + name: 'track.flac', + path: 'Artist/track.flac', + dir: 'Artist', + kind: 'audio', + mimeType: 'audio/flac', + size: 1, + modified: '', + previewable: false, + playable: true, + audioId: 'one', + }], + directories: [], + total: 1, + audioCount: 1, + page: 1, + }) + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/browse', name: 'browse', component: BrowseView }], + }) + await router.push({ name: 'browse', query: { dir: 'Artist' } }) + await router.isReady() + const pinia = createPinia() + const player = usePlayerStore(pinia) + player.queue = { id: 'queue', tag: '', createdGeneration: 1, total: 1, pageSize: 200 } + const prepend = vi.spyOn(player, 'prependDirectory').mockResolvedValue(1) + const wrapper = mount(BrowseView, { global: { plugins: [pinia, router] } }) + await flushPromises() + + const button = wrapper.get('.folder-play-button') + expect(button.text()).toContain('Play folder') + expect(button.attributes('disabled')).toBeUndefined() + await button.trigger('click') + await flushPromises() + + expect(prepend).toHaveBeenCalledWith('Artist') + expect(wrapper.get('.queue-message').text()).toBe('Playing 1 track from this folder') + }) + + it('disables empty folders and reports a directory queue failure', async () => { + vi.mocked(api.getBrowse).mockReset() + vi.mocked(api.getBrowse) + .mockResolvedValueOnce({ + files: [], + directories: [], + total: 0, + audioCount: 0, + page: 1, + }) + .mockResolvedValueOnce({ + files: [{ + id: 'one', + name: 'track.flac', + path: 'Artist/track.flac', + dir: 'Artist', + kind: 'audio', + mimeType: 'audio/flac', + size: 1, + modified: '', + previewable: false, + playable: true, + audioId: 'one', + }], + directories: [], + total: 1, + audioCount: 1, + page: 1, + }) + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/browse', name: 'browse', component: BrowseView }], + }) + await router.push('/browse') + await router.isReady() + const pinia = createPinia() + const player = usePlayerStore(pinia) + player.queue = { id: 'queue', tag: '', createdGeneration: 1, total: 1, pageSize: 200 } + const prepend = vi.spyOn(player, 'prependDirectory').mockRejectedValue(new Error('queue failed')) + const wrapper = mount(BrowseView, { global: { plugins: [pinia, router] } }) + await flushPromises() + + expect(wrapper.get('.folder-play-button').attributes('disabled')).toBeDefined() + + await router.push({ name: 'browse', query: { dir: 'Artist' } }) + await flushPromises() + const button = wrapper.get('.folder-play-button') + expect(button.attributes('disabled')).toBeUndefined() + await button.trigger('click') + await flushPromises() + + expect(prepend).toHaveBeenCalledWith('Artist') + expect(wrapper.get('[role="alert"]').text()).toBe('Failed to add this folder to the playlist') + }) + it('replaces the current page instead of appending results', async () => { vi.mocked(api.getBrowse).mockReset() vi.mocked(api.getBrowse) .mockResolvedValueOnce({ files: [{ id: 'first', name: 'first.flac', path: 'first.flac', dir: '.', kind: 'audio', mimeType: 'audio/flac', size: 1, modified: '', previewable: false, playable: true, audioId: 'first' }], - directories: [], total: 51, page: 1, + directories: [], total: 51, audioCount: 51, page: 1, }) .mockResolvedValueOnce({ files: [{ id: 'last', name: 'last.flac', path: 'last.flac', dir: '.', kind: 'audio', mimeType: 'audio/flac', size: 1, modified: '', previewable: false, playable: true, audioId: 'last' }], - directories: [], total: 51, page: 2, + directories: [], total: 51, audioCount: 51, page: 2, }) const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/browse', name: 'browse', component: BrowseView }] }) await router.push('/browse') diff --git a/web/src/views/BrowseView.vue b/web/src/views/BrowseView.vue index 18909aa..e68374c 100644 --- a/web/src/views/BrowseView.vue +++ b/web/src/views/BrowseView.vue @@ -2,7 +2,7 @@ import axios from 'axios' import { computed, onUnmounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { ChevronRight, Folder, RefreshCw } from 'lucide-vue-next' +import { ChevronRight, Folder, ListPlus, RefreshCw } from 'lucide-vue-next' import * as api from '../api' import { usePlayerStore } from '../stores/player' import { useTagsStore } from '../stores/tags' @@ -18,13 +18,24 @@ const router = useRouter() const currentDir = ref('.') const directories = ref([]) const files = ref([]) +const audioCount = ref(0) const previewFile = ref(null) const loading = ref(false) const error = ref(null) +const queueMessage = ref(null) +const queueError = ref(null) +const prependingDirectory = ref(false) const page = ref(1) const total = ref(0) const pageSize = 50 const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))) +const canPlayDirectory = computed(() => ( + audioCount.value > 0 + && !loading.value + && !prependingDirectory.value + && !player.playlistLoading + && player.queue !== null +)) const breadcrumbs = ref<{ label: string; path: string }[]>([]) let browseRequest = 0 let browseController: AbortController | null = null @@ -45,11 +56,16 @@ async function loadFiles(dir: string, pageNum = 1) { browseController?.abort() const controller = new AbortController() browseController = controller + if (dir !== currentDir.value) { + queueMessage.value = null + queueError.value = null + } currentDir.value = dir page.value = pageNum directories.value = [] files.value = [] total.value = 0 + audioCount.value = 0 breadcrumbs.value = buildBreadcrumbs(dir) loading.value = true error.value = null @@ -59,6 +75,7 @@ async function loadFiles(dir: string, pageNum = 1) { files.value = result.files directories.value = result.directories total.value = result.total + audioCount.value = result.audioCount page.value = pageNum } catch (caught) { if (axios.isCancel(caught)) return @@ -74,6 +91,8 @@ async function loadFiles(dir: string, pageNum = 1) { function navigate(path: string) { previewFile.value = null + queueMessage.value = null + queueError.value = null const query = path === '.' ? {} : { dir: path } void router.push({ name: 'browse', query }) } @@ -93,6 +112,26 @@ function handlePlay(file: api.BrowseFileEntry) { }) } +async function handlePlayDirectory() { + if (!canPlayDirectory.value) return + const dir = currentDir.value + prependingDirectory.value = true + queueMessage.value = null + queueError.value = null + try { + const count = await player.prependDirectory(dir) + if (count !== null && currentDir.value === dir) { + queueMessage.value = `Playing ${count} track${count === 1 ? '' : 's'} from this folder` + } + } catch (caught) { + if (!axios.isCancel(caught) && currentDir.value === dir) { + queueError.value = 'Failed to add this folder to the playlist' + } + } finally { + prependingDirectory.value = false + } +} + watch(() => route.query.dir, value => { const dir = typeof value === 'string' && value !== '' ? value : '.' previewFile.value = null @@ -134,19 +173,32 @@ onUnmounted(() => { {{ library.scanError || 'Library scan failed' }}

- + +

{{ queueMessage }}

+
Loading...
{{ error }}
@@ -229,16 +281,37 @@ onUnmounted(() => { to { transform: rotate(360deg); } } -.breadcrumb { +.breadcrumb-toolbar { display: flex; align-items: center; - flex-wrap: wrap; - gap: 0.25rem; + gap: 0.75rem; min-height: 32px; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); } +.breadcrumb { + display: flex; + flex: 1 1 auto; + align-items: center; + flex-wrap: wrap; + gap: 0.25rem; + min-width: 0; +} + +.folder-play-button { + flex: 0 0 auto; +} + +.queue-message { + padding: 0.75rem 1rem; + border: 1px solid rgb(74 158 255 / 28%); + border-radius: var(--radius-md); + background: rgb(74 158 255 / 8%); + color: var(--text-secondary); + font-size: 0.8125rem; +} + .breadcrumb-link { padding: 0.25rem; border: 0; @@ -350,4 +423,15 @@ onUnmounted(() => { color: var(--text-muted); font-size: 0.8125rem; } + +@media (max-width: 600px) { + .breadcrumb-toolbar { + align-items: stretch; + flex-direction: column; + } + + .folder-play-button { + width: 100%; + } +} diff --git a/web/src/views/HomeView.test.ts b/web/src/views/HomeView.test.ts index 2d95dbb..2f09171 100644 --- a/web/src/views/HomeView.test.ts +++ b/web/src/views/HomeView.test.ts @@ -34,11 +34,27 @@ describe('HomeView', () => { vi.useRealTimers() }) - it('keeps tag filtering without a large Randomize action', async () => { - const wrapper = await mountHome() + it('places tag filtering below the cover without the welcome copy', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const player = usePlayerStore() + player.currentTrack = { + id: 'one', + name: 'Track One', + dir: 'Album', + filepath: 'Album/one.flac', + streamUrl: '/api/stream/one', + } + const wrapper = await mountHome(pinia) await flushPromises() expect(wrapper.find('#tag-filter').exists()).toBe(true) + expect(wrapper.get('label[for="tag-filter"]').text()).toBe('Filter by tag') + expect(wrapper.text()).not.toContain('Welcome to ShuffleMuse') + expect(wrapper.text()).not.toContain('Your personal music library') + const cover = wrapper.get('.now-playing-cover').element + const filter = wrapper.get('.tag-filter').element + expect(cover.compareDocumentPosition(filter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() expect(wrapper.findAll('button').some(button => button.text().includes('Randomize'))).toBe(false) }) diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index b4efccf..4f0715a 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -114,9 +114,6 @@ onBeforeUnmount(() => {