Compare commits
3 Commits
8ceee6028a
...
30c6400354
| Author | SHA1 | Date | |
|---|---|---|---|
| 30c6400354 | |||
| 480880f292 | |||
| 83a145d0a8 |
@@ -1,21 +1,34 @@
|
|||||||
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 {
|
import { API_ROOT, searchTracks, preloadStream } from './furumiApi'
|
||||||
API_ROOT,
|
import { store, useAppDispatch, useAppSelector } from './store'
|
||||||
searchTracks,
|
|
||||||
preloadStream,
|
|
||||||
} from './furumiApi'
|
|
||||||
import { 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'
|
||||||
import { fetchArtistTracks } from './store/slices/artistTracksSlice'
|
import { fetchArtistTracks } from './store/slices/artistTracksSlice'
|
||||||
import { fetchAlbumTracks } from './store/slices/albumTracksSlice'
|
import { fetchAlbumTracks } from './store/slices/albumTracksSlice'
|
||||||
import { fetchTrackDetail } from './store/slices/trackDetailSlice'
|
import { fetchTrackDetail } from './store/slices/trackDetailSlice'
|
||||||
|
import {
|
||||||
|
addTrack,
|
||||||
|
addTracksBatch,
|
||||||
|
replaceQueue,
|
||||||
|
clearQueue,
|
||||||
|
playAtIndex,
|
||||||
|
removeFromQueueAt,
|
||||||
|
moveQueueItemInOrder,
|
||||||
|
toggleShuffle,
|
||||||
|
toggleRepeat,
|
||||||
|
rebuildShuffleOrder,
|
||||||
|
selectQueueOrder,
|
||||||
|
selectPlayingOrigIdx,
|
||||||
|
selectQueueScrollSignal,
|
||||||
|
selectNowPlayingTrack,
|
||||||
|
selectQueueItems,
|
||||||
|
} from './store/slices/queueSlice'
|
||||||
|
import { attachAudioPlayback } from './audioPlaybackService'
|
||||||
import { fmt } from './utils'
|
import { fmt } from './utils'
|
||||||
import { Header } from './components/Header'
|
import { Header } from './components/Header'
|
||||||
import { MainPanel, type Crumb } from './components/MainPanel'
|
import { MainPanel, type Crumb } from './components/MainPanel'
|
||||||
import { PlayerBar } from './components/PlayerBar'
|
import { PlayerBar } from './components/PlayerBar'
|
||||||
import type { QueueItem } from './components/QueueList'
|
|
||||||
import type { Track } from './types'
|
import type { Track } from './types'
|
||||||
|
|
||||||
export function FurumiPlayer() {
|
export function FurumiPlayer() {
|
||||||
@@ -26,6 +39,13 @@ export function FurumiPlayer() {
|
|||||||
const albumsError = useAppSelector((s) => s.albums.error)
|
const albumsError = useAppSelector((s) => s.albums.error)
|
||||||
const albumTracksLoading = useAppSelector((s) => s.albumTracks.loading)
|
const albumTracksLoading = useAppSelector((s) => s.albumTracks.loading)
|
||||||
const albumTracksError = useAppSelector((s) => s.albumTracks.error)
|
const albumTracksError = useAppSelector((s) => s.albumTracks.error)
|
||||||
|
|
||||||
|
const queueItemsView = useAppSelector(selectQueueItems)
|
||||||
|
const queueOrderView = useAppSelector(selectQueueOrder)
|
||||||
|
const queuePlayingOrigIdxView = useAppSelector(selectPlayingOrigIdx)
|
||||||
|
const queueScrollSignal = useAppSelector(selectQueueScrollSignal)
|
||||||
|
const nowPlayingTrack = useAppSelector(selectNowPlayingTrack)
|
||||||
|
|
||||||
const [breadcrumbs, setBreadcrumbs] = useState<Crumb[]>(
|
const [breadcrumbs, setBreadcrumbs] = useState<Crumb[]>(
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -45,14 +65,7 @@ export function FurumiPlayer() {
|
|||||||
Array<{ result_type: string; slug: string; name: string; detail?: string }>
|
Array<{ result_type: string; slug: string; name: string; detail?: string }>
|
||||||
>([])
|
>([])
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const searchSelectRef = useRef<(type: string, slug: string) => void>(() => {})
|
const searchSelectRef = useRef<(type: string, slug: string) => void>(() => { })
|
||||||
|
|
||||||
const [nowPlayingTrack, setNowPlayingTrack] = useState<QueueItem | null>(null)
|
|
||||||
const [queueItemsView, setQueueItemsView] = useState<QueueItem[]>([])
|
|
||||||
const [queueOrderView, setQueueOrderView] = useState<number[]>([])
|
|
||||||
const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1)
|
|
||||||
const [queueScrollSignal, setQueueScrollSignal] = useState(0)
|
|
||||||
const [queue, setQueue] = useState<QueueItem[]>([])
|
|
||||||
|
|
||||||
const queueActionsRef = useRef<{
|
const queueActionsRef = useRef<{
|
||||||
playIndex: (i: number) => void
|
playIndex: (i: number) => void
|
||||||
@@ -63,63 +76,54 @@ export function FurumiPlayer() {
|
|||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// --- Original player script adapted for React environment ---
|
if (!nowPlayingTrack) {
|
||||||
|
document.title = 'Furumi Player'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
document.title = `${nowPlayingTrack.title} — Furumi`
|
||||||
|
const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover`
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||||
|
title: nowPlayingTrack.title,
|
||||||
|
artist: nowPlayingTrack.artist || '',
|
||||||
|
album: '',
|
||||||
|
artwork: [{ src: coverUrl, sizes: '512x512' }],
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [nowPlayingTrack])
|
||||||
|
|
||||||
|
const shuffle = useAppSelector((s) => s.queue.shuffle)
|
||||||
|
const repeatAll = useAppSelector((s) => s.queue.repeatAll)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const btnShuffle = document.getElementById('btnShuffle')
|
||||||
|
const btnRepeat = document.getElementById('btnRepeat')
|
||||||
|
btnShuffle?.classList.toggle('active', shuffle)
|
||||||
|
btnRepeat?.classList.toggle('active', repeatAll)
|
||||||
|
}, [shuffle, repeatAll])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const audioEl = audioRef.current
|
const audioEl = audioRef.current
|
||||||
if (!audioEl) return
|
if (!audioEl) return
|
||||||
const audio = audioEl
|
const audio = audioEl
|
||||||
|
|
||||||
let queueIndex = -1
|
|
||||||
let shuffle = false
|
|
||||||
let repeatAll = true
|
|
||||||
let shuffleOrder: number[] = []
|
|
||||||
let searchTimer: number | null = null
|
let searchTimer: number | null = null
|
||||||
let toastTimer: number | null = null
|
let toastTimer: number | null = null
|
||||||
let muted = false
|
|
||||||
|
|
||||||
// Restore prefs
|
function showToast(msg: string) {
|
||||||
try {
|
const t = document.getElementById('toast')
|
||||||
const v = window.localStorage.getItem('furumi_vol')
|
if (!t) return
|
||||||
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
t.textContent = msg
|
||||||
if (v !== null && volSlider) {
|
t.classList.add('show')
|
||||||
audio.volume = Number(v) / 100
|
if (toastTimer) window.clearTimeout(toastTimer)
|
||||||
volSlider.value = v
|
toastTimer = window.setTimeout(() => t.classList.remove('show'), 2500)
|
||||||
}
|
|
||||||
const btnShuffle = document.getElementById('btnShuffle')
|
|
||||||
const btnRepeat = document.getElementById('btnRepeat')
|
|
||||||
shuffle = window.localStorage.getItem('furumi_shuffle') === '1'
|
|
||||||
repeatAll = window.localStorage.getItem('furumi_repeat') !== '0'
|
|
||||||
btnShuffle?.classList.toggle('active', shuffle)
|
|
||||||
btnRepeat?.classList.toggle('active', repeatAll)
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Audio events ---
|
|
||||||
audio.addEventListener('timeupdate', () => {
|
|
||||||
if (audio.duration) {
|
|
||||||
const fill = document.getElementById('progressFill')
|
|
||||||
const timeElapsed = document.getElementById('timeElapsed')
|
|
||||||
const timeDuration = document.getElementById('timeDuration')
|
|
||||||
if (fill) fill.style.width = `${(audio.currentTime / audio.duration) * 100}%`
|
|
||||||
if (timeElapsed) timeElapsed.textContent = fmt(audio.currentTime)
|
|
||||||
if (timeDuration) timeDuration.textContent = fmt(audio.duration)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
audio.addEventListener('ended', () => nextTrack())
|
|
||||||
audio.addEventListener('play', () => {
|
|
||||||
const btn = document.getElementById('btnPlayPause')
|
|
||||||
if (btn) btn.innerHTML = '⏸'
|
|
||||||
})
|
|
||||||
audio.addEventListener('pause', () => {
|
|
||||||
const btn = document.getElementById('btnPlayPause')
|
|
||||||
if (btn) btn.innerHTML = '▶'
|
|
||||||
})
|
|
||||||
audio.addEventListener('error', () => {
|
|
||||||
showToast('Playback error')
|
|
||||||
nextTrack()
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Library navigation ---
|
|
||||||
async function showArtists() {
|
async function showArtists() {
|
||||||
setBreadcrumb([{ label: 'Artists', action: showArtists }])
|
setBreadcrumb([{ label: 'Artists', action: showArtists }])
|
||||||
try {
|
try {
|
||||||
@@ -231,7 +235,6 @@ export function FurumiPlayer() {
|
|||||||
setBreadcrumbs(parts)
|
setBreadcrumbs(parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Queue management ---
|
|
||||||
function addTrackToQueue(
|
function addTrackToQueue(
|
||||||
track: {
|
track: {
|
||||||
slug: string
|
slug: string
|
||||||
@@ -242,15 +245,11 @@ export function FurumiPlayer() {
|
|||||||
},
|
},
|
||||||
playNow?: boolean,
|
playNow?: boolean,
|
||||||
) {
|
) {
|
||||||
const existing = queue.findIndex((t) => t.slug === track.slug)
|
const prevIdx = store.getState().queue.currentIndex
|
||||||
if (existing !== -1) {
|
dispatch(addTrack({ track, playNow }))
|
||||||
if (playNow) playIndex(existing)
|
const q = store.getState().queue
|
||||||
return
|
if (q.currentIndex !== -1 && (playNow || q.currentIndex !== prevIdx)) {
|
||||||
}
|
playIndex(q.currentIndex)
|
||||||
setQueue((q) => [...q, track]);
|
|
||||||
updateQueueModel()
|
|
||||||
if (playNow || (queueIndex === -1 && queue.length === 1)) {
|
|
||||||
playIndex(queue.length - 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,23 +258,19 @@ export function FurumiPlayer() {
|
|||||||
if (result.meta.requestStatus === 'rejected') return
|
if (result.meta.requestStatus === 'rejected') return
|
||||||
const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
|
const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
|
||||||
if (!tracks || !tracks.length) return
|
if (!tracks || !tracks.length) return
|
||||||
const list = tracks
|
const list = tracks.map((t) => ({
|
||||||
let firstIdx = queue.length
|
slug: t.slug,
|
||||||
list.forEach((t) => {
|
title: t.title,
|
||||||
if (queue.find((q) => q.slug === t.slug)) return
|
artist: t.artist_name,
|
||||||
setQueue((q) => [
|
album_slug: t.album_slug,
|
||||||
...q,
|
duration: t.duration_secs,
|
||||||
{
|
}))
|
||||||
slug: t.slug,
|
const prevIdx = store.getState().queue.currentIndex
|
||||||
title: t.title,
|
dispatch(addTracksBatch({ tracks: list, playFirst }))
|
||||||
artist: t.artist_name,
|
const q = store.getState().queue
|
||||||
album_slug: t.album_slug,
|
if (q.currentIndex !== -1 && q.currentIndex !== prevIdx) {
|
||||||
duration: t.duration_secs,
|
playIndex(q.currentIndex)
|
||||||
},
|
}
|
||||||
])
|
|
||||||
})
|
|
||||||
updateQueueModel()
|
|
||||||
if (playFirst || queueIndex === -1) playIndex(firstIdx)
|
|
||||||
showToast(`Added ${list.length} tracks`)
|
showToast(`Added ${list.length} tracks`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,29 +279,30 @@ export function FurumiPlayer() {
|
|||||||
if (result.meta.requestStatus === 'rejected') return
|
if (result.meta.requestStatus === 'rejected') return
|
||||||
const { tracks } = result.payload as { artistSlug: string; tracks: Track[] }
|
const { tracks } = result.payload as { artistSlug: string; tracks: Track[] }
|
||||||
if (!tracks || !tracks.length) return
|
if (!tracks || !tracks.length) return
|
||||||
const list = tracks
|
const list = tracks.map((t) => ({
|
||||||
clearQueue()
|
|
||||||
setQueue(list.map((t) => ({
|
|
||||||
slug: t.slug,
|
slug: t.slug,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
artist: t.artist_name,
|
artist: t.artist_name,
|
||||||
album_slug: t.album_slug,
|
album_slug: t.album_slug,
|
||||||
duration: t.duration_secs,
|
duration: t.duration_secs,
|
||||||
})))
|
}))
|
||||||
updateQueueModel()
|
dispatch(replaceQueue({ items: list, playFromIndex: 0 }))
|
||||||
playIndex(0)
|
playIndex(0)
|
||||||
showToast(`Added ${list.length} tracks`)
|
showToast(`Added ${list.length} tracks`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const playback = attachAudioPlayback(audio, {
|
||||||
|
onEnded: nextTrack,
|
||||||
|
onErrorSkip: nextTrack,
|
||||||
|
onToast: showToast,
|
||||||
|
})
|
||||||
|
|
||||||
function playIndex(i: number) {
|
function playIndex(i: number) {
|
||||||
if (i < 0 || i >= queue.length) return
|
const q = store.getState().queue
|
||||||
queueIndex = i
|
if (i < 0 || i >= q.items.length) return
|
||||||
const track = queue[i]
|
dispatch(playAtIndex(i))
|
||||||
audio.src = `${API_ROOT}/stream/${track.slug}`
|
const track = store.getState().queue.items[i]
|
||||||
void audio.play().catch(() => {})
|
void playback.loadStreamForTrack(track.slug)
|
||||||
updateNowPlaying(track)
|
|
||||||
updateQueueModel()
|
|
||||||
setQueueScrollSignal((s) => s + 1)
|
|
||||||
if (window.history && window.history.replaceState) {
|
if (window.history && window.history.replaceState) {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.set('t', track.slug)
|
url.searchParams.set('t', track.slug)
|
||||||
@@ -314,88 +310,49 @@ export function FurumiPlayer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateNowPlaying(track: QueueItem | null) {
|
|
||||||
setNowPlayingTrack(track)
|
|
||||||
if (!track) return
|
|
||||||
|
|
||||||
document.title = `${track.title} — Furumi`
|
|
||||||
|
|
||||||
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
navigator.mediaSession.metadata = new window.MediaMetadata({
|
|
||||||
title: track.title,
|
|
||||||
artist: track.artist || '',
|
|
||||||
album: '',
|
|
||||||
artwork: [{ src: coverUrl, sizes: '512x512' }],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentOrder() {
|
|
||||||
if (!shuffle) return [...Array(queue.length).keys()]
|
|
||||||
if (shuffleOrder.length !== queue.length) buildShuffleOrder()
|
|
||||||
return shuffleOrder
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildShuffleOrder() {
|
|
||||||
shuffleOrder = [...Array(queue.length).keys()]
|
|
||||||
for (let i = shuffleOrder.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1))
|
|
||||||
;[shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]]
|
|
||||||
}
|
|
||||||
if (queueIndex !== -1) {
|
|
||||||
const ci = shuffleOrder.indexOf(queueIndex)
|
|
||||||
if (ci > 0) {
|
|
||||||
shuffleOrder.splice(ci, 1)
|
|
||||||
shuffleOrder.unshift(queueIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateQueueModel() {
|
|
||||||
const order = currentOrder()
|
|
||||||
setQueueItemsView(queue)
|
|
||||||
setQueueOrderView(order.slice())
|
|
||||||
setQueuePlayingOrigIdxView(queueIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFromQueue(idx: number) {
|
function removeFromQueue(idx: number) {
|
||||||
if (idx === queueIndex) {
|
const wasPlaying = store.getState().queue.currentIndex === idx
|
||||||
queueIndex = -1
|
dispatch(removeFromQueueAt(idx))
|
||||||
audio.pause()
|
if (wasPlaying) playback.pauseAndClearSource()
|
||||||
audio.src = ''
|
|
||||||
updateNowPlaying(null)
|
|
||||||
} else if (queueIndex > idx) {
|
|
||||||
queueIndex--
|
|
||||||
}
|
|
||||||
|
|
||||||
// queue.splice(idx, 1)
|
|
||||||
setQueue((q) => q.filter((_, i) => i !== idx));
|
|
||||||
|
|
||||||
if (shuffle) {
|
|
||||||
const si = shuffleOrder.indexOf(idx)
|
|
||||||
if (si !== -1) shuffleOrder.splice(si, 1)
|
|
||||||
for (let i = 0; i < shuffleOrder.length; i++) {
|
|
||||||
if (shuffleOrder[i] > idx) shuffleOrder[i]--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateQueueModel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveQueueItem(from: number, to: number) {
|
function moveQueueItem(fromPos: number, toPos: number) {
|
||||||
if (from === to) return
|
dispatch(moveQueueItemInOrder({ fromPos, toPos }))
|
||||||
if (shuffle) {
|
}
|
||||||
const item = shuffleOrder.splice(from, 1)[0]
|
|
||||||
shuffleOrder.splice(to, 0, item)
|
function clearQueuePlayback() {
|
||||||
} else {
|
dispatch(clearQueue())
|
||||||
const item = queue.splice(from, 1)[0]
|
playback.pauseAndClearSource()
|
||||||
queue.splice(to, 0, item)
|
}
|
||||||
if (queueIndex === from) queueIndex = to
|
|
||||||
else if (from < queueIndex && to >= queueIndex) queueIndex--
|
function nextTrack() {
|
||||||
else if (from > queueIndex && to <= queueIndex) queueIndex++
|
const q = store.getState().queue
|
||||||
|
if (!q.items.length) return
|
||||||
|
const order = selectQueueOrder(store.getState())
|
||||||
|
const pos = order.indexOf(q.currentIndex)
|
||||||
|
if (pos < order.length - 1) playIndex(order[pos + 1])
|
||||||
|
else if (q.repeatAll) {
|
||||||
|
if (q.shuffle) dispatch(rebuildShuffleOrder())
|
||||||
|
const first = selectQueueOrder(store.getState())[0]
|
||||||
|
if (first !== undefined) playIndex(first)
|
||||||
}
|
}
|
||||||
updateQueueModel()
|
}
|
||||||
|
|
||||||
|
function prevTrack() {
|
||||||
|
const q = store.getState().queue
|
||||||
|
if (!q.items.length) return
|
||||||
|
if (playback.rewindCurrentTrackIfPastThreshold()) return
|
||||||
|
const order = selectQueueOrder(store.getState())
|
||||||
|
const pos = order.indexOf(q.currentIndex)
|
||||||
|
if (pos > 0) playIndex(order[pos - 1])
|
||||||
|
else if (q.repeatAll) playIndex(order[order.length - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlay() {
|
||||||
|
const q = store.getState().queue
|
||||||
|
playback.togglePlay(() => {
|
||||||
|
playIndex(q.currentIndex === -1 ? 0 : q.currentIndex)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
queueActionsRef.current = {
|
queueActionsRef.current = {
|
||||||
@@ -404,91 +361,14 @@ export function FurumiPlayer() {
|
|||||||
moveQueueItem,
|
moveQueueItem,
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearQueue() {
|
function onToggleShuffle() {
|
||||||
setQueue([]);
|
dispatch(toggleShuffle())
|
||||||
queueIndex = -1
|
|
||||||
shuffleOrder = []
|
|
||||||
audio.pause()
|
|
||||||
audio.src = ''
|
|
||||||
updateNowPlaying(null)
|
|
||||||
document.title = 'Furumi Player'
|
|
||||||
updateQueueModel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Playback controls ---
|
function onToggleRepeat() {
|
||||||
function togglePlay() {
|
dispatch(toggleRepeat())
|
||||||
if (!audio.src && queue.length) {
|
|
||||||
playIndex(queueIndex === -1 ? 0 : queueIndex)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (audio.paused) void audio.play()
|
|
||||||
else audio.pause()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextTrack() {
|
|
||||||
if (!queue.length) return
|
|
||||||
const order = currentOrder()
|
|
||||||
const pos = order.indexOf(queueIndex)
|
|
||||||
if (pos < order.length - 1) playIndex(order[pos + 1])
|
|
||||||
else if (repeatAll) {
|
|
||||||
if (shuffle) buildShuffleOrder()
|
|
||||||
playIndex(currentOrder()[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prevTrack() {
|
|
||||||
if (!queue.length) return
|
|
||||||
if (audio.currentTime > 3) {
|
|
||||||
audio.currentTime = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const order = currentOrder()
|
|
||||||
const pos = order.indexOf(queueIndex)
|
|
||||||
if (pos > 0) playIndex(order[pos - 1])
|
|
||||||
else if (repeatAll) playIndex(order[order.length - 1])
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleShuffle() {
|
|
||||||
shuffle = !shuffle
|
|
||||||
if (shuffle) buildShuffleOrder()
|
|
||||||
const btn = document.getElementById('btnShuffle')
|
|
||||||
btn?.classList.toggle('active', shuffle)
|
|
||||||
window.localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0')
|
|
||||||
updateQueueModel()
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRepeat() {
|
|
||||||
repeatAll = !repeatAll
|
|
||||||
const btn = document.getElementById('btnRepeat')
|
|
||||||
btn?.classList.toggle('active', repeatAll)
|
|
||||||
window.localStorage.setItem('furumi_repeat', repeatAll ? '1' : '0')
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Seek & Volume ---
|
|
||||||
function seekTo(e: MouseEvent) {
|
|
||||||
if (!audio.duration) return
|
|
||||||
const bar = document.getElementById('progressBar') as HTMLDivElement | null
|
|
||||||
if (!bar) return
|
|
||||||
const rect = bar.getBoundingClientRect()
|
|
||||||
const pct = (e.clientX - rect.left) / rect.width
|
|
||||||
audio.currentTime = pct * audio.duration
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMute() {
|
|
||||||
muted = !muted
|
|
||||||
audio.muted = muted
|
|
||||||
const volIcon = document.getElementById('volIcon')
|
|
||||||
if (volIcon) volIcon.innerHTML = muted ? '🔇' : '🔊'
|
|
||||||
}
|
|
||||||
|
|
||||||
function setVolume(v: number) {
|
|
||||||
audio.volume = v / 100
|
|
||||||
const volIcon = document.getElementById('volIcon')
|
|
||||||
if (volIcon) volIcon.innerHTML = v === 0 ? '🔇' : '🔊'
|
|
||||||
window.localStorage.setItem('furumi_vol', String(v))
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Search ---
|
|
||||||
function onSearch(q: string) {
|
function onSearch(q: string) {
|
||||||
if (searchTimer) {
|
if (searchTimer) {
|
||||||
window.clearTimeout(searchTimer)
|
window.clearTimeout(searchTimer)
|
||||||
@@ -527,16 +407,6 @@ export function FurumiPlayer() {
|
|||||||
}
|
}
|
||||||
searchSelectRef.current = onSearchSelect
|
searchSelectRef.current = onSearchSelect
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
function showToast(msg: string) {
|
|
||||||
const t = document.getElementById('toast')
|
|
||||||
if (!t) return
|
|
||||||
t.textContent = msg
|
|
||||||
t.classList.add('show')
|
|
||||||
if (toastTimer) window.clearTimeout(toastTimer)
|
|
||||||
toastTimer = window.setTimeout(() => t.classList.remove('show'), 2500)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
const sidebar = document.getElementById('sidebar')
|
const sidebar = document.getElementById('sidebar')
|
||||||
const overlay = document.getElementById('sidebarOverlay')
|
const overlay = document.getElementById('sidebarOverlay')
|
||||||
@@ -544,7 +414,6 @@ export function FurumiPlayer() {
|
|||||||
overlay?.classList.toggle('show')
|
overlay?.classList.toggle('show')
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- MediaSession ---
|
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
try {
|
try {
|
||||||
navigator.mediaSession.setActionHandler('play', togglePlay)
|
navigator.mediaSession.setActionHandler('play', togglePlay)
|
||||||
@@ -553,7 +422,7 @@ export function FurumiPlayer() {
|
|||||||
navigator.mediaSession.setActionHandler('nexttrack', nextTrack)
|
navigator.mediaSession.setActionHandler('nexttrack', nextTrack)
|
||||||
navigator.mediaSession.setActionHandler('seekto', (d: any) => {
|
navigator.mediaSession.setActionHandler('seekto', (d: any) => {
|
||||||
if (typeof d.seekTime === 'number') {
|
if (typeof d.seekTime === 'number') {
|
||||||
audio.currentTime = d.seekTime
|
playback.seekToTime(d.seekTime)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
@@ -561,7 +430,6 @@ export function FurumiPlayer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Wire DOM events that were inline in HTML ---
|
|
||||||
const btnMenu = document.querySelector('.btn-menu')
|
const btnMenu = document.querySelector('.btn-menu')
|
||||||
btnMenu?.addEventListener('click', () => toggleSidebar())
|
btnMenu?.addEventListener('click', () => toggleSidebar())
|
||||||
|
|
||||||
@@ -579,11 +447,11 @@ export function FurumiPlayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const btnShuffle = document.getElementById('btnShuffle')
|
const btnShuffle = document.getElementById('btnShuffle')
|
||||||
btnShuffle?.addEventListener('click', () => toggleShuffle())
|
btnShuffle?.addEventListener('click', () => onToggleShuffle())
|
||||||
const btnRepeat = document.getElementById('btnRepeat')
|
const btnRepeat = document.getElementById('btnRepeat')
|
||||||
btnRepeat?.addEventListener('click', () => toggleRepeat())
|
btnRepeat?.addEventListener('click', () => onToggleRepeat())
|
||||||
const btnClear = document.getElementById('btnClearQueue')
|
const btnClear = document.getElementById('btnClearQueue')
|
||||||
btnClear?.addEventListener('click', () => clearQueue())
|
btnClear?.addEventListener('click', () => clearQueuePlayback())
|
||||||
|
|
||||||
const btnPrev = document.getElementById('btnPrev')
|
const btnPrev = document.getElementById('btnPrev')
|
||||||
btnPrev?.addEventListener('click', () => prevTrack())
|
btnPrev?.addEventListener('click', () => prevTrack())
|
||||||
@@ -592,52 +460,34 @@ export function FurumiPlayer() {
|
|||||||
const btnNext = document.getElementById('btnNext')
|
const btnNext = document.getElementById('btnNext')
|
||||||
btnNext?.addEventListener('click', () => nextTrack())
|
btnNext?.addEventListener('click', () => nextTrack())
|
||||||
|
|
||||||
const progressBar = document.getElementById('progressBar')
|
; (async () => {
|
||||||
progressBar?.addEventListener('click', (e) => seekTo(e as MouseEvent))
|
const url = new URL(window.location.href)
|
||||||
|
const urlSlug = url.searchParams.get('t')
|
||||||
const volIcon = document.getElementById('volIcon')
|
if (urlSlug) {
|
||||||
volIcon?.addEventListener('click', () => toggleMute())
|
try {
|
||||||
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap()
|
||||||
if (volSlider) {
|
addTrackToQueue(
|
||||||
volSlider.addEventListener('input', (e) => {
|
{
|
||||||
const v = Number((e.target as HTMLInputElement).value)
|
slug: detail.slug,
|
||||||
setVolume(v)
|
title: detail.title,
|
||||||
})
|
artist: detail.artist_name,
|
||||||
}
|
album_slug: detail.album_slug,
|
||||||
|
duration: detail.duration_secs,
|
||||||
const clearQueueBtn = document.getElementById('btnClearQueue')
|
},
|
||||||
clearQueueBtn?.addEventListener('click', () => clearQueue())
|
true,
|
||||||
|
)
|
||||||
// --- Init ---
|
} catch {
|
||||||
;(async () => {
|
// fetchTrackDetail rejected — track not found or error
|
||||||
const url = new URL(window.location.href)
|
}
|
||||||
const urlSlug = url.searchParams.get('t')
|
|
||||||
if (urlSlug) {
|
|
||||||
try {
|
|
||||||
const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap()
|
|
||||||
addTrackToQueue(
|
|
||||||
{
|
|
||||||
slug: detail.slug,
|
|
||||||
title: detail.title,
|
|
||||||
artist: detail.artist_name,
|
|
||||||
album_slug: detail.album_slug,
|
|
||||||
duration: detail.duration_secs,
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
// fetchTrackDetail rejected — track not found or error
|
|
||||||
}
|
}
|
||||||
}
|
void showArtists()
|
||||||
void showArtists()
|
})()
|
||||||
})()
|
|
||||||
|
|
||||||
// Cleanup: best-effort remove listeners on unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
queueActionsRef.current = null
|
queueActionsRef.current = null
|
||||||
audio.pause()
|
playback.dispose()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [dispatch])
|
||||||
|
|
||||||
const libraryLoading =
|
const libraryLoading =
|
||||||
breadcrumbs.length === 1
|
breadcrumbs.length === 1
|
||||||
@@ -686,4 +536,3 @@ export function FurumiPlayer() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { preloadStream } from './furumiApi'
|
||||||
|
import { fmt } from './utils'
|
||||||
|
|
||||||
|
const MAX_PLAYBACK_ERROR_SKIPS = 5
|
||||||
|
|
||||||
|
/** Seconds from track start above which "previous" rewinds current track instead. */
|
||||||
|
const PREV_TRACK_REWIND_THRESHOLD_SEC = 3
|
||||||
|
|
||||||
|
export interface AudioPlaybackCallbacks {
|
||||||
|
onEnded: () => void
|
||||||
|
/** Called after a recoverable playback error (to advance queue). */
|
||||||
|
onErrorSkip: () => void
|
||||||
|
onToast: (msg: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioPlaybackHandle {
|
||||||
|
loadStreamForTrack(slug: string): Promise<void>
|
||||||
|
pauseAndClearSource(): void
|
||||||
|
togglePlay(whenNoSource: () => void): void
|
||||||
|
seekFromProgressBarClick(e: MouseEvent): void
|
||||||
|
toggleMute(): void
|
||||||
|
setVolume(percent: number): void
|
||||||
|
seekToTime(seconds: number): void
|
||||||
|
/** If current time is past the threshold, seeks to 0 and returns true (caller should skip prev-track logic). */
|
||||||
|
rewindCurrentTrackIfPastThreshold(): boolean
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVolumeFromStorage(audio: HTMLAudioElement) {
|
||||||
|
try {
|
||||||
|
const v = window.localStorage.getItem('furumi_vol')
|
||||||
|
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
||||||
|
if (v !== null && volSlider) {
|
||||||
|
audio.volume = Number(v) / 100
|
||||||
|
volSlider.value = v
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachAudioPlayback(
|
||||||
|
audio: HTMLAudioElement,
|
||||||
|
callbacks: AudioPlaybackCallbacks,
|
||||||
|
): AudioPlaybackHandle {
|
||||||
|
let muted = false
|
||||||
|
let playbackErrorSkips = 0
|
||||||
|
|
||||||
|
syncVolumeFromStorage(audio)
|
||||||
|
|
||||||
|
function onTimeUpdate() {
|
||||||
|
if (!audio.duration) return
|
||||||
|
const fill = document.getElementById('progressFill')
|
||||||
|
const timeElapsed = document.getElementById('timeElapsed')
|
||||||
|
const timeDuration = document.getElementById('timeDuration')
|
||||||
|
if (fill) fill.style.width = `${(audio.currentTime / audio.duration) * 100}%`
|
||||||
|
if (timeElapsed) timeElapsed.textContent = fmt(audio.currentTime)
|
||||||
|
if (timeDuration) timeDuration.textContent = fmt(audio.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlayPauseButtonPlaying(playing: boolean) {
|
||||||
|
const btn = document.getElementById('btnPlayPause')
|
||||||
|
if (btn) btn.innerHTML = playing ? '⏸' : '▶'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlaying() {
|
||||||
|
playbackErrorSkips = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlay() {
|
||||||
|
setPlayPauseButtonPlaying(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPause() {
|
||||||
|
setPlayPauseButtonPlaying(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError() {
|
||||||
|
callbacks.onToast('Playback error')
|
||||||
|
if (playbackErrorSkips >= MAX_PLAYBACK_ERROR_SKIPS) return
|
||||||
|
playbackErrorSkips += 1
|
||||||
|
callbacks.onErrorSkip()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnded() {
|
||||||
|
callbacks.onEnded()
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', onTimeUpdate)
|
||||||
|
audio.addEventListener('ended', onEnded)
|
||||||
|
audio.addEventListener('playing', onPlaying)
|
||||||
|
audio.addEventListener('play', onPlay)
|
||||||
|
audio.addEventListener('pause', onPause)
|
||||||
|
audio.addEventListener('error', onError)
|
||||||
|
|
||||||
|
const progressBar = document.getElementById('progressBar')
|
||||||
|
const onProgressClick = (e: Event) => seekFromProgressBarClick(e as MouseEvent)
|
||||||
|
progressBar?.addEventListener('click', onProgressClick)
|
||||||
|
|
||||||
|
const volIcon = document.getElementById('volIcon')
|
||||||
|
const onVolIconClick = () => toggleMute()
|
||||||
|
volIcon?.addEventListener('click', onVolIconClick)
|
||||||
|
|
||||||
|
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
||||||
|
const onVolInput = (e: Event) => {
|
||||||
|
const v = Number((e.target as HTMLInputElement).value)
|
||||||
|
setVolume(v)
|
||||||
|
}
|
||||||
|
volSlider?.addEventListener('input', onVolInput)
|
||||||
|
|
||||||
|
async function loadStreamForTrack(slug: string) {
|
||||||
|
const response = await preloadStream(slug)
|
||||||
|
audio.src = URL.createObjectURL(response?.data)
|
||||||
|
await audio.play().catch(() => { })
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseAndClearSource() {
|
||||||
|
audio.pause()
|
||||||
|
audio.src = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlay(whenNoSource: () => void) {
|
||||||
|
if (!audio.src) {
|
||||||
|
whenNoSource()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (audio.paused) void audio.play()
|
||||||
|
else audio.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekFromProgressBarClick(e: MouseEvent) {
|
||||||
|
if (!audio.duration) return
|
||||||
|
const bar = document.getElementById('progressBar') as HTMLDivElement | null
|
||||||
|
if (!bar) return
|
||||||
|
const rect = bar.getBoundingClientRect()
|
||||||
|
const pct = (e.clientX - rect.left) / rect.width
|
||||||
|
audio.currentTime = pct * audio.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
muted = !muted
|
||||||
|
audio.muted = muted
|
||||||
|
const icon = document.getElementById('volIcon')
|
||||||
|
if (icon) icon.innerHTML = muted ? '🔇' : '🔊'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolume(percent: number) {
|
||||||
|
audio.volume = percent / 100
|
||||||
|
const icon = document.getElementById('volIcon')
|
||||||
|
if (icon) icon.innerHTML = percent === 0 ? '🔇' : '🔊'
|
||||||
|
window.localStorage.setItem('furumi_vol', String(percent))
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekToTime(seconds: number) {
|
||||||
|
audio.currentTime = seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewindCurrentTrackIfPastThreshold(): boolean {
|
||||||
|
if (audio.currentTime > PREV_TRACK_REWIND_THRESHOLD_SEC) {
|
||||||
|
audio.currentTime = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
audio.removeEventListener('timeupdate', onTimeUpdate)
|
||||||
|
audio.removeEventListener('ended', onEnded)
|
||||||
|
audio.removeEventListener('playing', onPlaying)
|
||||||
|
audio.removeEventListener('play', onPlay)
|
||||||
|
audio.removeEventListener('pause', onPause)
|
||||||
|
audio.removeEventListener('error', onError)
|
||||||
|
progressBar?.removeEventListener('click', onProgressClick)
|
||||||
|
volIcon?.removeEventListener('click', onVolIconClick)
|
||||||
|
volSlider?.removeEventListener('input', onVolInput)
|
||||||
|
audio.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadStreamForTrack,
|
||||||
|
pauseAndClearSource,
|
||||||
|
togglePlay,
|
||||||
|
seekFromProgressBarClick,
|
||||||
|
toggleMute,
|
||||||
|
setVolume,
|
||||||
|
seekToTime,
|
||||||
|
rewindCurrentTrackIfPastThreshold,
|
||||||
|
dispose,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,6 @@ export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | nul
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function preloadStream(trackSlug: string) {
|
export async function preloadStream(trackSlug: string) {
|
||||||
await furumiApi.get(`/stream/${trackSlug}`).catch(() => null)
|
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import albumsReducer from './slices/albumsSlice'
|
|||||||
import albumTracksReducer from './slices/albumTracksSlice'
|
import albumTracksReducer from './slices/albumTracksSlice'
|
||||||
import artistTracksReducer from './slices/artistTracksSlice'
|
import artistTracksReducer from './slices/artistTracksSlice'
|
||||||
import trackDetailReducer from './slices/trackDetailSlice'
|
import trackDetailReducer from './slices/trackDetailSlice'
|
||||||
|
import queueReducer from './slices/queueSlice'
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
@@ -13,6 +14,7 @@ export const store = configureStore({
|
|||||||
albumTracks: albumTracksReducer,
|
albumTracks: albumTracksReducer,
|
||||||
artistTracks: artistTracksReducer,
|
artistTracks: artistTracksReducer,
|
||||||
trackDetail: trackDetailReducer,
|
trackDetail: trackDetailReducer,
|
||||||
|
queue: queueReducer,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
import type { QueueItem } from '../../components/QueueList'
|
||||||
|
|
||||||
|
export interface QueueState {
|
||||||
|
items: QueueItem[]
|
||||||
|
currentIndex: number
|
||||||
|
shuffle: boolean
|
||||||
|
repeatAll: boolean
|
||||||
|
shuffleOrder: number[]
|
||||||
|
scrollSignal: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function readShufflePref(): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem('furumi_shuffle') === '1'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRepeatPref(): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem('furumi_repeat') !== '0'
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShuffleOrder(state: QueueState) {
|
||||||
|
const n = state.items.length
|
||||||
|
if (n === 0) {
|
||||||
|
state.shuffleOrder = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const order = [...Array(n).keys()]
|
||||||
|
for (let i = order.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[order[i], order[j]] = [order[j], order[i]]
|
||||||
|
}
|
||||||
|
if (state.currentIndex !== -1) {
|
||||||
|
const ci = order.indexOf(state.currentIndex)
|
||||||
|
if (ci > 0) {
|
||||||
|
order.splice(ci, 1)
|
||||||
|
order.unshift(state.currentIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.shuffleOrder = order
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureShuffleOrder(state: QueueState) {
|
||||||
|
if (!state.shuffle) return
|
||||||
|
if (state.shuffleOrder.length !== state.items.length) {
|
||||||
|
buildShuffleOrder(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: QueueState = {
|
||||||
|
items: [],
|
||||||
|
currentIndex: -1,
|
||||||
|
shuffle: typeof window !== 'undefined' ? readShufflePref() : false,
|
||||||
|
repeatAll: typeof window !== 'undefined' ? readRepeatPref() : true,
|
||||||
|
shuffleOrder: [],
|
||||||
|
scrollSignal: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueSlice = createSlice({
|
||||||
|
name: 'queue',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
addTrack(
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
track: QueueItem
|
||||||
|
playNow?: boolean
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const { track, playNow } = action.payload
|
||||||
|
const existing = state.items.findIndex((t) => t.slug === track.slug)
|
||||||
|
if (existing !== -1) {
|
||||||
|
if (playNow) {
|
||||||
|
state.currentIndex = existing
|
||||||
|
state.scrollSignal += 1
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const oldLen = state.items.length
|
||||||
|
const idle = state.currentIndex === -1
|
||||||
|
state.items.push(track)
|
||||||
|
ensureShuffleOrder(state)
|
||||||
|
if (playNow || (oldLen === 0 && idle)) {
|
||||||
|
state.currentIndex = state.items.length - 1
|
||||||
|
state.scrollSignal += 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addTracksBatch(
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
tracks: QueueItem[]
|
||||||
|
playFirst?: boolean
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const { tracks, playFirst } = action.payload
|
||||||
|
let firstNewIdx: number | null = null
|
||||||
|
for (const t of tracks) {
|
||||||
|
if (state.items.some((q) => q.slug === t.slug)) continue
|
||||||
|
if (firstNewIdx === null) firstNewIdx = state.items.length
|
||||||
|
state.items.push(t)
|
||||||
|
}
|
||||||
|
ensureShuffleOrder(state)
|
||||||
|
if (firstNewIdx === null) return
|
||||||
|
if (playFirst || state.currentIndex === -1) {
|
||||||
|
state.currentIndex = firstNewIdx
|
||||||
|
state.scrollSignal += 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
replaceQueue(
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
items: QueueItem[]
|
||||||
|
playFromIndex?: number
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const { items, playFromIndex = 0 } = action.payload
|
||||||
|
state.items = items
|
||||||
|
state.currentIndex =
|
||||||
|
items.length > 0 ? Math.min(playFromIndex, items.length - 1) : -1
|
||||||
|
state.shuffleOrder = []
|
||||||
|
ensureShuffleOrder(state)
|
||||||
|
},
|
||||||
|
|
||||||
|
clearQueue(state) {
|
||||||
|
state.items = []
|
||||||
|
state.currentIndex = -1
|
||||||
|
state.shuffleOrder = []
|
||||||
|
state.scrollSignal += 1
|
||||||
|
},
|
||||||
|
|
||||||
|
playAtIndex(state, action: PayloadAction<number>) {
|
||||||
|
const i = action.payload
|
||||||
|
if (i < 0 || i >= state.items.length) return
|
||||||
|
state.currentIndex = i
|
||||||
|
state.scrollSignal += 1
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromQueueAt(state, action: PayloadAction<number>) {
|
||||||
|
const idx = action.payload
|
||||||
|
if (idx < 0 || idx >= state.items.length) return
|
||||||
|
|
||||||
|
if (idx === state.currentIndex) {
|
||||||
|
state.currentIndex = -1
|
||||||
|
} else if (state.currentIndex > idx) {
|
||||||
|
state.currentIndex -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
state.items.splice(idx, 1)
|
||||||
|
|
||||||
|
if (state.shuffle) {
|
||||||
|
const si = state.shuffleOrder.indexOf(idx)
|
||||||
|
if (si !== -1) state.shuffleOrder.splice(si, 1)
|
||||||
|
for (let i = 0; i < state.shuffleOrder.length; i++) {
|
||||||
|
if (state.shuffleOrder[i] > idx) state.shuffleOrder[i] -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureShuffleOrder(state)
|
||||||
|
},
|
||||||
|
|
||||||
|
moveQueueItemInOrder(
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ fromPos: number; toPos: number }>,
|
||||||
|
) {
|
||||||
|
const { fromPos, toPos } = action.payload
|
||||||
|
if (fromPos === toPos) return
|
||||||
|
|
||||||
|
if (state.shuffle) {
|
||||||
|
const order = state.shuffleOrder
|
||||||
|
if (fromPos < 0 || fromPos >= order.length) return
|
||||||
|
if (toPos < 0 || toPos >= order.length) return
|
||||||
|
const item = order.splice(fromPos, 1)[0]
|
||||||
|
order.splice(toPos, 0, item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = state.items
|
||||||
|
if (fromPos < 0 || fromPos >= items.length) return
|
||||||
|
if (toPos < 0 || toPos >= items.length) return
|
||||||
|
const qIdx = state.currentIndex
|
||||||
|
const item = items.splice(fromPos, 1)[0]
|
||||||
|
items.splice(toPos, 0, item)
|
||||||
|
if (qIdx === fromPos) state.currentIndex = toPos
|
||||||
|
else if (fromPos < qIdx && toPos >= qIdx) state.currentIndex -= 1
|
||||||
|
else if (fromPos > qIdx && toPos <= qIdx) state.currentIndex += 1
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleShuffle(state) {
|
||||||
|
state.shuffle = !state.shuffle
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('furumi_shuffle', state.shuffle ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (state.shuffle) buildShuffleOrder(state)
|
||||||
|
else state.shuffleOrder = []
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleRepeat(state) {
|
||||||
|
state.repeatAll = !state.repeatAll
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('furumi_repeat', state.repeatAll ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
rebuildShuffleOrder(state) {
|
||||||
|
if (state.shuffle) buildShuffleOrder(state)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const {
|
||||||
|
addTrack,
|
||||||
|
addTracksBatch,
|
||||||
|
replaceQueue,
|
||||||
|
clearQueue,
|
||||||
|
playAtIndex,
|
||||||
|
removeFromQueueAt,
|
||||||
|
moveQueueItemInOrder,
|
||||||
|
toggleShuffle,
|
||||||
|
toggleRepeat,
|
||||||
|
rebuildShuffleOrder,
|
||||||
|
} = queueSlice.actions
|
||||||
|
|
||||||
|
type QueueSliceRoot = { queue: QueueState }
|
||||||
|
|
||||||
|
export function selectQueueItems(state: QueueSliceRoot) {
|
||||||
|
return state.queue.items
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectQueueOrder(state: QueueSliceRoot): number[] {
|
||||||
|
const q = state.queue
|
||||||
|
if (!q.shuffle) return q.items.map((_, i) => i)
|
||||||
|
if (q.shuffleOrder.length !== q.items.length) {
|
||||||
|
return q.items.map((_, i) => i)
|
||||||
|
}
|
||||||
|
return q.shuffleOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectPlayingOrigIdx(state: QueueSliceRoot) {
|
||||||
|
return state.queue.currentIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectQueueScrollSignal(state: QueueSliceRoot) {
|
||||||
|
return state.queue.scrollSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectNowPlayingTrack(state: QueueSliceRoot): QueueItem | null {
|
||||||
|
const q = state.queue
|
||||||
|
if (q.currentIndex < 0 || q.currentIndex >= q.items.length) return null
|
||||||
|
return q.items[q.currentIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectShuffle(state: QueueSliceRoot) {
|
||||||
|
return state.queue.shuffle
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectRepeatAll(state: QueueSliceRoot) {
|
||||||
|
return state.queue.repeatAll
|
||||||
|
}
|
||||||
|
|
||||||
|
export default queueSlice.reducer
|
||||||
Reference in New Issue
Block a user