This commit is contained in:
louiscklaw
2025-02-01 01:16:09 +08:00
commit 91fab4a5d5
4178 changed files with 407527 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
import { Howl, HowlOptions } from "howler"
import { AudioLoadOptions } from "./types"
import { Action, ActionTypes } from "./audioPlayerState"
export type AudioActionCallback = (action: Action) => void
export class HowlInstanceManager {
private callbacks: Map<string, AudioActionCallback> = new Map()
private howl: Howl | undefined = undefined
private options: AudioLoadOptions = {}
private subscriptionIndex = 0
public subscribe(cb: AudioActionCallback): string {
const id = (this.subscriptionIndex++).toString()
this.callbacks.set(id, cb)
return id
}
public unsubscribe(subscriptionId: string) {
this.callbacks.delete(subscriptionId)
}
public getHowl() {
return this.howl
}
public getNumberOfConnections() {
return this.callbacks.size
}
public createHowl(options: { src: string } & AudioLoadOptions) {
this.destroyHowl()
this.options = options
const {
initialVolume,
initialRate,
initialMute,
...rest
} = this.options
const newHowl = new Howl({
mute: initialMute,
volume: initialVolume,
rate: initialRate,
...rest
} as HowlOptions)
this.callbacks.forEach(cb =>
cb({ type: ActionTypes.START_LOAD, howl: newHowl })
)
this.howl = newHowl
return newHowl
}
public destroyHowl() {
if (this.options.onload) {
this.howl?.off("load", this.options.onload)
}
if (this.options.onend) {
this.howl?.off("end", this.options.onend)
}
if (this.options.onplay) {
this.howl?.off("play", this.options.onplay)
}
if (this.options.onpause) {
this.howl?.off("pause", this.options.onpause)
}
if (this.options.onstop) {
this.howl?.off("stop", this.options.onstop)
}
this.howl?.unload()
}
public broadcast(action: Action) {
this.callbacks.forEach(cb => cb(action))
}
}
export class HowlInstanceManagerSingleton {
private static instance: HowlInstanceManager
public static getInstance() {
if (this.instance === undefined) {
HowlInstanceManagerSingleton.instance = new HowlInstanceManager()
}
return HowlInstanceManagerSingleton.instance
}
}

View File

@@ -0,0 +1,170 @@
import { Howl } from "howler"
export enum ActionTypes {
START_LOAD = "START_LOAD",
ON_LOAD = "ON_LOAD",
ON_ERROR = "ON_ERROR",
ON_PLAY = "ON_PLAY",
ON_PAUSE = "ON_PAUSE",
ON_STOP = "ON_STOP",
ON_END = "ON_END",
ON_RATE = "ON_RATE",
ON_MUTE = "ON_MUTE",
ON_VOLUME = "ON_VOLUME",
ON_LOOP = "ON_LOOP"
}
export type StartLoadAction = {
type: ActionTypes.START_LOAD
linkMediaSession?: boolean
howl: Howl
}
// TODO: the main state reducer should be decoupled from Howler
// to accomplish this, each action should describe the type of change using an abstraction rather than passing in the howl
export type AudioEventAction = {
type: Exclude<ActionTypes, ActionTypes.START_LOAD | ActionTypes.ON_ERROR>
howl: Howl
toggleValue?: boolean
debugId?: string
}
export type ErrorEvent = {
type: ActionTypes.ON_ERROR
message: string
}
export type Action = StartLoadAction | AudioEventAction | ErrorEvent
export interface AudioPlayerState {
src: string | null
looping: boolean
isReady: boolean
isLoading: boolean
paused: boolean
stopped: boolean
playing: boolean
duration: number
muted: boolean
rate: number
volume: number
error: string | null
}
export function initStateFromHowl(howl?: Howl): AudioPlayerState {
if (howl === undefined) {
return {
src: null,
isReady: false,
isLoading: false,
looping: false,
duration: 0,
rate: 1,
volume: 1,
muted: false,
playing: false,
paused: false,
stopped: false,
error: null
}
}
const position = howl.seek()
const playing = howl.playing()
return {
isReady: howl.state() === "loaded",
isLoading: howl.state() === "loading",
// @ts-ignore _src exists
src: howl._src,
looping: howl.loop(),
duration: howl.duration(),
rate: howl.rate(),
volume: howl.volume(),
muted: howl.mute(),
playing,
paused: !playing,
stopped: !playing && position === 0,
error: null
}
}
export function reducer(state: AudioPlayerState, action: Action) {
switch (action.type) {
case ActionTypes.START_LOAD:
return {
// when called without a Howl object it will return an empty/init state object
...initStateFromHowl(),
isLoading: true
}
case ActionTypes.ON_LOAD:
// in React 18 there is a weird race condition where ON_LOAD receives a Howl object that has been unloaded
// if we detect this case just return the existing state to wait for another action
if (action.howl.state() === "unloaded") {
return state
}
return initStateFromHowl(action.howl)
case ActionTypes.ON_ERROR:
return {
// this essentially resets state when called with undefined
...initStateFromHowl(),
error: action.message
}
case ActionTypes.ON_PLAY:
return {
...state,
playing: true,
paused: false,
stopped: false
}
case ActionTypes.ON_PAUSE:
return {
...state,
playing: false,
paused: true
}
case ActionTypes.ON_STOP: {
return {
...state,
playing: false,
paused: false,
stopped: true
}
}
case ActionTypes.ON_END: {
return {
...state,
playing: state.looping,
stopped: !state.looping
}
}
case ActionTypes.ON_MUTE: {
return {
...state,
muted: action.howl.mute() ?? false
}
}
case ActionTypes.ON_RATE: {
return {
...state,
rate: action.howl?.rate() ?? 1.0
}
}
case ActionTypes.ON_VOLUME: {
return {
...state,
volume: action.howl?.volume() ?? 1.0
}
}
case ActionTypes.ON_LOOP: {
const { toggleValue = false, howl } = action
howl.loop(toggleValue)
return {
...state,
looping: toggleValue
}
}
default:
return state
}
}

View File

@@ -0,0 +1,3 @@
export * from "./useAudioPlayer"
export * from "./useGlobalAudioPlayer"
export * from "./types"

View File

@@ -0,0 +1,36 @@
import { AudioPlayerState } from "./audioPlayerState"
export interface AudioPlayer extends AudioPlayerState {
play: () => void
pause: () => void
togglePlayPause: () => void
stop: () => void
setVolume: (volume: number) => void
fade: (from: number, to: number, duration: number) => void
setRate: (speed: number) => void
seek: (seconds: number) => void
mute: (muteOnOff: boolean) => void
loop: (loopOnOff: boolean) => void
getPosition: () => number
load: (...args: LoadArguments) => void
}
export interface UserListeners {
onstop?: () => void | undefined
onpause?: () => void | undefined
onload?: () => void | undefined
onend?: () => void | undefined
onplay?: () => void | undefined
}
export interface AudioLoadOptions extends UserListeners {
loop?: boolean
autoplay?: boolean
initialVolume?: number
initialMute?: boolean
initialRate?: number
format?: string
html5?: boolean
}
export type LoadArguments = [src: string, options?: AudioLoadOptions]

View File

@@ -0,0 +1,170 @@
import { useCallback, useReducer, useRef } from "react"
import {
ActionTypes,
initStateFromHowl,
reducer as audioStateReducer
} from "./audioPlayerState"
import { useHowlEventSync } from "./useHowlEventSync"
import { HowlInstanceManager } from "./HowlInstanceManager"
import { AudioPlayer, LoadArguments } from "./types"
export const useAudioPlayer = (): AudioPlayer & {
cleanup: VoidFunction
} => {
const howlManager = useRef<HowlInstanceManager | null>(null)
function getHowlManager() {
if (howlManager.current !== null) {
return howlManager.current
}
const manager = new HowlInstanceManager()
howlManager.current = manager
return manager
}
const [state, dispatch] = useHowlEventSync(
getHowlManager(),
useReducer(
audioStateReducer,
getHowlManager().getHowl(),
initStateFromHowl
)
)
const load = useCallback((...[src, options = {}]: LoadArguments) => {
// TODO investigate: if we try to avoid loading the same sound (existing howl & same src in call)
// then there are some bugs like in the MultipleSounds demo, the "play" button will not switch to "pause"
const howl = getHowlManager().createHowl({
src,
...options
})
dispatch({ type: ActionTypes.START_LOAD, howl })
}, [])
const seek = useCallback((seconds: number) => {
const howl = getHowlManager().getHowl()
if (howl === undefined) {
return
}
howl.seek(seconds)
}, [])
const getPosition = useCallback(() => {
const howl = getHowlManager().getHowl()
if (howl === undefined) {
return 0
}
return howl.seek() ?? 0
}, [])
const play = useCallback(() => {
const howl = getHowlManager().getHowl()
if (howl === undefined) {
return
}
howl.play()
}, [])
const pause = useCallback(() => {
const howl = getHowlManager().getHowl()
if (howl === undefined) {
return
}
howl.pause()
}, [])
const togglePlayPause = useCallback(() => {
const howl = getHowlManager().getHowl()
if (howl === undefined) {
return
}
if (state.playing) {
howl.pause()
} else {
howl.play()
}
}, [state])
const stop = useCallback(() => {
const howl = getHowlManager().getHowl()
if (howl === undefined) {
return
}
howl.stop()
}, [])
const fade = useCallback((from: number, to: number, duration: number) => {
const howl = getHowlManager().getHowl()
if (howl === undefined) {
return
}
howl.fade(from, to, duration)
}, [])
const setRate = useCallback((speed: number) => {
const howl = getHowlManager().getHowl()
if (howl === undefined) {
return
}
howl.rate(speed)
}, [])
const setVolume = useCallback((vol: number) => {
const howl = getHowlManager().getHowl()
if (howl === undefined) {
return
}
howl.volume(vol)
}, [])
const mute = useCallback((muteOnOff: boolean) => {
const howl = getHowlManager().getHowl()
if (howl === undefined) {
return
}
howl.mute(muteOnOff)
}, [])
const loop = useCallback((loopOnOff: boolean) => {
const howl = getHowlManager().getHowl()
if (howl === undefined) {
return
}
// this differs from the implementation in useGlobalAudioPlayer which needs to broadcast the action to itself and all other instances of the hook
// maybe these two behaviors could be abstracted with one interface in the future
dispatch({ type: ActionTypes.ON_LOOP, howl, toggleValue: loopOnOff })
}, [])
const cleanup = useCallback(() => {
getHowlManager()?.destroyHowl()
}, [])
return {
...state,
load,
seek,
getPosition,
play,
pause,
togglePlayPause,
stop,
mute,
fade,
setRate,
setVolume,
loop,
cleanup
}
}

View File

@@ -0,0 +1,174 @@
import { useCallback, useEffect, useReducer, useRef } from "react"
import {
Action,
ActionTypes,
initStateFromHowl,
reducer as audioStateReducer
} from "./audioPlayerState"
import { useHowlEventSync } from "./useHowlEventSync"
import { HowlInstanceManagerSingleton } from "./HowlInstanceManager"
import { AudioPlayer, LoadArguments } from "./types"
export function useGlobalAudioPlayer(): AudioPlayer {
const howlManager = useRef(HowlInstanceManagerSingleton.getInstance())
const [state, dispatch] = useHowlEventSync(
howlManager.current,
useReducer(
audioStateReducer,
howlManager.current.getHowl(),
initStateFromHowl
)
)
useEffect(() => {
const howlOnMount = howlManager.current.getHowl()
if (howlOnMount !== undefined) {
dispatch({ type: ActionTypes.START_LOAD, howl: howlOnMount })
if (howlOnMount.state() === "loaded") {
dispatch({ type: ActionTypes.ON_LOAD, howl: howlOnMount })
}
}
function sync(action: Action) {
dispatch(action)
}
const subscriptionId = howlManager.current.subscribe(sync)
return () => {
howlManager.current.unsubscribe(subscriptionId)
}
}, [])
const load = useCallback((...[src, options = {}]: LoadArguments) => {
// the HowlInstanceManager will intercept this newly created howl and broadcast it to registered hooks
howlManager.current.createHowl({
src,
...options
})
}, [])
const seek = useCallback((seconds: number) => {
const howl = howlManager.current.getHowl()
if (howl === undefined) {
return
}
howl.seek(seconds)
}, [])
const getPosition = useCallback(() => {
const howl = howlManager.current.getHowl()
if (howl === undefined) {
return 0
}
return howl.seek() ?? 0
}, [])
const play = useCallback(() => {
const howl = howlManager.current.getHowl()
if (howl === undefined) {
return
}
howl.play()
}, [])
const pause = useCallback(() => {
const howl = howlManager.current.getHowl()
if (howl === undefined) {
return
}
howl.pause()
}, [])
const togglePlayPause = useCallback(() => {
const howl = howlManager.current.getHowl()
if (howl === undefined) {
return
}
if (state.playing) {
howl.pause()
} else {
howl.play()
}
}, [state])
const stop = useCallback(() => {
const howl = howlManager.current.getHowl()
if (howl === undefined) {
return
}
howl.stop()
}, [])
const fade = useCallback((from: number, to: number, duration: number) => {
const howl = howlManager.current.getHowl()
if (howl === undefined) {
return
}
howl.fade(from, to, duration)
}, [])
const setRate = useCallback((speed: number) => {
const howl = howlManager.current.getHowl()
if (howl === undefined) {
return
}
howl.rate(speed)
}, [])
const setVolume = useCallback((vol: number) => {
const howl = howlManager.current.getHowl()
if (howl === undefined) {
return
}
howl.volume(vol)
}, [])
const mute = useCallback((muteOnOff: boolean) => {
const howl = howlManager.current.getHowl()
if (howl === undefined) {
return
}
howl.mute(muteOnOff)
}, [])
const loop = useCallback((loopOnOff: boolean) => {
const howl = howlManager.current.getHowl()
if (howl === undefined) {
return
}
howlManager.current.broadcast({
type: ActionTypes.ON_LOOP,
howl,
toggleValue: loopOnOff
})
}, [])
return {
...state,
load,
seek,
getPosition,
play,
pause,
togglePlayPause,
stop,
mute,
fade,
setRate,
setVolume,
loop
}
}

View File

@@ -0,0 +1,119 @@
import {
Dispatch,
ReducerAction,
ReducerState,
useCallback,
useEffect,
useRef
} from "react"
import {
Action,
ActionTypes,
AudioPlayerState,
reducer
} from "./audioPlayerState"
import { HowlInstanceManager } from "./HowlInstanceManager"
import { HowlErrorCallback } from "howler"
export function useHowlEventSync(
howlManager: HowlInstanceManager,
[state, dispatch]: [AudioPlayerState, Dispatch<Action>]
): [ReducerState<typeof reducer>, Dispatch<ReducerAction<typeof reducer>>] {
const onLoad = useCallback(() => {
const howl = howlManager.getHowl()
if (howl === undefined) return
dispatch({ type: ActionTypes.ON_LOAD, howl })
}, [dispatch, howlManager])
const onError: HowlErrorCallback = useCallback(
(_: number, errorCode: unknown) => {
dispatch({
type: ActionTypes.ON_ERROR,
message: errorCode as string
})
},
[dispatch]
)
const onPlay = useCallback(() => {
const howl = howlManager.getHowl()
// TODO since this is the sync layer i should really extract the info from the howl here and pass that in with the action payload
if (howl === undefined) return
dispatch({ type: ActionTypes.ON_PLAY, howl })
}, [dispatch, howlManager])
const onPause = useCallback(() => {
const howl = howlManager.getHowl()
if (howl === undefined) return
dispatch({ type: ActionTypes.ON_PAUSE, howl })
}, [dispatch, howlManager])
const onEnd = useCallback(() => {
const howl = howlManager.getHowl()
if (howl === undefined) return
dispatch({ type: ActionTypes.ON_END, howl })
}, [dispatch, howlManager])
const onStop = useCallback(() => {
const howl = howlManager.getHowl()
if (howl === undefined) return
dispatch({ type: ActionTypes.ON_STOP, howl })
}, [dispatch, howlManager])
const onMute = useCallback(() => {
const howl = howlManager.getHowl()
if (howl === undefined) return
dispatch({ type: ActionTypes.ON_MUTE, howl })
}, [dispatch, howlManager])
const onVolume = useCallback(() => {
const howl = howlManager.getHowl()
if (howl === undefined) return
dispatch({ type: ActionTypes.ON_VOLUME, howl })
}, [dispatch, howlManager])
const onRate = useCallback(() => {
const howl = howlManager.getHowl()
if (howl === undefined) return
dispatch({ type: ActionTypes.ON_RATE, howl })
}, [dispatch, howlManager])
useEffect(() => {
return () => {
const howl = howlManager.getHowl()
// howl?.off("load", onLoad)
howl?.off("loaderror", onError)
howl?.off("playerror", onError)
howl?.off("play", onPlay)
howl?.off("pause", onPause)
howl?.off("end", onEnd)
howl?.off("stop", onStop)
howl?.off("mute", onMute)
howl?.off("volume", onVolume)
howl?.off("rate", onRate)
}
}, [])
// using ref bc we don't want identity of dispatch function to change
// see talk: https://youtu.be/nUzLlHFVXx0?t=1558
const wrappedDispatch = useRef((action: Action) => {
if (action.type === ActionTypes.START_LOAD) {
const { howl } = action
// set up event listening
howl.once("load", onLoad)
howl.on("loaderror", onError)
howl.on("playerror", onError)
howl.on("play", onPlay)
howl.on("pause", onPause)
howl.on("end", onEnd)
howl.on("stop", onStop)
howl.on("mute", onMute)
howl.on("volume", onVolume)
howl.on("rate", onRate)
}
dispatch(action)
})
return [state, wrappedDispatch.current]
}