feat: create playback service
This commit is contained in:
@@ -1,10 +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 {
|
import { API_ROOT, searchTracks, preloadStream } from './furumiApi'
|
||||||
API_ROOT,
|
|
||||||
searchTracks,
|
|
||||||
preloadStream,
|
|
||||||
} 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'
|
||||||
@@ -28,6 +24,7 @@ import {
|
|||||||
selectNowPlayingTrack,
|
selectNowPlayingTrack,
|
||||||
selectQueueItems,
|
selectQueueItems,
|
||||||
} from './store/slices/queueSlice'
|
} 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'
|
||||||
@@ -117,50 +114,16 @@ export function FurumiPlayer() {
|
|||||||
|
|
||||||
let searchTimer: number | null = null
|
let searchTimer: number | null = null
|
||||||
let toastTimer: number | null = null
|
let toastTimer: number | null = null
|
||||||
let muted = false
|
|
||||||
let playbackErrorSkips = 0
|
|
||||||
const MAX_PLAYBACK_ERROR_SKIPS = 5
|
|
||||||
|
|
||||||
try {
|
function showToast(msg: string) {
|
||||||
const v = window.localStorage.getItem('furumi_vol')
|
const t = document.getElementById('toast')
|
||||||
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
if (!t) return
|
||||||
if (v !== null && volSlider) {
|
t.textContent = msg
|
||||||
audio.volume = Number(v) / 100
|
t.classList.add('show')
|
||||||
volSlider.value = v
|
if (toastTimer) window.clearTimeout(toastTimer)
|
||||||
}
|
toastTimer = window.setTimeout(() => t.classList.remove('show'), 2500)
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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('playing', () => {
|
|
||||||
playbackErrorSkips = 0
|
|
||||||
})
|
|
||||||
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')
|
|
||||||
if (playbackErrorSkips >= MAX_PLAYBACK_ERROR_SKIPS) return
|
|
||||||
playbackErrorSkips += 1
|
|
||||||
nextTrack()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function showArtists() {
|
async function showArtists() {
|
||||||
setBreadcrumb([{ label: 'Artists', action: showArtists }])
|
setBreadcrumb([{ label: 'Artists', action: showArtists }])
|
||||||
try {
|
try {
|
||||||
@@ -328,23 +291,18 @@ export function FurumiPlayer() {
|
|||||||
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) {
|
||||||
const q = store.getState().queue
|
const q = store.getState().queue
|
||||||
if (i < 0 || i >= q.items.length) return
|
if (i < 0 || i >= q.items.length) return
|
||||||
dispatch(playAtIndex(i))
|
dispatch(playAtIndex(i))
|
||||||
const track = store.getState().queue.items[i]
|
const track = store.getState().queue.items[i]
|
||||||
// TODO remove after auth refactor
|
void playback.loadStreamForTrack(track.slug)
|
||||||
preloadStream(track.slug).then(response => {
|
|
||||||
console.log('response', response)
|
|
||||||
audio.src = URL.createObjectURL(response?.data)
|
|
||||||
void audio.play().catch(() => { })
|
|
||||||
// Optionally revoke old object URL if needed to avoid memory leaks
|
|
||||||
// if (oldSrc?.startsWith('blob:')) {
|
|
||||||
// URL.revokeObjectURL(oldSrc)
|
|
||||||
// }
|
|
||||||
})
|
|
||||||
// audio.src = `${API_ROOT}/stream/${track.slug}`
|
|
||||||
// void audio.play().catch(() => {})
|
|
||||||
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)
|
||||||
@@ -355,36 +313,16 @@ export function FurumiPlayer() {
|
|||||||
function removeFromQueue(idx: number) {
|
function removeFromQueue(idx: number) {
|
||||||
const wasPlaying = store.getState().queue.currentIndex === idx
|
const wasPlaying = store.getState().queue.currentIndex === idx
|
||||||
dispatch(removeFromQueueAt(idx))
|
dispatch(removeFromQueueAt(idx))
|
||||||
if (wasPlaying) {
|
if (wasPlaying) playback.pauseAndClearSource()
|
||||||
audio.pause()
|
|
||||||
audio.src = ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveQueueItem(fromPos: number, toPos: number) {
|
function moveQueueItem(fromPos: number, toPos: number) {
|
||||||
dispatch(moveQueueItemInOrder({ fromPos, toPos }))
|
dispatch(moveQueueItemInOrder({ fromPos, toPos }))
|
||||||
}
|
}
|
||||||
|
|
||||||
queueActionsRef.current = {
|
|
||||||
playIndex,
|
|
||||||
removeFromQueue,
|
|
||||||
moveQueueItem,
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearQueuePlayback() {
|
function clearQueuePlayback() {
|
||||||
dispatch(clearQueue())
|
dispatch(clearQueue())
|
||||||
audio.pause()
|
playback.pauseAndClearSource()
|
||||||
audio.src = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePlay() {
|
|
||||||
const q = store.getState().queue
|
|
||||||
if (!audio.src && q.items.length) {
|
|
||||||
playIndex(q.currentIndex === -1 ? 0 : q.currentIndex)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (audio.paused) void audio.play()
|
|
||||||
else audio.pause()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextTrack() {
|
function nextTrack() {
|
||||||
@@ -403,16 +341,26 @@ export function FurumiPlayer() {
|
|||||||
function prevTrack() {
|
function prevTrack() {
|
||||||
const q = store.getState().queue
|
const q = store.getState().queue
|
||||||
if (!q.items.length) return
|
if (!q.items.length) return
|
||||||
if (audio.currentTime > 3) {
|
if (playback.rewindCurrentTrackIfPastThreshold()) return
|
||||||
audio.currentTime = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const order = selectQueueOrder(store.getState())
|
const order = selectQueueOrder(store.getState())
|
||||||
const pos = order.indexOf(q.currentIndex)
|
const pos = order.indexOf(q.currentIndex)
|
||||||
if (pos > 0) playIndex(order[pos - 1])
|
if (pos > 0) playIndex(order[pos - 1])
|
||||||
else if (q.repeatAll) playIndex(order[order.length - 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 = {
|
||||||
|
playIndex,
|
||||||
|
removeFromQueue,
|
||||||
|
moveQueueItem,
|
||||||
|
}
|
||||||
|
|
||||||
function onToggleShuffle() {
|
function onToggleShuffle() {
|
||||||
dispatch(toggleShuffle())
|
dispatch(toggleShuffle())
|
||||||
}
|
}
|
||||||
@@ -421,29 +369,6 @@ export function FurumiPlayer() {
|
|||||||
dispatch(toggleRepeat())
|
dispatch(toggleRepeat())
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSearch(q: string) {
|
function onSearch(q: string) {
|
||||||
if (searchTimer) {
|
if (searchTimer) {
|
||||||
window.clearTimeout(searchTimer)
|
window.clearTimeout(searchTimer)
|
||||||
@@ -482,15 +407,6 @@ export function FurumiPlayer() {
|
|||||||
}
|
}
|
||||||
searchSelectRef.current = onSearchSelect
|
searchSelectRef.current = onSearchSelect
|
||||||
|
|
||||||
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')
|
||||||
@@ -506,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 {
|
||||||
@@ -544,22 +460,6 @@ 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')
|
|
||||||
progressBar?.addEventListener('click', (e) => seekTo(e as MouseEvent))
|
|
||||||
|
|
||||||
const volIcon = document.getElementById('volIcon')
|
|
||||||
volIcon?.addEventListener('click', () => toggleMute())
|
|
||||||
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
|
|
||||||
if (volSlider) {
|
|
||||||
volSlider.addEventListener('input', (e) => {
|
|
||||||
const v = Number((e.target as HTMLInputElement).value)
|
|
||||||
setVolume(v)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearQueueBtn = document.getElementById('btnClearQueue')
|
|
||||||
clearQueueBtn?.addEventListener('click', () => clearQueuePlayback())
|
|
||||||
|
|
||||||
; (async () => {
|
; (async () => {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
const urlSlug = url.searchParams.get('t')
|
const urlSlug = url.searchParams.get('t')
|
||||||
@@ -585,7 +485,7 @@ export function FurumiPlayer() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
queueActionsRef.current = null
|
queueActionsRef.current = null
|
||||||
audio.pause()
|
playback.dispose()
|
||||||
}
|
}
|
||||||
}, [dispatch])
|
}, [dispatch])
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user