项目文件夹

文件
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

892 行
28 KiB
Go

package player
import (
"context"
"fmt"
"math"
"sync"
"sync/atomic"
"time"
"github.com/gopxl/beep/v2"
"github.com/gopxl/beep/v2/speaker"
)
// Quality holds configurable audio output parameters.
type Quality struct {
SampleRate int // output sample rate in Hz (e.g. 44100, 48000)
BufferMs int // speaker buffer in milliseconds
ResampleQuality int // beep resample quality factor (1–4)
BitDepth int // PCM bit depth for FFmpeg output: 16 or 32 (32 = lossless)
}
// StreamerFactory creates a beep.StreamSeekCloser for a custom URI scheme
// (e.g., spotify:track:xxx). Returns the streamer, its format, the track
// duration, and any error.
type StreamerFactory func(uri string) (beep.StreamSeekCloser, beep.Format, time.Duration, error)
// Player is the audio engine managing the playback pipeline:
//
// [Gapless] -> [10x Biquad EQ] -> [Volume] -> [Tap] -> [Ctrl] -> speaker
// ↑
// ├─ current: [Decode A] → [Resample A]
// └─ next: [Decode B] → [Resample B] (preloaded)
type Player struct {
mu sync.Mutex
sr beep.SampleRate
gapless *gaplessStreamer
current *trackPipeline // active track's resources
nextPipeline *trackPipeline // preloaded track's resources
started bool // true after first speaker.Play()
suspendMu sync.Mutex // guards suspended and speaker suspend/resume calls
suspended bool // true when speaker.Suspend() has been called
ctrl *beep.Ctrl
volMin atomic.Uint64 // dB floor stored as Float64bits, range [-90, 0]
volume atomic.Uint64 // dB stored as Float64bits, range [volMin, +6]
speed atomic.Uint64 // playback speed ratio as Float64bits; 1.0 = normal
eqBands [10]atomic.Uint64 // dB stored as math.Float64bits
tap *tap
playing atomic.Bool
paused atomic.Bool
mono atomic.Bool
resampleQuality int
bitDepth int // 16 or 32
gaplessAdvance atomic.Bool // set when gapless transition fires
seekGen atomic.Int64 // generation counter for yt-dlp seeks; incremented to cancel stale seeks
streamTitle atomic.Value // stores string, set by ICY reader callback
customFactories map[string]StreamerFactory // URI scheme prefix -> factory (e.g. "spotify:" -> fn)
bufferedURLMatch func(string) bool // optional: returns true for URLs needing navBuffer pipeline
streamMetaResolver StreamMetadataResolver // optional: API-based now-playing for streams without ICY
metaCancel context.CancelFunc // cancels the active metadata poller; guarded by mu
}
// StreamMetadataResolver matches a stream URL to a now-playing fetcher for
// broadcasters that carry no inline ICY metadata (e.g. NTS, FIP) and instead
// publish the current track via a separate JSON API. It returns a fetch
// function, the poll interval, and ok=false when the URL is not recognized.
type StreamMetadataResolver func(streamURL string) (fetch func(ctx context.Context) (string, error), interval time.Duration, ok bool)
// New creates a Player and initializes the speaker with the given quality settings.
func New(q Quality) (*Player, error) {
if q.SampleRate <= 0 || q.BufferMs <= 0 || q.ResampleQuality <= 0 {
return nil, fmt.Errorf("invalid quality settings: SampleRate=%d, BufferMs=%d, ResampleQuality=%d",
q.SampleRate, q.BufferMs, q.ResampleQuality)
}
sr := beep.SampleRate(q.SampleRate)
if err := speaker.Init(sr, sr.N(time.Duration(q.BufferMs)*time.Millisecond)); err != nil {
return nil, fmt.Errorf("speaker init: %w", err)
}
bitDepth := q.BitDepth
if bitDepth != 32 {
bitDepth = 16
}
p := &Player{sr: sr, resampleQuality: q.ResampleQuality, bitDepth: bitDepth}
p.volMin.Store(math.Float64bits(-50))
p.speed.Store(math.Float64bits(1.0))
p.gapless = &gaplessStreamer{}
// Suspend the speaker immediately; the ALSA audio callback goroutine
// burns ~2% CPU even on silence. Resume is called on every Play().
_ = speaker.Suspend()
p.suspended = true
p.gapless.onSwap = func() {
// Called from audio thread (goroutine) when gapless transition occurs.
// Swap current ← nextPipeline and close the old one. The API metadata
// poller is intentionally not restarted here: gapless advance is for
// finite tracks, while resolver-backed streams (NTS, FIP) are infinite
// live radio that is never preloaded as a gapless next track.
p.mu.Lock()
old := p.current
p.current = p.nextPipeline
p.nextPipeline = nil
p.mu.Unlock()
if old != nil {
old.close()
}
p.gaplessAdvance.Store(true)
}
return p, nil
}
// Play opens and starts playing an audio file. On the first call it builds
// the long-lived EQ → volume → tap → ctrl chain and starts the speaker.
// Subsequent calls swap only the track source via the gapless streamer.
// knownDuration is the metadata duration (use 0 if unknown); it is used as a
// fallback when the decoder cannot determine the length (e.g. HTTP streams).
func (p *Player) Play(path string, knownDuration time.Duration) error {
tp, err := p.buildPipeline(path)
if err != nil {
return err
}
tp.setKnownDuration(knownDuration)
return p.playPipeline(tp)
}
// PlayYTDL starts playing a yt-dlp page URL via a piped yt-dlp | ffmpeg chain.
// Playback starts as soon as the first PCM samples arrive (~1-3s). Not seekable.
func (p *Player) PlayYTDL(pageURL string, knownDuration time.Duration) error {
// Probe duration concurrently with pipeline setup so it doesn't delay playback.
probeCh := make(chan time.Duration, 1)
if knownDuration == 0 {
go func() { probeCh <- probeYTDLDuration(pageURL) }()
}
tp, err := p.buildYTDLPipeline(pageURL, 0)
if err != nil {
return err
}
if knownDuration == 0 {
// The probe ran concurrently with buildYTDLPipeline. Try to
// collect the result, but don't block playback for more than 2s.
// A hung probeYTDLDuration (e.g. yt-dlp zombie keeping pipes
// open) previously blocked here forever, leaving the UI stuck
// at "Buffering...".
select {
case d := <-probeCh:
if d > 0 {
knownDuration = d
}
case <-time.After(2 * time.Second):
// Probe still running — start playback without duration.
// The seek bar won't show progress but audio plays immediately.
}
}
tp.knownDuration = knownDuration
return p.playPipeline(tp)
}
// playPipeline wires a ready-to-play trackPipeline into the speaker chain.
// On the first call it builds the long-lived EQ → volume → tap → ctrl chain.
// Subsequent calls swap only the track source via the gapless streamer.
func (p *Player) playPipeline(tp *trackPipeline) error {
p.resumeSpeaker()
// Collect old pipelines to close after releasing locks.
var oldCurrent, oldNext *trackPipeline
if p.started {
// Lock the speaker so the goroutine finishes any in-progress Stream()
// call before we swap the source and unpause. The ctrl.Paused write
// must happen under the speaker lock because the audio thread reads it
// on every Stream() call.
speaker.Lock()
p.gapless.Replace(tp.stream)
p.ctrl.Paused = false
speaker.Unlock()
}
p.mu.Lock()
oldCurrent = p.current
oldNext = p.nextPipeline
p.current = tp
p.nextPipeline = nil
firstPlay := !p.started
if firstPlay {
p.gapless.Replace(tp.stream)
// Build the long-lived pipeline once
var s beep.Streamer = p.gapless
s = newSpeedStreamer(s, &p.speed)
for i := range 10 {
s = newBiquad(s, eqFreqs[i], 1.4, &p.eqBands[i], float64(p.sr))
}
p.tap = newTap(s, 4096)
s = &volumeStreamer{s: p.tap, vol: &p.volume, mono: &p.mono, cachedDB: math.NaN()}
p.ctrl = &beep.Ctrl{Streamer: s}
p.started = true
}
p.playing.Store(true)
p.paused.Store(false)
p.mu.Unlock()
if firstPlay {
speaker.Play(p.ctrl)
}
// Start API-based now-playing polling for streams without ICY metadata
// (no-op otherwise). Done here, not in buildPipelineAt, so preloaded
// pipelines that may never play don't spawn pollers.
p.startStreamMetadata(tp.path)
// Close old resources asynchronously to avoid blocking the caller
// (UI thread) on slow Close() operations (ffmpeg wait, HTTP teardown).
go closePipelines(oldCurrent, oldNext)
return nil
}
// Preload builds a pipeline for the next track and queues it for gapless transition.
// knownDuration is the metadata duration (use 0 if unknown).
func (p *Player) Preload(path string, knownDuration time.Duration) error {
tp, err := p.buildPipeline(path)
if err != nil {
return err
}
tp.setKnownDuration(knownDuration)
return p.preloadPipeline(tp)
}
// PreloadYTDL builds a yt-dlp pipe pipeline and queues it for gapless transition.
func (p *Player) PreloadYTDL(pageURL string, knownDuration time.Duration) error {
tp, err := p.buildYTDLPipeline(pageURL, 0)
if err != nil {
return err
}
tp.knownDuration = knownDuration
return p.preloadPipeline(tp)
}
// preloadPipeline queues a ready trackPipeline for gapless transition.
func (p *Player) preloadPipeline(tp *trackPipeline) error {
// Lock speaker to atomically swap the gapless next stream, ensuring no
// in-flight transition reads from the old pipeline we're about to close.
speaker.Lock()
p.gapless.SetNext(tp.stream)
speaker.Unlock()
p.mu.Lock()
old := p.nextPipeline
p.nextPipeline = tp
p.mu.Unlock()
if old != nil {
old.close()
}
return nil
}
// ClearPreload discards the preloaded next track (e.g., when shuffle/repeat changes).
// Speaker is locked to ensure no in-flight gapless transition can reference the
// pipeline we're about to close.
func (p *Player) ClearPreload() {
speaker.Lock()
p.gapless.SetNext(nil)
speaker.Unlock()
p.mu.Lock()
old := p.nextPipeline
p.nextPipeline = nil
p.mu.Unlock()
if old != nil {
old.close()
}
}
// GaplessAdvanced returns true (once) when a gapless transition happened.
func (p *Player) GaplessAdvanced() bool {
return p.gaplessAdvance.CompareAndSwap(true, false)
}
// TogglePause toggles between paused and playing states.
// When pausing, the speaker is suspended to save CPU; when unpausing
// it is resumed so the audio callback drains the queued samples.
func (p *Player) TogglePause() {
speaker.Lock()
if p.ctrl != nil {
p.ctrl.Paused = !p.ctrl.Paused
paused := p.ctrl.Paused
speaker.Unlock()
p.paused.Store(paused)
if paused {
p.suspendSpeaker()
} else {
p.resumeSpeaker()
}
} else {
speaker.Unlock()
}
}
// Stop halts playback and releases resources. The speaker is suspended so
// the ALSA audio callback goroutine blocks (zero CPU) instead of streaming
// silence. Resume is called automatically on the next Play().
func (p *Player) Stop() {
// Lock speaker to ensure the goroutine finishes any in-progress Stream()
// call, then clear the source and pause. After unlock, the speaker will
// only see silence from the gapless streamer (paused ctrl).
speaker.Lock()
p.gapless.Clear()
if p.ctrl != nil {
p.ctrl.Paused = true
}
speaker.Unlock()
// Now safe to close decoder resources — speaker can't be reading them.
p.mu.Lock()
oldCurrent := p.current
oldNext := p.nextPipeline
p.current = nil
p.nextPipeline = nil
p.playing.Store(false)
p.paused.Store(false)
p.mu.Unlock()
p.stopStreamMetadata()
closePipelines(oldCurrent, oldNext)
p.suspendSpeaker()
}
// Seek moves the playback position by the given duration (positive or negative).
// For seekable local files, the decoder's Seek method is used directly.
// For HTTP streams with a known Content-Length and duration (seekableStream),
// seek is implemented by reconnecting with a Range: bytes=N- header and
// rebuilding the decoder at the computed byte offset. This is known as
// seek-by-reconnect.
// Returns nil immediately for non-seekable streams (e.g., Icecast radio).
// The speaker lock is acquired first (outer), then p.mu briefly to snapshot
// the current pipeline, ensuring consistent lock ordering with the audio thread.
// Clears the preloaded next pipeline to prevent a stale gapless transition.
func (p *Player) Seek(d time.Duration) error {
speaker.Lock()
defer speaker.Unlock()
p.mu.Lock()
cur := p.current
p.mu.Unlock()
if cur == nil {
return nil
}
// seekableStream: HTTP stream with Content-Length — reconnect at byte offset.
// Release the speaker lock before the slow HTTP reconnect so the audio
// thread and UI tick handler aren't blocked during the request.
if cur.seekableStream && cur.knownDuration > 0 && cur.contentLength > 0 {
// Compute new absolute position.
curPos := cur.format.SampleRate.D(cur.decoder.Position()) + cur.streamOffset
newPos := max(curPos+d, 0)
if newPos >= cur.knownDuration {
newPos = cur.knownDuration - time.Second
}
// Map position to byte offset: offset = newPos/duration * contentLength.
// Use floating-point to avoid int64 overflow on large files.
ratio := float64(newPos) / float64(cur.knownDuration)
byteOffset := int64(ratio * float64(cur.contentLength))
// Snapshot values and mute audio while reconnecting — prevents the
// old stream from playing at the pre-seek position during the rebuild.
path := cur.path
knownDuration := cur.knownDuration
contentLength := cur.contentLength
p.gapless.Replace(nil)
speaker.Unlock()
// Build a new pipeline starting at the computed byte offset.
// Speaker lock is NOT held — HTTP I/O can take seconds on slow networks.
tp, err := p.buildPipelineAt(path, byteOffset, newPos)
speaker.Lock() // re-acquire for defer
if err != nil {
// Restore the old stream on failure if the pipeline hasn't changed.
p.mu.Lock()
if p.current == cur {
p.gapless.Replace(cur.stream)
}
p.mu.Unlock()
return fmt.Errorf("seek reconnect: %w", err)
}
tp.knownDuration = knownDuration
// seekableStream / contentLength / path are set by buildPipelineAt when
// contentLength > 0, but byteOffset shifts the origin, so we keep the
// original full-file contentLength and mark seekableStream explicitly.
tp.seekableStream = true
tp.contentLength = contentLength
// Verify the current pipeline hasn't changed while we were unlocked
// (e.g. track skip or another seek). If it changed, discard our work.
p.mu.Lock()
if p.current != cur {
p.mu.Unlock()
go closePipelines(tp)
return nil
}
p.mu.Unlock()
p.gapless.Replace(tp.stream)
// Clear any preloaded next pipeline — its transition point is now stale.
p.gapless.SetNext(nil)
p.mu.Lock()
old := p.current
oldNext := p.nextPipeline
p.current = tp
p.nextPipeline = nil
p.mu.Unlock()
go closePipelines(old, oldNext)
return nil
}
// yt-dlp seek-by-restart: handled outside the speaker lock via SeekYTDL.
if cur.ytdlSeek {
// Release speaker lock, then do the slow seek.
speaker.Unlock()
err := p.SeekYTDL(d)
speaker.Lock() // re-acquire so defer Unlock works
return err
}
// Local file (or ffmpeg-buffered PCM): use the decoder's native Seek.
if !cur.seekable {
return nil
}
curSample := cur.decoder.Position()
curDur := cur.format.SampleRate.D(curSample)
newSample := max(cur.format.SampleRate.N(curDur+d), 0)
if newSample >= cur.decoder.Len() {
newSample = cur.decoder.Len() - 1
}
if err := cur.decoder.Seek(newSample); err != nil {
return err
}
// Invalidate the preloaded next pipeline — the gapless transition point
// has moved and the old preload may be stale. The speaker lock is already
// held, so we can safely clear the gapless next stream.
p.gapless.SetNext(nil)
p.mu.Lock()
old := p.nextPipeline
p.nextPipeline = nil
p.mu.Unlock()
if old != nil {
old.close()
}
return nil
}
// CancelSeekYTDL increments the seek generation, causing any in-flight
// SeekYTDL to discard its result instead of swapping streams.
func (p *Player) CancelSeekYTDL() {
p.seekGen.Add(1)
}
// SeekYTDL seeks a yt-dlp stream by restarting the pipeline at the target
// position. Must NOT be called with the speaker lock held.
// If a newer seek is requested (via CancelSeekYTDL) while this one is
// building, the result is discarded.
func (p *Player) SeekYTDL(d time.Duration) error {
gen := p.seekGen.Load()
// Snapshot current state without speaker lock.
p.mu.Lock()
cur := p.current
p.mu.Unlock()
if cur == nil || !cur.ytdlSeek {
return nil
}
// Read position, then mute the current stream so the speaker outputs
// silence while the new pipeline is being built (which blocks on Peek
// waiting for yt-dlp data). Without this, the old audio keeps playing
// at the pre-seek position during the rebuild.
speaker.Lock()
curPos := cur.format.SampleRate.D(cur.decoder.Position()) + cur.streamOffset
p.gapless.Replace(nil)
speaker.Unlock()
newPos := max(curPos+d, 0)
if cur.knownDuration > 0 && newPos >= cur.knownDuration {
newPos = cur.knownDuration - time.Second
}
startSec := int(newPos.Seconds())
// Build pipeline WITHOUT speaker lock (this is the slow part — spawns yt-dlp).
tp, err := p.buildYTDLPipeline(cur.path, startSec)
if err != nil {
return fmt.Errorf("yt-dlp seek: %w", err)
}
tp.knownDuration = cur.knownDuration
tp.ytdlSeek = true
// Check if this seek was cancelled while we were building.
if p.seekGen.Load() != gen {
// A newer seek was requested — discard this result.
go closePipelines(tp)
return nil
}
// Now acquire speaker lock to swap streams.
speaker.Lock()
p.gapless.Replace(tp.stream)
p.gapless.SetNext(nil)
speaker.Unlock()
p.mu.Lock()
old := p.current
oldNext := p.nextPipeline
p.current = tp
p.nextPipeline = nil
p.mu.Unlock()
// Clean up old pipelines async to avoid blocking on process wait.
go closePipelines(old, oldNext)
return nil
}
// IsYTDLSeek reports whether the current track uses yt-dlp seek-by-restart.
func (p *Player) IsYTDLSeek() bool {
p.mu.Lock()
cur := p.current
p.mu.Unlock()
return cur != nil && cur.ytdlSeek
}
// IsStreamSeek reports whether the current track uses HTTP seek-by-reconnect.
// When true, Seek() releases the speaker lock during the HTTP request, so
// callers should dispatch seeks asynchronously to avoid blocking the UI.
func (p *Player) IsStreamSeek() bool {
p.mu.Lock()
cur := p.current
p.mu.Unlock()
return cur != nil && cur.seekableStream && cur.knownDuration > 0 && cur.contentLength > 0
}
// Position returns the current playback position.
// For ranged HTTP streams (seek-by-reconnect), streamOffset is added to the
// decoder's sample-based position so the reported time is absolute within
// the track, not relative to the reconnect point.
func (p *Player) Position() time.Duration {
speaker.Lock()
defer speaker.Unlock()
p.mu.Lock()
cur := p.current
p.mu.Unlock()
if cur == nil {
return 0
}
return cur.format.SampleRate.D(cur.decoder.Position()) + cur.streamOffset
}
// Duration returns the total duration of the current track.
// For seekable local files it is derived from the decoder's sample count.
// For HTTP streams where the decoder reports Len()==0, the metadata hint
// stored at pipeline build time (knownDuration) is returned instead.
func (p *Player) Duration() time.Duration {
speaker.Lock()
defer speaker.Unlock()
p.mu.Lock()
cur := p.current
p.mu.Unlock()
if cur == nil {
return 0
}
if n := cur.decoder.Len(); n > 0 {
return cur.format.SampleRate.D(n)
}
return cur.knownDuration
}
// PositionAndDuration returns both position and duration under a single
// speaker lock, avoiding two separate lock acquisitions per tick.
func (p *Player) PositionAndDuration() (time.Duration, time.Duration) {
speaker.Lock()
defer speaker.Unlock()
p.mu.Lock()
cur := p.current
p.mu.Unlock()
if cur == nil {
return 0, 0
}
pos := cur.format.SampleRate.D(cur.decoder.Position()) + cur.streamOffset
var dur time.Duration
if n := cur.decoder.Len(); n > 0 {
dur = cur.format.SampleRate.D(n)
} else {
dur = cur.knownDuration
}
return pos, dur
}
// SetVolumeMin sets the minimum volume floor in dB, clamped to [-90, 0].
// If the current volume is below the new floor it is immediately raised to match.
func (p *Player) SetVolumeMin(db float64) {
newMin := max(min(db, 0), -90)
p.volMin.Store(math.Float64bits(newMin))
for {
cur := p.volume.Load()
curDB := math.Float64frombits(cur)
if curDB >= newMin {
break
}
if p.volume.CompareAndSwap(cur, math.Float64bits(newMin)) {
break
}
}
}
// VolumeMin returns the current minimum volume floor in dB.
func (p *Player) VolumeMin() float64 {
return math.Float64frombits(p.volMin.Load())
}
// SetVolume sets the volume in dB, clamped to [VolumeMin, +6].
func (p *Player) SetVolume(db float64) {
p.volume.Store(math.Float64bits(max(min(db, 6), p.VolumeMin())))
}
// Volume returns the current volume in dB.
func (p *Player) Volume() float64 {
return math.Float64frombits(p.volume.Load())
}
// SetSpeed sets the playback speed ratio, clamped to [0.25, 2.0].
// 1.0 is normal speed, 2.0 is double speed, etc.
func (p *Player) SetSpeed(ratio float64) {
p.speed.Store(math.Float64bits(max(min(ratio, 2.0), 0.25)))
}
// Speed returns the current playback speed ratio.
func (p *Player) Speed() float64 {
return math.Float64frombits(p.speed.Load())
}
// ToggleMono switches between stereo and mono (L+R downmix) output.
func (p *Player) ToggleMono() {
p.mono.Store(!p.mono.Load())
}
// Mono returns true if mono output is enabled.
func (p *Player) Mono() bool {
return p.mono.Load()
}
// SetEQBand sets a single EQ band's gain in dB, clamped to [-12, +12].
func (p *Player) SetEQBand(band int, dB float64) {
if band < 0 || band >= 10 {
return
}
p.eqBands[band].Store(math.Float64bits(max(min(dB, 12), -12)))
}
// EQBands returns a copy of all 10 EQ band gains.
func (p *Player) EQBands() [10]float64 {
var bands [10]float64
for i := range 10 {
bands[i] = math.Float64frombits(p.eqBands[i].Load())
}
return bands
}
// IsPlaying returns true if a track is loaded and playing (possibly paused).
func (p *Player) IsPlaying() bool {
return p.playing.Load()
}
// IsPaused returns true if playback is paused.
func (p *Player) IsPaused() bool {
return p.paused.Load()
}
// Drained returns true if the current track ended with no preloaded next track.
func (p *Player) Drained() bool {
return p.gapless.Drained()
}
// HasPreload returns true if a next track is already queued for gapless transition.
func (p *Player) HasPreload() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.nextPipeline != nil
}
// StreamTitle returns the current ICY stream title (e.g., "Artist - Song").
// Returns "" when no ICY metadata has been received.
func (p *Player) StreamTitle() string {
v, _ := p.streamTitle.Load().(string)
return v
}
// setStreamTitle is the ICY onMeta callback, called from the reader goroutine.
func (p *Player) setStreamTitle(title string) {
p.streamTitle.Store(title)
}
// RegisterStreamMetadataResolver installs a resolver used to pull now-playing
// metadata for streams that lack inline ICY metadata. Pass nil to disable.
func (p *Player) RegisterStreamMetadataResolver(r StreamMetadataResolver) {
p.mu.Lock()
p.streamMetaResolver = r
p.mu.Unlock()
}
// startStreamMetadata (re)starts background now-playing polling for streamURL,
// cancelling any previous poller. It is a no-op unless a registered resolver
// recognizes the URL, so streams that carry ICY metadata (or local files) are
// unaffected. The poller feeds titles through the same path as ICY metadata.
func (p *Player) startStreamMetadata(streamURL string) {
p.stopStreamMetadata()
p.mu.Lock()
resolver := p.streamMetaResolver
p.mu.Unlock()
if resolver == nil || streamURL == "" || !isURL(streamURL) {
return
}
fetch, interval, ok := resolver(streamURL)
if !ok || fetch == nil {
return
}
if interval <= 0 {
interval = 15 * time.Second
}
ctx, cancel := context.WithCancel(context.Background())
p.mu.Lock()
p.metaCancel = cancel
p.mu.Unlock()
go p.pollStreamMetadata(ctx, fetch, interval)
}
// stopStreamMetadata cancels the active metadata poller, if any.
func (p *Player) stopStreamMetadata() {
p.mu.Lock()
cancel := p.metaCancel
p.metaCancel = nil
p.mu.Unlock()
if cancel != nil {
cancel()
}
}
// pollStreamMetadata fetches the current title immediately and then on each
// interval tick until ctx is cancelled, publishing non-empty titles via
// setStreamTitle. A title fetched after cancellation is discarded so a stale
// poller cannot clobber the next stream's metadata.
func (p *Player) pollStreamMetadata(ctx context.Context, fetch func(context.Context) (string, error), interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
title, err := fetch(ctx)
if ctx.Err() != nil {
return
}
if err == nil && title != "" {
p.setStreamTitle(title)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// Seekable reports whether the current track supports seeking.
// Returns true for local files (decoder-native seek) and for HTTP streams
// with a known Content-Length and duration (seek-by-reconnect).
func (p *Player) Seekable() bool {
p.mu.Lock()
cur := p.current
p.mu.Unlock()
if cur == nil {
return false
}
return cur.seekable || (cur.seekableStream && cur.knownDuration > 0) || (cur.ytdlSeek && cur.knownDuration > 0)
}
// StreamErr returns the current streamer error, if any (e.g., connection drops).
func (p *Player) StreamErr() error {
p.mu.Lock()
cur := p.current
p.mu.Unlock()
if cur == nil {
return nil
}
return cur.decoder.Err()
}
// SamplesInto copies the latest audio samples into dst, avoiding allocation.
// Returns the number of samples written.
func (p *Player) SamplesInto(dst []float64) int {
p.mu.Lock()
tap := p.tap
p.mu.Unlock()
if tap == nil {
return 0
}
return tap.SamplesInto(dst)
}
// SampleRate returns the output sample rate in Hz.
func (p *Player) SampleRate() int {
return int(p.sr)
}
// StreamBytes returns the bytes downloaded and total content length for the
// current HTTP stream. Returns (0, 0) for local files or when no counter exists.
func (p *Player) StreamBytes() (downloaded, total int64) {
p.mu.Lock()
cur := p.current
p.mu.Unlock()
if cur == nil {
return 0, 0
}
if cur.bytesRead != nil {
downloaded = cur.bytesRead.Load()
}
total = cur.contentLength
return downloaded, total
}
// RegisterStreamerFactory registers a factory for a custom URI scheme prefix
// (e.g., "spotify:"). When buildPipeline encounters a path starting with this
// prefix, it calls the factory to create the decoder instead of the normal
// file/HTTP pipeline.
func (p *Player) RegisterStreamerFactory(scheme string, f StreamerFactory) {
p.mu.Lock()
defer p.mu.Unlock()
if p.customFactories == nil {
p.customFactories = make(map[string]StreamerFactory)
}
p.customFactories[scheme] = f
}
// RegisterBufferedURLMatcher registers a function that identifies HTTP URLs
// requiring the buffered download + ffmpeg pipeline (e.g. Subsonic stream
// endpoints). This replaces hardcoded URL pattern checks.
func (p *Player) RegisterBufferedURLMatcher(match func(string) bool) {
p.mu.Lock()
defer p.mu.Unlock()
p.bufferedURLMatch = match
}
// suspendSpeaker suspends the ALSA audio callback goroutine so it blocks
// on a condition variable instead of busy-looping. Safe to call multiple
// times; subsequent calls are no-ops.
func (p *Player) suspendSpeaker() {
p.suspendMu.Lock()
defer p.suspendMu.Unlock()
if p.suspended {
return
}
if err := speaker.Suspend(); err != nil {
// Non-fatal: the ALSA driver may return an error if the context
// has already hit a terminal error. Continue without tracking
// the suspended state so we don't try to resume a dead context.
return
}
p.suspended = true
}
// resumeSpeaker resumes the ALSA audio callback goroutine. Safe to call
// multiple times; subsequent calls are no-ops.
func (p *Player) resumeSpeaker() {
p.suspendMu.Lock()
defer p.suspendMu.Unlock()
if !p.suspended {
return
}
if err := speaker.Resume(); err != nil {
return
}
p.suspended = false
}
// Close fully stops the speaker and cleans up all resources.
func (p *Player) Close() {
p.Stop()
speaker.Clear()
}