diff --git a/backend/app.go b/backend/app.go index b63bf38..5e5dc1e 100644 --- a/backend/app.go +++ b/backend/app.go @@ -44,6 +44,7 @@ type App struct { Config *Config ServerManager *ServerManager ImageManager *ImageManager + AudioCache *AudioCache PlaybackManager *PlaybackManager LocalPlayer *mpv.Player UpdateChecker UpdateChecker @@ -142,8 +143,15 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas } a.ServerManager = NewServerManager(appName, appVersion, a.Config, !portableMode && a.Config.Application.EnablePasswordStorage) - a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.LocalPlayer, &a.Config.Playback, &a.Config.Scrobbling, &a.Config.Transcoding, &a.Config.Application) a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, cacheDir) + if a.Config.Playback.UseWaveformSeekbar { + ac, err := NewAudioCache(a.bgrndCtx, a.ServerManager, filepath.Join(cacheDir, "audio")) + if err != nil { + log.Printf("failed to create audio cache: %s", err.Error()) + } + a.AudioCache = ac + } + a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.AudioCache, a.LocalPlayer, &a.Config.Playback, &a.Config.Scrobbling, &a.Config.Transcoding, &a.Config.Application) a.Config.Application.MaxImageCacheSizeMB = clamp(a.Config.Application.MaxImageCacheSizeMB, 1, 500) a.ImageManager.SetMaxOnDiskCacheSizeBytes(int64(a.Config.Application.MaxImageCacheSizeMB) * 1_048_576) a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) { @@ -403,7 +411,7 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) { } meta := nowPlaying.Metadata() smtc.UpdateMetadata(meta.Name, strings.Join(meta.Artists, ", ")) - smtc.UpdatePosition(0, meta.Duration*1000) + smtc.UpdatePosition(0, int(meta.Duration.Milliseconds())) go func() { a.ImageManager.GetCoverThumbnail(meta.CoverArtID) // ensure image is cached locally if path, err := a.ImageManager.GetCoverArtPath(meta.CoverArtID); err == nil { @@ -475,6 +483,9 @@ func (a *App) Shutdown() { } a.PlaybackManager.DisableCallbacks() a.PlaybackManager.Shutdown() // will trigger scrobble check + if a.AudioCache != nil { + a.AudioCache.Shutdown() + } a.cancel() a.LocalPlayer.Destroy() } diff --git a/backend/audiocache.go b/backend/audiocache.go new file mode 100644 index 0000000..d9d9cb8 --- /dev/null +++ b/backend/audiocache.go @@ -0,0 +1,200 @@ +package backend + +import ( + "context" + "errors" + "log" + "os" + "path/filepath" + "slices" + "sync" + + "github.com/20after4/configdir" + "github.com/dweymouth/supersonic/sharedutil" +) + +// AudioCache manages temporary local storage of audio files fetched from the remote music server. +// It prefetches and stores tracks on disk based on an upcoming play queue. +type AudioCache struct { + mutex sync.Mutex + + s *ServerManager + rootCtx context.Context + baseCacheDir string + + entries map[string]*cacheEntry +} + +type cacheEntry struct { + done bool + refCount int + pendingDeletion bool + cancel context.CancelFunc +} + +// AudioCacheRequest represents a request to prefetch and cache an audio file. +type AudioCacheRequest struct { + ID string + DownloadURL string +} + +// NewAudioCache initializes an AudioCache using the given context, server manager, +// and local filesystem directory for storing audio files. +func NewAudioCache(ctx context.Context, s *ServerManager, baseCacheDir string) (*AudioCache, error) { + if err := configdir.MakePath(baseCacheDir); err != nil { + return nil, errors.New("failed to create audio cache dir") + } + return &AudioCache{ + s: s, + rootCtx: ctx, + baseCacheDir: baseCacheDir, + entries: make(map[string]*cacheEntry), + }, nil +} + +// PathForCachedFile returns the local filesystem path for a cached track, +// if the file has finished downloading. If not cached, it returns an empty string. +func (a *AudioCache) PathForCachedFile(id string) string { + a.mutex.Lock() + defer a.mutex.Unlock() + + if item, ok := a.entries[id]; ok && item.done { + return a.pathForID(id) + } + return "" +} + +// IsFullyDownloaded returns true if the file for the given id is fully downloaded. +func (a *AudioCache) IsFullyDownloaded(id string) bool { + return a.PathForCachedFile(id) != "" +} + +// PathForCachedFile returns the local filesystem path for a cached track, +// including one that is in the process of downloading. +// If it is not cached or downloading, it returns an empty string. +func (a *AudioCache) PathForCachedOrDownloadingFile(id string) string { + return a.pathForCachedOrDownloadingFile(id, false) +} + +// ObtainReferenceToFile returns the local filesystem path for a cached track, +// including one that is in the process of downloading, and obtains a refernce +// to it such that it will not be deleted until ReleaseReferenceToFile is called. + +// If it is not cached or downloading, it returns an empty string. +func (a *AudioCache) ObtainReferenceToFile(id string) string { + return a.pathForCachedOrDownloadingFile(id, true) +} + +func (a *AudioCache) pathForCachedOrDownloadingFile(id string, obtainReference bool) string { + a.mutex.Lock() + defer a.mutex.Unlock() + + if entry, ok := a.entries[id]; ok { + if obtainReference { + entry.refCount++ + } + return a.pathForID(id) + } + return "" +} + +func (a *AudioCache) ReleaseReferenceToFile(id string) { + a.mutex.Lock() + defer a.mutex.Unlock() + + if e, ok := a.entries[id]; ok { + e.refCount-- + if e.refCount == 0 && e.pendingDeletion { + a.deleteEntry(id, e) + } + } +} + +// CacheFile begins downloading a file (if not already downloading) and stores it +// to the cache directory under its ID as filename. The download is asynchronous. +func (a *AudioCache) CacheFile(id, dlURL string) { + s := a.s.Server + if s == nil { + return + } + a.mutex.Lock() + defer a.mutex.Unlock() + + a.cacheFile(id, dlURL) +} + +func (a *AudioCache) cacheFile(id, dlURL string) { + if _, ok := a.entries[id]; !ok { + ctx, cancel := context.WithCancel(a.rootCtx) + a.entries[id] = &cacheEntry{cancel: cancel} + go func() { + ok, err := sharedutil.DownloadFileWithContext(ctx, dlURL, a.pathForID(id)) + if ok { + a.mutex.Lock() + if e, ok := a.entries[id]; ok { + e.done = true + } + a.mutex.Unlock() + } else if err != nil && err != context.DeadlineExceeded { + log.Printf("error downloading audio file: %v", err) + } + cancel() // release ctx resources when done + }() + } +} + +// CacheOnly ensures that only the given 'fetch' list of files (plus one extra 'keep' ID) remain cached. +// Any other cached files are cancelled and deleted from disk. +func (a *AudioCache) CacheOnly(keep string, fetch []AudioCacheRequest) { + a.mutex.Lock() + defer a.mutex.Unlock() + + // delete files we're not keeping + for id, e := range a.entries { + if id != keep && !slices.ContainsFunc(fetch, func(a AudioCacheRequest) bool { + return a.ID == id + }) { + if e.refCount == 0 { + a.deleteEntry(id, e) + } else { + e.pendingDeletion = true + } + } + } + + // start caching the ones from fetch if not already present + for _, item := range fetch { + if _, ok := a.entries[item.ID]; !ok { + a.cacheFile(item.ID, item.DownloadURL) + } + } +} + +// Shutdown cancels all in-progress downloads and deletes all files in the audio cache directory. +// This should be called during application shutdown to clean up temporary audio data. +func (a *AudioCache) Shutdown() { + a.mutex.Lock() + // Cancel all active downloads + for _, entry := range a.entries { + if entry.cancel != nil { + entry.cancel() + } + } + a.entries = nil // clear cache state + a.mutex.Unlock() + + // Remove all files in the cache directory + if a.baseCacheDir != "" { + _ = os.RemoveAll(a.baseCacheDir) + } +} + +func (a *AudioCache) pathForID(id string) string { + return filepath.Join(a.baseCacheDir, id) +} + +func (a *AudioCache) deleteEntry(id string, e *cacheEntry) { + e.cancel() + _ = os.Remove(a.pathForID(id)) + delete(a.entries, id) +} diff --git a/backend/config.go b/backend/config.go index 69563d8..a0a8a4d 100644 --- a/backend/config.go +++ b/backend/config.go @@ -110,8 +110,9 @@ type NowPlayingPageConfig struct { } type PlaybackConfig struct { - Autoplay bool - RepeatMode string + Autoplay bool + RepeatMode string + UseWaveformSeekbar bool } type LocalPlaybackConfig struct { @@ -239,8 +240,9 @@ func DefaultConfig(appVersionTag string) *Config { TracklistColumns: []string{"Album", "Time", "Plays"}, }, Playback: PlaybackConfig{ - Autoplay: false, - RepeatMode: "None", + Autoplay: false, + RepeatMode: "None", + UseWaveformSeekbar: true, }, LocalPlayback: LocalPlaybackConfig{ // "auto" is the name to pass to MPV for autoselecting the output device diff --git a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go index f84b951..48c091f 100644 --- a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go +++ b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go @@ -15,8 +15,8 @@ import ( ) const ( - cacheValidDurationSeconds = 60 - runTimeTicksPerSecond = 10_000_000 + cacheValidDurationSeconds = 60 + runTimeTicksPerMicrosecond = 10 ) type JellyfinServer struct { @@ -383,7 +383,7 @@ func (j *jellyfinMediaProvider) TrackBeganPlayback(trackID string) error { } func (j *jellyfinMediaProvider) TrackEndedPlayback(trackID string, position int, submission bool) error { - return j.client.UpdatePlayStatus(trackID, jellyfin.Stop, int64(position)*runTimeTicksPerSecond) + return j.client.UpdatePlayStatus(trackID, jellyfin.Stop, int64(position)*runTimeTicksPerMicrosecond*1_000_000) } func (j *jellyfinMediaProvider) RescanLibrary() error { @@ -408,7 +408,7 @@ func (j *jellyfinMediaProvider) GetLyrics(tr *mediaprovider.Track) (*mediaprovid func toLyricLine(ll jellyfin.LyricLine) mediaprovider.LyricLine { return mediaprovider.LyricLine{ Text: ll.Text, - Start: float64(ll.Start) / float64(runTimeTicksPerSecond), + Start: (time.Duration(ll.Start/runTimeTicksPerMicrosecond) * time.Microsecond).Seconds(), } } @@ -432,7 +432,7 @@ func toTrack(ch *jellyfin.Song) *mediaprovider.Track { CoverArtID: coverArtID, ParentID: ch.AlbumID, Title: ch.Name, - Duration: int(ch.RunTimeTicks / runTimeTicksPerSecond), + Duration: time.Duration(ch.RunTimeTicks/runTimeTicksPerMicrosecond) * time.Microsecond, TrackNumber: ch.IndexNumber, DiscNumber: ch.DiscNumber, //Genre: ch.Genres, @@ -483,7 +483,7 @@ func fillAlbum(a *jellyfin.Album, album *mediaprovider.Album) { album.ID = a.ID album.CoverArtID = a.ID album.Name = a.Name - album.Duration = int(a.RunTimeTicks / runTimeTicksPerSecond) + album.Duration = time.Duration(a.RunTimeTicks/runTimeTicksPerMicrosecond) * time.Microsecond album.ArtistIDs = artistIDs album.ArtistNames = artistNames album.Date.Year = &a.Year @@ -505,7 +505,7 @@ func (j *jellyfinMediaProvider) fillPlaylist(p *jellyfin.Playlist, pl *mediaprov pl.CoverArtID = p.ID pl.Description = p.Overview pl.TrackCount = p.SongCount - pl.Duration = int(p.RunTimeTicks / runTimeTicksPerSecond) + pl.Duration = time.Duration(p.RunTimeTicks/runTimeTicksPerMicrosecond) * time.Microsecond // Jellyfin does not have public playlists pl.Owner = j.client.LoggedInUser() pl.Public = false diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index f2ebb27..626ea62 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -67,7 +67,7 @@ type Album struct { ID string CoverArtID string Name string - Duration int + Duration time.Duration ArtistIDs []string ArtistNames []string Date ItemDate @@ -127,7 +127,7 @@ type Track struct { CoverArtID string ParentID string Title string - Duration int + Duration time.Duration TrackNumber int DiscNumber int Genres []string @@ -165,7 +165,7 @@ type Playlist struct { Description string Public bool Owner string - Duration int + Duration time.Duration TrackCount int } @@ -216,7 +216,7 @@ type MediaItemMetadata struct { Album string AlbumID string CoverArtID string - Duration int + Duration time.Duration } type MediaItem interface { diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index 3f38069..c3bf658 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -551,7 +551,7 @@ func toTrack(ch *subsonic.Child) *mediaprovider.Track { CoverArtID: ch.CoverArt, ParentID: ch.Parent, Title: ch.Title, - Duration: ch.Duration, + Duration: time.Duration(ch.Duration) * time.Second, TrackNumber: ch.Track, DiscNumber: ch.DiscNumber, Genres: genres, @@ -628,7 +628,7 @@ func fillAlbum(subAlbum *subsonic.AlbumID3, album *mediaprovider.Album) { album.ID = subAlbum.ID album.CoverArtID = subAlbum.CoverArt album.Name = subAlbum.Name - album.Duration = subAlbum.Duration + album.Duration = time.Duration(subAlbum.Duration) * time.Second album.ArtistIDs = artistIDs album.ArtistNames = artistNames album.TrackCount = subAlbum.SongCount @@ -714,7 +714,7 @@ func fillPlaylist(pl *subsonic.Playlist, playlist *mediaprovider.Playlist) { playlist.Owner = pl.Owner playlist.Public = pl.Public playlist.TrackCount = pl.SongCount - playlist.Duration = pl.Duration + playlist.Duration = time.Duration(pl.Duration) * time.Second } func (s *subsonicMediaProvider) GetSongRadio(trackID string, count int) ([]*mediaprovider.Track, error) { diff --git a/backend/mpmedia_mac.go b/backend/mpmedia_mac.go index e7616ee..84fdd6f 100644 --- a/backend/mpmedia_mac.go +++ b/backend/mpmedia_mac.go @@ -112,7 +112,7 @@ func (mp *MPMediaHandler) updateMetadata(meta *mediaprovider.MediaItemMetadata) } } artist = strings.Join(meta.Artists, ", ") - duration = meta.Duration + duration = int(meta.Duration.Seconds()) } cTitle := C.CString(title) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index fa54680..fa0d15f 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -52,6 +52,7 @@ type playbackEngine struct { ctx context.Context cancelPollPos context.CancelFunc sm *ServerManager + audiocache *AudioCache player player.BasePlayer playTimeStopwatch util.Stopwatch @@ -76,28 +77,36 @@ type playbackEngine struct { pendingPlayerChange bool pendingPlayerChangeStatus player.Status + // Whether we need to set the next track on the Player + // before the current track completes (normally when 10 seconds remain + // in the time pos polling function) + needToSetNextTrack bool + // to pass to onSongChange listeners; clear once listeners have been called lastScrobbled *mediaprovider.Track + playbackCfg *PlaybackConfig scrobbleCfg *ScrobbleConfig transcodeCfg *TranscodingConfig replayGainCfg ReplayGainConfig // registered callbacks - onSongChange []func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track) - onPlayTimeUpdate []func(float64, float64, bool) - onLoopModeChange []func(LoopMode) - onVolumeChange []func(int) - onSeek []func() - onPaused []func() - onStopped []func() - onPlaying []func() - onPlayerChange []func() - onQueueChange []func() + onBeforeSongChange []func(next mediaprovider.MediaItem) + onSongChange []func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track) + onPlayTimeUpdate []func(float64, float64, bool) + onLoopModeChange []func(LoopMode) + onVolumeChange []func(int) + onSeek []func() + onPaused []func() + onStopped []func() + onPlaying []func() + onPlayerChange []func() + onQueueChange []func() } func NewPlaybackEngine( ctx context.Context, s *ServerManager, + c *AudioCache, p player.BasePlayer, playbackCfg *PlaybackConfig, scrobbleCfg *ScrobbleConfig, @@ -108,7 +117,9 @@ func NewPlaybackEngine( pm := &playbackEngine{ ctx: ctx, sm: s, + audiocache: c, player: p, + playbackCfg: playbackCfg, scrobbleCfg: scrobbleCfg, transcodeCfg: transcodeCfg, nowPlayingIdx: -1, @@ -132,7 +143,7 @@ func NewPlaybackEngine( func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { pl.OnTrackChange(p.handleOnTrackChange) pl.OnSeek(func() { - p.doUpdateTimePos(true) + p.handleTimePosUpdate(true) p.invokeNoArgCallbacks(p.onSeek) }) pl.OnStopped(p.handleOnStopped) @@ -236,7 +247,9 @@ func (p *playbackEngine) NowPlayingIndex() int { func (p *playbackEngine) SetLoopMode(loopMode LoopMode) { p.loopMode = loopMode if p.nowPlayingIdx >= 0 { - p.setNextTrackBasedOnLoopMode(true) + // TODO - don't need when going from LoopNone to LoopAll + // if not on last track + p.handleNextTrackUpdated() } for _, cb := range p.onLoopModeChange { @@ -256,7 +269,7 @@ func (p *playbackEngine) PlaybackStatus() PlaybackStatus { return PlaybackStatus{ State: stat.State, TimePos: stat.TimePos, - Duration: stat.Duration, + Duration: p.curTrackDuration, } } @@ -301,7 +314,7 @@ func (p *playbackEngine) SeekFwdBackN(n int) error { if idx == lastIdx && n > 0 { return nil // already on last track, nothing to seek next to } - newIdx := minInt(lastIdx, maxInt(0, idx+n)) + newIdx := min(lastIdx, max(0, idx+n)) return p.PlayTrackAt(newIdx) } @@ -357,7 +370,9 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu p.nowPlayingIdx = -1 p.playQueue = nil } - needToSetNext := len(items) > 0 && (insertQueueMode == InsertNext || (insertQueueMode == Append && p.nowPlayingIdx == len(p.playQueue)-1)) + if nextChanged := len(items) > 0 && (insertQueueMode != Append || (p.nowPlayingIdx == len(p.playQueue)-1)); nextChanged { + defer p.handleNextTrackUpdated() + } if shuffle { rand.Shuffle(len(items), func(i, j int) { items[i], items[j] = items[j], items[i] }) @@ -369,10 +384,6 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu } p.playQueue = append(p.playQueue[:insertIdx], append(items, p.playQueue[insertIdx:]...)...) - if needToSetNext { - p.setNextTrack(p.nowPlayingIdx + 1) - } - p.invokeNoArgCallbacks(p.onQueueChange) return nil } @@ -383,7 +394,9 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins p.nowPlayingIdx = -1 p.playQueue = nil } - needToSetNext := insertMode == InsertNext || (insertMode == Append && p.nowPlayingIdx == len(p.playQueue)-1) + if nextChanged := insertMode == InsertNext || (insertMode == Append && p.nowPlayingIdx == len(p.playQueue)-1); nextChanged { + p.handleNextTrackUpdated() + } insertIdx := len(p.playQueue) if insertMode == InsertNext { insertIdx = p.nowPlayingIdx + 1 @@ -395,10 +408,6 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins copy(new[len(firstHalf)+1:], p.playQueue[insertIdx:]) p.playQueue = new - if needToSetNext { - p.setNextTrack(p.nowPlayingIdx + 1) - } - p.invokeNoArgCallbacks(p.onQueueChange) } @@ -457,11 +466,10 @@ func (p *playbackEngine) UpdatePlayQueue(items []mediaprovider.MediaItem) error if p.nowPlayingIdx >= 0 && newNowPlayingIdx == -1 { return p.Stop() } - needToUpdateNext := p.nowPlayingIdx >= 0 - p.nowPlayingIdx = newNowPlayingIdx - if needToUpdateNext { - p.setNextTrackAfterQueueUpdate() + if p.nowPlayingIdx >= 0 { + p.handleNextTrackUpdated() } + p.nowPlayingIdx = newNowPlayingIdx p.invokeNoArgCallbacks(p.onQueueChange) return nil @@ -507,7 +515,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) { // when we receive new track event from player } else if isNextPlayingTrackremoved { if newNowPlaying < len(newQueue)-1 { - p.setNextTrack(p.nowPlayingIdx + 1) + p.handleNextTrackUpdated() } else { // no next track to play p.setNextTrack(-1) @@ -555,6 +563,32 @@ func (p *playbackEngine) SetReplayGainMode(mode player.ReplayGainMode) { }) } +func (p *playbackEngine) cacheNextTracks() { + if p.audiocache != nil { + // fetch up to the 2 next tracks in the queue to the cache + fetch := make([]AudioCacheRequest, 0, 3) + // if nothing is playing (index = -1), treat the beginning of the queue as + // the "currently" playing track, since we're probably about to play it + npI := max(p.nowPlayingIdx, 0) + for _, idx := range [3]int{npI, npI + 1, npI + 2} { + if idx > 0 && idx < len(p.playQueue) { + item := p.playQueue[idx] + if item.Metadata().Type == mediaprovider.MediaItemTypeTrack { + fetch = append(fetch, AudioCacheRequest{ + ID: p.playQueue[idx].Metadata().ID, + DownloadURL: p.getMediaURLForIdx(idx), + }) + } + } + } + id := "" + if np := p.NowPlaying(); np != nil { + id = np.Metadata().ID + } + p.audiocache.CacheOnly(id, fetch) + } +} + func (p *playbackEngine) handleOnTrackChange() { // scrobble the previous song if needed if !p.alreadyScrobbled { @@ -581,11 +615,11 @@ func (p *playbackEngine) handleOnTrackChange() { p.wasStopped = false p.alreadyScrobbled = false - p.curTrackDuration = float64(nowPlaying.Metadata().Duration) + p.curTrackDuration = nowPlaying.Metadata().Duration.Seconds() p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled p.invokeOnSongChangeCallbacks() - p.doUpdateTimePos(false) - p.setNextTrackBasedOnLoopMode(false) + p.handleTimePosUpdate(false) + p.handleNextTrackUpdated() } func (p *playbackEngine) handleOnStopped() { @@ -594,7 +628,7 @@ func (p *playbackEngine) handleOnStopped() { p.checkScrobble() } p.stopPollTimePos() - p.doUpdateTimePos(false) + p.handleTimePosUpdate(false) p.invokeOnSongChangeCallbacks() p.invokeNoArgCallbacks(p.onStopped) p.alreadyScrobbled = false @@ -602,69 +636,60 @@ func (p *playbackEngine) handleOnStopped() { p.nowPlayingIdx = -1 } -func (p *playbackEngine) setNextTrackBasedOnLoopMode(onLoopModeChange bool) { - switch p.loopMode { - case LoopNone: - if p.nowPlayingIdx < len(p.playQueue)-1 { - p.setNextTrack(p.nowPlayingIdx + 1) - } else if onLoopModeChange { - // prev was LoopOne - need to erase next track - p.setNextTrack(-1) - } - case LoopOne: - p.setNextTrack(p.nowPlayingIdx) - case LoopAll: - if p.nowPlayingIdx >= len(p.playQueue)-1 { - p.setNextTrack(0) - } else if !onLoopModeChange { - // if onloopmodechange, prev mode was LoopNone and next track is already set - p.setNextTrack(p.nowPlayingIdx + 1) +// to be invoked as soon as the next item in the queue that should play changes +func (p *playbackEngine) handleNextTrackUpdated() { + p.cacheNextTracks() + p.needToSetNextTrack = true + for _, cb := range p.onBeforeSongChange { + var item mediaprovider.MediaItem + if idx := p.nextPlayingIndex(); idx >= 0 { + item = p.playQueue[idx] } + cb(item) } } -func (p *playbackEngine) setNextTrackAfterQueueUpdate() { +func (p *playbackEngine) nextPlayingIndex() int { switch p.loopMode { case LoopNone: - if p.nowPlayingIdx < len(p.playQueue)-1 { - p.setNextTrack(p.nowPlayingIdx + 1) - } else { - // need to erase next track - p.setNextTrack(-1) + if p.nowPlayingIdx >= len(p.playQueue)-1 { + return -1 } + return p.nowPlayingIdx + 1 case LoopOne: - p.setNextTrack(p.nowPlayingIdx) + return p.nowPlayingIdx case LoopAll: if p.nowPlayingIdx >= len(p.playQueue)-1 { - p.setNextTrack(0) - } else { - p.setNextTrack(p.nowPlayingIdx + 1) + return 0 } + return p.nowPlayingIdx + 1 } + return -1 // unreached } func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { + var item mediaprovider.MediaItem + var url string + if idx >= 0 { + item = p.playQueue[idx] + url = p.getMediaURLForIdx(idx) + } + track, isTrack := item.(*mediaprovider.Track) + if p.audiocache != nil && isTrack { + p.audiocache.CacheFile(item.Metadata().ID, p.getMediaURLForIdx(idx)) + } + if urlP, ok := p.player.(player.URLPlayer); ok { - url := "" var meta mediaprovider.MediaItemMetadata if idx >= 0 { - var err error - item := p.playQueue[idx] meta = item.Metadata() - if tr, ok := item.(*mediaprovider.Track); ok { - var ts *mediaprovider.TranscodeSettings - if p.transcodeCfg.RequestTranscode { - ts = &mediaprovider.TranscodeSettings{ - Codec: p.transcodeCfg.Codec, - BitRateKBPS: p.transcodeCfg.MaxBitRateKBPS, - } + if isTrack && p.audiocache != nil { + if filepath := p.audiocache.PathForCachedFile(track.ID); filepath != "" { + url = filepath } - url, err = p.sm.Server.GetStreamURL(tr.ID, ts, p.transcodeCfg.ForceRawFile) - } else { - url = item.(*mediaprovider.RadioStation).StreamURL } - if err != nil { - return err + if url == "" { + return errors.New("no stream URL") } } if next { @@ -687,6 +712,24 @@ func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { panic("Unsupported player type") } +func (p *playbackEngine) getMediaURLForIdx(idx int) string { + var url string + item := p.playQueue[idx] + if tr, ok := item.(*mediaprovider.Track); ok { + var ts *mediaprovider.TranscodeSettings + if p.transcodeCfg.RequestTranscode { + ts = &mediaprovider.TranscodeSettings{ + Codec: p.transcodeCfg.Codec, + BitRateKBPS: p.transcodeCfg.MaxBitRateKBPS, + } + } + url, _ = p.sm.Server.GetStreamURL(tr.ID, ts, p.transcodeCfg.ForceRawFile) + } else { + url = item.(*mediaprovider.RadioStation).StreamURL + } + return url +} + func (p *playbackEngine) setNextTrack(idx int) error { return p.setTrack(idx, true, 0) } @@ -779,7 +822,11 @@ func (pm *playbackEngine) invokeNoArgCallbacks(cbs []func()) { func (p *playbackEngine) startPollTimePos() { ctx, cancel := context.WithCancel(p.ctx) p.cancelPollPos = cancel - pollingTick := time.NewTicker(250 * time.Millisecond) + pollFrequency := 250 * time.Millisecond + if p.playbackCfg.UseWaveformSeekbar { + pollFrequency = 100 * time.Millisecond + } + pollingTick := time.NewTicker(pollFrequency) go func() { for { @@ -788,7 +835,7 @@ func (p *playbackEngine) startPollTimePos() { pollingTick.Stop() return case <-pollingTick.C: - p.doUpdateTimePos(false) + p.handleTimePosUpdate(false) } } }() @@ -801,11 +848,20 @@ func (p *playbackEngine) stopPollTimePos() { } } -func (p *playbackEngine) doUpdateTimePos(seeked bool) { +func (p *playbackEngine) handleTimePosUpdate(seeked bool) { + s := p.PlaybackStatus() + var meta mediaprovider.MediaItemMetadata + if np := p.NowPlaying(); np != nil { + meta = np.Metadata() + } + isNearEnd := meta.Type != mediaprovider.MediaItemTypeRadioStation && s.TimePos > meta.Duration.Seconds()-10 + if p.needToSetNextTrack && isNearEnd { + p.needToSetNextTrack = false + p.setNextTrack(p.nextPlayingIndex()) + } if p.callbacksDisabled { return } - s := p.PlaybackStatus() if s.TimePos > p.latestTrackPosition { p.latestTrackPosition = s.TimePos } @@ -818,17 +874,3 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) { cb(s.TimePos, duration, seeked) } } - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 153760f..72109f7 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -23,6 +23,8 @@ import ( // intermediary between the frontend and various Player backends. type PlaybackManager struct { engine *playbackEngine + wfmGen *WaveformImageGenerator + cache *AudioCache cmdQueue *playbackCommandQueue cfg *AppConfig @@ -31,9 +33,14 @@ type PlaybackManager struct { remotePlayers []RemotePlaybackDevice currentRemotePlayer *RemotePlaybackDevice + onWaveformImgUpdate []func(*WaveformImage) + autoplay bool - lastPlayTime float64 + lastPlayTime float64 + lastPlayingID string + wfmUpdateImageCancel context.CancelFunc + wfmImageJobs [3]*WaveformImageJob } type RemotePlaybackDevice struct { @@ -46,13 +53,14 @@ type RemotePlaybackDevice struct { func NewPlaybackManager( ctx context.Context, s *ServerManager, + c *AudioCache, p player.BasePlayer, playbackCfg *PlaybackConfig, scrobbleCfg *ScrobbleConfig, transcodeCfg *TranscodingConfig, appCfg *AppConfig, ) *PlaybackManager { - e := NewPlaybackEngine(ctx, s, p, playbackCfg, scrobbleCfg, transcodeCfg) + e := NewPlaybackEngine(ctx, s, c, p, playbackCfg, scrobbleCfg, transcodeCfg) q := NewCommandQueue() pm := &PlaybackManager{ engine: e, @@ -60,12 +68,32 @@ func NewPlaybackManager( cfg: appCfg, autoplay: playbackCfg.Autoplay, localPlayer: p, + cache: c, + } + if c != nil { + pm.wfmGen = NewWaveformImageGenerator(c) } pm.addOnTrackChangeHook() go pm.runCmdQueue(ctx) return pm } +func (p *PlaybackManager) findWfmImageJob(id string, uncanceledOnly bool) (*WaveformImageJob, bool) { + for _, j := range p.wfmImageJobs { + if j != nil && j.ItemID == id && (!uncanceledOnly || !j.Canceled()) { + return j, true + } + } + return nil, false +} + +func (p *PlaybackManager) addWfmImageJob(job *WaveformImageJob) { + p.wfmImageJobs[0].Cancel() + p.wfmImageJobs[0] = p.wfmImageJobs[1] + p.wfmImageJobs[1] = p.wfmImageJobs[2] + p.wfmImageJobs[2] = job +} + func (p *PlaybackManager) addOnTrackChangeHook() { // See https://github.com/dweymouth/supersonic/issues/483 // On Windows, MPV sometimes fails to start playback when switching to a track @@ -75,11 +103,23 @@ func (p *PlaybackManager) addOnTrackChangeHook() { p.lastPlayTime = curTime }) - p.OnSongChange(func(mediaprovider.MediaItem, *mediaprovider.Track) { + p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) { + if p.engine.playbackCfg.UseWaveformSeekbar { + if p.wfmGen != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack { + if _, ok := p.findWfmImageJob(item.Metadata().ID, true); !ok { + // start generating waveform image for next-up track + p.addWfmImageJob(p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track))) + } + } + } + }) + + p.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) { // Autoplay if enabled and we are on the last track if p.autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 { p.enqueueAutoplayTracks() } + p.handleWaveformImageSongChange(item) if runtime.GOOS != "windows" { return @@ -98,6 +138,73 @@ func (p *PlaybackManager) addOnTrackChangeHook() { }) } +func (p *PlaybackManager) handleWaveformImageSongChange(item mediaprovider.MediaItem) { + if p.wfmUpdateImageCancel != nil { + p.wfmUpdateImageCancel() + } + if !p.engine.playbackCfg.UseWaveformSeekbar { + return + } + + updateUnfinishedJob := func(job *WaveformImageJob) { + ctx, c := context.WithCancel(p.cache.rootCtx) + p.wfmUpdateImageCancel = c + go func(ctx context.Context, job *WaveformImageJob) { + for { + time.Sleep(333 * time.Millisecond) + select { + case <-ctx.Done(): + return + default: + img := job.Get() + for _, cb := range p.onWaveformImgUpdate { + cb(img) + } + if job.Done() { + return + } + } + } + }(ctx, job) + } + + if item != nil { + // cancel possible waveform generation job for previous track + if p.lastPlayingID != item.Metadata().ID { + if old, ok := p.findWfmImageJob(p.lastPlayingID, false); ok { + old.Cancel() + } + p.lastPlayingID = item.Metadata().ID + } + + var job *WaveformImageJob + if j, ok := p.findWfmImageJob(item.Metadata().ID, true); ok { + job = j + } else if tr, ok := item.(*mediaprovider.Track); ok { + job = p.wfmGen.StartWaveformGeneration(tr) + p.addWfmImageJob(job) + } + if job != nil { + img := job.Get() + for _, cb := range p.onWaveformImgUpdate { + cb(img) + } + if !job.done && job != nil { + updateUnfinishedJob(job) + } + } + } + + if item == nil || item.Metadata().Type != mediaprovider.MediaItemTypeTrack { + // set a zero waveform image when we're not playing anything + // or playing a media type we can't derive a waveform from (e.g. radio) + img := NewWaveformImage() + for _, cb := range p.onWaveformImgUpdate { + cb(img) + } + } +} + func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, fastScan bool) { if fastScan { p.scanRemotePlayers(ctx, 1 /*waitSec*/) @@ -168,6 +275,10 @@ func (p *PlaybackManager) OnPlayerChange(cb func()) { p.engine.onPlayerChange = append(p.engine.onPlayerChange, cb) } +func (p *PlaybackManager) OnWaveformImgUpdate(cb func(*WaveformImage)) { + p.onWaveformImgUpdate = append(p.onWaveformImgUpdate, cb) +} + func (p *PlaybackManager) IsSeeking() bool { return p.engine.IsSeeking() } diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index bbd9e2b..bdaf4a8 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -181,8 +181,8 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat }() } d.state = playing - remainingDur := meta.Duration - int(startTime) - d.setTrackChangeTimer(time.Duration(remainingDur) * time.Second) + remainingDur := meta.Duration - time.Duration(startTime)*time.Second + d.setTrackChangeTimer(remainingDur) d.stopwatch.Reset() d.stopwatch.Start() d.lastStartTime = int(startTime) @@ -265,7 +265,7 @@ func (d *DLNAPlayer) Continue() error { return err } d.metaLock.Lock() - nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - d.curPlayPos() + nextTrackChange := d.curTrackMeta.Duration - d.curPlayPos() d.metaLock.Unlock() d.state = playing d.setTrackChangeTimer(nextTrackChange) @@ -349,7 +349,7 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { if d.state == playing { d.metaLock.Lock() - nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - time.Duration(secs)*time.Second + nextTrackChange := d.curTrackMeta.Duration - time.Duration(secs)*time.Second d.metaLock.Unlock() d.setTrackChangeTimer(nextTrackChange) d.stopwatch.Start() @@ -397,7 +397,7 @@ func (d *DLNAPlayer) GetStatus() player.Status { return player.Status{ State: state, TimePos: timePos, - Duration: float64(d.curTrackMeta.Duration), + Duration: d.curTrackMeta.Duration.Seconds(), } } @@ -428,7 +428,7 @@ func (d *DLNAPlayer) syncPlaybackTime() { if d.state == playing { d.stopwatch.Start() } - d.setTrackChangeTimer(time.Duration(d.curTrackMeta.Duration-d.lastStartTime) * time.Second) + d.setTrackChangeTimer(d.curTrackMeta.Duration - time.Duration(d.lastStartTime)*time.Second) d.InvokeOnSeek() } } @@ -511,7 +511,7 @@ func (d *DLNAPlayer) handleOnTrackChange() { } d.curTrackMeta = d.nextTrackMeta d.nextTrackMeta = mediaprovider.MediaItemMetadata{} - nextTrackChange := time.Duration(d.curTrackMeta.Duration) * time.Second + nextTrackChange := d.curTrackMeta.Duration d.metaLock.Unlock() if stopping { @@ -559,7 +559,13 @@ func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) { return } - // Create a new request to the target server + // if the url is a filepath for a local cached file, serve it + if info, err := os.Stat(url); err == nil && info.Size() > 0 { + http.ServeFile(w, r, url) + return + } + + // Otherwise, proxy request to the music server proxyReq, err := http.NewRequest(r.Method, url, r.Body) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/backend/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go index b4bad7d..fd7be0a 100644 --- a/backend/player/jukebox/jukeboxplayer.go +++ b/backend/player/jukebox/jukeboxplayer.go @@ -90,7 +90,7 @@ func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track, _ float64) error { j.curTrack = 0 j.queueLength = 1 - j.curTrackDuration = float64(track.Duration) + j.curTrackDuration = track.Duration.Seconds() return nil } diff --git a/backend/util/filestreamer.go b/backend/util/filestreamer.go new file mode 100644 index 0000000..36e4892 --- /dev/null +++ b/backend/util/filestreamer.go @@ -0,0 +1,130 @@ +package util + +import ( + "context" + "io" + "log" + "net" + "net/http" + "os" + "time" +) + +type FileStreamerServer struct { + Path string + IsComplete func() bool + listener net.Listener + server *http.Server + done chan struct{} +} + +// NewFileStreamerServer creates a new server but doesn't start it yet. +func NewFileStreamerServer(path string, isComplete func() bool) (*FileStreamerServer, error) { + listener, err := net.Listen("tcp", ":0") + if err != nil { + return nil, err + } + + fs := &FileStreamerServer{ + Path: path, + IsComplete: isComplete, + listener: listener, + done: make(chan struct{}), + } + + fs.server = &http.Server{ + Handler: handler{fs}, + } + + return fs, nil +} + +// Addr returns the server address (host:port). +func (fs *FileStreamerServer) Addr() string { + _, port, _ := net.SplitHostPort(fs.listener.Addr().String()) + return "http://127.0.0.1:" + port + "/" +} + +// Serve starts serving and waits for a single request to complete. +func (fs *FileStreamerServer) Serve() error { + go func() { + _ = fs.server.Serve(fs.listener) + }() + + <-fs.done // wait for the handler to finish + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + return fs.server.Shutdown(ctx) +} + +// Handler that streams the file using chunked transfer encoding. +func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, _ *http.Request) { + defer close(fs.done) // signal Serve() to shut down after this request + + file, err := os.Open(fs.Path) + if err != nil { + log.Println("File streamer failed to open source file") + http.Error(w, "could not open file", http.StatusInternalServerError) + return + } + defer file.Close() + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Transfer-Encoding", "chunked") + w.WriteHeader(http.StatusOK) + + flusher, canFlush := w.(http.Flusher) + + bytesRead := int64(0) + buf := make([]byte, 4096) + for { + complete := fs.IsComplete() + if !complete { + if s, err := os.Stat(fs.Path); err == nil { + // make sure we don't read near EOF until file is complete + maxToRead := max(0, s.Size()-bytesRead-1024) /*safety buffer*/ + buf = buf[:min(int64(cap(buf)), maxToRead)] + } + } else { + buf = buf[:cap(buf)] + } + n, err := file.Read(buf) + if err != nil && err != io.EOF { + log.Printf("read error: %v", err) + break + } + bytesRead += int64(n) + + if n > 0 { + _, err := w.Write(buf[:n]) + if err != nil { + log.Printf("client write error: %v", err) + break + } + if canFlush { + flusher.Flush() + } + } + + if n == 0 && complete { + break + } + + // Wait for more content to be written to the source file + if n == 0 { + time.Sleep(50 * time.Millisecond) + } + } +} + +type handler struct { + fs *FileStreamerServer +} + +var _ http.Handler = handler{} + +func (h handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + h.fs.streamHandler(w, req) +} diff --git a/backend/waveformimage.go b/backend/waveformimage.go new file mode 100644 index 0000000..e065e0f --- /dev/null +++ b/backend/waveformimage.go @@ -0,0 +1,462 @@ +package backend + +import ( + "context" + "errors" + "fmt" + "image" + "image/color" + "io" + "log" + "math" + "os" + "path/filepath" + "sync" + "time" + + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/backend/util" + "github.com/go-audio/audio" + "github.com/go-audio/wav" + "github.com/supersonic-app/go-mpv" +) + +type WaveformImageGenerator struct { + audioCache *AudioCache +} + +type WaveformImage = image.NRGBA + +func NewWaveformImage() *WaveformImage { + img := image.NewNRGBA(image.Rect(0, 0, 1024, 48)) + centerTop := img.Rect.Dy() / 2 // 24 + centerBottom := centerTop - 1 // 23 + + // color in center line + for x := 0; x < img.Bounds().Dx(); x++ { + setPixel(img, x, centerTop, color.NRGBA{R: 255, G: 255, B: 255, A: 255}) + setPixel(img, x, centerBottom, color.NRGBA{R: 255, G: 255, B: 255, A: 255}) + } + return img +} + +type WaveformImageJob struct { + ItemID string + lock sync.Mutex + img *WaveformImage + err error + progress int // first invalid pixel in X direction + done bool + cancel func() + canceled bool +} + +func (w *WaveformImageJob) Cancel() { + if w != nil { + w.canceled = true + if w.cancel != nil { + w.cancel() + } + } +} + +func (w *WaveformImageJob) Canceled() bool { + return w.canceled +} + +func (w *WaveformImageJob) Done() bool { + w.lock.Lock() + defer w.lock.Unlock() + return w.err != nil || w.done +} + +func (w *WaveformImageJob) Err() error { + w.lock.Lock() + defer w.lock.Unlock() + return w.err +} + +func (w *WaveformImageJob) Get() *WaveformImage { + if w.Done() { + return w.img + } + // return a new *WaveformImage with data copied + // from the valid region of w.img + height := w.img.Bounds().Dy() + result := NewWaveformImage() + + // Copy each scanline from w.img to result + for y := 0; y < height; y++ { + srcOffset := w.img.PixOffset(0, y) + dstOffset := result.PixOffset(0, y) + copy(result.Pix[dstOffset:dstOffset+w.progress*4], w.img.Pix[srcOffset:srcOffset+w.progress*4]) + } + + return result +} + +func NewWaveformImageGenerator(cache *AudioCache) *WaveformImageGenerator { + return &WaveformImageGenerator{audioCache: cache} +} + +func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Track) *WaveformImageJob { + ctx, cancel := context.WithCancel(w.audioCache.rootCtx) + job := &WaveformImageJob{ + img: NewWaveformImage(), + ItemID: item.ID, + cancel: cancel, + } + + // Set up a pipeline of concurrent tasks that need to complete to generate + // a waveform image: + // 1. Begin downloading the file from the server + // 2. Begin transcoding it to WAV + // 3. Begin analyzing the resulting WAV file + // 4. Begin generating the image from the analysis data + go func() { + path := w.audioCache.ObtainReferenceToFile(job.ItemID) + // wait for file to begin downloading if not already + for path == "" { + time.Sleep(50 * time.Millisecond) + if e := ctx.Err(); e != nil { + job.setError(e) + return + } + path = w.audioCache.ObtainReferenceToFile(job.ItemID) + } + // and wait for content to begin being written + for { + if s, err := os.Stat(path); err == nil && s.Size() > 0 { + break + } + time.Sleep(50 * time.Millisecond) + if e := ctx.Err(); e != nil { + job.setError(e) + return + } + } + + dir := filepath.Dir(path) + var transcodeFile string + for i := 0; true; i++ { + if i > 0 { + transcodeFile = filepath.Join(dir, fmt.Sprintf("%s_waveform_%d.wav", filepath.Base(path), i)) + } else { + transcodeFile = filepath.Join(dir, filepath.Base(path)+"_waveform.wav") + } + if _, err := os.Stat(transcodeFile); os.IsNotExist(err) { + break // found a suitable filename that doesn't exist + } + } + + fileDone := func() bool { + return w.audioCache.IsFullyDownloaded(job.ItemID) + } + + // If file isn't fully downloaded from server, + // stream it to MPV via fifo so it doesn't possibly + // terminate the conversion to WAV early encountering EOF + if !fileDone() { + srv, err := util.NewFileStreamerServer(path, fileDone) + if err != nil { + job.setError(err) + return + } + + path = srv.Addr() + + go srv.Serve() + time.Sleep(10 * time.Millisecond) // make sure server has time to come up + } + + // Start converting the file to WAV for analysis + var wavConvertDone bool + go func() { + err := w.convertToWav(ctx, job.ItemID, path, transcodeFile) + wavConvertDone = true + if err != nil { + job.setError(err) + } + }() + + // Wait for transcoded WAV file to begin being written + for { + if s, err := os.Stat(transcodeFile); err == nil && s.Size() > 0 { + break + } + time.Sleep(50 * time.Millisecond) + if e := ctx.Err(); e != nil { + job.setError(e) + return + } + } + + // Start analyzing the converted wav file + data := &waveformData{} + go func() { + err := analyzeWavFile(ctx, transcodeFile, data, item.Duration.Milliseconds(), func() bool { return wavConvertDone }) + if err != nil { + job.setError(err) + } + data.done = true + }() + + // Start generating the waveform image + go func() { + generateWaveformImage(ctx, data, job) + job.done = true + }() + }() + return job +} + +type waveformData struct { + Peak [1024]byte + RMS [1024]byte + + progress int // first invalid index for Peak/RMS data + done bool +} + +func generateWaveformImage(ctx context.Context, data *waveformData, job *WaveformImageJob) { + centerY := job.img.Rect.Dy() / 2 // 24 + top := centerY - 1 // 23 + bottom := centerY // 24 + + opaqueColor := color.NRGBA{R: 255, G: 255, B: 255, A: 255} + translucentColor := color.NRGBA{R: 255, G: 255, B: 255, A: 128} + + for x := 0; x < 1024; x++ { + for data.progress <= x { + if data.done { + return + } + if ctx.Err() != nil { + return // expired + } + time.Sleep(50 * time.Millisecond) + } + + rmsPixels := int(data.RMS[x]) * centerY / 255 + peakPixels := int(data.Peak[x]) * centerY / 255 + + // Always draw at least 2 center pixels + setPixel(job.img, x, top, opaqueColor) + setPixel(job.img, x, bottom, opaqueColor) + + // Draw RMS pixels (solid) + for i := 1; i < rmsPixels; i++ { + setPixel(job.img, x, top-i, opaqueColor) + setPixel(job.img, x, bottom+i, opaqueColor) + } + + // Draw Peak extension (translucent) + for i := max(1, rmsPixels); i < peakPixels; i++ { + setPixel(job.img, x, top-i, translucentColor) + setPixel(job.img, x, bottom+i, translucentColor) + } + job.progress = x + 1 + } +} + +// assumes mono, 16 bit +func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformData, millisecs int64, fileDone func() bool) error { + f, err := os.Open(transcodeFile) + if err != nil { + return err + } + defer f.Close() + defer os.Remove(transcodeFile) + + decoder := wav.NewDecoder(f) + if !decoder.IsValidFile() { + return errors.New("invalid wav file") + } + + format := decoder.Format() + + totalSamples := format.SampleRate * int(millisecs) / 1000 + samplesPerChunk := totalSamples / 1024 + + if err := decoder.FwdToPCM(); err != nil { + return err + } + + buf := &audio.IntBuffer{Data: make([]int, 4096)} + curChunk := 0 + chunkSamples := make([]float64, 0, samplesPerChunk) + bytesPerSample := int64(2 * format.NumChannels) // 16-bit = 2 bytes per channel + + // file read loop + doneReading := false + for !doneReading { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + fileIsDone := fileDone() + + if !fileIsDone { + // Check how many samples we can safely read without encountering EOF + // and adjust read buffer size accordingly + + stat, err := f.Stat() + if err != nil { + return fmt.Errorf("stat failed: %w", err) + } + currentSize := stat.Size() + + // how many bytes can we read without nearing EOF + readableBytes := currentSize - int64(samplesPerChunk)*int64(curChunk)*bytesPerSample - 8192 //buffer for safety + + // Estimate how many samples we can read + maxSamples := int(readableBytes / bytesPerSample) + + if maxSamples <= 0 { + // Wait for more data to be written to file + time.Sleep(50 * time.Millisecond) + continue + } + + // Resize buffer to fit only what’s safe + safeSamples := min(maxSamples, cap(buf.Data)) + buf.Data = buf.Data[:safeSamples] + } else { + // File is done being written, resize read buf to the max + buf.Data = buf.Data[:cap(buf.Data)] + } + + n, err := decoder.PCMBuffer(buf) + if n == 0 || err == io.EOF { + if fileIsDone { + doneReading = true + } + if err == io.EOF && !fileDone() { + return errors.New("WAV read got premature EOF") + } + } + if err != nil { + return err + } + + // Process samples + for i := 0; i < n; i++ { + sample := float64(buf.Data[i]) / float64(1<<15) // Normalize to [-1, 1] + chunkSamples = append(chunkSamples, sample) + + if len(chunkSamples) >= samplesPerChunk { + if curChunk < 1024 { + peak, rms := computePeakAndRMS(chunkSamples) + data.Peak[curChunk] = float64ToByte(peak) + data.RMS[curChunk] = float64ToByte(rms) + } + curChunk++ + data.progress = curChunk + chunkSamples = chunkSamples[:0] + if curChunk >= 1024 { + break + } + } + } + } + + // analyze the last chunk if it's partially filled with samples + if curChunk < 1024 && len(chunkSamples) > 0 { + peak, rms := computePeakAndRMS(chunkSamples) + data.Peak[curChunk] = float64ToByte(peak) + data.RMS[curChunk] = float64ToByte(rms) + data.progress = curChunk + 1 + } + + return nil +} + +func (j *WaveformImageJob) setError(err error) { + j.lock.Lock() + defer j.lock.Unlock() + + j.err = err +} + +func computePeakAndRMS(chunk []float64) (peak float64, rms float64) { + var sumSquares float64 + peak = 0.0 + for _, v := range chunk { + if v > peak { + peak = v + } else if v < -peak { + peak = -v + } + sumSquares += float64(v * v) + } + rms = math.Sqrt(sumSquares / float64(len(chunk))) + return +} + +func float64ToByte(val float64) byte { + if val > 1.0 { + val = 1.0 + } + if val < 0.0 { + val = 0.0 + } + return byte(val * 255) +} + +func (w *WaveformImageGenerator) convertToWav(ctx context.Context, id, inPath, outPath string) error { + m := mpv.Create() + m.SetOptionString("video", "no") + m.SetOptionString("audio-display", "no") + m.SetOptionString("terminal", "no") + m.SetOptionString("idle", "yes") + m.SetOptionString("ao-pcm-file", outPath) + m.SetOptionString("ao", "pcm") + m.SetOption("volume", mpv.FORMAT_INT64, 100) + // no need to preserve full sample resolution just for waveform image + // let's make less data to process and smaller on-disk file + m.SetOption("audio-samplerate", mpv.FORMAT_INT64, 22050) + m.SetOptionString("audio-channels", "mono") + m.SetOptionString("audio-format", "s16") + if err := m.Initialize(); err != nil { + return err + } + + defer m.TerminateDestroy() + + m.Command([]string{"loadfile", inPath, "replace"}) + defer w.audioCache.ReleaseReferenceToFile(id) + + // Wait for MPV idle or ctx expiry + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + // use small timeout to allow detecting ctx expiry + // without too much delay + e := m.WaitEvent(0.05 /*timeout seconds*/) + if e.Event_Id == mpv.EVENT_IDLE { + if _, err := os.Stat(outPath); os.IsNotExist(err) { + log.Printf("WARNING! file %s does not exist after MPV convert", outPath) + } + return nil + } + ia := m.GetPropertyString("idle-active") + if ia == "yes" || ia == "true" { + if _, err := os.Stat(outPath); os.IsNotExist(err) { + log.Printf("WARNING! file %s does not exist after MPV convert", outPath) + } + return nil + } + } + } +} + +func setPixel(img *image.NRGBA, x, y int, c color.NRGBA) { + offset := img.PixOffset(x, y) + img.Pix[offset+0] = c.R + img.Pix[offset+1] = c.G + img.Pix[offset+2] = c.B + img.Pix[offset+3] = c.A +} diff --git a/go.mod b/go.mod index 13ba0ec..f26226d 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,8 @@ require ( github.com/dweymouth/fyne-advanced-list v0.0.0-20250211191927-58ea85eec72c github.com/dweymouth/fyne-tooltip v0.3.0 github.com/dweymouth/go-jellyfin v0.0.0-20250716005557-0ea2becece1f + github.com/go-audio/audio v1.0.0 + github.com/go-audio/wav v1.1.0 github.com/godbus/dbus/v5 v5.1.0 github.com/google/uuid v1.3.0 github.com/hashicorp/go-retryablehttp v0.7.7 @@ -39,6 +41,7 @@ require ( github.com/fyne-io/glfw-js v0.2.0 // indirect github.com/fyne-io/image v0.1.1 // indirect github.com/fyne-io/oksvg v0.1.0 // indirect + github.com/go-audio/riff v1.0.0 // indirect github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect github.com/go-text/render v0.2.0 // indirect @@ -64,3 +67,5 @@ require ( ) replace fyne.io/fyne/v2 v2.6.1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250712002006-5064d705dac4 + +replace github.com/go-audio/wav v1.1.0 => github.com/dweymouth/go-wav v0.0.0-20250719173115-e60429a83eb0 diff --git a/go.sum b/go.sum index 2ae2ab4..fbd0355 100644 --- a/go.sum +++ b/go.sum @@ -23,12 +23,10 @@ github.com/dweymouth/fyne-tooltip v0.3.0 h1:NKCyTkh9NtvnTsiHtTOtaJzRDOFYP8AckQ2t github.com/dweymouth/fyne-tooltip v0.3.0/go.mod h1:m04ShLW/Tp6LXrNieTumApvNgo7YSB+wi+jZTN+kDBU= github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250712002006-5064d705dac4 h1:Q3r94AcVL8yaF4Nrd3EQFKyLL3UN/zHPkj+My9B2vCQ= github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250712002006-5064d705dac4/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU= -github.com/dweymouth/go-jellyfin v0.0.0-20250531151636-29591764f0a0 h1:9t1CR83uzn5Va4Nycwncmuw/NEAN64O4lf82VBPzdfE= -github.com/dweymouth/go-jellyfin v0.0.0-20250531151636-29591764f0a0/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= -github.com/dweymouth/go-jellyfin v0.0.0-20250715154414-d0a1630ee74f h1:7O7Cn17pwKHG7zPgNhxMabXJP1ZdmNVxCb4i8yJgVZo= -github.com/dweymouth/go-jellyfin v0.0.0-20250715154414-d0a1630ee74f/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= github.com/dweymouth/go-jellyfin v0.0.0-20250716005557-0ea2becece1f h1:QsKPwFpTHuYHEEuhvp4VBClkHh00bNNgQ/2Ij1bkk8M= github.com/dweymouth/go-jellyfin v0.0.0-20250716005557-0ea2becece1f/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= +github.com/dweymouth/go-wav v0.0.0-20250719173115-e60429a83eb0 h1:mYcctuWgVArHhSLJxndlUM43C3hoE18BLDBkXKM2tl0= +github.com/dweymouth/go-wav v0.0.0-20250719173115-e60429a83eb0/go.mod h1:bp2870jtp/ixAJLIOdShBfl1WpyLGDZ57jnVWMgkgIc= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= @@ -47,6 +45,14 @@ github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-audio/aiff v0.0.0-20180403003018-6c3a8a6aff12/go.mod h1:AMSAp6W1zd0koOdX6QDgGIuBDTUvLa2SLQtm7d9eM3c= +github.com/go-audio/aiff v1.0.0/go.mod h1:Kazp+9JR/Y1ITCXaDlO6OIIOrz6eGGAn+dGT04V4HPM= +github.com/go-audio/audio v0.0.0-20180206231410-b697a35b5608/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= +github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4= +github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= +github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA= +github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= +github.com/go-audio/wav v0.0.0-20181013172942-de841e69b884/go.mod h1:UiqzUyfX0zs3pJ/DPyvS5v8sN6s5bXPUDDIVA5v8dks= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= @@ -87,6 +93,7 @@ github.com/koron/go-ssdp v0.0.5 h1:E1iSMxIs4WqxTbIBLtmNBeOOC+1sCIXQeqTWVnpmwhk= github.com/koron/go-ssdp v0.0.5/go.mod h1:Qm59B7hpKpDqfyRNWRNr00jGwLdXjDyZh6y7rH6VS0w= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattetti/audio v0.0.0-20180912171649-01576cde1f21/go.mod h1:LlQmBGkOuV/SKzEDXBPKauvN2UqCgzXO2XjecTGj40s= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go index cf5bccb..e094364 100644 --- a/sharedutil/sharedutil.go +++ b/sharedutil/sharedutil.go @@ -1,6 +1,12 @@ package sharedutil import ( + "context" + "fmt" + "io" + "net/http" + "os" + "github.com/dweymouth/supersonic/backend/mediaprovider" ) @@ -133,3 +139,50 @@ func ReorderItems[T any](items []T, idxToMove []int, insertIdx int) []T { return newItems } + +// DownloadFileWithContext downloads a file from the specified URL and saves it to destPath. +// It respects the provided context and will cancel the request and cleanup if context is done. +// Returns an error if an error other than cancellation occurs, and returns true IFF the file was completely downloaded. +func DownloadFileWithContext(ctx context.Context, url string, destPath string) (bool, error) { + // Create HTTP request with context + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, fmt.Errorf("creating request: %w", err) + } + + // Perform the request + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, fmt.Errorf("performing request: %w", err) + } + defer resp.Body.Close() + + // Check for non-200 status codes + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("bad status: %s", resp.Status) + } + + // Create the destination file + out, err := os.Create(destPath) + if err != nil { + return false, fmt.Errorf("creating file: %w", err) + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + + select { + case <-ctx.Done(): + // Cancelled, delete partial file + out.Close() + os.Remove(destPath) + return false, nil + default: + if err != nil { + os.Remove(destPath) + return false, fmt.Errorf("error copying data: %w", err) + } + } + + return true, nil +} diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 60e0ae4..ce0a2ba 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -28,11 +28,12 @@ type BottomPanel struct { var _ fyne.Widget = (*BottomPanel)(nil) -func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr *controller.Controller) *BottomPanel { +func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr *controller.Controller, useWaveformSeekbar bool) *BottomPanel { bp := &BottomPanel{} bp.ExtendBaseWidget(bp) pm.OnSongChange(bp.onSongChange) + pm.OnWaveformImgUpdate(bp.updateWaveformImg) pm.OnPlayTimeUpdate(func(cur, total float64, _ bool) { fyne.Do(func() { if !pm.IsSeeking() { @@ -85,7 +86,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr contr.ShowShareDialog(tr.ID) } } - bp.Controls = widgets.NewPlayerControls() + bp.Controls = widgets.NewPlayerControls(useWaveformSeekbar) bp.Controls.OnPlayPause(func() { pm.PlayPause() }) @@ -124,7 +125,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage) - bp.container = container.New(layouts.NewLeftMiddleRightLayout(500), + bp.container = container.New(layouts.NewLeftMiddleRightLayout(300, 0.4), bp.NowPlaying, bp.Controls, bp.AuxControls) return bp } @@ -141,6 +142,12 @@ func (bp *BottomPanel) onSongChange(song mediaprovider.MediaItem, _ *mediaprovid }) } +func (bp *BottomPanel) updateWaveformImg(img *backend.WaveformImage) { + fyne.Do(func() { + bp.Controls.UpdateWaveformImg(img) + }) +} + func (bp *BottomPanel) CreateRenderer() fyne.WidgetRenderer { bp.ExtendBaseWidget(bp) return widget.NewSimpleRenderer(bp.container) diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go index 0223958..8bb04a4 100644 --- a/ui/browsing/albumpage.go +++ b/ui/browsing/albumpage.go @@ -404,7 +404,7 @@ func formatMiscLabelStr(a *mediaprovider.AlbumWithTracks) string { if y := a.ReissueDate.Year; y != nil && *y > a.YearOrZero() { yearStr += fmt.Sprintf(" (%s %s)", lang.L("reissued"), util.FormatItemDate(a.ReissueDate)) } - return fmt.Sprintf("%s · %s · %s%s", yearStr, tracksMsg, discs, util.SecondsToTimeString(float64(a.Duration))) + return fmt.Sprintf("%s · %s · %s%s", yearStr, tracksMsg, discs, util.SecondsToTimeString(a.Duration.Seconds())) } func (s *albumPageState) Restore() Page { diff --git a/ui/browsing/browsingpane.go b/ui/browsing/browsingpane.go index e666ae7..deecf88 100644 --- a/ui/browsing/browsingpane.go +++ b/ui/browsing/browsingpane.go @@ -109,7 +109,7 @@ func NewBrowsingPane(app *backend.App, contr *controller.Controller, onGoHome fu b.navBtnsPageMap = map[controller.PageName]fyne.Resource{} b.container = container.NewBorder(container.New( &layout.CustomPaddedLayout{LeftPadding: -5, RightPadding: -5}, - container.New(layouts.NewLeftMiddleRightLayout(0), + container.New(layouts.NewLeftMiddleRightLayout(0, 0), container.NewHBox(b.home, b.back, b.forward, b.reload), b.navBtnsContainer, container.NewHBox(layout.NewSpacer(), quickSearchBtn, b.settingsBtn))), nil, nil, nil, b.pageContainer) diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 7bd3563..90ff4f3 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -375,7 +375,7 @@ func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Tr } } if lyrics == nil && a.lrcFetch != nil { - lyrics, err = a.lrcFetch.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, song.Duration) + lyrics, err = a.lrcFetch.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, int(song.Duration.Seconds())) if err != nil { log.Println(err.Error()) } @@ -445,7 +445,7 @@ func (a *NowPlayingPage) Reload() { a.queueList.SetItems(a.queue) a.totalTime = 0.0 for _, tr := range a.queue { - a.totalTime += float64(tr.Metadata().Duration) + a.totalTime += tr.Metadata().Duration.Seconds() } a.formatStatusLine() @@ -546,7 +546,7 @@ func (a *NowPlayingPage) formatStatusLine() { dur := 0.0 if np := a.pm.NowPlaying(); np != nil { - dur = float64(np.Metadata().Duration) + dur = np.Metadata().Duration.Seconds() } statusSuffix := "" trackNum := 0 diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index a9b3cf1..5448eb1 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -514,7 +514,7 @@ func (a *PlaylistPageHeader) formatPlaylistTrackTimeStr(p *mediaprovider.Playlis fallbackTracksMsg := fmt.Sprintf("%d %s", p.TrackCount, tracks) tracksMsg := lang.LocalizePluralKey("{{.trackCount}} tracks", fallbackTracksMsg, p.TrackCount, map[string]string{"trackCount": strconv.Itoa(p.TrackCount)}) - return fmt.Sprintf("%s, %s", tracksMsg, util.SecondsToTimeString(float64(p.Duration))) + return fmt.Sprintf("%s, %s", tracksMsg, util.SecondsToTimeString(p.Duration.Seconds())) } func (s *playlistPageState) Restore() Page { diff --git a/ui/dialogs/settingsdialog.go b/ui/dialogs/settingsdialog.go index 8b26732..9bb0b29 100644 --- a/ui/dialogs/settingsdialog.go +++ b/ui/dialogs/settingsdialog.go @@ -559,6 +559,12 @@ func (s *SettingsDialog) createAppearanceTab(window fyne.Window) *container.TabI } } + useWaveformSeekbar := widget.NewCheck(lang.L("Use waveform seekbar"), func(b bool) { + s.config.Playback.UseWaveformSeekbar = b + s.setRestartRequired() + }) + useWaveformSeekbar.Checked = s.config.Playback.UseWaveformSeekbar + return container.NewTabItem(lang.L("Appearance"), container.NewVBox( util.NewHSpace(0), // insert a theme.Padding amount of space at top container.NewBorder(nil, nil, widget.NewLabel(lang.L("Theme")), /*left*/ @@ -570,6 +576,8 @@ func (s *SettingsDialog) createAppearanceTab(window fyne.Window) *container.TabI container.NewBorder(nil, nil, widget.NewLabel(lang.L("Grid card size")), nil, gridCardSize), disableDPI, s.newSectionSeparator(), + useWaveformSeekbar, + s.newSectionSeparator(), widget.NewRichText(&widget.TextSegment{Text: lang.L("Application font"), Style: util.BoldRichTextStyle}), container.New(layout.NewFormLayout(), widget.NewLabel(lang.L("Normal font")), container.NewBorder(nil, nil, nil, normalFontBrowse, normalFontEntry), diff --git a/ui/dialogs/trackinfodialog.go b/ui/dialogs/trackinfodialog.go index 050d22c..f4d7205 100644 --- a/ui/dialogs/trackinfodialog.go +++ b/ui/dialogs/trackinfodialog.go @@ -82,7 +82,7 @@ func (t *TrackInfoDialog) CreateRenderer() fyne.WidgetRenderer { c.Add(genres) } - addFormRow(c, lang.L("Duration"), util.SecondsToTimeString(float64(t.track.Duration))) + addFormRow(c, lang.L("Duration"), util.SecondsToTimeString(t.track.Duration.Seconds())) addFormRow(c, lang.L("Comment"), t.track.Comment) addFormRow(c, lang.L("Year"), strconv.Itoa(t.track.Year)) addFormRow(c, lang.L("Track number"), strconv.Itoa(t.track.TrackNumber)) diff --git a/ui/layouts/leftmiddlerightlayout.go b/ui/layouts/leftmiddlerightlayout.go index 13bbe22..f4433ad 100644 --- a/ui/layouts/leftmiddlerightlayout.go +++ b/ui/layouts/leftmiddlerightlayout.go @@ -7,17 +7,20 @@ import ( ) // Lays out up to 3 objects such that the middle object, Objects[1], -// is centered in the available space and takes up a fixed width. -// The left, and right (if non-nil), split the leftover space equally. +// is centered in the available space and takes up a fixed width, +// or optionally a fraction of the overall space. +// The left, and right split the leftover space equally. type LeftMiddleRightLayout struct { - middleWidth float32 - hbox fyne.Layout + middleWidthMin float32 + middleWidthFraction float32 + hbox fyne.Layout } -func NewLeftMiddleRightLayout(middleWidth float32) *LeftMiddleRightLayout { +func NewLeftMiddleRightLayout(middleWidthMin, middleWidthFraction float32) *LeftMiddleRightLayout { return &LeftMiddleRightLayout{ - middleWidth: middleWidth, - hbox: layout.NewHBoxLayout(), + middleWidthMin: middleWidthMin, + middleWidthFraction: middleWidthFraction, + hbox: layout.NewHBoxLayout(), } } @@ -25,20 +28,21 @@ func (b *LeftMiddleRightLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { hboxSize := b.hbox.MinSize(objects) return fyne.Size{ Height: hboxSize.Height, - Width: hboxSize.Width + fyne.Max(0, b.middleWidth-objects[1].MinSize().Width), + Width: hboxSize.Width + fyne.Max(0, b.middleWidthMin-objects[1].MinSize().Width), } } func (b *LeftMiddleRightLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { pad := theme.Padding() - midW := fyne.Max(b.middleWidth, objects[1].MinSize().Width) + lrMinWidth := fyne.Max(objects[0].MinSize().Width, objects[2].MinSize().Width) + midMinWidth := fyne.Max(b.middleWidthMin, objects[1].MinSize().Width) + midMaxWidth := size.Width - lrMinWidth*2 - pad*4 + midW := fyne.Min(midMaxWidth, fyne.Max(midMinWidth, b.middleWidthFraction*size.Width)) lrW := (size.Width - midW - pad*4) / 2 objects[0].Resize(fyne.NewSize(lrW, size.Height)) objects[0].Move(fyne.NewPos(pad, 0)) objects[1].Resize(fyne.NewSize(midW, size.Height)) objects[1].Move(fyne.NewPos(lrW+pad*2, 0)) - if objects[2] != nil { - objects[2].Resize(fyne.NewSize(lrW, size.Height)) - objects[2].Move(fyne.NewPos(lrW+midW+pad*3, 0)) - } + objects[2].Resize(fyne.NewSize(lrW, size.Height)) + objects[2].Move(fyne.NewPos(lrW+midW+pad*3, 0)) } diff --git a/ui/mainwindow.go b/ui/mainwindow.go index c77b98d..57450de 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -90,7 +90,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, )) } - m.BottomPanel = NewBottomPanel(app.PlaybackManager, app.ImageManager, m.Controller) + m.BottomPanel = NewBottomPanel(app.PlaybackManager, app.ImageManager, m.Controller, m.Controller.App.Config.Playback.UseWaveformSeekbar) app.PlaybackManager.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) { fyne.Do(func() { m.UpdateOnTrackChange(item) }) }) diff --git a/ui/widgets/playercontrols.go b/ui/widgets/playercontrols.go index 619aece..82425fb 100644 --- a/ui/widgets/playercontrols.go +++ b/ui/widgets/playercontrols.go @@ -1,6 +1,7 @@ package widgets import ( + "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/ui/util" "fyne.io/fyne/v2" @@ -81,7 +82,10 @@ func (t *TrackPosSlider) IsDragging() bool { type PlayerControls struct { widget.BaseWidget + UseWaveformSeekbar bool + slider *TrackPosSlider + waveform *WaveformSeekbar curTimeLabel *labelMinSize totalTimeLabel *labelMinSize prev *IconButton @@ -110,12 +114,19 @@ func NewLabelMinSize(text string, minWidth float32) *labelMinSize { } // NewPlayerControls sets up the seek bar, and transport buttons. -func NewPlayerControls() *PlayerControls { - pc := &PlayerControls{} +func NewPlayerControls(useWaveformSeekbar bool) *PlayerControls { + pc := &PlayerControls{UseWaveformSeekbar: useWaveformSeekbar} pc.ExtendBaseWidget(pc) pc.slider = NewTrackPosSlider() pc.slider.Disable() + pc.waveform = NewWaveformSeekbar() + pc.waveform.Disable() + if useWaveformSeekbar { + pc.slider.Hidden = true + } else { + pc.waveform.Hidden = true + } pc.curTimeLabel = NewLabelMinSize(util.SecondsToMMSS(0), 55) pc.curTimeLabel.Alignment = fyne.TextAlignTrailing pc.totalTimeLabel = NewLabelMinSize(util.SecondsToMMSS(0), 55) @@ -138,7 +149,11 @@ func NewPlayerControls() *PlayerControls { buttons := container.NewHBox(layout.NewSpacer(), pc.prev, pc.playpause, pc.next, layout.NewSpacer()) - c := container.NewBorder(nil, nil, pc.curTimeLabel, pc.totalTimeLabel, pc.slider) + seekCtrl := container.NewStack( + pc.slider, + pc.waveform, + ) + c := container.NewBorder(nil, nil, pc.curTimeLabel, pc.totalTimeLabel, seekCtrl) pc.container = container.New(layout.NewCustomPaddedVBoxLayout(0), c, buttons) return pc @@ -152,6 +167,7 @@ func (pc *PlayerControls) OnSeek(f func(float64)) { f(pos) } } + pc.waveform.OnSeeked = f } func (pc *PlayerControls) OnSeekPrevious(f func()) { @@ -191,8 +207,10 @@ func (pc *PlayerControls) UpdatePlayTime(curTime, totalTime float64) { } if totalTime > 0 { pc.slider.Enable() + pc.waveform.Enable() } else { pc.slider.Disable() + pc.waveform.Disable() } if !pc.slider.IsDragging() { ct := util.SecondsToMMSS(curTime) @@ -200,6 +218,7 @@ func (pc *PlayerControls) UpdatePlayTime(curTime, totalTime float64) { pc.curTimeLabel.SetText(ct) updated = true } + pc.waveform.SetProgress(v) if updated { // Only update slider once a second when time label changes pc.slider.SetValue(v) @@ -207,6 +226,16 @@ func (pc *PlayerControls) UpdatePlayTime(curTime, totalTime float64) { } } +func (p *PlayerControls) UpdateWaveformImg(img *backend.WaveformImage) { + p.waveform.UpdateImage(img) +} + +func (p *PlayerControls) Refresh() { + p.waveform.Hidden = !p.UseWaveformSeekbar + p.slider.Hidden = p.UseWaveformSeekbar + p.BaseWidget.Refresh() +} + func (p *PlayerControls) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(p.container) } diff --git a/ui/widgets/playqueuelist.go b/ui/widgets/playqueuelist.go index f8532b2..ba536ed 100644 --- a/ui/widgets/playqueuelist.go +++ b/ui/widgets/playqueuelist.go @@ -453,7 +453,7 @@ func (p *PlayQueueListRow) Update(tm *util.TrackListModel, rowNum int) { p.title.Text = meta.Name p.title.SetToolTip(meta.Name) p.artist.BuildSegments(meta.Artists, meta.ArtistIDs) - p.time.Text = util.SecondsToMMSS(float64(meta.Duration)) + p.time.Text = util.SecondsToMMSS(meta.Duration.Seconds()) } // Render whether track is playing or not diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index 8a05fb3..05c90b6 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -439,7 +439,7 @@ func (t *Tracklist) doSortTracks() { case ColumnRating: t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Rating) }) case ColumnTime: - t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Duration) }) + t.intSort(func(tr *util.TrackListModel) int64 { return tr.Track().Duration.Milliseconds() }) case ColumnYear: t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Year) }) case ColumnSize: diff --git a/ui/widgets/tracklistrow.go b/ui/widgets/tracklistrow.go index 5d4f33a..411418e 100644 --- a/ui/widgets/tracklistrow.go +++ b/ui/widgets/tracklistrow.go @@ -401,7 +401,7 @@ func (t *tracklistRowBase) doUpdate(tm *util.TrackListModel, rowNum int) { t.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs) t.album.BuildSegments([]string{tr.Album}, []string{tr.AlbumID}) t.composer.BuildSegments(tr.ComposerNames, tr.ComposerIDs) - t.dur.Text = util.SecondsToMMSS(float64(tr.Duration)) + t.dur.Text = util.SecondsToMMSS(tr.Duration.Seconds()) t.year.Text = strconv.Itoa(tr.Year) t.plays.Text = strconv.Itoa(int(tr.PlayCount)) t.comment.Text = strings.ReplaceAll(tr.Comment, "\n", " ") diff --git a/ui/widgets/waveformseekbar.go b/ui/widgets/waveformseekbar.go new file mode 100644 index 0000000..f6e79a2 --- /dev/null +++ b/ui/widgets/waveformseekbar.go @@ -0,0 +1,227 @@ +package widgets + +import ( + "image" + "image/color" + "math" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend" +) + +type WaveformSeekbar struct { + widget.DisableableWidget + + OnSeeked func(float64) + + imgColorL color.Color + imgColorR color.Color + imgProgressPixel int + + focused bool + + img *canvas.Image + cursor *canvas.Rectangle + focus *canvas.Rectangle +} + +func NewWaveformSeekbar() *WaveformSeekbar { + w := &WaveformSeekbar{ + img: &canvas.Image{ + ScaleMode: canvas.ImageScaleFastest, + Image: backend.NewWaveformImage(), + }, + cursor: canvas.NewRectangle(color.Transparent), + focus: canvas.NewRectangle(color.Transparent), + } + w.ExtendBaseWidget(w) + w.cursor.Hidden = true + w.focus.Hidden = true + return w +} + +func (w *WaveformSeekbar) UpdateImage(img *backend.WaveformImage) { + prm, fg, _ := w.getThemeColors() + recolorWaveformImage(img, prm, fg, 0, w.imgProgressPixel, true) + w.img.Image = img + w.img.Refresh() +} + +func (w *WaveformSeekbar) Refresh() { + w.cursor.Resize(fyne.NewSize(1, w.Size().Height-4)) + w.focus.Resize(fyne.NewSize(3, w.Size().Height-2)) + prm, fg, focus := w.getThemeColors() + w.updateImageProgress(prm, fg, w.imgProgressPixel) + w.recolorCursor(prm, fg, w.cursor.Position().X) + w.focus.FillColor = focus + + w.BaseWidget.Refresh() +} + +var _ desktop.Hoverable = (*WaveformSeekbar)(nil) + +func (w *WaveformSeekbar) MouseIn(e *desktop.MouseEvent) { + if w.Disabled() { + return + } + prm, fg, _ := w.getThemeColors() + w.recolorCursor(prm, fg, e.Position.X) + w.cursor.Resize(fyne.NewSize(1, w.Size().Height-4)) + w.cursor.Move(fyne.NewPos(e.Position.X, 2)) + w.cursor.Show() +} + +func (w *WaveformSeekbar) MouseMoved(e *desktop.MouseEvent) { + if w.Disabled() { + return + } + prm, fg, _ := w.getThemeColors() + w.recolorCursor(prm, fg, e.Position.X) + w.cursor.Move(fyne.NewPos(e.Position.X, 2)) +} + +func (w *WaveformSeekbar) MouseOut() { + if !w.focused { + w.cursor.Hide() + } +} + +var _ fyne.Focusable = (*WaveformSeekbar)(nil) + +func (w *WaveformSeekbar) FocusGained() { + w.focused = true + prm, fg, _ := w.getThemeColors() + w.recolorCursor(prm, fg, w.cursor.Position().X) + w.cursor.Resize(fyne.NewSize(1, w.Size().Height-4)) + w.moveCursorAndFocusToCurrentPosition() + w.cursor.Show() + w.focus.Show() +} + +func (w *WaveformSeekbar) FocusLost() { + w.focused = false + w.cursor.Hide() + w.focus.Hide() +} + +func (w *WaveformSeekbar) TypedKey(e *fyne.KeyEvent) { + progress := float32(w.imgProgressPixel) / 1024 + switch e.Name { + case fyne.KeyLeft: + progress = max(progress-0.05, 0) + case fyne.KeyRight: + progress = min(progress+0.05, 1) + default: + return + } + w.Tapped(&fyne.PointEvent{Position: fyne.NewPos(w.Size().Width*progress, 0)}) +} + +func (w *WaveformSeekbar) TypedRune(r rune) { +} + +var _ fyne.Tappable = (*WaveformSeekbar)(nil) + +func (w *WaveformSeekbar) Tapped(e *fyne.PointEvent) { + if !w.Disabled() && w.OnSeeked != nil { + w.OnSeeked(float64(e.Position.X / w.Size().Width)) + } +} + +func (w *WaveformSeekbar) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer( + container.NewStack( + container.New(layout.NewCustomPaddedLayout(4, 4, 0, 0), w.img), + container.NewWithoutLayout(w.cursor, w.focus), + ), + ) +} + +// SetProgress sets how much of the seekbar has been played +// (ratio from 0 to 1) +func (w *WaveformSeekbar) SetProgress(v float64) { + prm, fg, _ := w.getThemeColors() + thresholdPixel := int(math.Round(1024.0 /*pixel width of waveform*/ * v)) + if w.updateImageProgress(prm, fg, thresholdPixel) { + w.img.Refresh() + if w.focused { + w.moveCursorAndFocusToCurrentPosition() + } + } +} + +func (w *WaveformSeekbar) getThemeColors() (primary, foreground, focus color.Color) { + th := w.Theme() + vnt := fyne.CurrentApp().Settings().ThemeVariant() + primary = th.Color(theme.ColorNamePrimary, vnt) + foreground = th.Color(theme.ColorNameForeground, vnt) + focus = th.Color(theme.ColorNameFocus, vnt) + return primary, foreground, focus +} + +func (w *WaveformSeekbar) recolorCursor(prm, fg color.Color, posX float32) { + progress := float32(w.imgProgressPixel) / 1024 /*waveform image width*/ + if posX/w.Size().Width < progress { + w.cursor.FillColor = fg + } else { + w.cursor.FillColor = prm + } +} + +func (w *WaveformSeekbar) moveCursorAndFocusToCurrentPosition() { + progress := float32(w.imgProgressPixel) / 1024 + pos := w.Size().Width * progress + w.cursor.Move(fyne.NewPos(pos, 2)) + w.focus.Move(fyne.NewPos(pos-1, 1)) +} + +func (w *WaveformSeekbar) updateImageProgress(cL, cR color.Color, progress int) (updated bool) { + if w.img.Image == nil { + return false + } + if w.imgColorL == cL && w.imgColorR == cR && w.imgProgressPixel == progress { + return false + } + + img := w.img.Image.(*image.NRGBA) + recolorWaveformImage(img, cL, cR, w.imgProgressPixel, progress, false) + w.imgColorL, w.imgColorR = cL, cR + w.imgProgressPixel = progress + return true +} + +func recolorWaveformImage(img *image.NRGBA, cL, cR color.Color, oldProgress, newProgress int, fullRecolor bool) { + _r, _g, _b, _ := cL.RGBA() + rL, gL, bL := byte(_r>>8), byte(_g>>8), byte(_b>>8) + _r, _g, _b, _ = cR.RGBA() + rR, gR, bR := byte(_r>>8), byte(_g>>8), byte(_b>>8) + + bnds := img.Rect.Bounds() + xMin, xMax := 0, bnds.Dx() + if !fullRecolor { + xMin = max(0, min(oldProgress, newProgress)) + xMax = min(bnds.Dx(), max(oldProgress, newProgress)) + } + for x := xMin; x < xMax; x++ { + for y := 0; y < bnds.Dy(); y++ { + if x < newProgress { + setPixelRGB(img, x, y, rL, gL, bL) + } else { + setPixelRGB(img, x, y, rR, gR, bR) + } + } + } +} + +func setPixelRGB(img *image.NRGBA, x, y int, r, g, b byte) { + offset := img.PixOffset(x, y) + img.Pix[offset+0] = r + img.Pix[offset+1] = g + img.Pix[offset+2] = b +}