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/public/sw.js b/furumi-node-player/client/public/sw.js new file mode 100644 index 0000000..6ef25be --- /dev/null +++ b/furumi-node-player/client/public/sw.js @@ -0,0 +1,23 @@ +let bearerToken = null + +self.addEventListener('message', (e) => { + if (e.data?.type === 'SET_TOKEN') { + bearerToken = e.data.token + } +}) + +self.addEventListener('fetch', (e) => { + const url = new URL(e.request.url) + // Only intercept /api/ requests to the same origin + if (url.origin !== self.location.origin || !url.pathname.startsWith('/api/')) return + if (!bearerToken) return + + const authedRequest = new Request(e.request, { + headers: new Headers(e.request.headers), + }) + authedRequest.headers.set('Authorization', `Bearer ${bearerToken}`) + e.respondWith(fetch(authedRequest)) +}) + +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..2633975 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 { API_ROOT, searchTracks, recordPlay } from './furumiApi' import { store, useAppDispatch, useAppSelector } from './store' import { fetchArtists } from './store/slices/artistsSlice' import { fetchArtistAlbums } from './store/slices/albumsSlice' @@ -88,14 +88,12 @@ export function FurumiPlayer({ user }: { user: UserProfile }) { document.title = `${nowPlayingTrack.title} — Furumi` if ('mediaSession' in navigator) { try { - const meta = new window.MediaMetadata({ + const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover` + navigator.mediaSession.metadata = new window.MediaMetadata({ title: nowPlayingTrack.title, artist: nowPlayingTrack.artist || '', album: '', - }) - navigator.mediaSession.metadata = meta - fetchCoverBlob(nowPlayingTrack.slug).then((url) => { - if (url) meta.artwork = [{ src: url, sizes: '512x512' }] + artwork: [{ src: coverUrl, sizes: '512x512' }], }) } catch { // ignore @@ -392,7 +390,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..9d94ad2 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 { API_ROOT } from './furumiApi' import { fmt } from './utils' const MAX_PLAYBACK_ERROR_SKIPS = 5 @@ -109,8 +109,7 @@ export function attachAudioPlayback( volSlider?.addEventListener('input', onVolInput) async function loadStreamForTrack(slug: string) { - const response = await preloadStream(slug) - audio.src = URL.createObjectURL(response?.data) + audio.src = `${API_ROOT}/stream/${slug}` await audio.play().catch(() => { }) } diff --git a/furumi-node-player/client/src/components/NowPlaying.tsx b/furumi-node-player/client/src/components/NowPlaying.tsx index 25e4793..ac323bc 100644 --- a/furumi-node-player/client/src/components/NowPlaying.tsx +++ b/furumi-node-player/client/src/components/NowPlaying.tsx @@ -1,12 +1,15 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { API_ROOT } from '../furumiApi' import type { QueueItem } from './QueueList' -import { useCoverUrl } from '../hooks/useCoverUrl' -function Cover({ slug }: { slug: string }) { +function Cover({ src }: { src: string }) { const [errored, setErrored] = useState(false) - const src = useCoverUrl(slug) - if (!src || errored) return <>🎵 + useEffect(() => { + setErrored(false) + }, [src]) + + if (errored) return <>🎵 return setErrored(true)} /> } @@ -29,10 +32,12 @@ export function NowPlaying({ track }: { track: QueueItem | null }) { ) } + const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover` + return (
- +
@@ -45,4 +50,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..d2877d3 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 { API_ROOT } from '../furumiApi' export type QueueItem = { slug: string @@ -32,11 +32,10 @@ function fmt(secs: number) { return `${m}:${pad(s % 60)}` } -function Cover({ slug }: { slug: string }) { +function Cover({ src }: { src: string }) { const [errored, setErrored] = useState(false) - const src = useCoverUrl(slug) - - if (!src || errored) return <>🎵 + useEffect(() => { setErrored(false) }, [src]) + if (errored) return <>🎵 return setErrored(true)} /> } @@ -75,7 +74,7 @@ export function QueueList({ if (!t) return null const isPlaying = origIdx === playingOrigIdx - const hasAlbum = !!t.album_slug + const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : '' const dur = t.duration ? fmt(t.duration) : '' const isDragging = draggingPos === pos const isDragOver = dragOverPos === pos @@ -116,7 +115,7 @@ export function QueueList({ > {isPlaying ? '' : pos + 1}
- {hasAlbum ? : <>🎵} + {coverSrc ? : <>🎵}
{t.title}
diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index 4ed3312..c37eee3 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -8,8 +8,17 @@ export const furumiApi = axios.create({ baseURL: API_ROOT, }) +function sendTokenToSW(token: string) { + navigator.serviceWorker?.controller?.postMessage({ type: 'SET_TOKEN', token }) + // Also send to waiting/installing SW + navigator.serviceWorker?.ready.then((reg) => { + reg.active?.postMessage({ type: 'SET_TOKEN', token }) + }) +} + export function setAuthToken(token: string) { furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}` + sendTokenToSW(token) } export function clearAuthToken() { @@ -37,7 +46,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 +104,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(