incremental waveform generation (kind-of) working
This commit is contained in:
+1
-1
@@ -409,7 +409,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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -564,9 +564,9 @@ func (p *playbackEngine) SetReplayGainMode(mode player.ReplayGainMode) {
|
|||||||
func (p *playbackEngine) cacheNextTracks() {
|
func (p *playbackEngine) cacheNextTracks() {
|
||||||
if p.audiocache != nil {
|
if p.audiocache != nil {
|
||||||
// fetch up to the 2 next tracks in the queue to the cache
|
// fetch up to the 2 next tracks in the queue to the cache
|
||||||
fetch := make([]AudioCacheRequest, 0, 2)
|
fetch := make([]AudioCacheRequest, 0, 3)
|
||||||
for _, idx := range [2]int{p.nowPlayingIdx + 1, p.nowPlayingIdx + 2} {
|
for _, idx := range [3]int{p.nowPlayingIdx, p.nowPlayingIdx + 1, p.nowPlayingIdx + 2} {
|
||||||
if idx < len(p.playQueue) {
|
if idx > 0 && idx < len(p.playQueue) {
|
||||||
item := p.playQueue[idx]
|
item := p.playQueue[idx]
|
||||||
if item.Metadata().Type == mediaprovider.MediaItemTypeTrack {
|
if item.Metadata().Type == mediaprovider.MediaItemTypeTrack {
|
||||||
fetch = append(fetch, AudioCacheRequest{
|
fetch = append(fetch, AudioCacheRequest{
|
||||||
@@ -610,7 +610,7 @@ 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.handleTimePosUpdate(false)
|
p.handleTimePosUpdate(false)
|
||||||
@@ -841,7 +841,7 @@ func (p *playbackEngine) handleTimePosUpdate(seeked bool) {
|
|||||||
if np := p.NowPlaying(); np != nil {
|
if np := p.NowPlaying(); np != nil {
|
||||||
meta = np.Metadata()
|
meta = np.Metadata()
|
||||||
}
|
}
|
||||||
isNearEnd := meta.Type != mediaprovider.MediaItemTypeRadioStation && s.TimePos > float64(meta.Duration)-10
|
isNearEnd := meta.Type != mediaprovider.MediaItemTypeRadioStation && s.TimePos > meta.Duration.Seconds()-10
|
||||||
if p.needToSetNextTrack && isNearEnd {
|
if p.needToSetNextTrack && isNearEnd {
|
||||||
p.needToSetNextTrack = false
|
p.needToSetNextTrack = false
|
||||||
p.setNextTrack(p.nextPlayingIndex())
|
p.setNextTrack(p.nextPlayingIndex())
|
||||||
|
|||||||
+53
-51
@@ -3,7 +3,6 @@ package backend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -24,6 +23,7 @@ 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
|
cache *AudioCache
|
||||||
cmdQueue *playbackCommandQueue
|
cmdQueue *playbackCommandQueue
|
||||||
cfg *AppConfig
|
cfg *AppConfig
|
||||||
@@ -47,13 +47,6 @@ type RemotePlaybackDevice struct {
|
|||||||
new func() (player.BasePlayer, error)
|
new func() (player.BasePlayer, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var zeroWaveformImage *WaveformImage
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
zeroWaveformImage = NewWaveformImage()
|
|
||||||
GenerateWaveformImage(&WaveformData{}, zeroWaveformImage, color.White)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPlaybackManager(
|
func NewPlaybackManager(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
s *ServerManager,
|
s *ServerManager,
|
||||||
@@ -74,6 +67,9 @@ func NewPlaybackManager(
|
|||||||
localPlayer: p,
|
localPlayer: p,
|
||||||
cache: c,
|
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
|
||||||
@@ -88,45 +84,19 @@ func (p *PlaybackManager) addOnTrackChangeHook() {
|
|||||||
p.lastPlayTime = curTime
|
p.lastPlayTime = curTime
|
||||||
})
|
})
|
||||||
|
|
||||||
var nextWaveformImgLock sync.Mutex
|
var curWaveformJob *WaveformImageJob
|
||||||
var nextWaveformImg *WaveformImage
|
var nextWaveformJob *WaveformImageJob
|
||||||
var nextWaveformImgID string
|
var refreshCancel context.CancelFunc
|
||||||
var cancel context.CancelFunc
|
|
||||||
|
|
||||||
p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) {
|
p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) {
|
||||||
if p.cache != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack {
|
if p.wfmGen != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack {
|
||||||
log.Println("preparing waveform image for next track ", item.Metadata().ID)
|
log.Println("preparing waveform image for next track ", item.Metadata().ID)
|
||||||
nextWaveformImgLock.Lock()
|
|
||||||
if cancel != nil {
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
nextWaveformImgLock.Unlock()
|
|
||||||
id := item.Metadata().ID
|
id := item.Metadata().ID
|
||||||
path := p.cache.PathForCachedOrDownloadingFile(id)
|
_ = p.cache.PathForCachedOrDownloadingFile(id)
|
||||||
go func() {
|
|
||||||
ctx, cncl := context.WithCancel(p.engine.ctx)
|
|
||||||
nextWaveformImgLock.Lock()
|
|
||||||
cancel = cncl
|
|
||||||
nextWaveformImgLock.Unlock()
|
|
||||||
wd, err := GetWaveformDataForFile(ctx, path, func() bool {
|
|
||||||
return p.cache.PathForCachedFile(id) != ""
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err.Error())
|
|
||||||
} else {
|
|
||||||
im := NewWaveformImage()
|
|
||||||
GenerateWaveformImage(wd, im, color.White)
|
|
||||||
|
|
||||||
if ctx.Err() != nil {
|
curWaveformJob.Cancel()
|
||||||
log.Println("canceled")
|
curWaveformJob = nextWaveformJob
|
||||||
}
|
nextWaveformJob = p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track))
|
||||||
log.Println("have waveform image for track %s", item.Metadata().ID)
|
|
||||||
nextWaveformImgLock.Lock()
|
|
||||||
defer nextWaveformImgLock.Unlock()
|
|
||||||
nextWaveformImg = im
|
|
||||||
nextWaveformImgID = item.Metadata().ID
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -135,20 +105,52 @@ func (p *PlaybackManager) addOnTrackChangeHook() {
|
|||||||
if p.autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 {
|
if p.autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 {
|
||||||
p.enqueueAutoplayTracks()
|
p.enqueueAutoplayTracks()
|
||||||
}
|
}
|
||||||
|
if refreshCancel != nil {
|
||||||
|
refreshCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUnfinishedJob := func(job *WaveformImageJob) {
|
||||||
|
ctx, c := context.WithCancel(p.cache.rootCtx)
|
||||||
|
refreshCancel = c
|
||||||
|
log.Println("starting img update func")
|
||||||
|
go func(ctx context.Context, job *WaveformImageJob) {
|
||||||
|
for {
|
||||||
|
time.Sleep(333 * time.Millisecond)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
log.Println("updating waveform img")
|
||||||
|
img := job.Get()
|
||||||
|
for _, cb := range p.onWaveformImgUpdate {
|
||||||
|
cb(img)
|
||||||
|
}
|
||||||
|
if job.Done() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(ctx, job)
|
||||||
|
}
|
||||||
|
|
||||||
if item != nil {
|
if item != nil {
|
||||||
log.Println("Playing track ", item.Metadata().ID)
|
log.Println("Playing track ", item.Metadata().ID)
|
||||||
var im *WaveformImage
|
var im *WaveformImage
|
||||||
nextWaveformImgLock.Lock()
|
done := false
|
||||||
if nextWaveformImgID == item.Metadata().ID {
|
if nextWaveformJob.ItemID == item.Metadata().ID {
|
||||||
im = nextWaveformImg
|
done = nextWaveformJob.Done()
|
||||||
|
im = nextWaveformJob.Get()
|
||||||
}
|
}
|
||||||
nextWaveformImgLock.Unlock()
|
if im != nil {
|
||||||
if im == nil {
|
for _, cb := range p.onWaveformImgUpdate {
|
||||||
im = zeroWaveformImage
|
cb(im)
|
||||||
}
|
}
|
||||||
for _, cb := range p.onWaveformImgUpdate {
|
if !done {
|
||||||
cb(im)
|
updateUnfinishedJob(nextWaveformJob)
|
||||||
|
}
|
||||||
|
} else if tr, ok := item.(*mediaprovider.Track); ok {
|
||||||
|
curWaveformJob = p.wfmGen.StartWaveformGeneration(tr)
|
||||||
|
updateUnfinishedJob(curWaveformJob)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+274
-71
@@ -3,6 +3,7 @@ package backend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"io"
|
"io"
|
||||||
@@ -10,16 +11,18 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
"github.com/dweymouth/supersonic/backend/util"
|
"github.com/dweymouth/supersonic/backend/util"
|
||||||
"github.com/go-audio/audio"
|
"github.com/go-audio/audio"
|
||||||
"github.com/go-audio/wav"
|
"github.com/go-audio/wav"
|
||||||
"github.com/supersonic-app/go-mpv"
|
"github.com/supersonic-app/go-mpv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WaveformData struct {
|
type WaveformImageGenerator struct {
|
||||||
Peak [1024]byte
|
audioCache *AudioCache
|
||||||
RMS [1024]byte
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WaveformImage = image.NRGBA
|
type WaveformImage = image.NRGBA
|
||||||
@@ -28,27 +31,165 @@ func NewWaveformImage() *WaveformImage {
|
|||||||
return image.NewNRGBA(image.Rect(0, 0, 1024, 32))
|
return image.NewNRGBA(image.Rect(0, 0, 1024, 32))
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateWaveformImage(data *WaveformData, imgbuf *WaveformImage, c color.Color) {
|
type WaveformImageJob struct {
|
||||||
centerY := imgbuf.Rect.Dy() / 2 // 16
|
ItemID string
|
||||||
|
lock sync.Mutex
|
||||||
|
img *WaveformImage
|
||||||
|
err error
|
||||||
|
progress int // first invalid pixel in X direction
|
||||||
|
cancel func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WaveformImageJob) Cancel() {
|
||||||
|
if w != nil && w.cancel != nil {
|
||||||
|
w.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WaveformImageJob) Done() bool {
|
||||||
|
w.lock.Lock()
|
||||||
|
defer w.lock.Unlock()
|
||||||
|
return w.err != nil || w.progress >= w.img.Bounds().Dx()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WaveformImageJob) Err() error {
|
||||||
|
w.lock.Lock()
|
||||||
|
defer w.lock.Unlock()
|
||||||
|
return w.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WaveformImageJob) Get() *WaveformImage {
|
||||||
|
if w.Done() {
|
||||||
|
log.Println("returning image directly")
|
||||||
|
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.PathForCachedOrDownloadingFile(job.ItemID)
|
||||||
|
// wait for file to begin downloading if not already
|
||||||
|
for path == "" {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if e := ctx.Err(); e != nil {
|
||||||
|
job.setError(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path = w.audioCache.PathForCachedOrDownloadingFile(job.ItemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
transcodeFile := filepath.Join(dir, filepath.Base(path)+"_waveform.wav")
|
||||||
|
|
||||||
|
fileDone := func() bool {
|
||||||
|
return w.audioCache.PathForCachedFile(job.ItemID) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// If file isn't fully downloaded from server,
|
||||||
|
// stream it to MPV via HTTP 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()
|
||||||
|
log.Println("streaming file to MPV at ", path)
|
||||||
|
go srv.Serve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start converting the file to WAV for analysis
|
||||||
|
var wavConvertDone bool
|
||||||
|
go func() {
|
||||||
|
err := convertToWav(ctx, 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(10 * 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 {
|
||||||
|
log.Println("error analyzing wav", err.Error())
|
||||||
|
job.setError(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Start generating the waveform image
|
||||||
|
go generateWaveformImage(ctx, data, job)
|
||||||
|
}()
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
type waveformData struct {
|
||||||
|
Peak [1024]byte
|
||||||
|
RMS [1024]byte
|
||||||
|
|
||||||
|
progress int // first invalid index for Peak/RMS data
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateWaveformImage(ctx context.Context, data *waveformData, job *WaveformImageJob) {
|
||||||
|
centerY := job.img.Rect.Dy() / 2 // 16
|
||||||
top := centerY - 1
|
top := centerY - 1
|
||||||
bottom := centerY
|
bottom := centerY
|
||||||
|
|
||||||
// Convert the input color to RGBA
|
opaqueColor := color.NRGBA{R: 255, G: 255, B: 255, A: 255}
|
||||||
r, g, b, _ := c.RGBA()
|
translucentColor := color.NRGBA{R: 255, G: 255, B: 255, A: 128}
|
||||||
opaqueColor := color.NRGBA{
|
|
||||||
R: uint8(r >> 8),
|
|
||||||
G: uint8(g >> 8),
|
|
||||||
B: uint8(b >> 8),
|
|
||||||
A: 255,
|
|
||||||
}
|
|
||||||
translucentColor := color.NRGBA{
|
|
||||||
R: uint8(r >> 8),
|
|
||||||
G: uint8(g >> 8),
|
|
||||||
B: uint8(b >> 8),
|
|
||||||
A: 128, // 50% opacity
|
|
||||||
}
|
|
||||||
|
|
||||||
for x := 0; x < 1024; x++ {
|
for x := 0; x < 1024; x++ {
|
||||||
|
if data.progress <= x {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return // expired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rms := float64(data.RMS[x]) / 255.0
|
rms := float64(data.RMS[x]) / 255.0
|
||||||
peak := float64(data.Peak[x]) / 255.0
|
peak := float64(data.Peak[x]) / 255.0
|
||||||
|
|
||||||
@@ -56,80 +197,110 @@ func GenerateWaveformImage(data *WaveformData, imgbuf *WaveformImage, c color.Co
|
|||||||
peakPixels := int((peak - rms) * 16)
|
peakPixels := int((peak - rms) * 16)
|
||||||
|
|
||||||
// Always draw at least 2 center pixels
|
// Always draw at least 2 center pixels
|
||||||
setPixel(imgbuf, x, top, opaqueColor)
|
setPixel(job.img, x, top, opaqueColor)
|
||||||
setPixel(imgbuf, x, bottom, opaqueColor)
|
setPixel(job.img, x, bottom, opaqueColor)
|
||||||
|
|
||||||
// Draw RMS pixels (solid)
|
// Draw RMS pixels (solid)
|
||||||
for i := 1; i <= rmsPixels; i++ {
|
for i := 1; i <= rmsPixels; i++ {
|
||||||
setPixel(imgbuf, x, top-i, opaqueColor)
|
setPixel(job.img, x, top-i, opaqueColor)
|
||||||
setPixel(imgbuf, x, bottom+i, opaqueColor)
|
setPixel(job.img, x, bottom+i, opaqueColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw Peak extension (translucent)
|
// Draw Peak extension (translucent)
|
||||||
for i := 1; i <= peakPixels; i++ {
|
for i := 1; i <= peakPixels; i++ {
|
||||||
setPixel(imgbuf, x, top-rmsPixels-i, translucentColor)
|
setPixel(job.img, x, top-rmsPixels-i, translucentColor)
|
||||||
setPixel(imgbuf, x, bottom+rmsPixels+i, translucentColor)
|
setPixel(job.img, x, bottom+rmsPixels+i, translucentColor)
|
||||||
}
|
}
|
||||||
|
job.progress = x + 1
|
||||||
}
|
}
|
||||||
|
log.Println("done generating image")
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetWaveformDataForFile(ctx context.Context, fpath string, fileIsDone func() bool) (*WaveformData, error) {
|
func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformData, millisecs int64, fileDone func() bool) error {
|
||||||
dir := filepath.Dir(fpath)
|
if fileDone() {
|
||||||
transcodeFile := filepath.Join(dir, filepath.Base(fpath)+"_waveform.wav")
|
log.Println("Analyzing completely written file!!")
|
||||||
|
|
||||||
if !fileIsDone() {
|
|
||||||
srv, err := util.NewFileStreamerServer(fpath, fileIsDone)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
fpath = srv.Addr()
|
|
||||||
log.Println("streaming file to MPV at ", fpath)
|
|
||||||
go srv.Serve()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := convertToWav(ctx, fpath, transcodeFile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.Open(transcodeFile)
|
f, err := os.Open(transcodeFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("error opening transcoded file")
|
log.Println("error opening transcoded file")
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
defer os.Remove(transcodeFile)
|
//defer os.Remove(transcodeFile)
|
||||||
|
|
||||||
decoder := wav.NewDecoder(f)
|
reader := trackingReader{rs: f}
|
||||||
|
|
||||||
|
decoder := wav.NewDecoder(&reader)
|
||||||
if !decoder.IsValidFile() {
|
if !decoder.IsValidFile() {
|
||||||
return nil, errors.New("invalid wav file")
|
return errors.New("invalid wav file")
|
||||||
}
|
|
||||||
|
|
||||||
dur, err := decoder.Duration()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
format := decoder.Format()
|
format := decoder.Format()
|
||||||
|
|
||||||
totalSamples := format.SampleRate * int(dur.Milliseconds()) / 1000
|
totalSamples := format.SampleRate * int(millisecs) / 1000
|
||||||
samplesPerChunk := totalSamples / 1024
|
samplesPerChunk := totalSamples / 1024
|
||||||
|
|
||||||
if err := decoder.FwdToPCM(); err != nil {
|
if err := decoder.FwdToPCM(); err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := &audio.IntBuffer{Data: make([]int, 4096)}
|
buf := &audio.IntBuffer{Data: make([]int, 4096)}
|
||||||
data := &WaveformData{}
|
|
||||||
curChunk := 0
|
curChunk := 0
|
||||||
chunkSamples := make([]float32, 0, samplesPerChunk)
|
chunkSamples := make([]float32, 0, samplesPerChunk)
|
||||||
|
|
||||||
|
// file read loop
|
||||||
for {
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fileDone() {
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
readableBytes := currentSize - reader.Pos() // how many bytes are still available
|
||||||
|
|
||||||
|
// Estimate how many samples we can read
|
||||||
|
bytesPerSample := int64(2 * format.NumChannels) // 16-bit = 2 bytes per channel
|
||||||
|
maxSamples := int(readableBytes / bytesPerSample)
|
||||||
|
|
||||||
|
if maxSamples <= 0 {
|
||||||
|
// Wait for more data to be written to file
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize buffer to fit only what’s safe
|
||||||
|
safeSamples := maxSamples
|
||||||
|
if safeSamples > cap(buf.Data) {
|
||||||
|
safeSamples = 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)
|
n, err := decoder.PCMBuffer(buf)
|
||||||
if n == 0 || err == io.EOF {
|
if n == 0 || err == io.EOF {
|
||||||
|
if fileDone() {
|
||||||
|
data.progress = 1024 // set progress to done
|
||||||
|
}
|
||||||
|
if err == io.EOF && !fileDone() {
|
||||||
|
return errors.New("WAV read got premature EOF")
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return data, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process samples
|
// Process samples
|
||||||
@@ -150,6 +321,7 @@ func GetWaveformDataForFile(ctx context.Context, fpath string, fileIsDone func()
|
|||||||
data.RMS[curChunk] = float32ToByte(rms)
|
data.RMS[curChunk] = float32ToByte(rms)
|
||||||
}
|
}
|
||||||
curChunk++
|
curChunk++
|
||||||
|
data.progress = curChunk
|
||||||
chunkSamples = chunkSamples[:0]
|
chunkSamples = chunkSamples[:0]
|
||||||
if curChunk >= 1024 {
|
if curChunk >= 1024 {
|
||||||
break
|
break
|
||||||
@@ -158,14 +330,24 @@ func GetWaveformDataForFile(ctx context.Context, fpath string, fileIsDone func()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally fill the last chunk if it's partially filled
|
// analyze the last chunk if it's partially filled with samples
|
||||||
if curChunk < 1024 && len(chunkSamples) > 0 {
|
if curChunk < 1024 && len(chunkSamples) > 0 {
|
||||||
peak, rms := computePeakAndRMS(chunkSamples)
|
peak, rms := computePeakAndRMS(chunkSamples)
|
||||||
data.Peak[curChunk] = float32ToByte(peak)
|
data.Peak[curChunk] = float32ToByte(peak)
|
||||||
data.RMS[curChunk] = float32ToByte(rms)
|
data.RMS[curChunk] = float32ToByte(rms)
|
||||||
|
data.progress = curChunk + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, nil
|
log.Println("final chunk is", curChunk)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *WaveformImageJob) setError(err error) {
|
||||||
|
j.lock.Lock()
|
||||||
|
defer j.lock.Unlock()
|
||||||
|
|
||||||
|
j.err = err
|
||||||
}
|
}
|
||||||
|
|
||||||
func computePeakAndRMS(chunk []float32) (peak float32, rms float32) {
|
func computePeakAndRMS(chunk []float32) (peak float32, rms float32) {
|
||||||
@@ -212,18 +394,7 @@ func convertToWav(ctx context.Context, inPath, outPath string) error {
|
|||||||
|
|
||||||
m.Command([]string{"loadfile", inPath, "replace"})
|
m.Command([]string{"loadfile", inPath, "replace"})
|
||||||
|
|
||||||
return mpvWaitForIdle(ctx, m)
|
// Wait for MPV idle or ctx expiry
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func mpvWaitForIdle(ctx context.Context, m *mpv.Mpv) error {
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -235,10 +406,42 @@ func mpvWaitForIdle(ctx context.Context, m *mpv.Mpv) error {
|
|||||||
}
|
}
|
||||||
// use small timeout to allow detecting ctx expiry
|
// use small timeout to allow detecting ctx expiry
|
||||||
// without too much delay
|
// without too much delay
|
||||||
e := m.WaitEvent(0.1 /*timeout seconds*/)
|
e := m.WaitEvent(0.05 /*timeout seconds*/)
|
||||||
if e.Event_Id == mpv.EVENT_IDLE {
|
if e.Event_Id == mpv.EVENT_IDLE {
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrap an io.ReadSeeker with support for tracking bytes read (Pos())
|
||||||
|
type trackingReader struct {
|
||||||
|
rs io.ReadSeeker
|
||||||
|
pos int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trackingReader) Read(p []byte) (int, error) {
|
||||||
|
n, err := t.rs.Read(p)
|
||||||
|
t.pos += int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trackingReader) Seek(offset int64, whence int) (int64, error) {
|
||||||
|
newPos, err := t.rs.Seek(offset, whence)
|
||||||
|
if err == nil {
|
||||||
|
t.pos = newPos
|
||||||
|
}
|
||||||
|
return newPos, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trackingReader) Pos() int64 {
|
||||||
|
return t.pos
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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", " ")
|
||||||
|
|||||||
Reference in New Issue
Block a user