package model import ( "strings" "time" "github.com/bjarneo/cliamp/internal/playback" "github.com/bjarneo/cliamp/luaplugin" "github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/provider" ) // notifyAll sends the current playback state to both OS media controls and Lua plugins. func (m *Model) notifyAll() { m.notifyPlayback() m.notifyPlugins() } func (m *Model) attachNotifier(notifier playback.Notifier) { m.notifier = notifier m.notifyAll() } // notifyPlugins emits a playback state event to Lua plugins. func (m *Model) notifyPlugins() { if m.luaMgr == nil || !m.luaMgr.HasHooks() { return } track, _ := m.currentPlaybackTrack() artist, title := m.resolveTrackDisplay(track) status := "stopped" if m.player.IsPlaying() { if m.player.IsPaused() { status = "paused" } else { status = "playing" } } data := trackToMap(track) data["status"] = status data["title"] = title data["artist"] = artist data["position"] = m.player.Position().Seconds() m.luaMgr.Emit(luaplugin.EventPlaybackState, data) } // resolveTrackDisplay returns the display artist and title, applying ICY // stream title override for radio streams. func (m *Model) resolveTrackDisplay(track playlist.Track) (artist, title string) { artist, title = track.Artist, track.Title if m.streamTitle != "" && track.Stream { if a, t, ok := strings.Cut(m.streamTitle, " - "); ok { artist, title = a, t } else { title = m.streamTitle } } return } // trackToMap builds a metadata map from a track for Lua plugin events. func trackToMap(track playlist.Track) map[string]any { return map[string]any{ "title": track.Title, "artist": track.Artist, "album": track.Album, "genre": track.Genre, "year": track.Year, "path": track.Path, "duration": track.DurationSecs, "stream": track.Stream, } } func (m *Model) notifyPlayback() { if m.notifier == nil { return } status := playback.StatusStopped if m.player.IsPlaying() { if m.player.IsPaused() { status = playback.StatusPaused } else { status = playback.StatusPlaying } } track, _ := m.currentPlaybackTrack() artist, title := m.resolveTrackDisplay(track) m.notifier.Update(playback.State{ Status: status, Track: playback.Track{ Title: title, Artist: artist, Album: track.Album, Genre: track.Genre, TrackNumber: track.TrackNumber, URL: track.Path, ArtURL: track.AlbumArtURL, Duration: m.player.Duration(), }, VolumeDB: m.player.Volume(), Position: m.player.Position(), Seekable: m.player.Seekable(), }) } // nowPlaying fires a now-playing notification for the given track if configured. func (m *Model) nowPlaying(track playlist.Track) { if m.luaMgr != nil && m.luaMgr.HasHooks() { m.luaMgr.Emit(luaplugin.EventTrackChange, trackToMap(track)) } reporter := m.findPlaybackReporter(track) if reporter == nil { return } canSeek := m.player.Seekable() go reporter.ReportNowPlaying(track, m.player.Position(), canSeek) } // maybeScrobble fires a playback-complete report for the given track if all // conditions are met: // - a provider claims the track via provider metadata // - the track reached at least 50% of its known duration // // The call is dispatched in a goroutine so it never blocks the UI. The same // 50% threshold gates a local history entry so skipped tracks never land in // "Recently Played". func (m *Model) maybeScrobble(track playlist.Track, elapsed, duration time.Duration) { dur := duration if dur <= 0 { dur = time.Duration(track.DurationSecs) * time.Second } pastThreshold := dur > 0 && elapsed >= dur/2 // Emit scrobble event to Lua plugins for all tracks (not just Navidrome). if m.luaMgr != nil && m.luaMgr.HasHooks() && pastThreshold { data := trackToMap(track) data["played_secs"] = elapsed.Seconds() m.luaMgr.Emit(luaplugin.EventTrackScrobble, data) } // Record into local history regardless of provider. Live streams without // duration are filtered by pastThreshold. The write is synchronous so // successive scrobbles preserve their ordering on disk; the file is small // (~30 KB at the 200-entry cap) so the latency is sub-millisecond. if pastThreshold && m.historyStore != nil { _ = m.historyStore.Record(track, time.Now()) } reporter := m.findPlaybackReporter(track) if reporter == nil { return } if duration <= 0 { // Unknown duration: use DurationSecs metadata as fallback. duration = time.Duration(track.DurationSecs) * time.Second } if duration <= 0 { return // still unknown — skip } if elapsed < duration/2 { return // less than 50% played } canSeek := m.player.Seekable() go reporter.ReportScrobble(track, elapsed, duration, canSeek) } // findPlaybackReporter returns the first registered provider that can report // playback for the given track. func (m *Model) findPlaybackReporter(track playlist.Track) provider.PlaybackReporter { match := func(p playlist.Provider) provider.PlaybackReporter { reporter, ok := p.(provider.PlaybackReporter) if !ok || !reporter.CanReportPlayback(track) { return nil } return reporter } if reporter := match(m.provider); reporter != nil { return reporter } for _, pe := range m.providers { if pe.Provider == nil { continue } if reporter := match(pe.Provider); reporter != nil { return reporter } } return nil }