Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fa79423bd |
@@ -2,4 +2,3 @@
|
|||||||
/docker/inbox
|
/docker/inbox
|
||||||
/docker/storage
|
/docker/storage
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
# Leave empty — vite proxy handles /api in dev, same-origin in production
|
VITE_FURUMI_API_URL=http://localhost:8085
|
||||||
VITE_FURUMI_API_URL=
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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()))
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||||
import './furumi-player.css'
|
import './furumi-player.css'
|
||||||
import { furumiApi, searchTracks, recordPlay } from './furumiApi'
|
import { searchTracks, preloadStream, fetchCoverBlob, recordPlay } from './furumiApi'
|
||||||
import { store, useAppDispatch, useAppSelector } from './store'
|
import { store, useAppDispatch, useAppSelector } from './store'
|
||||||
import { fetchArtists } from './store/slices/artistsSlice'
|
import { fetchArtists } from './store/slices/artistsSlice'
|
||||||
import { fetchArtistAlbums } from './store/slices/albumsSlice'
|
import { fetchArtistAlbums } from './store/slices/albumsSlice'
|
||||||
@@ -94,11 +94,9 @@ export function FurumiPlayer({ user }: { user: UserProfile }) {
|
|||||||
album: '',
|
album: '',
|
||||||
})
|
})
|
||||||
navigator.mediaSession.metadata = meta
|
navigator.mediaSession.metadata = meta
|
||||||
furumiApi.get(`/tracks/${nowPlayingTrack.slug}/cover`, { responseType: 'blob' })
|
fetchCoverBlob(nowPlayingTrack.slug).then((url) => {
|
||||||
.then((res) => {
|
if (url) meta.artwork = [{ src: url, sizes: '512x512' }]
|
||||||
meta.artwork = [{ src: URL.createObjectURL(res.data), sizes: '512x512' }]
|
})
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -394,7 +392,7 @@ export function FurumiPlayer({ user }: { user: UserProfile }) {
|
|||||||
{ slug, title: '', artist: '', album_slug: null, duration: null },
|
{ slug, title: '', artist: '', album_slug: null, duration: null },
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
void playback.loadStreamForTrack(slug)
|
void preloadStream(slug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
searchSelectRef.current = onSearchSelect
|
searchSelectRef.current = onSearchSelect
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { furumiApi } from './furumiApi'
|
import { preloadStream } from './furumiApi'
|
||||||
import { fmt } from './utils'
|
import { fmt } from './utils'
|
||||||
|
|
||||||
const MAX_PLAYBACK_ERROR_SKIPS = 5
|
const MAX_PLAYBACK_ERROR_SKIPS = 5
|
||||||
@@ -109,13 +109,9 @@ export function attachAudioPlayback(
|
|||||||
volSlider?.addEventListener('input', onVolInput)
|
volSlider?.addEventListener('input', onVolInput)
|
||||||
|
|
||||||
async function loadStreamForTrack(slug: string) {
|
async function loadStreamForTrack(slug: string) {
|
||||||
try {
|
const response = await preloadStream(slug)
|
||||||
const res = await furumiApi.get(`/stream/${slug}`, { responseType: 'blob' })
|
audio.src = URL.createObjectURL(response?.data)
|
||||||
audio.src = URL.createObjectURL(res.data)
|
await audio.play().catch(() => { })
|
||||||
await audio.play().catch(() => { })
|
|
||||||
} catch {
|
|
||||||
// stream failed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pauseAndClearSource() {
|
function pauseAndClearSource() {
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { furumiApi } from '../furumiApi'
|
|
||||||
|
|
||||||
export function AuthImg({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) {
|
|
||||||
const [blobUrl, setBlobUrl] = useState<string | null>(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 <img src={blobUrl} alt={alt ?? ''} {...props} />
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AuthImg } from './AuthImg'
|
|
||||||
import type { QueueItem } from './QueueList'
|
import type { QueueItem } from './QueueList'
|
||||||
|
import { useCoverUrl } from '../hooks/useCoverUrl'
|
||||||
|
|
||||||
function Cover({ slug }: { slug: string }) {
|
function Cover({ slug }: { slug: string }) {
|
||||||
const [errored, setErrored] = useState(false)
|
const [errored, setErrored] = useState(false)
|
||||||
const src = `/tracks/${slug}/cover`
|
const src = useCoverUrl(slug)
|
||||||
|
|
||||||
if (errored) return <>🎵</>
|
if (!src || errored) return <>🎵</>
|
||||||
return <AuthImg src={src} alt="" onError={() => setErrored(true)} />
|
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NowPlaying({ track }: { track: QueueItem | null }) {
|
export function NowPlaying({ track }: { track: QueueItem | null }) {
|
||||||
@@ -45,3 +45,4 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { AuthImg } from './AuthImg'
|
import { useCoverUrl } from '../hooks/useCoverUrl'
|
||||||
|
|
||||||
export type QueueItem = {
|
export type QueueItem = {
|
||||||
slug: string
|
slug: string
|
||||||
@@ -34,8 +34,10 @@ function fmt(secs: number) {
|
|||||||
|
|
||||||
function Cover({ slug }: { slug: string }) {
|
function Cover({ slug }: { slug: string }) {
|
||||||
const [errored, setErrored] = useState(false)
|
const [errored, setErrored] = useState(false)
|
||||||
if (errored) return <>🎵</>
|
const src = useCoverUrl(slug)
|
||||||
return <AuthImg src={`/tracks/${slug}/cover`} alt="" onError={() => setErrored(true)} />
|
|
||||||
|
if (!src || errored) return <>🎵</>
|
||||||
|
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueueList({
|
export function QueueList({
|
||||||
|
|||||||
@@ -8,20 +8,8 @@ export const furumiApi = axios.create({
|
|||||||
baseURL: API_ROOT,
|
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) {
|
export function setAuthToken(token: string) {
|
||||||
furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||||
sendTokenToSW(token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuthToken() {
|
export function clearAuthToken() {
|
||||||
@@ -49,6 +37,7 @@ furumiApi.interceptors.response.use(
|
|||||||
const original = error.config
|
const original = error.config
|
||||||
if (error.response?.status === 401 && !original._retried) {
|
if (error.response?.status === 401 && !original._retried) {
|
||||||
original._retried = true
|
original._retried = true
|
||||||
|
// Deduplicate concurrent refresh attempts
|
||||||
if (!refreshPromise) {
|
if (!refreshPromise) {
|
||||||
refreshPromise = refreshToken().finally(() => { refreshPromise = null })
|
refreshPromise = refreshToken().finally(() => { refreshPromise = null })
|
||||||
}
|
}
|
||||||
@@ -107,3 +96,14 @@ export async function getRecentPlays(): Promise<RecentPlay[] | null> {
|
|||||||
export async function recordPlay(trackSlug: string): Promise<void> {
|
export async function recordPlay(trackSlug: string): Promise<void> {
|
||||||
await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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,10 +5,6 @@ import { store } from './store'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
navigator.serviceWorker.register('/sw.js')
|
|
||||||
}
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ export default defineConfig({
|
|||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:8085',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user