diff --git a/.gitignore b/.gitignore index 2f3c66b..30f4604 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /docker/inbox /docker/storage .env +.DS_Store diff --git a/furumi-node-player/client/.env.example b/furumi-node-player/client/.env.example index eeec224..2ce30f6 100644 --- a/furumi-node-player/client/.env.example +++ b/furumi-node-player/client/.env.example @@ -1 +1,2 @@ -VITE_FURUMI_API_URL=http://localhost:8085 +# Leave empty — vite proxy handles /api in dev, same-origin in production +VITE_FURUMI_API_URL= diff --git a/furumi-node-player/client/public/sw.js b/furumi-node-player/client/public/sw.js new file mode 100644 index 0000000..a17ac24 --- /dev/null +++ b/furumi-node-player/client/public/sw.js @@ -0,0 +1,45 @@ +const DB_NAME = 'furumi-sw' +const STORE = 'auth' +const KEY = 'bearer' + +function openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1) + req.onupgradeneeded = () => req.result.createObjectStore(STORE) + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) +} + +async function getToken() { + try { + const db = await openDB() + return new Promise((resolve) => { + const tx = db.transaction(STORE, 'readonly') + const req = tx.objectStore(STORE).get(KEY) + req.onsuccess = () => resolve(req.result || null) + req.onerror = () => resolve(null) + }) + } catch { + return null + } +} + +self.addEventListener('fetch', (e) => { + const url = new URL(e.request.url) + if (url.origin !== self.location.origin || !url.pathname.startsWith('/api/')) return + + e.respondWith( + (async () => { + const token = await getToken() + if (!token) return fetch(e.request) + + const headers = new Headers(e.request.headers) + headers.set('Authorization', `Bearer ${token}`) + return fetch(new Request(e.request, { headers })) + })() + ) +}) + +self.addEventListener('install', () => self.skipWaiting()) +self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim())) diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index 4217803..6c058f7 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import './furumi-player.css' -import { searchTracks, preloadStream, fetchCoverBlob, recordPlay } from './furumiApi' +import { furumiApi, searchTracks, recordPlay } from './furumiApi' import { store, useAppDispatch, useAppSelector } from './store' import { fetchArtists } from './store/slices/artistsSlice' import { fetchArtistAlbums } from './store/slices/albumsSlice' @@ -94,9 +94,11 @@ export function FurumiPlayer({ user }: { user: UserProfile }) { album: '', }) navigator.mediaSession.metadata = meta - fetchCoverBlob(nowPlayingTrack.slug).then((url) => { - if (url) meta.artwork = [{ src: url, sizes: '512x512' }] - }) + furumiApi.get(`/tracks/${nowPlayingTrack.slug}/cover`, { responseType: 'blob' }) + .then((res) => { + meta.artwork = [{ src: URL.createObjectURL(res.data), sizes: '512x512' }] + }) + .catch(() => {}) } catch { // ignore } @@ -392,7 +394,7 @@ export function FurumiPlayer({ user }: { user: UserProfile }) { { slug, title: '', artist: '', album_slug: null, duration: null }, true, ) - void preloadStream(slug) + void playback.loadStreamForTrack(slug) } } searchSelectRef.current = onSearchSelect diff --git a/furumi-node-player/client/src/audioPlaybackService.ts b/furumi-node-player/client/src/audioPlaybackService.ts index e064fbd..a632e7f 100644 --- a/furumi-node-player/client/src/audioPlaybackService.ts +++ b/furumi-node-player/client/src/audioPlaybackService.ts @@ -1,4 +1,4 @@ -import { preloadStream } from './furumiApi' +import { furumiApi } from './furumiApi' import { fmt } from './utils' const MAX_PLAYBACK_ERROR_SKIPS = 5 @@ -109,9 +109,13 @@ export function attachAudioPlayback( volSlider?.addEventListener('input', onVolInput) async function loadStreamForTrack(slug: string) { - const response = await preloadStream(slug) - audio.src = URL.createObjectURL(response?.data) - await audio.play().catch(() => { }) + try { + const res = await furumiApi.get(`/stream/${slug}`, { responseType: 'blob' }) + audio.src = URL.createObjectURL(res.data) + await audio.play().catch(() => { }) + } catch { + // stream failed + } } function pauseAndClearSource() { diff --git a/furumi-node-player/client/src/components/AuthImg.tsx b/furumi-node-player/client/src/components/AuthImg.tsx new file mode 100644 index 0000000..fadfedf --- /dev/null +++ b/furumi-node-player/client/src/components/AuthImg.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' +import { furumiApi } from '../furumiApi' + +export function AuthImg({ src, alt, ...props }: React.ImgHTMLAttributes) { + const [blobUrl, setBlobUrl] = useState(null) + + useEffect(() => { + if (!src) return + let revoked = false + furumiApi.get(src, { responseType: 'blob' }) + .then((res) => { + if (!revoked) setBlobUrl(URL.createObjectURL(res.data)) + }) + .catch(() => {}) + return () => { + revoked = true + if (blobUrl) URL.revokeObjectURL(blobUrl) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [src]) + + if (!blobUrl) return null + return {alt +} diff --git a/furumi-node-player/client/src/components/NowPlaying.tsx b/furumi-node-player/client/src/components/NowPlaying.tsx index 25e4793..8adf61a 100644 --- a/furumi-node-player/client/src/components/NowPlaying.tsx +++ b/furumi-node-player/client/src/components/NowPlaying.tsx @@ -1,13 +1,14 @@ import { useState } from 'react' +import { API_ROOT } from '../furumiApi' +import { AuthImg } from './AuthImg' import type { QueueItem } from './QueueList' -import { useCoverUrl } from '../hooks/useCoverUrl' function Cover({ slug }: { slug: string }) { const [errored, setErrored] = useState(false) - const src = useCoverUrl(slug) + const src = `/tracks/${slug}/cover` - if (!src || errored) return <>🎵 - return setErrored(true)} /> + if (errored) return <>🎵 + return setErrored(true)} /> } export function NowPlaying({ track }: { track: QueueItem | null }) { @@ -45,4 +46,3 @@ export function NowPlaying({ track }: { track: QueueItem | null }) { ) } - diff --git a/furumi-node-player/client/src/components/QueueList.tsx b/furumi-node-player/client/src/components/QueueList.tsx index 3cb7428..57865de 100644 --- a/furumi-node-player/client/src/components/QueueList.tsx +++ b/furumi-node-player/client/src/components/QueueList.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { useCoverUrl } from '../hooks/useCoverUrl' +import { AuthImg } from './AuthImg' export type QueueItem = { slug: string @@ -34,10 +34,8 @@ function fmt(secs: number) { function Cover({ slug }: { slug: string }) { const [errored, setErrored] = useState(false) - const src = useCoverUrl(slug) - - if (!src || errored) return <>🎵 - return setErrored(true)} /> + if (errored) return <>🎵 + return setErrored(true)} /> } export function QueueList({ diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index 4ed3312..b7fc636 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -8,8 +8,20 @@ export const furumiApi = axios.create({ baseURL: API_ROOT, }) +function sendTokenToSW(token: string) { + try { + const req = indexedDB.open('furumi-sw', 1) + req.onupgradeneeded = () => req.result.createObjectStore('auth') + req.onsuccess = () => { + const tx = req.result.transaction('auth', 'readwrite') + tx.objectStore('auth').put(token, 'bearer') + } + } catch { /* ignore */ } +} + export function setAuthToken(token: string) { furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}` + sendTokenToSW(token) } export function clearAuthToken() { @@ -37,7 +49,6 @@ furumiApi.interceptors.response.use( const original = error.config if (error.response?.status === 401 && !original._retried) { original._retried = true - // Deduplicate concurrent refresh attempts if (!refreshPromise) { refreshPromise = refreshToken().finally(() => { refreshPromise = null }) } @@ -96,14 +107,3 @@ export async function getRecentPlays(): Promise { export async function recordPlay(trackSlug: string): Promise { await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null) } - -export async function preloadStream(trackSlug: string) { - return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null) -} - -export async function fetchCoverBlob(trackSlug: string): Promise { - const res = await furumiApi.get(`/tracks/${trackSlug}/cover`, { responseType: 'blob' }).catch(() => null) - if (!res?.data) return null - return URL.createObjectURL(res.data) -} - diff --git a/furumi-node-player/client/src/hooks/useCoverUrl.ts b/furumi-node-player/client/src/hooks/useCoverUrl.ts deleted file mode 100644 index 1b53be0..0000000 --- a/furumi-node-player/client/src/hooks/useCoverUrl.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useEffect, useState } from 'react' -import { fetchCoverBlob } from '../furumiApi' - -export function useCoverUrl(trackSlug: string | undefined): string | null { - const [url, setUrl] = useState(null) - - useEffect(() => { - if (!trackSlug) { - setUrl(null) - return - } - - let revoke: string | null = null - - fetchCoverBlob(trackSlug).then((blobUrl) => { - if (blobUrl) { - revoke = blobUrl - setUrl(blobUrl) - } - }) - - return () => { - if (revoke) URL.revokeObjectURL(revoke) - } - }, [trackSlug]) - - return url -} diff --git a/furumi-node-player/client/src/main.tsx b/furumi-node-player/client/src/main.tsx index 9d4c1bf..edf42b0 100644 --- a/furumi-node-player/client/src/main.tsx +++ b/furumi-node-player/client/src/main.tsx @@ -5,6 +5,10 @@ import { store } from './store' import './index.css' import App from './App.tsx' +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js') +} + createRoot(document.getElementById('root')!).render( diff --git a/furumi-node-player/client/vite.config.ts b/furumi-node-player/client/vite.config.ts index 58fa2c9..9f9fba5 100644 --- a/furumi-node-player/client/vite.config.ts +++ b/furumi-node-player/client/vite.config.ts @@ -14,6 +14,10 @@ export default defineConfig({ target: 'http://localhost:3001', changeOrigin: true, }, + '/api': { + target: 'http://localhost:8085', + changeOrigin: true, + }, }, }, })