feat(node-player): use Service Worker for auth, enable streaming playback
Add a Service Worker that intercepts /api/* requests and injects the Bearer token. This allows <audio> and <img> elements to use direct URLs instead of downloading entire files as blobs first. - Audio now streams progressively (no full download before playback) - Cover art loads via regular <img src> (SW adds auth header) - Remove blob-based preloadStream, fetchCoverBlob, useCoverUrl hook - Register SW in main.tsx, token synced via postMessage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,3 +2,4 @@
|
||||
/docker/inbox
|
||||
/docker/storage
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
@@ -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()))
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => { })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <img src={src} alt="" onError={() => setErrored(true)} />
|
||||
}
|
||||
|
||||
@@ -29,10 +32,12 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
|
||||
|
||||
return (
|
||||
<div className="np-info">
|
||||
<div className="np-cover" id="npCover">
|
||||
<Cover slug={track.slug} />
|
||||
<Cover src={coverUrl} />
|
||||
</div>
|
||||
<div className="np-text">
|
||||
<div className="np-title" id="npTitle">
|
||||
@@ -45,4 +50,3 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <img src={src} alt="" onError={() => 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({
|
||||
>
|
||||
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
|
||||
<div className="qi-cover">
|
||||
{hasAlbum ? <Cover slug={t.slug} /> : <>🎵</>}
|
||||
{coverSrc ? <Cover src={coverSrc} /> : <>🎵</>}
|
||||
</div>
|
||||
<div className="qi-info">
|
||||
<div className="qi-title">{t.title}</div>
|
||||
|
||||
@@ -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<RecentPlay[] | null> {
|
||||
export async function recordPlay(trackSlug: string): Promise<void> {
|
||||
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<string | null> {
|
||||
const res = await furumiApi.get(`/tracks/${trackSlug}/cover`, { responseType: 'blob' }).catch(() => null)
|
||||
if (!res?.data) return null
|
||||
return URL.createObjectURL(res.data)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | null>(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
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<Provider store={store}>
|
||||
|
||||
Reference in New Issue
Block a user