Merge pull request #669 from dweymouth/feature/waveform-seekbar
Waveform Seekbar
This commit is contained in:
+13
-2
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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) })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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", " ")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user