Merge pull request #669 from dweymouth/feature/waveform-seekbar

Waveform Seekbar
This commit is contained in:
Drew Weymouth
2025-08-04 08:37:22 -07:00
committed by GitHub
30 changed files with 1464 additions and 160 deletions
+13 -2
View File
@@ -44,6 +44,7 @@ type App struct {
Config *Config Config *Config
ServerManager *ServerManager ServerManager *ServerManager
ImageManager *ImageManager ImageManager *ImageManager
AudioCache *AudioCache
PlaybackManager *PlaybackManager PlaybackManager *PlaybackManager
LocalPlayer *mpv.Player LocalPlayer *mpv.Player
UpdateChecker UpdateChecker 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.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) 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.Config.Application.MaxImageCacheSizeMB = clamp(a.Config.Application.MaxImageCacheSizeMB, 1, 500)
a.ImageManager.SetMaxOnDiskCacheSizeBytes(int64(a.Config.Application.MaxImageCacheSizeMB) * 1_048_576) a.ImageManager.SetMaxOnDiskCacheSizeBytes(int64(a.Config.Application.MaxImageCacheSizeMB) * 1_048_576)
a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) { a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) {
@@ -403,7 +411,7 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) {
} }
meta := nowPlaying.Metadata() meta := nowPlaying.Metadata()
smtc.UpdateMetadata(meta.Name, strings.Join(meta.Artists, ", ")) smtc.UpdateMetadata(meta.Name, strings.Join(meta.Artists, ", "))
smtc.UpdatePosition(0, meta.Duration*1000) smtc.UpdatePosition(0, int(meta.Duration.Milliseconds()))
go func() { go func() {
a.ImageManager.GetCoverThumbnail(meta.CoverArtID) // ensure image is cached locally a.ImageManager.GetCoverThumbnail(meta.CoverArtID) // ensure image is cached locally
if path, err := a.ImageManager.GetCoverArtPath(meta.CoverArtID); err == nil { if path, err := a.ImageManager.GetCoverArtPath(meta.CoverArtID); err == nil {
@@ -475,6 +483,9 @@ func (a *App) Shutdown() {
} }
a.PlaybackManager.DisableCallbacks() a.PlaybackManager.DisableCallbacks()
a.PlaybackManager.Shutdown() // will trigger scrobble check a.PlaybackManager.Shutdown() // will trigger scrobble check
if a.AudioCache != nil {
a.AudioCache.Shutdown()
}
a.cancel() a.cancel()
a.LocalPlayer.Destroy() a.LocalPlayer.Destroy()
} }
+200
View File
@@ -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)
}
+6 -4
View File
@@ -110,8 +110,9 @@ type NowPlayingPageConfig struct {
} }
type PlaybackConfig struct { type PlaybackConfig struct {
Autoplay bool Autoplay bool
RepeatMode string RepeatMode string
UseWaveformSeekbar bool
} }
type LocalPlaybackConfig struct { type LocalPlaybackConfig struct {
@@ -239,8 +240,9 @@ func DefaultConfig(appVersionTag string) *Config {
TracklistColumns: []string{"Album", "Time", "Plays"}, TracklistColumns: []string{"Album", "Time", "Plays"},
}, },
Playback: PlaybackConfig{ Playback: PlaybackConfig{
Autoplay: false, Autoplay: false,
RepeatMode: "None", RepeatMode: "None",
UseWaveformSeekbar: true,
}, },
LocalPlayback: LocalPlaybackConfig{ LocalPlayback: LocalPlaybackConfig{
// "auto" is the name to pass to MPV for autoselecting the output device // "auto" is the name to pass to MPV for autoselecting the output device
@@ -15,8 +15,8 @@ import (
) )
const ( const (
cacheValidDurationSeconds = 60 cacheValidDurationSeconds = 60
runTimeTicksPerSecond = 10_000_000 runTimeTicksPerMicrosecond = 10
) )
type JellyfinServer struct { 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 { 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 { func (j *jellyfinMediaProvider) RescanLibrary() error {
@@ -408,7 +408,7 @@ func (j *jellyfinMediaProvider) GetLyrics(tr *mediaprovider.Track) (*mediaprovid
func toLyricLine(ll jellyfin.LyricLine) mediaprovider.LyricLine { func toLyricLine(ll jellyfin.LyricLine) mediaprovider.LyricLine {
return mediaprovider.LyricLine{ return mediaprovider.LyricLine{
Text: ll.Text, 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, CoverArtID: coverArtID,
ParentID: ch.AlbumID, ParentID: ch.AlbumID,
Title: ch.Name, Title: ch.Name,
Duration: int(ch.RunTimeTicks / runTimeTicksPerSecond), Duration: time.Duration(ch.RunTimeTicks/runTimeTicksPerMicrosecond) * time.Microsecond,
TrackNumber: ch.IndexNumber, TrackNumber: ch.IndexNumber,
DiscNumber: ch.DiscNumber, DiscNumber: ch.DiscNumber,
//Genre: ch.Genres, //Genre: ch.Genres,
@@ -483,7 +483,7 @@ func fillAlbum(a *jellyfin.Album, album *mediaprovider.Album) {
album.ID = a.ID album.ID = a.ID
album.CoverArtID = a.ID album.CoverArtID = a.ID
album.Name = a.Name album.Name = a.Name
album.Duration = int(a.RunTimeTicks / runTimeTicksPerSecond) album.Duration = time.Duration(a.RunTimeTicks/runTimeTicksPerMicrosecond) * time.Microsecond
album.ArtistIDs = artistIDs album.ArtistIDs = artistIDs
album.ArtistNames = artistNames album.ArtistNames = artistNames
album.Date.Year = &a.Year album.Date.Year = &a.Year
@@ -505,7 +505,7 @@ func (j *jellyfinMediaProvider) fillPlaylist(p *jellyfin.Playlist, pl *mediaprov
pl.CoverArtID = p.ID pl.CoverArtID = p.ID
pl.Description = p.Overview pl.Description = p.Overview
pl.TrackCount = p.SongCount 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 // Jellyfin does not have public playlists
pl.Owner = j.client.LoggedInUser() pl.Owner = j.client.LoggedInUser()
pl.Public = false pl.Public = false
+4 -4
View File
@@ -67,7 +67,7 @@ type Album struct {
ID string ID string
CoverArtID string CoverArtID string
Name string Name string
Duration int Duration time.Duration
ArtistIDs []string ArtistIDs []string
ArtistNames []string ArtistNames []string
Date ItemDate Date ItemDate
@@ -127,7 +127,7 @@ type Track struct {
CoverArtID string CoverArtID string
ParentID string ParentID string
Title string Title string
Duration int Duration time.Duration
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
Genres []string Genres []string
@@ -165,7 +165,7 @@ type Playlist struct {
Description string Description string
Public bool Public bool
Owner string Owner string
Duration int Duration time.Duration
TrackCount int TrackCount int
} }
@@ -216,7 +216,7 @@ type MediaItemMetadata struct {
Album string Album string
AlbumID string AlbumID string
CoverArtID string CoverArtID string
Duration int Duration time.Duration
} }
type MediaItem interface { type MediaItem interface {
@@ -551,7 +551,7 @@ func toTrack(ch *subsonic.Child) *mediaprovider.Track {
CoverArtID: ch.CoverArt, CoverArtID: ch.CoverArt,
ParentID: ch.Parent, ParentID: ch.Parent,
Title: ch.Title, Title: ch.Title,
Duration: ch.Duration, Duration: time.Duration(ch.Duration) * time.Second,
TrackNumber: ch.Track, TrackNumber: ch.Track,
DiscNumber: ch.DiscNumber, DiscNumber: ch.DiscNumber,
Genres: genres, Genres: genres,
@@ -628,7 +628,7 @@ func fillAlbum(subAlbum *subsonic.AlbumID3, album *mediaprovider.Album) {
album.ID = subAlbum.ID album.ID = subAlbum.ID
album.CoverArtID = subAlbum.CoverArt album.CoverArtID = subAlbum.CoverArt
album.Name = subAlbum.Name album.Name = subAlbum.Name
album.Duration = subAlbum.Duration album.Duration = time.Duration(subAlbum.Duration) * time.Second
album.ArtistIDs = artistIDs album.ArtistIDs = artistIDs
album.ArtistNames = artistNames album.ArtistNames = artistNames
album.TrackCount = subAlbum.SongCount album.TrackCount = subAlbum.SongCount
@@ -714,7 +714,7 @@ func fillPlaylist(pl *subsonic.Playlist, playlist *mediaprovider.Playlist) {
playlist.Owner = pl.Owner playlist.Owner = pl.Owner
playlist.Public = pl.Public playlist.Public = pl.Public
playlist.TrackCount = pl.SongCount 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) { func (s *subsonicMediaProvider) GetSongRadio(trackID string, count int) ([]*mediaprovider.Track, error) {
+1 -1
View File
@@ -112,7 +112,7 @@ func (mp *MPMediaHandler) updateMetadata(meta *mediaprovider.MediaItemMetadata)
} }
} }
artist = strings.Join(meta.Artists, ", ") artist = strings.Join(meta.Artists, ", ")
duration = meta.Duration duration = int(meta.Duration.Seconds())
} }
cTitle := C.CString(title) cTitle := C.CString(title)
+135 -93
View File
@@ -52,6 +52,7 @@ type playbackEngine struct {
ctx context.Context ctx context.Context
cancelPollPos context.CancelFunc cancelPollPos context.CancelFunc
sm *ServerManager sm *ServerManager
audiocache *AudioCache
player player.BasePlayer player player.BasePlayer
playTimeStopwatch util.Stopwatch playTimeStopwatch util.Stopwatch
@@ -76,28 +77,36 @@ type playbackEngine struct {
pendingPlayerChange bool pendingPlayerChange bool
pendingPlayerChangeStatus player.Status 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 // to pass to onSongChange listeners; clear once listeners have been called
lastScrobbled *mediaprovider.Track lastScrobbled *mediaprovider.Track
playbackCfg *PlaybackConfig
scrobbleCfg *ScrobbleConfig scrobbleCfg *ScrobbleConfig
transcodeCfg *TranscodingConfig transcodeCfg *TranscodingConfig
replayGainCfg ReplayGainConfig replayGainCfg ReplayGainConfig
// registered callbacks // registered callbacks
onSongChange []func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track) onBeforeSongChange []func(next mediaprovider.MediaItem)
onPlayTimeUpdate []func(float64, float64, bool) onSongChange []func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track)
onLoopModeChange []func(LoopMode) onPlayTimeUpdate []func(float64, float64, bool)
onVolumeChange []func(int) onLoopModeChange []func(LoopMode)
onSeek []func() onVolumeChange []func(int)
onPaused []func() onSeek []func()
onStopped []func() onPaused []func()
onPlaying []func() onStopped []func()
onPlayerChange []func() onPlaying []func()
onQueueChange []func() onPlayerChange []func()
onQueueChange []func()
} }
func NewPlaybackEngine( func NewPlaybackEngine(
ctx context.Context, ctx context.Context,
s *ServerManager, s *ServerManager,
c *AudioCache,
p player.BasePlayer, p player.BasePlayer,
playbackCfg *PlaybackConfig, playbackCfg *PlaybackConfig,
scrobbleCfg *ScrobbleConfig, scrobbleCfg *ScrobbleConfig,
@@ -108,7 +117,9 @@ func NewPlaybackEngine(
pm := &playbackEngine{ pm := &playbackEngine{
ctx: ctx, ctx: ctx,
sm: s, sm: s,
audiocache: c,
player: p, player: p,
playbackCfg: playbackCfg,
scrobbleCfg: scrobbleCfg, scrobbleCfg: scrobbleCfg,
transcodeCfg: transcodeCfg, transcodeCfg: transcodeCfg,
nowPlayingIdx: -1, nowPlayingIdx: -1,
@@ -132,7 +143,7 @@ func NewPlaybackEngine(
func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) {
pl.OnTrackChange(p.handleOnTrackChange) pl.OnTrackChange(p.handleOnTrackChange)
pl.OnSeek(func() { pl.OnSeek(func() {
p.doUpdateTimePos(true) p.handleTimePosUpdate(true)
p.invokeNoArgCallbacks(p.onSeek) p.invokeNoArgCallbacks(p.onSeek)
}) })
pl.OnStopped(p.handleOnStopped) pl.OnStopped(p.handleOnStopped)
@@ -236,7 +247,9 @@ func (p *playbackEngine) NowPlayingIndex() int {
func (p *playbackEngine) SetLoopMode(loopMode LoopMode) { func (p *playbackEngine) SetLoopMode(loopMode LoopMode) {
p.loopMode = loopMode p.loopMode = loopMode
if p.nowPlayingIdx >= 0 { 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 { for _, cb := range p.onLoopModeChange {
@@ -256,7 +269,7 @@ func (p *playbackEngine) PlaybackStatus() PlaybackStatus {
return PlaybackStatus{ return PlaybackStatus{
State: stat.State, State: stat.State,
TimePos: stat.TimePos, 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 { if idx == lastIdx && n > 0 {
return nil // already on last track, nothing to seek next to 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) return p.PlayTrackAt(newIdx)
} }
@@ -357,7 +370,9 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu
p.nowPlayingIdx = -1 p.nowPlayingIdx = -1
p.playQueue = nil 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 { if shuffle {
rand.Shuffle(len(items), func(i, j int) { items[i], items[j] = items[j], items[i] }) 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:]...)...) p.playQueue = append(p.playQueue[:insertIdx], append(items, p.playQueue[insertIdx:]...)...)
if needToSetNext {
p.setNextTrack(p.nowPlayingIdx + 1)
}
p.invokeNoArgCallbacks(p.onQueueChange) p.invokeNoArgCallbacks(p.onQueueChange)
return nil return nil
} }
@@ -383,7 +394,9 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins
p.nowPlayingIdx = -1 p.nowPlayingIdx = -1
p.playQueue = nil 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) insertIdx := len(p.playQueue)
if insertMode == InsertNext { if insertMode == InsertNext {
insertIdx = p.nowPlayingIdx + 1 insertIdx = p.nowPlayingIdx + 1
@@ -395,10 +408,6 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins
copy(new[len(firstHalf)+1:], p.playQueue[insertIdx:]) copy(new[len(firstHalf)+1:], p.playQueue[insertIdx:])
p.playQueue = new p.playQueue = new
if needToSetNext {
p.setNextTrack(p.nowPlayingIdx + 1)
}
p.invokeNoArgCallbacks(p.onQueueChange) p.invokeNoArgCallbacks(p.onQueueChange)
} }
@@ -457,11 +466,10 @@ func (p *playbackEngine) UpdatePlayQueue(items []mediaprovider.MediaItem) error
if p.nowPlayingIdx >= 0 && newNowPlayingIdx == -1 { if p.nowPlayingIdx >= 0 && newNowPlayingIdx == -1 {
return p.Stop() return p.Stop()
} }
needToUpdateNext := p.nowPlayingIdx >= 0 if p.nowPlayingIdx >= 0 {
p.nowPlayingIdx = newNowPlayingIdx p.handleNextTrackUpdated()
if needToUpdateNext {
p.setNextTrackAfterQueueUpdate()
} }
p.nowPlayingIdx = newNowPlayingIdx
p.invokeNoArgCallbacks(p.onQueueChange) p.invokeNoArgCallbacks(p.onQueueChange)
return nil return nil
@@ -507,7 +515,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) {
// when we receive new track event from player // when we receive new track event from player
} else if isNextPlayingTrackremoved { } else if isNextPlayingTrackremoved {
if newNowPlaying < len(newQueue)-1 { if newNowPlaying < len(newQueue)-1 {
p.setNextTrack(p.nowPlayingIdx + 1) p.handleNextTrackUpdated()
} else { } else {
// no next track to play // no next track to play
p.setNextTrack(-1) 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() { func (p *playbackEngine) handleOnTrackChange() {
// scrobble the previous song if needed // scrobble the previous song if needed
if !p.alreadyScrobbled { if !p.alreadyScrobbled {
@@ -581,11 +615,11 @@ func (p *playbackEngine) handleOnTrackChange() {
p.wasStopped = false p.wasStopped = false
p.alreadyScrobbled = 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.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled
p.invokeOnSongChangeCallbacks() p.invokeOnSongChangeCallbacks()
p.doUpdateTimePos(false) p.handleTimePosUpdate(false)
p.setNextTrackBasedOnLoopMode(false) p.handleNextTrackUpdated()
} }
func (p *playbackEngine) handleOnStopped() { func (p *playbackEngine) handleOnStopped() {
@@ -594,7 +628,7 @@ func (p *playbackEngine) handleOnStopped() {
p.checkScrobble() p.checkScrobble()
} }
p.stopPollTimePos() p.stopPollTimePos()
p.doUpdateTimePos(false) p.handleTimePosUpdate(false)
p.invokeOnSongChangeCallbacks() p.invokeOnSongChangeCallbacks()
p.invokeNoArgCallbacks(p.onStopped) p.invokeNoArgCallbacks(p.onStopped)
p.alreadyScrobbled = false p.alreadyScrobbled = false
@@ -602,69 +636,60 @@ func (p *playbackEngine) handleOnStopped() {
p.nowPlayingIdx = -1 p.nowPlayingIdx = -1
} }
func (p *playbackEngine) setNextTrackBasedOnLoopMode(onLoopModeChange bool) { // to be invoked as soon as the next item in the queue that should play changes
switch p.loopMode { func (p *playbackEngine) handleNextTrackUpdated() {
case LoopNone: p.cacheNextTracks()
if p.nowPlayingIdx < len(p.playQueue)-1 { p.needToSetNextTrack = true
p.setNextTrack(p.nowPlayingIdx + 1) for _, cb := range p.onBeforeSongChange {
} else if onLoopModeChange { var item mediaprovider.MediaItem
// prev was LoopOne - need to erase next track if idx := p.nextPlayingIndex(); idx >= 0 {
p.setNextTrack(-1) item = p.playQueue[idx]
}
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)
} }
cb(item)
} }
} }
func (p *playbackEngine) setNextTrackAfterQueueUpdate() { func (p *playbackEngine) nextPlayingIndex() int {
switch p.loopMode { switch p.loopMode {
case LoopNone: case LoopNone:
if p.nowPlayingIdx < len(p.playQueue)-1 { if p.nowPlayingIdx >= len(p.playQueue)-1 {
p.setNextTrack(p.nowPlayingIdx + 1) return -1
} else {
// need to erase next track
p.setNextTrack(-1)
} }
return p.nowPlayingIdx + 1
case LoopOne: case LoopOne:
p.setNextTrack(p.nowPlayingIdx) return p.nowPlayingIdx
case LoopAll: case LoopAll:
if p.nowPlayingIdx >= len(p.playQueue)-1 { if p.nowPlayingIdx >= len(p.playQueue)-1 {
p.setNextTrack(0) return 0
} else {
p.setNextTrack(p.nowPlayingIdx + 1)
} }
return p.nowPlayingIdx + 1
} }
return -1 // unreached
} }
func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { 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 { if urlP, ok := p.player.(player.URLPlayer); ok {
url := ""
var meta mediaprovider.MediaItemMetadata var meta mediaprovider.MediaItemMetadata
if idx >= 0 { if idx >= 0 {
var err error
item := p.playQueue[idx]
meta = item.Metadata() meta = item.Metadata()
if tr, ok := item.(*mediaprovider.Track); ok { if isTrack && p.audiocache != nil {
var ts *mediaprovider.TranscodeSettings if filepath := p.audiocache.PathForCachedFile(track.ID); filepath != "" {
if p.transcodeCfg.RequestTranscode { url = filepath
ts = &mediaprovider.TranscodeSettings{
Codec: p.transcodeCfg.Codec,
BitRateKBPS: p.transcodeCfg.MaxBitRateKBPS,
}
} }
url, err = p.sm.Server.GetStreamURL(tr.ID, ts, p.transcodeCfg.ForceRawFile)
} else {
url = item.(*mediaprovider.RadioStation).StreamURL
} }
if err != nil { if url == "" {
return err return errors.New("no stream URL")
} }
} }
if next { if next {
@@ -687,6 +712,24 @@ func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error {
panic("Unsupported player type") 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 { func (p *playbackEngine) setNextTrack(idx int) error {
return p.setTrack(idx, true, 0) return p.setTrack(idx, true, 0)
} }
@@ -779,7 +822,11 @@ func (pm *playbackEngine) invokeNoArgCallbacks(cbs []func()) {
func (p *playbackEngine) startPollTimePos() { func (p *playbackEngine) startPollTimePos() {
ctx, cancel := context.WithCancel(p.ctx) ctx, cancel := context.WithCancel(p.ctx)
p.cancelPollPos = cancel 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() { go func() {
for { for {
@@ -788,7 +835,7 @@ func (p *playbackEngine) startPollTimePos() {
pollingTick.Stop() pollingTick.Stop()
return return
case <-pollingTick.C: 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 { if p.callbacksDisabled {
return return
} }
s := p.PlaybackStatus()
if s.TimePos > p.latestTrackPosition { if s.TimePos > p.latestTrackPosition {
p.latestTrackPosition = s.TimePos p.latestTrackPosition = s.TimePos
} }
@@ -818,17 +874,3 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) {
cb(s.TimePos, duration, seeked) 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
}
+114 -3
View File
@@ -23,6 +23,8 @@ import (
// intermediary between the frontend and various Player backends. // intermediary between the frontend and various Player backends.
type PlaybackManager struct { type PlaybackManager struct {
engine *playbackEngine engine *playbackEngine
wfmGen *WaveformImageGenerator
cache *AudioCache
cmdQueue *playbackCommandQueue cmdQueue *playbackCommandQueue
cfg *AppConfig cfg *AppConfig
@@ -31,9 +33,14 @@ type PlaybackManager struct {
remotePlayers []RemotePlaybackDevice remotePlayers []RemotePlaybackDevice
currentRemotePlayer *RemotePlaybackDevice currentRemotePlayer *RemotePlaybackDevice
onWaveformImgUpdate []func(*WaveformImage)
autoplay bool autoplay bool
lastPlayTime float64 lastPlayTime float64
lastPlayingID string
wfmUpdateImageCancel context.CancelFunc
wfmImageJobs [3]*WaveformImageJob
} }
type RemotePlaybackDevice struct { type RemotePlaybackDevice struct {
@@ -46,13 +53,14 @@ type RemotePlaybackDevice struct {
func NewPlaybackManager( func NewPlaybackManager(
ctx context.Context, ctx context.Context,
s *ServerManager, s *ServerManager,
c *AudioCache,
p player.BasePlayer, p player.BasePlayer,
playbackCfg *PlaybackConfig, playbackCfg *PlaybackConfig,
scrobbleCfg *ScrobbleConfig, scrobbleCfg *ScrobbleConfig,
transcodeCfg *TranscodingConfig, transcodeCfg *TranscodingConfig,
appCfg *AppConfig, appCfg *AppConfig,
) *PlaybackManager { ) *PlaybackManager {
e := NewPlaybackEngine(ctx, s, p, playbackCfg, scrobbleCfg, transcodeCfg) e := NewPlaybackEngine(ctx, s, c, p, playbackCfg, scrobbleCfg, transcodeCfg)
q := NewCommandQueue() q := NewCommandQueue()
pm := &PlaybackManager{ pm := &PlaybackManager{
engine: e, engine: e,
@@ -60,12 +68,32 @@ func NewPlaybackManager(
cfg: appCfg, cfg: appCfg,
autoplay: playbackCfg.Autoplay, autoplay: playbackCfg.Autoplay,
localPlayer: p, localPlayer: p,
cache: c,
}
if c != nil {
pm.wfmGen = NewWaveformImageGenerator(c)
} }
pm.addOnTrackChangeHook() pm.addOnTrackChangeHook()
go pm.runCmdQueue(ctx) go pm.runCmdQueue(ctx)
return pm 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() { func (p *PlaybackManager) addOnTrackChangeHook() {
// See https://github.com/dweymouth/supersonic/issues/483 // See https://github.com/dweymouth/supersonic/issues/483
// On Windows, MPV sometimes fails to start playback when switching to a track // 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.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 // Autoplay if enabled and we are on the last track
if p.autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 { if p.autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 {
p.enqueueAutoplayTracks() p.enqueueAutoplayTracks()
} }
p.handleWaveformImageSongChange(item)
if runtime.GOOS != "windows" { if runtime.GOOS != "windows" {
return 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) { func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, fastScan bool) {
if fastScan { if fastScan {
p.scanRemotePlayers(ctx, 1 /*waitSec*/) p.scanRemotePlayers(ctx, 1 /*waitSec*/)
@@ -168,6 +275,10 @@ func (p *PlaybackManager) OnPlayerChange(cb func()) {
p.engine.onPlayerChange = append(p.engine.onPlayerChange, cb) 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 { func (p *PlaybackManager) IsSeeking() bool {
return p.engine.IsSeeking() return p.engine.IsSeeking()
} }
+14 -8
View File
@@ -181,8 +181,8 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat
}() }()
} }
d.state = playing d.state = playing
remainingDur := meta.Duration - int(startTime) remainingDur := meta.Duration - time.Duration(startTime)*time.Second
d.setTrackChangeTimer(time.Duration(remainingDur) * time.Second) d.setTrackChangeTimer(remainingDur)
d.stopwatch.Reset() d.stopwatch.Reset()
d.stopwatch.Start() d.stopwatch.Start()
d.lastStartTime = int(startTime) d.lastStartTime = int(startTime)
@@ -265,7 +265,7 @@ func (d *DLNAPlayer) Continue() error {
return err return err
} }
d.metaLock.Lock() d.metaLock.Lock()
nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - d.curPlayPos() nextTrackChange := d.curTrackMeta.Duration - d.curPlayPos()
d.metaLock.Unlock() d.metaLock.Unlock()
d.state = playing d.state = playing
d.setTrackChangeTimer(nextTrackChange) d.setTrackChangeTimer(nextTrackChange)
@@ -349,7 +349,7 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error {
if d.state == playing { if d.state == playing {
d.metaLock.Lock() 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.metaLock.Unlock()
d.setTrackChangeTimer(nextTrackChange) d.setTrackChangeTimer(nextTrackChange)
d.stopwatch.Start() d.stopwatch.Start()
@@ -397,7 +397,7 @@ func (d *DLNAPlayer) GetStatus() player.Status {
return player.Status{ return player.Status{
State: state, State: state,
TimePos: timePos, TimePos: timePos,
Duration: float64(d.curTrackMeta.Duration), Duration: d.curTrackMeta.Duration.Seconds(),
} }
} }
@@ -428,7 +428,7 @@ func (d *DLNAPlayer) syncPlaybackTime() {
if d.state == playing { if d.state == playing {
d.stopwatch.Start() 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() d.InvokeOnSeek()
} }
} }
@@ -511,7 +511,7 @@ func (d *DLNAPlayer) handleOnTrackChange() {
} }
d.curTrackMeta = d.nextTrackMeta d.curTrackMeta = d.nextTrackMeta
d.nextTrackMeta = mediaprovider.MediaItemMetadata{} d.nextTrackMeta = mediaprovider.MediaItemMetadata{}
nextTrackChange := time.Duration(d.curTrackMeta.Duration) * time.Second nextTrackChange := d.curTrackMeta.Duration
d.metaLock.Unlock() d.metaLock.Unlock()
if stopping { if stopping {
@@ -559,7 +559,13 @@ func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) {
return 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) proxyReq, err := http.NewRequest(r.Method, url, r.Body)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
+1 -1
View File
@@ -90,7 +90,7 @@ func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track, _ float64) error {
j.curTrack = 0 j.curTrack = 0
j.queueLength = 1 j.queueLength = 1
j.curTrackDuration = float64(track.Duration) j.curTrackDuration = track.Duration.Seconds()
return nil return nil
} }
+130
View File
@@ -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)
}
+462
View File
@@ -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 whats 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
}
+5
View File
@@ -11,6 +11,8 @@ require (
github.com/dweymouth/fyne-advanced-list v0.0.0-20250211191927-58ea85eec72c github.com/dweymouth/fyne-advanced-list v0.0.0-20250211191927-58ea85eec72c
github.com/dweymouth/fyne-tooltip v0.3.0 github.com/dweymouth/fyne-tooltip v0.3.0
github.com/dweymouth/go-jellyfin v0.0.0-20250716005557-0ea2becece1f 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/godbus/dbus/v5 v5.1.0
github.com/google/uuid v1.3.0 github.com/google/uuid v1.3.0
github.com/hashicorp/go-retryablehttp v0.7.7 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/glfw-js v0.2.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.1.0 // 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/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
github.com/go-text/render v0.2.0 // 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 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
+11 -4
View File
@@ -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-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 h1:Q3r94AcVL8yaF4Nrd3EQFKyLL3UN/zHPkj+My9B2vCQ=
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250712002006-5064d705dac4/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU= 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 h1:QsKPwFpTHuYHEEuhvp4VBClkHh00bNNgQ/2Ij1bkk8M=
github.com/dweymouth/go-jellyfin v0.0.0-20250716005557-0ea2becece1f/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= 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 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= 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/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 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= 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 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= 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= 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/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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 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= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+53
View File
@@ -1,6 +1,12 @@
package sharedutil package sharedutil
import ( import (
"context"
"fmt"
"io"
"net/http"
"os"
"github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider"
) )
@@ -133,3 +139,50 @@ func ReorderItems[T any](items []T, idxToMove []int, insertIdx int) []T {
return newItems 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
}
+10 -3
View File
@@ -28,11 +28,12 @@ type BottomPanel struct {
var _ fyne.Widget = (*BottomPanel)(nil) 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 := &BottomPanel{}
bp.ExtendBaseWidget(bp) bp.ExtendBaseWidget(bp)
pm.OnSongChange(bp.onSongChange) pm.OnSongChange(bp.onSongChange)
pm.OnWaveformImgUpdate(bp.updateWaveformImg)
pm.OnPlayTimeUpdate(func(cur, total float64, _ bool) { pm.OnPlayTimeUpdate(func(cur, total float64, _ bool) {
fyne.Do(func() { fyne.Do(func() {
if !pm.IsSeeking() { if !pm.IsSeeking() {
@@ -85,7 +86,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
contr.ShowShareDialog(tr.ID) contr.ShowShareDialog(tr.ID)
} }
} }
bp.Controls = widgets.NewPlayerControls() bp.Controls = widgets.NewPlayerControls(useWaveformSeekbar)
bp.Controls.OnPlayPause(func() { bp.Controls.OnPlayPause(func() {
pm.PlayPause() pm.PlayPause()
}) })
@@ -124,7 +125,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage) 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) bp.NowPlaying, bp.Controls, bp.AuxControls)
return bp 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 { func (bp *BottomPanel) CreateRenderer() fyne.WidgetRenderer {
bp.ExtendBaseWidget(bp) bp.ExtendBaseWidget(bp)
return widget.NewSimpleRenderer(bp.container) return widget.NewSimpleRenderer(bp.container)
+1 -1
View File
@@ -404,7 +404,7 @@ func formatMiscLabelStr(a *mediaprovider.AlbumWithTracks) string {
if y := a.ReissueDate.Year; y != nil && *y > a.YearOrZero() { if y := a.ReissueDate.Year; y != nil && *y > a.YearOrZero() {
yearStr += fmt.Sprintf(" (%s %s)", lang.L("reissued"), util.FormatItemDate(a.ReissueDate)) 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 { func (s *albumPageState) Restore() Page {
+1 -1
View File
@@ -109,7 +109,7 @@ func NewBrowsingPane(app *backend.App, contr *controller.Controller, onGoHome fu
b.navBtnsPageMap = map[controller.PageName]fyne.Resource{} b.navBtnsPageMap = map[controller.PageName]fyne.Resource{}
b.container = container.NewBorder(container.New( b.container = container.NewBorder(container.New(
&layout.CustomPaddedLayout{LeftPadding: -5, RightPadding: -5}, &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(b.home, b.back, b.forward, b.reload), b.navBtnsContainer,
container.NewHBox(layout.NewSpacer(), quickSearchBtn, b.settingsBtn))), container.NewHBox(layout.NewSpacer(), quickSearchBtn, b.settingsBtn))),
nil, nil, nil, b.pageContainer) nil, nil, nil, b.pageContainer)
+3 -3
View File
@@ -375,7 +375,7 @@ func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Tr
} }
} }
if lyrics == nil && a.lrcFetch != nil { 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 { if err != nil {
log.Println(err.Error()) log.Println(err.Error())
} }
@@ -445,7 +445,7 @@ func (a *NowPlayingPage) Reload() {
a.queueList.SetItems(a.queue) a.queueList.SetItems(a.queue)
a.totalTime = 0.0 a.totalTime = 0.0
for _, tr := range a.queue { for _, tr := range a.queue {
a.totalTime += float64(tr.Metadata().Duration) a.totalTime += tr.Metadata().Duration.Seconds()
} }
a.formatStatusLine() a.formatStatusLine()
@@ -546,7 +546,7 @@ func (a *NowPlayingPage) formatStatusLine() {
dur := 0.0 dur := 0.0
if np := a.pm.NowPlaying(); np != nil { if np := a.pm.NowPlaying(); np != nil {
dur = float64(np.Metadata().Duration) dur = np.Metadata().Duration.Seconds()
} }
statusSuffix := "" statusSuffix := ""
trackNum := 0 trackNum := 0
+1 -1
View File
@@ -514,7 +514,7 @@ func (a *PlaylistPageHeader) formatPlaylistTrackTimeStr(p *mediaprovider.Playlis
fallbackTracksMsg := fmt.Sprintf("%d %s", p.TrackCount, tracks) fallbackTracksMsg := fmt.Sprintf("%d %s", p.TrackCount, tracks)
tracksMsg := lang.LocalizePluralKey("{{.trackCount}} tracks", tracksMsg := lang.LocalizePluralKey("{{.trackCount}} tracks",
fallbackTracksMsg, p.TrackCount, map[string]string{"trackCount": strconv.Itoa(p.TrackCount)}) 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 { func (s *playlistPageState) Restore() Page {
+8
View File
@@ -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( return container.NewTabItem(lang.L("Appearance"), container.NewVBox(
util.NewHSpace(0), // insert a theme.Padding amount of space at top util.NewHSpace(0), // insert a theme.Padding amount of space at top
container.NewBorder(nil, nil, widget.NewLabel(lang.L("Theme")), /*left*/ 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), container.NewBorder(nil, nil, widget.NewLabel(lang.L("Grid card size")), nil, gridCardSize),
disableDPI, disableDPI,
s.newSectionSeparator(), s.newSectionSeparator(),
useWaveformSeekbar,
s.newSectionSeparator(),
widget.NewRichText(&widget.TextSegment{Text: lang.L("Application font"), Style: util.BoldRichTextStyle}), widget.NewRichText(&widget.TextSegment{Text: lang.L("Application font"), Style: util.BoldRichTextStyle}),
container.New(layout.NewFormLayout(), container.New(layout.NewFormLayout(),
widget.NewLabel(lang.L("Normal font")), container.NewBorder(nil, nil, nil, normalFontBrowse, normalFontEntry), widget.NewLabel(lang.L("Normal font")), container.NewBorder(nil, nil, nil, normalFontBrowse, normalFontEntry),
+1 -1
View File
@@ -82,7 +82,7 @@ func (t *TrackInfoDialog) CreateRenderer() fyne.WidgetRenderer {
c.Add(genres) 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("Comment"), t.track.Comment)
addFormRow(c, lang.L("Year"), strconv.Itoa(t.track.Year)) addFormRow(c, lang.L("Year"), strconv.Itoa(t.track.Year))
addFormRow(c, lang.L("Track number"), strconv.Itoa(t.track.TrackNumber)) addFormRow(c, lang.L("Track number"), strconv.Itoa(t.track.TrackNumber))
+17 -13
View File
@@ -7,17 +7,20 @@ import (
) )
// Lays out up to 3 objects such that the middle object, Objects[1], // 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. // is centered in the available space and takes up a fixed width,
// The left, and right (if non-nil), split the leftover space equally. // or optionally a fraction of the overall space.
// The left, and right split the leftover space equally.
type LeftMiddleRightLayout struct { type LeftMiddleRightLayout struct {
middleWidth float32 middleWidthMin float32
hbox fyne.Layout middleWidthFraction float32
hbox fyne.Layout
} }
func NewLeftMiddleRightLayout(middleWidth float32) *LeftMiddleRightLayout { func NewLeftMiddleRightLayout(middleWidthMin, middleWidthFraction float32) *LeftMiddleRightLayout {
return &LeftMiddleRightLayout{ return &LeftMiddleRightLayout{
middleWidth: middleWidth, middleWidthMin: middleWidthMin,
hbox: layout.NewHBoxLayout(), middleWidthFraction: middleWidthFraction,
hbox: layout.NewHBoxLayout(),
} }
} }
@@ -25,20 +28,21 @@ func (b *LeftMiddleRightLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
hboxSize := b.hbox.MinSize(objects) hboxSize := b.hbox.MinSize(objects)
return fyne.Size{ return fyne.Size{
Height: hboxSize.Height, 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) { func (b *LeftMiddleRightLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
pad := theme.Padding() 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 lrW := (size.Width - midW - pad*4) / 2
objects[0].Resize(fyne.NewSize(lrW, size.Height)) objects[0].Resize(fyne.NewSize(lrW, size.Height))
objects[0].Move(fyne.NewPos(pad, 0)) objects[0].Move(fyne.NewPos(pad, 0))
objects[1].Resize(fyne.NewSize(midW, size.Height)) objects[1].Resize(fyne.NewSize(midW, size.Height))
objects[1].Move(fyne.NewPos(lrW+pad*2, 0)) objects[1].Move(fyne.NewPos(lrW+pad*2, 0))
if objects[2] != nil { objects[2].Resize(fyne.NewSize(lrW, size.Height))
objects[2].Resize(fyne.NewSize(lrW, size.Height)) objects[2].Move(fyne.NewPos(lrW+midW+pad*3, 0))
objects[2].Move(fyne.NewPos(lrW+midW+pad*3, 0))
}
} }
+1 -1
View File
@@ -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) { app.PlaybackManager.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) {
fyne.Do(func() { m.UpdateOnTrackChange(item) }) fyne.Do(func() { m.UpdateOnTrackChange(item) })
}) })
+32 -3
View File
@@ -1,6 +1,7 @@
package widgets package widgets
import ( import (
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/util"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
@@ -81,7 +82,10 @@ func (t *TrackPosSlider) IsDragging() bool {
type PlayerControls struct { type PlayerControls struct {
widget.BaseWidget widget.BaseWidget
UseWaveformSeekbar bool
slider *TrackPosSlider slider *TrackPosSlider
waveform *WaveformSeekbar
curTimeLabel *labelMinSize curTimeLabel *labelMinSize
totalTimeLabel *labelMinSize totalTimeLabel *labelMinSize
prev *IconButton prev *IconButton
@@ -110,12 +114,19 @@ func NewLabelMinSize(text string, minWidth float32) *labelMinSize {
} }
// NewPlayerControls sets up the seek bar, and transport buttons. // NewPlayerControls sets up the seek bar, and transport buttons.
func NewPlayerControls() *PlayerControls { func NewPlayerControls(useWaveformSeekbar bool) *PlayerControls {
pc := &PlayerControls{} pc := &PlayerControls{UseWaveformSeekbar: useWaveformSeekbar}
pc.ExtendBaseWidget(pc) pc.ExtendBaseWidget(pc)
pc.slider = NewTrackPosSlider() pc.slider = NewTrackPosSlider()
pc.slider.Disable() 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 = NewLabelMinSize(util.SecondsToMMSS(0), 55)
pc.curTimeLabel.Alignment = fyne.TextAlignTrailing pc.curTimeLabel.Alignment = fyne.TextAlignTrailing
pc.totalTimeLabel = NewLabelMinSize(util.SecondsToMMSS(0), 55) 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()) 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) pc.container = container.New(layout.NewCustomPaddedVBoxLayout(0), c, buttons)
return pc return pc
@@ -152,6 +167,7 @@ func (pc *PlayerControls) OnSeek(f func(float64)) {
f(pos) f(pos)
} }
} }
pc.waveform.OnSeeked = f
} }
func (pc *PlayerControls) OnSeekPrevious(f func()) { func (pc *PlayerControls) OnSeekPrevious(f func()) {
@@ -191,8 +207,10 @@ func (pc *PlayerControls) UpdatePlayTime(curTime, totalTime float64) {
} }
if totalTime > 0 { if totalTime > 0 {
pc.slider.Enable() pc.slider.Enable()
pc.waveform.Enable()
} else { } else {
pc.slider.Disable() pc.slider.Disable()
pc.waveform.Disable()
} }
if !pc.slider.IsDragging() { if !pc.slider.IsDragging() {
ct := util.SecondsToMMSS(curTime) ct := util.SecondsToMMSS(curTime)
@@ -200,6 +218,7 @@ func (pc *PlayerControls) UpdatePlayTime(curTime, totalTime float64) {
pc.curTimeLabel.SetText(ct) pc.curTimeLabel.SetText(ct)
updated = true updated = true
} }
pc.waveform.SetProgress(v)
if updated { if updated {
// Only update slider once a second when time label changes // Only update slider once a second when time label changes
pc.slider.SetValue(v) 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 { func (p *PlayerControls) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(p.container) return widget.NewSimpleRenderer(p.container)
} }
+1 -1
View File
@@ -453,7 +453,7 @@ func (p *PlayQueueListRow) Update(tm *util.TrackListModel, rowNum int) {
p.title.Text = meta.Name p.title.Text = meta.Name
p.title.SetToolTip(meta.Name) p.title.SetToolTip(meta.Name)
p.artist.BuildSegments(meta.Artists, meta.ArtistIDs) 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 // Render whether track is playing or not
+1 -1
View File
@@ -439,7 +439,7 @@ func (t *Tracklist) doSortTracks() {
case ColumnRating: case ColumnRating:
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Rating) }) t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Rating) })
case ColumnTime: 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: case ColumnYear:
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Year) }) t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Year) })
case ColumnSize: case ColumnSize:
+1 -1
View File
@@ -401,7 +401,7 @@ func (t *tracklistRowBase) doUpdate(tm *util.TrackListModel, rowNum int) {
t.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs) t.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs)
t.album.BuildSegments([]string{tr.Album}, []string{tr.AlbumID}) t.album.BuildSegments([]string{tr.Album}, []string{tr.AlbumID})
t.composer.BuildSegments(tr.ComposerNames, tr.ComposerIDs) 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.year.Text = strconv.Itoa(tr.Year)
t.plays.Text = strconv.Itoa(int(tr.PlayCount)) t.plays.Text = strconv.Itoa(int(tr.PlayCount))
t.comment.Text = strings.ReplaceAll(tr.Comment, "\n", " ") t.comment.Text = strings.ReplaceAll(tr.Comment, "\n", " ")
+227
View File
@@ -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
}