Backend (src/main.py): - Add shared httpx.Client singleton for connection reuse (#24) - Add cache eviction for expired entries (#23) - Expand CORS to allow POST and OPTIONS (#25) - Preserve HLS tags (EXT-X-TARGETDURATION, EXT-X-MAP, etc) in rewrite (#12) - Replace urllib with httpx.stream for direct streaming Backend (src/modules/stream_extractor.py): - Use extract_flat='in_playlist' for reduced latency (#35) Tests (tests/integration/test_api.py): - Parameterize channel count assertion against CHANNELS (#20) Android (Channel.kt): - Change mutable vars to immutable vals, use copy() pattern (#19) Android (ServerApi.kt): - Fix JSON parsing for bare array response (#21) - Close response body on error path (#29) Android (YouTubeExtractor.kt): - Add ConnectionPool config (10 idle, 30s keepalive) (#28) - Tighten findHlsUrl to require audio codec (#26) Android (AudioPlayer.kt): - Remove false error on STATE_READY + !isPlaying (#33) Android (MainActivity.kt): - Reuse fragments via findFragmentByTag + show/hide (#18) - Move AudioPlayer.release() to Activity.onDestroy (#17) Android (LofiViewModel.kt): - Wrap YouTubeExtractor calls in withContext(Dispatchers.IO) (#16) - Use immutable channel copies in discoverAllChannels (#19) - Remove audioPlayer.release() from onCleared (#17) Android (ChannelListFragment.kt): - Tie swipe refresh to isDiscovering LiveData (#32) Android (AndroidManifest.xml): - Set allowBackup=false (#15) Android (Preferences.kt): - Remove hardcoded IP, default to empty string (#14) Android (proguard-rules.pro): - Add ExoPlayer media3 HLS ProGuard rules (#34) Frontend (useAudioPlayer.ts): - Add retry limit (3) with exponential backoff for NETWORK_ERROR (#27) Docker (Dockerfile.backend): - Add playwright install chromium step (#22)
101 lines
2.7 KiB
TypeScript
101 lines
2.7 KiB
TypeScript
import { useCallback, useEffect, useRef } from "react"
|
|
|
|
import Hls from "hls.js"
|
|
|
|
export function useAudioPlayer() {
|
|
const audioRef = useRef<HTMLAudioElement>(null)
|
|
const hlsRef = useRef<Hls | null>(null)
|
|
const networkRetryCount = useRef(0)
|
|
const onStateChange = useRef<((state: "playing" | "paused" | "stopped" | "error") => void) | null>(null)
|
|
|
|
const play = useCallback((streamUrl: string, streamType: "hls" | "direct" | null) => {
|
|
if (!audioRef.current) return
|
|
|
|
if (hlsRef.current) {
|
|
hlsRef.current.destroy()
|
|
hlsRef.current = null
|
|
}
|
|
|
|
if (streamType === "hls" && Hls.isSupported()) {
|
|
const hls = new Hls({
|
|
maxBufferLength: 30,
|
|
maxMaxBufferLength: 60,
|
|
})
|
|
hls.loadSource(streamUrl)
|
|
hls.attachMedia(audioRef.current)
|
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
audioRef.current?.play()
|
|
onStateChange.current?.("playing")
|
|
})
|
|
|
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
|
if (data.fatal) {
|
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
|
networkRetryCount.current++
|
|
if (networkRetryCount.current > 3) {
|
|
hls.destroy()
|
|
hlsRef.current = null
|
|
onStateChange.current?.("error")
|
|
} else {
|
|
const backoff = Math.min(2 ** networkRetryCount.current * 1000, 10000)
|
|
setTimeout(() => hls.startLoad(), backoff)
|
|
}
|
|
} else {
|
|
hls.destroy()
|
|
hlsRef.current = null
|
|
onStateChange.current?.("error")
|
|
}
|
|
}
|
|
})
|
|
|
|
networkRetryCount.current = 0
|
|
hlsRef.current = hls
|
|
} else {
|
|
audioRef.current.src = streamUrl
|
|
audioRef.current.play().then(() => {
|
|
onStateChange.current?.("playing")
|
|
}).catch(() => {
|
|
onStateChange.current?.("error")
|
|
})
|
|
}
|
|
}, [])
|
|
|
|
const pause = useCallback(() => {
|
|
audioRef.current?.pause()
|
|
onStateChange.current?.("paused")
|
|
}, [])
|
|
|
|
const stop = useCallback(() => {
|
|
audioRef.current?.pause()
|
|
if (hlsRef.current) {
|
|
hlsRef.current.destroy()
|
|
hlsRef.current = null
|
|
}
|
|
if (audioRef.current) {
|
|
audioRef.current.src = ""
|
|
}
|
|
onStateChange.current?.("stopped")
|
|
}, [])
|
|
|
|
const setVolume = useCallback((volume: number) => {
|
|
if (audioRef.current) {
|
|
audioRef.current.volume = volume
|
|
}
|
|
}, [])
|
|
|
|
const setStateChange = useCallback((handler: (state: "playing" | "paused" | "stopped" | "error") => void) => {
|
|
onStateChange.current = handler
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (hlsRef.current) {
|
|
hlsRef.current.destroy()
|
|
hlsRef.current = null
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
return { audioRef, play, pause, stop, setVolume, setStateChange }
|
|
} |