From 88b6cbfa5bc37d89310f625bc854649a683694f4 Mon Sep 17 00:00:00 2001
From: Music Player Bot
Date: Tue, 28 Jul 2026 19:26:35 +0800
Subject: [PATCH 1/5] perf: optimize file streaming and status polling
---
internal/api/handler.go | 11 +++
internal/api/middleware.go | 14 +++
internal/api/response_writer_test.go | 142 +++++++++++++++++++++++++++
web/src/App.vue | 2 +-
web/src/stores/auth.test.ts | 56 +++++++++--
web/src/stores/auth.ts | 29 ++++--
web/src/stores/library.test.ts | 128 +++++++++++++++++++++++-
web/src/stores/library.ts | 71 +++++++++++---
8 files changed, 421 insertions(+), 32 deletions(-)
create mode 100644 internal/api/response_writer_test.go
diff --git a/internal/api/handler.go b/internal/api/handler.go
index f2f684c..010db05 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"
@@ -228,6 +229,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/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/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/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()
+ }
}
}
From 7121ba9dd157de313a7544455198e4b79f91ccf1 Mon Sep 17 00:00:00 2001
From: Music Player Bot
Date: Wed, 29 Jul 2026 10:07:57 +0800
Subject: [PATCH 2/5] fix: prevent stream mode from covering next control
---
web/src/components/NowPlayingBar.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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;
From c472ed81882847b35e881dcc768a85be95dcd8d8 Mon Sep 17 00:00:00 2001
From: Music Player Bot
Date: Wed, 29 Jul 2026 15:07:27 +0800
Subject: [PATCH 3/5] feat: improve browse queue and playback startup
---
internal/api/browse.go | 11 +-
internal/api/browse_test.go | 14 +
internal/api/handler.go | 2 +
internal/api/queue.go | 112 ++++++++
internal/api/queue_test.go | 110 ++++++++
internal/playqueue/manager.go | 130 +++++++++
internal/playqueue/manager_test.go | 54 ++++
web/src/api/index.ts | 14 +
web/src/stores/player.test.ts | 365 +++++++++++++++++++++++-
web/src/stores/player.ts | 434 +++++++++++++++++++++++++++--
web/src/views/BrowseView.test.ts | 110 +++++++-
web/src/views/BrowseView.vue | 118 ++++++--
web/src/views/HomeView.test.ts | 20 +-
web/src/views/HomeView.vue | 55 ++--
14 files changed, 1465 insertions(+), 84 deletions(-)
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 010db05..2b331e0 100644
--- a/internal/api/handler.go
+++ b/internal/api/handler.go
@@ -131,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))
@@ -169,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)))
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/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/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/stores/player.test.ts b/web/src/stores/player.test.ts
index 9b0c346..fca2ab5 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,182 @@ describe('player store server queues', () => {
expect(player.isPlaying).toBe(true)
})
+ it('warms a cold audio endpoint for 1.5 seconds before starting media', 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(0)
+ expect(player.isBuffering).toBe(true)
+
+ await vi.advanceTimersByTimeAsync(1499)
+ expect(audio.playCalls).toBe(0)
+ await vi.advanceTimersByTimeAsync(1)
+ await playback
+
+ expect(audio.playCalls).toBe(1)
+ expect(player.isPlaying).toBe(true)
+ expect(player.isBuffering).toBe(false)
+ 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()
+
+ const first = player.playAt(0)
+ await vi.advanceTimersByTimeAsync(1500)
+ await first
+ 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 warm for 15 seconds after pause, then warms it again', async () => {
+ vi.useFakeTimers()
+ enableWebAudio()
+ const player = usePlayerStore()
+ await player.preparePlaylist()
+ const first = player.playAt(0)
+ await vi.advanceTimersByTimeAsync(1500)
+ await first
+ 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')
+
+ const resumed = player.resume()
+ await vi.advanceTimersByTimeAsync(1499)
+ expect(audio.playCalls).toBe(2)
+ await vi.advanceTimersByTimeAsync(1)
+ await resumed
+ expect(audio.playCalls).toBe(3)
+ expect(context.resumeCalls).toBe(2)
+ player.reset()
+ })
+
+ it('lets pause cancel media playback while the endpoint is warming', async () => {
+ vi.useFakeTimers()
+ enableWebAudio()
+ const player = usePlayerStore()
+ await player.preparePlaylist()
+
+ const playback = player.playAt(0)
+ await vi.advanceTimersByTimeAsync(500)
+ expect(player.isBuffering).toBe(true)
+ await player.togglePlay()
+ expect(player.isBuffering).toBe(false)
+
+ await vi.advanceTimersByTimeAsync(1000)
+ await playback
+ expect(FakeAudio.instances[0].playCalls).toBe(0)
+ expect(player.isPlaying).toBe(false)
+ player.reset()
+ })
+
+ it('only starts the latest track selected during endpoint warmup', 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()
+
+ const first = player.playAt(0)
+ await vi.advanceTimersByTimeAsync(500)
+ const second = player.playAt(1)
+ await vi.advanceTimersByTimeAsync(1000)
+ 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 warmup playback and releases endpoint resources on reset', async () => {
+ vi.useFakeTimers()
+ enableWebAudio()
+ const player = usePlayerStore()
+ await player.preparePlaylist()
+
+ const playback = player.playAt(0)
+ await vi.advanceTimersByTimeAsync(0)
+ const context = FakeAudioContext.instances[0]
+ const audio = FakeAudio.instances[0]
+ player.pause()
+ player.pause()
+ expect(vi.getTimerCount()).toBe(2)
+
+ 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 vi.advanceTimersByTimeAsync(1500)
+ await playback
+ expect(audio.playCalls).toBe(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 +596,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..cfa0ead 100644
--- a/web/src/stores/player.ts
+++ b/web/src/stores/player.ts
@@ -7,6 +7,8 @@ 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_WARMUP_MS = 1500
+const ENDPOINT_KEEPALIVE_MS = 15000
export type StreamMode = 'original' | 'opus'
@@ -23,6 +25,11 @@ interface CachedPage {
libraryGeneration: number
}
+interface PlaybackIntent {
+ id: number
+ endpointReady: Promise
+}
+
function loadVolume(): number {
try {
const saved = localStorage.getItem(STORAGE_KEY_VOLUME)
@@ -90,10 +97,23 @@ 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 endpointWarm = false
+ let endpointDisabled = false
+ let endpointGeneration = 0
+ let endpointWarmupTimer: ReturnType | null = null
+ let endpointWarmupPromise: Promise | null = null
+ let resolveEndpointWarmup: (() => void) | null = null
+ let endpointSuspendTimer: ReturnType | null = null
let queueController: AbortController | null = null
let selectController: AbortController | null = null
let metadataController: AbortController | null = null
@@ -113,6 +133,190 @@ 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 settleEndpointWarmup() {
+ if (endpointWarmupTimer !== null) {
+ clearTimeout(endpointWarmupTimer)
+ endpointWarmupTimer = null
+ }
+ const resolve = resolveEndpointWarmup
+ resolveEndpointWarmup = null
+ endpointWarmupPromise = null
+ resolve?.()
+ }
+
+ function closeEndpoint(disable: boolean) {
+ endpointGeneration += 1
+ clearEndpointSuspendTimer()
+ settleEndpointWarmup()
+
+ const context = endpointContext
+ const source = endpointSource
+ const gain = endpointGain
+ endpointContext = null
+ endpointSource = null
+ endpointGain = null
+ endpointWarm = false
+ 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 warmEndpoint(): Promise {
+ endpointGeneration += 1
+ clearEndpointSuspendTimer()
+ if (endpointDisabled) return Promise.resolve()
+ if (endpointWarmupPromise) return endpointWarmupPromise
+
+ if (endpointContext?.state === 'closed') closeEndpoint(false)
+ if (endpointContext && endpointWarm && endpointContext.state === 'running') {
+ return Promise.resolve()
+ }
+
+ const context = endpointContext ?? createEndpointContext()
+ if (!context) return Promise.resolve()
+ endpointWarm = false
+
+ endpointWarmupPromise = new Promise(resolve => {
+ resolveEndpointWarmup = resolve
+ })
+ const ready = endpointWarmupPromise
+ endpointWarmupTimer = setTimeout(() => {
+ if (endpointContext === context) endpointWarm = context.state === 'running'
+ settleEndpointWarmup()
+ }, ENDPOINT_WARMUP_MS)
+
+ let resumed: Promise
+ try {
+ resumed = context.state === 'running' ? Promise.resolve() : context.resume()
+ } catch {
+ closeEndpoint(true)
+ return ready
+ }
+ void resumed.then(
+ () => {
+ if (endpointContext === context && endpointWarmupPromise === null) {
+ endpointWarm = context.state === 'running'
+ }
+ },
+ () => {
+ if (endpointContext === context) closeEndpoint(true)
+ },
+ )
+ return ready
+ }
+
+ 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
+ endpointWarm = false
+ try {
+ void context.suspend().then(
+ () => {
+ if (endpointContext !== context || endpointGeneration === generation) return
+ try {
+ void context.resume().then(() => {
+ if (endpointContext === context && endpointWarmupPromise === null) {
+ endpointWarm = context.state === 'running'
+ }
+ }, () => {})
+ } catch { /* playback still falls back after the warmup deadline */ }
+ },
+ () => {
+ if (endpointContext === context && endpointGeneration === generation) {
+ endpointWarm = context.state === 'running'
+ }
+ },
+ )
+ } catch {
+ endpointWarm = context.state === 'running'
+ }
+ }, 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
+ }
+ return { id, endpointReady: warmEndpoint() }
+ }
+
+ 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 +335,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 +348,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 +366,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 +487,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 +509,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 +520,44 @@ export const usePlayerStore = defineStore('player', () => {
element.currentTime = target
}
if (autoplay) {
+ if (!intent) return
+ await intent.endpointReady
+ 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 +566,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 +583,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 +595,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 +604,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 +642,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 +675,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 +752,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 +804,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 +829,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 +872,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 +881,78 @@ 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
+ await playback.endpointReady
+ 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 +968,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 +999,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 +1007,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 +1028,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 +1059,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 +1069,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 +1103,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 +1175,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 }}
+ {{ queueError }}
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(() => {
-
Welcome to ShuffleMuse
-
Your personal music library
-
📁
@@ -130,28 +127,6 @@ onBeforeUnmount(() => {
Scanning library...
-
-
-
-
-
-
-
-
Now Playing
{{ player.displayTitle }}
@@ -182,25 +157,37 @@ onBeforeUnmount(() => {
No artwork
+
+
+
+