Merge pull request #393 from dweymouth/feature/radiostations

Add support for Subsonic internet radio stations
This commit is contained in:
Drew Weymouth
2024-06-02 09:32:10 -07:00
committed by GitHub
38 changed files with 1108 additions and 382 deletions
+1 -1
View File
@@ -378,7 +378,7 @@ func (a *App) LoadSavedPlayQueue() error {
if err := a.PlaybackManager.LoadTracks(queue.Tracks, Replace, false); err != nil { if err := a.PlaybackManager.LoadTracks(queue.Tracks, Replace, false); err != nil {
return err return err
} }
if queue.TrackIndex >= 0 { if queue.TrackIndex >= 0 && queue.TrackIndex < len(queue.Tracks) {
// TODO: This isn't ideal but doesn't seem to cause an audible play-for-a-split-second artifact // TODO: This isn't ideal but doesn't seem to cause an audible play-for-a-split-second artifact
a.PlaybackManager.PlayTrackAt(queue.TrackIndex) a.PlaybackManager.PlayTrackAt(queue.TrackIndex)
a.PlaybackManager.Pause() a.PlaybackManager.Pause()
-9
View File
@@ -120,15 +120,6 @@ func (i *ImageManager) GetCoverThumbnail(coverID string) (image.Image, error) {
return i.fetchAndCacheCoverFromDiskOrServer(context.Background(), coverID, i.thumbnailCache.DefaultTTL, nil) return i.fetchAndCacheCoverFromDiskOrServer(context.Background(), coverID, i.thumbnailCache.DefaultTTL, nil)
} }
// GetCoverThumbnailWithTTL fetches the cover for the given coverID and updates the TTL
// in the in-memory image cache. It blocks until the image fetch is complete.
func (i *ImageManager) GetCoverThumbnailWithTTL(coverID string, ttl time.Duration) (image.Image, error) {
if img, err := i.thumbnailCache.GetWithNewTTL(coverID, ttl); err == nil {
return img, nil
}
return i.fetchAndCacheCoverFromDiskOrServer(context.Background(), coverID, ttl, nil)
}
// GetFullSizeCoverArt fetches the full size cover image for the given coverID. // GetFullSizeCoverArt fetches the full size cover image for the given coverID.
// It blocks until the fetch is complete. // It blocks until the fetch is complete.
func (i *ImageManager) GetFullSizeCoverArt(coverID string) (image.Image, error) { func (i *ImageManager) GetFullSizeCoverArt(coverID string) (image.Image, error) {
@@ -395,7 +395,7 @@ func toTrack(ch *jellyfin.Song) *mediaprovider.Track {
ID: ch.Id, ID: ch.Id,
CoverArtID: coverArtID, CoverArtID: coverArtID,
ParentID: ch.AlbumID, ParentID: ch.AlbumID,
Name: ch.Name, Title: ch.Name,
Duration: int(ch.RunTimeTicks / runTimeTicksPerSecond), Duration: int(ch.RunTimeTicks / runTimeTicksPerSecond),
TrackNumber: ch.IndexNumber, TrackNumber: ch.IndexNumber,
DiscNumber: ch.DiscNumber, DiscNumber: ch.DiscNumber,
+4
View File
@@ -267,6 +267,10 @@ type LyricsProvider interface {
GetLyrics(track *Track) (*Lyrics, error) GetLyrics(track *Track) (*Lyrics, error)
} }
type RadioProvider interface {
GetRadioStations() ([]*RadioStation, error)
}
type JukeboxProvider interface { type JukeboxProvider interface {
JukeboxStart() error JukeboxStart() error
JukeboxStop() error JukeboxStop() error
+69 -1
View File
@@ -86,7 +86,7 @@ type Track struct {
ID string ID string
CoverArtID string CoverArtID string
ParentID string ParentID string
Name string Title string
Duration int Duration int
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
@@ -139,6 +139,74 @@ type SavedPlayQueue struct {
TimePos int // seconds TimePos int // seconds
} }
type RadioStation struct {
Name string
ID string
HomePageURL string
StreamURL string
}
type MediaItemType int
const (
MediaItemTypeTrack MediaItemType = iota
MediaItemTypeRadioStation
)
type MediaItemMetadata struct {
Type MediaItemType
ID string
Name string
Artists []string
ArtistIDs []string
Album string
AlbumID string
CoverArtID string
Duration int
}
type MediaItem interface {
Metadata() MediaItemMetadata
Copy() MediaItem
}
func (t *Track) Metadata() MediaItemMetadata {
if t == nil {
return MediaItemMetadata{}
}
return MediaItemMetadata{
Type: MediaItemTypeTrack,
ID: t.ID,
Name: t.Title,
Artists: t.ArtistNames,
ArtistIDs: t.ArtistIDs,
Album: t.Album,
AlbumID: t.AlbumID,
CoverArtID: t.CoverArtID,
Duration: t.Duration,
}
}
func (t *Track) Copy() MediaItem {
new := *t
return &new
}
func (r *RadioStation) Metadata() MediaItemMetadata {
if r == nil {
return MediaItemMetadata{}
}
return MediaItemMetadata{
Type: MediaItemTypeRadioStation,
ID: r.ID,
Name: r.Name,
}
}
func (r *RadioStation) Copy() MediaItem {
return r // no need to copy since RadioStations are immutable
}
type ContentType int type ContentType int
const ( const (
@@ -367,7 +367,7 @@ func (s *subsonicMediaProvider) GetLyrics(track *mediaprovider.Track) (*mediapro
return mpLyrics, nil return mpLyrics, nil
} }
// fallback to legacy getLyrics endpoint // fallback to legacy getLyrics endpoint
lyrics, err := s.client.GetLyrics(track.Name, track.ArtistNames[0]) lyrics, err := s.client.GetLyrics(track.Title, track.ArtistNames[0])
if err != nil || lyrics == nil || lyrics.Text == "" { if err != nil || lyrics == nil || lyrics.Text == "" {
return nil, err return nil, err
} }
@@ -386,6 +386,7 @@ func (s *subsonicMediaProvider) GetLyrics(track *mediaprovider.Track) (*mediapro
} }
// CanSavePlayQueue interface // CanSavePlayQueue interface
var _ mediaprovider.CanSavePlayQueue = (*subsonicMediaProvider)(nil)
func (s *subsonicMediaProvider) SavePlayQueue(trackIDs []string, currentTrackIdx int, timeSeconds int) error { func (s *subsonicMediaProvider) SavePlayQueue(trackIDs []string, currentTrackIdx int, timeSeconds int) error {
if len(trackIDs) == 0 { if len(trackIDs) == 0 {
@@ -413,6 +414,25 @@ func (s *subsonicMediaProvider) GetPlayQueue() (*mediaprovider.SavedPlayQueue, e
return savedQueue, nil return savedQueue, nil
} }
// RadioProvider interface
var _ mediaprovider.RadioProvider = (*subsonicMediaProvider)(nil)
func (s *subsonicMediaProvider) GetRadioStations() ([]*mediaprovider.RadioStation, error) {
rs, err := s.client.GetInternetRadioStations()
if err != nil {
return nil, err
}
return sharedutil.MapSlice(rs, func(rs *subsonic.InternetRadioStation) *mediaprovider.RadioStation {
return &mediaprovider.RadioStation{
// TODO - subsonic library is missing ID in its radiostation object. add it
ID: "radio-" + strings.ReplaceAll(rs.Name, " ", ""),
Name: rs.Name,
HomePageURL: rs.HomePageUrl,
StreamURL: rs.StreamUrl,
}
}), nil
}
func toTrack(ch *subsonic.Child) *mediaprovider.Track { func toTrack(ch *subsonic.Child) *mediaprovider.Track {
if ch == nil { if ch == nil {
return nil return nil
@@ -433,7 +453,7 @@ func toTrack(ch *subsonic.Child) *mediaprovider.Track {
ID: ch.ID, ID: ch.ID,
CoverArtID: ch.CoverArt, CoverArtID: ch.CoverArt,
ParentID: ch.Parent, ParentID: ch.Parent,
Name: ch.Title, Title: ch.Title,
Duration: ch.Duration, Duration: ch.Duration,
TrackNumber: ch.Track, TrackNumber: ch.Track,
DiscNumber: ch.DiscNumber, DiscNumber: ch.DiscNumber,
+16 -9
View File
@@ -69,9 +69,14 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac
mpMediaEventRecipient = mp mpMediaEventRecipient = mp
C.register_os_remote_commands() C.register_os_remote_commands()
mp.playbackManager.OnSongChange(func(track, _ *mediaprovider.Track) { mp.playbackManager.OnSongChange(func(track mediaprovider.MediaItem, _ *mediaprovider.Track) {
// Asynchronously because artwork fetching can take time // Asynchronously because artwork fetching can take time
go mp.updateMetadata(track) var meta *mediaprovider.MediaItemMetadata
if track != nil {
m := track.Metadata()
meta = &m
}
go mp.updateMetadata(meta)
}) })
mp.playbackManager.OnStopped(func() { mp.playbackManager.OnStopped(func() {
@@ -95,17 +100,19 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac
return nil return nil
} }
func (mp *MPMediaHandler) updateMetadata(track *mediaprovider.Track) { func (mp *MPMediaHandler) updateMetadata(meta *mediaprovider.MediaItemMetadata) {
var title, artist, artURL string var title, artist, artURL string
var duration int var duration int
if track != nil && track.ID != "" { if meta != nil && meta.ID != "" {
title = meta.Name
var err error var err error
if artURL, err = mp.artURLLookup(track.CoverArtID); err != nil { if artURL, err = mp.artURLLookup(meta.CoverArtID); err != nil {
log.Printf("error fetching art url: %s", err.Error()) if meta.CoverArtID != "" {
log.Printf("error fetching art url: %s", err.Error())
}
} }
title = track.Name artist = strings.Join(meta.Artists, ", ")
artist = strings.Join(track.ArtistNames, ", ") duration = meta.Duration
duration = track.Duration
} }
cTitle := C.CString(title) cTitle := C.CString(title)
+38 -20
View File
@@ -58,11 +58,11 @@ func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler {
m.evt.Player.OnSeek(pos) m.evt.Player.OnSeek(pos)
} }
}) })
pm.OnSongChange(func(tr, _ *mediaprovider.Track) { pm.OnSongChange(func(tr mediaprovider.MediaItem, _ *mediaprovider.Track) {
if tr == nil { if tr == nil {
m.curTrackPath = "" m.curTrackPath = ""
} else { } else {
m.curTrackPath = dbusTrackIDPrefix + encodeTrackId(tr.ID) m.curTrackPath = dbusTrackIDPrefix + encodeTrackId(tr.Metadata().ID)
} }
if m.connErr == nil { if m.connErr == nil {
m.evt.Player.OnTitle() m.evt.Player.OnTitle()
@@ -251,30 +251,48 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) {
trackObjPath = m.curTrackPath trackObjPath = m.curTrackPath
} }
status := m.pm.PlayerStatus() status := m.pm.PlayerStatus()
var tr mediaprovider.Track
var meta mediaprovider.MediaItemMetadata
// metadata that can come only from tracks
var discNumber, trackNumber, userRating, playCount, year int
var genre string
if np := m.pm.NowPlaying(); np != nil && status.State != player.Stopped { if np := m.pm.NowPlaying(); np != nil && status.State != player.Stopped {
tr = *np meta = np.Metadata()
if track, ok := np.(*mediaprovider.Track); ok {
discNumber = track.DiscNumber
trackNumber = track.TrackNumber
userRating = track.Rating
playCount = track.PlayCount
year = track.Year
genre = track.Genre
}
} }
var artURL string var artURL string
if tr.ID != "" && m.ArtURLLookup != nil { if meta.ID != "" && m.ArtURLLookup != nil {
if u, err := m.ArtURLLookup(tr.CoverArtID); err == nil { if u, err := m.ArtURLLookup(meta.CoverArtID); err == nil {
artURL = u artURL = u
} }
} }
return types.Metadata{ mprisMeta := types.Metadata{
TrackId: dbus.ObjectPath(trackObjPath), TrackId: dbus.ObjectPath(trackObjPath),
Length: secondsToMicroseconds(status.Duration), Length: secondsToMicroseconds(status.Duration),
Title: tr.Name, Title: meta.Name,
Album: tr.Album, Album: meta.Album,
Artist: tr.ArtistNames, Artist: meta.Artists,
DiscNumber: tr.DiscNumber, DiscNumber: discNumber,
TrackNumber: tr.TrackNumber, TrackNumber: trackNumber,
Genre: []string{tr.Genre}, UserRating: float64(userRating) / 5,
UserRating: float64(tr.Rating) / 5, UseCount: playCount,
ContentCreated: strconv.Itoa(tr.Year), ArtUrl: artURL,
UseCount: tr.PlayCount, }
ArtUrl: artURL, if genre != "" {
}, nil mprisMeta.Genre = []string{genre}
}
if year != 0 {
mprisMeta.ContentCreated = strconv.Itoa(year)
}
return mprisMeta, nil
} }
func (m *MPRISHandler) Volume() (float64, error) { func (m *MPRISHandler) Volume() (float64, error) {
+94 -30
View File
@@ -44,12 +44,13 @@ type playbackEngine struct {
player player.BasePlayer player player.BasePlayer
playTimeStopwatch util.Stopwatch playTimeStopwatch util.Stopwatch
curTrackTime float64 curTrackDuration float64
latestTrackPosition float64 // cleared by checkScrobble latestTrackPosition float64 // cleared by checkScrobble
callbacksDisabled bool callbacksDisabled bool
playQueue []*mediaprovider.Track playQueue []mediaprovider.MediaItem
nowPlayingIdx int nowPlayingIdx int
isRadio bool
wasStopped bool // true iff player was stopped before handleOnTrackChange invocation wasStopped bool // true iff player was stopped before handleOnTrackChange invocation
loopMode LoopMode loopMode LoopMode
@@ -60,7 +61,7 @@ type playbackEngine struct {
replayGainCfg ReplayGainConfig replayGainCfg ReplayGainConfig
// registered callbacks // registered callbacks
onSongChange []func(nowPlaying, justScrobbledIfAny *mediaprovider.Track) onSongChange []func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track)
onPlayTimeUpdate []func(float64, float64, bool) onPlayTimeUpdate []func(float64, float64, bool)
onLoopModeChange []func(LoopMode) onLoopModeChange []func(LoopMode)
onVolumeChange []func(int) onVolumeChange []func(int)
@@ -122,8 +123,8 @@ func (p *playbackEngine) PlayTrackAt(idx int) error {
return p.setTrack(idx, false) return p.setTrack(idx, false)
} }
// Gets the curently playing song, if any. // Gets the curently playing media item, if any.
func (p *playbackEngine) NowPlaying() *mediaprovider.Track { func (p *playbackEngine) NowPlaying() mediaprovider.MediaItem {
if p.nowPlayingIdx < 0 || len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped { if p.nowPlayingIdx < 0 || len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped {
return nil return nil
} }
@@ -184,6 +185,9 @@ func (p *playbackEngine) SeekBackOrPrevious() error {
// Seek to given absolute position in the current track by seconds. // Seek to given absolute position in the current track by seconds.
func (p *playbackEngine) SeekSeconds(sec float64) error { func (p *playbackEngine) SeekSeconds(sec float64) error {
if p.isRadio {
return nil // can't seek radio stations
}
return p.player.SeekSeconds(sec) return p.player.SeekSeconds(sec)
} }
@@ -216,7 +220,7 @@ func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, insertQueueMo
} }
needToSetNext := len(tracks) > 0 && (insertQueueMode == InsertNext || (insertQueueMode == Append && p.nowPlayingIdx == len(p.playQueue)-1)) needToSetNext := len(tracks) > 0 && (insertQueueMode == InsertNext || (insertQueueMode == Append && p.nowPlayingIdx == len(p.playQueue)-1))
newTracks := p.deepCopyTrackSlice(tracks) newTracks := p.copyTrackSliceToMediaItemSlice(tracks)
if shuffle { if shuffle {
rand.Shuffle(len(newTracks), func(i, j int) { newTracks[i], newTracks[j] = newTracks[j], newTracks[i] }) rand.Shuffle(len(newTracks), func(i, j int) { newTracks[i], newTracks[j] = newTracks[j], newTracks[i] })
} }
@@ -235,6 +239,31 @@ func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, insertQueueMo
return nil return nil
} }
func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) {
if insertMode == Replace {
p.player.Stop()
p.nowPlayingIdx = -1
p.playQueue = nil
}
needToSetNext := insertMode == InsertNext || (insertMode == Append && p.nowPlayingIdx == len(p.playQueue)-1)
insertIdx := len(p.playQueue)
if insertMode == InsertNext {
insertIdx = p.nowPlayingIdx + 1
}
new := make([]mediaprovider.MediaItem, len(p.playQueue)+1)
firstHalf := p.playQueue[:insertIdx]
copy(new, firstHalf)
new[len(firstHalf)] = radio
copy(new[len(firstHalf)+1:], p.playQueue[insertIdx:])
p.playQueue = new
if needToSetNext {
p.setNextTrack(p.nowPlayingIdx + 1)
}
p.invokeNoArgCallbacks(p.onQueueChange)
}
// Stop playback and clear the play queue. // Stop playback and clear the play queue.
func (p *playbackEngine) StopAndClearPlayQueue() { func (p *playbackEngine) StopAndClearPlayQueue() {
changed := len(p.playQueue) > 0 changed := len(p.playQueue) > 0
@@ -247,36 +276,40 @@ func (p *playbackEngine) StopAndClearPlayQueue() {
} }
} }
func (p *playbackEngine) GetPlayQueue() []*mediaprovider.Track { func (p *playbackEngine) GetPlayQueue() []mediaprovider.MediaItem {
return p.deepCopyTrackSlice(p.playQueue) return p.deepCopyMediaItemSlice(p.playQueue)
} }
// Any time the user changes the favorite status of a track elsewhere in the app, // Any time the user changes the favorite status of a track elsewhere in the app,
// this should be called to ensure the in-memory track model is updated. // this should be called to ensure the in-memory track model is updated.
func (p *playbackEngine) OnTrackFavoriteStatusChanged(id string, fav bool) { func (p *playbackEngine) OnTrackFavoriteStatusChanged(id string, fav bool) {
if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil { if item := sharedutil.FindMediaItemByID(id, p.playQueue); item != nil {
tr.Favorite = fav if tr, ok := item.(*mediaprovider.Track); ok {
tr.Favorite = fav
}
} }
} }
// Any time the user changes the rating of a track elsewhere in the app, // Any time the user changes the rating of a track elsewhere in the app,
// this should be called to ensure the in-memory track model is updated. // this should be called to ensure the in-memory track model is updated.
func (p *playbackEngine) OnTrackRatingChanged(id string, rating int) { func (p *playbackEngine) OnTrackRatingChanged(id string, rating int) {
if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil { if item := sharedutil.FindMediaItemByID(id, p.playQueue); item != nil {
tr.Rating = rating if tr, ok := item.(*mediaprovider.Track); ok {
tr.Rating = rating
}
} }
} }
// Replaces the play queue with the given set of tracks. // Replaces the play queue with the given set of tracks.
// Does not stop playback if the currently playing track is in the new queue, // Does not stop playback if the currently playing track is in the new queue,
// but updates the now playing index to point to the first instance of the track in the new queue. // but updates the now playing index to point to the first instance of the track in the new queue.
func (p *playbackEngine) UpdatePlayQueue(tracks []*mediaprovider.Track) error { func (p *playbackEngine) UpdatePlayQueue(items []mediaprovider.MediaItem) error {
newQueue := p.deepCopyTrackSlice(tracks) newQueue := p.deepCopyMediaItemSlice(items)
newNowPlayingIdx := -1 newNowPlayingIdx := -1
if p.nowPlayingIdx >= 0 { if p.nowPlayingIdx >= 0 {
nowPlayingID := p.playQueue[p.nowPlayingIdx].ID nowPlayingID := p.playQueue[p.nowPlayingIdx].Metadata().ID
for i, tr := range newQueue { for i, tr := range newQueue {
if tr.ID == nowPlayingID { if tr.Metadata().ID == nowPlayingID {
newNowPlayingIdx = i newNowPlayingIdx = i
break break
} }
@@ -298,14 +331,14 @@ func (p *playbackEngine) UpdatePlayQueue(tracks []*mediaprovider.Track) error {
} }
func (p *playbackEngine) RemoveTracksFromQueue(trackIDs []string) { func (p *playbackEngine) RemoveTracksFromQueue(trackIDs []string) {
newQueue := make([]*mediaprovider.Track, 0, len(p.playQueue)-len(trackIDs)) newQueue := make([]mediaprovider.MediaItem, 0, len(p.playQueue)-len(trackIDs))
idSet := sharedutil.ToSet(trackIDs) idSet := sharedutil.ToSet(trackIDs)
isPlayingTrackRemoved := false isPlayingTrackRemoved := false
isNextPlayingTrackremoved := false isNextPlayingTrackremoved := false
nowPlaying := p.NowPlayingIndex() nowPlaying := p.NowPlayingIndex()
newNowPlaying := nowPlaying newNowPlaying := nowPlaying
for i, tr := range p.playQueue { for i, tr := range p.playQueue {
if _, ok := idSet[tr.ID]; ok { if _, ok := idSet[tr.Metadata().ID]; ok {
if i < nowPlaying { if i < nowPlaying {
// if removing a track earlier than the currently playing one (if any), // if removing a track earlier than the currently playing one (if any),
// decrement new now playing index by one to account for new position in queue // decrement new now playing index by one to account for new position in queue
@@ -395,8 +428,11 @@ func (p *playbackEngine) handleOnTrackChange() {
p.nowPlayingIdx = 0 // wrapped around p.nowPlayingIdx = 0 // wrapped around
} }
} }
nowPlaying := p.playQueue[p.nowPlayingIdx]
_, isRadio := nowPlaying.(*mediaprovider.RadioStation)
p.isRadio = isRadio
p.wasStopped = false p.wasStopped = false
p.curTrackTime = float64(p.playQueue[p.nowPlayingIdx].Duration) p.curTrackDuration = float64(nowPlaying.Metadata().Duration)
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.doUpdateTimePos(false)
@@ -460,7 +496,12 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
url := "" url := ""
if idx >= 0 { if idx >= 0 {
var err error var err error
url, err = p.sm.Server.GetStreamURL(p.playQueue[idx].ID, p.transcodeCfg.ForceRawFile) item := p.playQueue[idx]
if tr, ok := item.(*mediaprovider.Track); ok {
url, err = p.sm.Server.GetStreamURL(tr.ID, p.transcodeCfg.ForceRawFile)
} else {
url = item.(*mediaprovider.RadioStation).StreamURL
}
if err != nil { if err != nil {
return err return err
} }
@@ -472,7 +513,10 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
} else if trP, ok := p.player.(player.TrackPlayer); ok { } else if trP, ok := p.player.(player.TrackPlayer); ok {
var track *mediaprovider.Track var track *mediaprovider.Track
if idx >= 0 { if idx >= 0 {
track = p.playQueue[idx] track, ok = p.playQueue[idx].(*mediaprovider.Track)
if !ok {
return errors.New("cannot play non-Track media item with TrackPlayer")
}
} }
if next { if next {
return trP.SetNextTrack(track) return trP.SetNextTrack(track)
@@ -491,15 +535,19 @@ func (p *playbackEngine) checkScrobble() {
if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 { if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 {
return return
} }
track, ok := p.playQueue[p.nowPlayingIdx].(*mediaprovider.Track)
if !ok {
return // radio stations are not scrobbled
}
playDur := p.playTimeStopwatch.Elapsed() playDur := p.playTimeStopwatch.Elapsed()
if playDur.Seconds() < 0.1 || p.curTrackTime < 0.1 { if playDur.Seconds() < 0.1 || p.curTrackDuration < 0.1 {
return return
} }
pcnt := playDur.Seconds() / p.curTrackTime * 100 pcnt := playDur.Seconds() / p.curTrackDuration * 100
timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 && timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 &&
playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds) playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds)
track := p.playQueue[p.nowPlayingIdx]
var submission bool var submission bool
server := p.sm.Server server := p.sm.Server
if server.ClientDecidesScrobble() && (timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent)) { if server.ClientDecidesScrobble() && (timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent)) {
@@ -516,7 +564,11 @@ func (p *playbackEngine) sendNowPlayingScrobble() {
if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 { if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 {
return return
} }
track := p.playQueue[p.nowPlayingIdx] track, ok := p.playQueue[p.nowPlayingIdx].(*mediaprovider.Track)
if !ok {
return // radio stations are not scrobbled
}
server := p.sm.Server server := p.sm.Server
if !server.ClientDecidesScrobble() { if !server.ClientDecidesScrobble() {
// server will count track as scrobbled as soon as it starts playing // server will count track as scrobbled as soon as it starts playing
@@ -528,11 +580,18 @@ func (p *playbackEngine) sendNowPlayingScrobble() {
// creates a deep copy of the track info so that we can maintain our own state // creates a deep copy of the track info so that we can maintain our own state
// (play count increases, favorite, and rating) without messing up other views' track models // (play count increases, favorite, and rating) without messing up other views' track models
func (p *playbackEngine) deepCopyTrackSlice(tracks []*mediaprovider.Track) []*mediaprovider.Track { func (p *playbackEngine) deepCopyMediaItemSlice(tracks []mediaprovider.MediaItem) []mediaprovider.MediaItem {
newTracks := make([]*mediaprovider.Track, len(tracks)) newTracks := make([]mediaprovider.MediaItem, len(tracks))
for i, tr := range tracks { for i, tr := range tracks {
copy := *tr newTracks[i] = tr.Copy()
newTracks[i] = &copy }
return newTracks
}
func (p *playbackEngine) copyTrackSliceToMediaItemSlice(tracks []*mediaprovider.Track) []mediaprovider.MediaItem {
newTracks := make([]mediaprovider.MediaItem, len(tracks))
for i, tr := range tracks {
newTracks[i] = tr.Copy()
} }
return newTracks return newTracks
} }
@@ -589,7 +648,12 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) {
if s.TimePos > p.latestTrackPosition { if s.TimePos > p.latestTrackPosition {
p.latestTrackPosition = s.TimePos p.latestTrackPosition = s.TimePos
} }
duration := s.Duration
if p.isRadio {
// MPV reports buffered duration - we don't want to show this
duration = 0
}
for _, cb := range p.onPlayTimeUpdate { for _, cb := range p.onPlayTimeUpdate {
cb(s.TimePos, s.Duration, seeked) cb(s.TimePos, duration, seeked)
} }
} }
+11 -7
View File
@@ -45,8 +45,8 @@ func (p *PlaybackManager) DisableCallbacks() {
p.engine.callbacksDisabled = true p.engine.callbacksDisabled = true
} }
// Gets the curently playing song, if any. // Gets the now playing media item, if any.
func (p *PlaybackManager) NowPlaying() *mediaprovider.Track { func (p *PlaybackManager) NowPlaying() mediaprovider.MediaItem {
return p.engine.NowPlaying() return p.engine.NowPlaying()
} }
@@ -55,7 +55,7 @@ func (p *PlaybackManager) NowPlayingIndex() int {
} }
// Sets a callback that is notified whenever a new song begins playing. // Sets a callback that is notified whenever a new song begins playing.
func (p *PlaybackManager) OnSongChange(cb func(nowPlaying *mediaprovider.Track, justScrobbledIfAny *mediaprovider.Track)) { func (p *PlaybackManager) OnSongChange(cb func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track)) {
p.engine.onSongChange = append(p.engine.onSongChange, cb) p.engine.onSongChange = append(p.engine.onSongChange, cb)
} }
@@ -126,8 +126,8 @@ func (p *PlaybackManager) LoadTracks(tracks []*mediaprovider.Track, insertQueueM
// Replaces the play queue with the given set of tracks. // Replaces the play queue with the given set of tracks.
// Does not stop playback if the currently playing track is in the new queue, // Does not stop playback if the currently playing track is in the new queue,
// but updates the now playing index to point to the first instance of the track in the new queue. // but updates the now playing index to point to the first instance of the track in the new queue.
func (p *PlaybackManager) UpdatePlayQueue(tracks []*mediaprovider.Track) error { func (p *PlaybackManager) UpdatePlayQueue(items []mediaprovider.MediaItem) error {
return p.engine.UpdatePlayQueue(tracks) return p.engine.UpdatePlayQueue(items)
} }
func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int, shuffle bool) error { func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int, shuffle bool) error {
@@ -182,6 +182,10 @@ func (p *PlaybackManager) PlaySimilarSongs(id string) {
}) })
} }
func (p *PlaybackManager) LoadRadioStation(station *mediaprovider.RadioStation, queueMode InsertQueueMode) {
p.engine.LoadRadioStation(station, queueMode)
}
func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Track, error)) { func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Track, error)) {
if songs, err := fetchFn(); err != nil { if songs, err := fetchFn(); err != nil {
log.Printf("error fetching tracks: %s", err.Error()) log.Printf("error fetching tracks: %s", err.Error())
@@ -194,7 +198,7 @@ func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Tr
} }
} }
func (p *PlaybackManager) GetPlayQueue() []*mediaprovider.Track { func (p *PlaybackManager) GetPlayQueue() []mediaprovider.MediaItem {
return p.engine.GetPlayQueue() return p.engine.GetPlayQueue()
} }
@@ -281,7 +285,7 @@ func (p *PlaybackManager) SeekFraction(fraction float64) error {
} else if fraction > 1 { } else if fraction > 1 {
fraction = 1 fraction = 1
} }
target := p.engine.curTrackTime * fraction target := p.engine.curTrackDuration * fraction
return p.engine.SeekSeconds(target) return p.engine.SeekSeconds(target)
} }
+13 -3
View File
@@ -29,9 +29,19 @@ func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server
stats := pm.PlayerStatus() stats := pm.PlayerStatus()
trackIdx := pm.NowPlayingIndex() trackIdx := pm.NowPlayingIndex()
trackIDs := make([]string, len(queue)) trackIDs := make([]string, 0, len(queue))
for i, tr := range queue { for _, item := range queue {
trackIDs[i] = tr.ID if _, ok := item.(*mediaprovider.Track); ok {
// don't save radio stations in play queue
trackIDs = append(trackIDs, item.Metadata().ID)
}
}
if l := len(trackIDs); trackIdx >= l {
if l == 0 {
trackIdx = 0
} else {
trackIdx = l - 1
}
} }
saved := serializedSavedPlayQueue{ saved := serializedSavedPlayQueue{
+1 -1
View File
@@ -88,7 +88,7 @@ var ResFilterSvg = &fyne.StaticResource{
var ResBroadcastSvg = &fyne.StaticResource{ var ResBroadcastSvg = &fyne.StaticResource{
StaticName: "broadcast.svg", StaticName: "broadcast.svg",
StaticContent: []byte( StaticContent: []byte(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<svg width=\"800px\" height=\"800px\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n <path d=\"M4.92893 2.92896L6.34315 4.34317C4.89543 5.79088 4 7.79088 4 10C4 12.2092 4.89543 14.2092 6.34315 15.6569L4.92893 17.0711C3.11929 15.2614 2 12.7614 2 10C2 7.2386 3.11929 4.7386 4.92893 2.92896ZM19.0711 2.92896C20.8807 4.7386 22 7.2386 22 10C22 12.7614 20.8807 15.2614 19.0711 17.0711L17.6569 15.6569C19.1046 14.2092 20 12.2092 20 10C20 7.79088 19.1046 5.79088 17.6569 4.34317L19.0711 2.92896ZM7.75736 5.75738L9.17157 7.1716C8.44771 7.89545 8 8.89545 8 10C8 11.1046 8.44771 12.1046 9.17157 12.8285L7.75736 14.2427C6.67157 13.1569 6 11.6569 6 10C6 8.34317 6.67157 6.84317 7.75736 5.75738ZM16.2426 5.75738C17.3284 6.84317 18 8.34317 18 10C18 11.6569 17.3284 13.1569 16.2426 14.2427L14.8284 12.8285C15.5523 12.1046 16 11.1046 16 10C16 8.89545 15.5523 7.89545 14.8284 7.1716L16.2426 5.75738ZM12 12C10.8954 12 10 11.1046 10 10C10 8.89545 10.8954 8.00002 12 8.00002C13.1046 8.00002 14 8.89545 14 10C14 11.1046 13.1046 12 12 12ZM12 14C12.5798 14 13.0774 14.413 13.1843 14.9829L14.5 22H9.5L10.8157 14.9829C10.9226 14.413 11.4202 14 12 14Z\"/>\n</svg>"), "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<svg width=\"800px\" height=\"800px\" viewBox=\"2 2 22 22\" xmlns=\"http://www.w3.org/2000/svg\">\n <path d=\"M4.92893 2.92896L6.34315 4.34317C4.89543 5.79088 4 7.79088 4 10C4 12.2092 4.89543 14.2092 6.34315 15.6569L4.92893 17.0711C3.11929 15.2614 2 12.7614 2 10C2 7.2386 3.11929 4.7386 4.92893 2.92896ZM19.0711 2.92896C20.8807 4.7386 22 7.2386 22 10C22 12.7614 20.8807 15.2614 19.0711 17.0711L17.6569 15.6569C19.1046 14.2092 20 12.2092 20 10C20 7.79088 19.1046 5.79088 17.6569 4.34317L19.0711 2.92896ZM7.75736 5.75738L9.17157 7.1716C8.44771 7.89545 8 8.89545 8 10C8 11.1046 8.44771 12.1046 9.17157 12.8285L7.75736 14.2427C6.67157 13.1569 6 11.6569 6 10C6 8.34317 6.67157 6.84317 7.75736 5.75738ZM16.2426 5.75738C17.3284 6.84317 18 8.34317 18 10C18 11.6569 17.3284 13.1569 16.2426 14.2427L14.8284 12.8285C15.5523 12.1046 16 11.1046 16 10C16 8.89545 15.5523 7.89545 14.8284 7.1716L16.2426 5.75738ZM12 12C10.8954 12 10 11.1046 10 10C10 8.89545 10.8954 8.00002 12 8.00002C13.1046 8.00002 14 8.89545 14 10C14 11.1046 13.1046 12 12 12ZM12 14C12.5798 14 13.0774 14.413 13.1843 14.9829L14.5 22H9.5L10.8157 14.9829C10.9226 14.413 11.4202 14 12 14Z\"/>\n</svg>"),
} }
var ResRepeatSvg = &fyne.StaticResource{ var ResRepeatSvg = &fyne.StaticResource{
StaticName: "repeat.svg", StaticName: "repeat.svg",
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg width="800px" height="800px" viewBox="2 2 22 22" xmlns="http://www.w3.org/2000/svg">
<path d="M4.92893 2.92896L6.34315 4.34317C4.89543 5.79088 4 7.79088 4 10C4 12.2092 4.89543 14.2092 6.34315 15.6569L4.92893 17.0711C3.11929 15.2614 2 12.7614 2 10C2 7.2386 3.11929 4.7386 4.92893 2.92896ZM19.0711 2.92896C20.8807 4.7386 22 7.2386 22 10C22 12.7614 20.8807 15.2614 19.0711 17.0711L17.6569 15.6569C19.1046 14.2092 20 12.2092 20 10C20 7.79088 19.1046 5.79088 17.6569 4.34317L19.0711 2.92896ZM7.75736 5.75738L9.17157 7.1716C8.44771 7.89545 8 8.89545 8 10C8 11.1046 8.44771 12.1046 9.17157 12.8285L7.75736 14.2427C6.67157 13.1569 6 11.6569 6 10C6 8.34317 6.67157 6.84317 7.75736 5.75738ZM16.2426 5.75738C17.3284 6.84317 18 8.34317 18 10C18 11.6569 17.3284 13.1569 16.2426 14.2427L14.8284 12.8285C15.5523 12.1046 16 11.1046 16 10C16 8.89545 15.5523 7.89545 14.8284 7.1716L16.2426 5.75738ZM12 12C10.8954 12 10 11.1046 10 10C10 8.89545 10.8954 8.00002 12 8.00002C13.1046 8.00002 14 8.89545 14 10C14 11.1046 13.1046 12 12 12ZM12 14C12.5798 14 13.0774 14.413 13.1843 14.9829L14.5 22H9.5L10.8157 14.9829C10.9226 14.413 11.4202 14 12 14Z"/> <path d="M4.92893 2.92896L6.34315 4.34317C4.89543 5.79088 4 7.79088 4 10C4 12.2092 4.89543 14.2092 6.34315 15.6569L4.92893 17.0711C3.11929 15.2614 2 12.7614 2 10C2 7.2386 3.11929 4.7386 4.92893 2.92896ZM19.0711 2.92896C20.8807 4.7386 22 7.2386 22 10C22 12.7614 20.8807 15.2614 19.0711 17.0711L17.6569 15.6569C19.1046 14.2092 20 12.2092 20 10C20 7.79088 19.1046 5.79088 17.6569 4.34317L19.0711 2.92896ZM7.75736 5.75738L9.17157 7.1716C8.44771 7.89545 8 8.89545 8 10C8 11.1046 8.44771 12.1046 9.17157 12.8285L7.75736 14.2427C6.67157 13.1569 6 11.6569 6 10C6 8.34317 6.67157 6.84317 7.75736 5.75738ZM16.2426 5.75738C17.3284 6.84317 18 8.34317 18 10C18 11.6569 17.3284 13.1569 16.2426 14.2427L14.8284 12.8285C15.5523 12.1046 16 11.1046 16 10C16 8.89545 15.5523 7.89545 14.8284 7.1716L16.2426 5.75738ZM12 12C10.8954 12 10 11.1046 10 10C10 8.89545 10.8954 8.00002 12 8.00002C13.1046 8.00002 14 8.89545 14 10C14 11.1046 13.1046 12 12 12ZM12 14C12.5798 14 13.0774 14.413 13.1843 14.9829L14.5 22H9.5L10.8157 14.9829C10.9226 14.413 11.4202 14 12 14Z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+32 -20
View File
@@ -74,11 +74,23 @@ func FindTrackByID(id string, tracks []*mediaprovider.Track) *mediaprovider.Trac
return nil return nil
} }
func TrackIDOrEmptyStr(track *mediaprovider.Track) string { func FindMediaItemByID(id string, items []mediaprovider.MediaItem) mediaprovider.MediaItem {
if track == nil { for _, tr := range items {
return "" if id == tr.Metadata().ID {
return tr
}
} }
return track.ID return nil
}
func MediaItemIDOrEmptyStr(item mediaprovider.MediaItem) string {
if tr, ok := item.(*mediaprovider.Track); ok && tr != nil {
return tr.ID
}
if rd, ok := item.(*mediaprovider.RadioStation); ok && rd != nil {
return rd.ID
}
return ""
} }
func AlbumIDOrEmptyStr(track *mediaprovider.Track) string { func AlbumIDOrEmptyStr(track *mediaprovider.Track) string {
@@ -103,58 +115,58 @@ const (
MoveDown MoveDown
) )
// Reorder tracks and return a new track slice. // Reorder items and return a new track slice.
// idxToMove must contain only valid indexes into tracks, and no repeats // idxToMove must contain only valid indexes into tracks, and no repeats
func ReorderTracks(tracks []*mediaprovider.Track, idxToMove []int, op TrackReorderOp) []*mediaprovider.Track { func ReorderItems[T any](items []T, idxToMove []int, op TrackReorderOp) []T {
newTracks := make([]*mediaprovider.Track, len(tracks)) newItems := make([]T, len(items))
switch op { switch op {
case MoveToTop: case MoveToTop:
topIdx := 0 topIdx := 0
botIdx := len(idxToMove) botIdx := len(idxToMove)
idxToMoveSet := ToSet(idxToMove) idxToMoveSet := ToSet(idxToMove)
for i, t := range tracks { for i, t := range items {
if _, ok := idxToMoveSet[i]; ok { if _, ok := idxToMoveSet[i]; ok {
newTracks[topIdx] = t newItems[topIdx] = t
topIdx++ topIdx++
} else { } else {
newTracks[botIdx] = t newItems[botIdx] = t
botIdx++ botIdx++
} }
} }
case MoveToBottom: case MoveToBottom:
topIdx := 0 topIdx := 0
botIdx := len(tracks) - len(idxToMove) botIdx := len(items) - len(idxToMove)
idxToMoveSet := ToSet(idxToMove) idxToMoveSet := ToSet(idxToMove)
for i, t := range tracks { for i, t := range items {
if _, ok := idxToMoveSet[i]; ok { if _, ok := idxToMoveSet[i]; ok {
newTracks[botIdx] = t newItems[botIdx] = t
botIdx++ botIdx++
} else { } else {
newTracks[topIdx] = t newItems[topIdx] = t
topIdx++ topIdx++
} }
} }
case MoveUp: case MoveUp:
first := firstIdxCanMoveUp(idxToMove) first := firstIdxCanMoveUp(idxToMove)
copy(newTracks, tracks) copy(newItems, items)
for _, i := range idxToMove { for _, i := range idxToMove {
if i < first { if i < first {
continue continue
} }
newTracks[i-1], newTracks[i] = newTracks[i], newTracks[i-1] newItems[i-1], newItems[i] = newItems[i], newItems[i-1]
} }
case MoveDown: case MoveDown:
last := lastIdxCanMoveDown(idxToMove, len(tracks)) last := lastIdxCanMoveDown(idxToMove, len(items))
copy(newTracks, tracks) copy(newItems, items)
for i := len(idxToMove) - 1; i >= 0; i-- { for i := len(idxToMove) - 1; i >= 0; i-- {
idx := idxToMove[i] idx := idxToMove[i]
if idx > last { if idx > last {
continue continue
} }
newTracks[idx+1], newTracks[idx] = newTracks[idx], newTracks[idx+1] newItems[idx+1], newItems[idx] = newItems[idx], newItems[idx+1]
} }
} }
return newTracks return newItems
} }
func firstIdxCanMoveUp(idxs []int) int { func firstIdxCanMoveUp(idxs []int) int {
+5 -5
View File
@@ -7,7 +7,7 @@ import (
"github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider"
) )
func Test_ReorderTracks(t *testing.T) { func Test_ReorderItems(t *testing.T) {
tracks := []*mediaprovider.Track{ tracks := []*mediaprovider.Track{
{ID: "a"}, // 0 {ID: "a"}, // 0
{ID: "b"}, // 1 {ID: "b"}, // 1
@@ -27,7 +27,7 @@ func Test_ReorderTracks(t *testing.T) {
{ID: "b"}, {ID: "b"},
{ID: "e"}, {ID: "e"},
} }
newTracks := ReorderTracks(tracks, idxToMove, MoveToTop) newTracks := ReorderItems(tracks, idxToMove, MoveToTop)
if !tracklistsEqual(t, newTracks, want) { if !tracklistsEqual(t, newTracks, want) {
t.Error("ReorderTracks: MoveToTop order incorrect") t.Error("ReorderTracks: MoveToTop order incorrect")
} }
@@ -42,7 +42,7 @@ func Test_ReorderTracks(t *testing.T) {
{ID: "c"}, {ID: "c"},
{ID: "f"}, {ID: "f"},
} }
newTracks = ReorderTracks(tracks, idxToMove, MoveToBottom) newTracks = ReorderItems(tracks, idxToMove, MoveToBottom)
if !tracklistsEqual(t, newTracks, want) { if !tracklistsEqual(t, newTracks, want) {
t.Error("ReorderTracks: MoveToBottom order incorrect") t.Error("ReorderTracks: MoveToBottom order incorrect")
} }
@@ -57,7 +57,7 @@ func Test_ReorderTracks(t *testing.T) {
{ID: "f"}, {ID: "f"},
{ID: "e"}, {ID: "e"},
} }
newTracks = ReorderTracks(tracks, idxToMove, MoveUp) newTracks = ReorderItems(tracks, idxToMove, MoveUp)
if !tracklistsEqual(t, newTracks, want) { if !tracklistsEqual(t, newTracks, want) {
t.Error("ReorderTracks: MoveUp order incorrect") t.Error("ReorderTracks: MoveUp order incorrect")
} }
@@ -72,7 +72,7 @@ func Test_ReorderTracks(t *testing.T) {
{ID: "e"}, {ID: "e"},
{ID: "f"}, {ID: "f"},
} }
newTracks = ReorderTracks(tracks, idxToMove, MoveDown) newTracks = ReorderItems(tracks, idxToMove, MoveDown)
if !tracklistsEqual(t, newTracks, want) { if !tracklistsEqual(t, newTracks, want) {
t.Error("ReorderTracks: MoveDown order incorrect") t.Error("ReorderTracks: MoveDown order incorrect")
} }
+24 -25
View File
@@ -1,13 +1,11 @@
package ui package ui
import ( import (
"image"
"time"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/layouts"
"github.com/dweymouth/supersonic/ui/util"
"github.com/dweymouth/supersonic/ui/widgets" "github.com/dweymouth/supersonic/ui/widgets"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
@@ -18,19 +16,18 @@ import (
type BottomPanel struct { type BottomPanel struct {
widget.BaseWidget widget.BaseWidget
ImageManager *backend.ImageManager imageLoader util.ThumbnailLoader
NowPlaying *widgets.NowPlayingCard NowPlaying *widgets.NowPlayingCard
Controls *widgets.PlayerControls Controls *widgets.PlayerControls
AuxControls *widgets.AuxControls AuxControls *widgets.AuxControls
coverArtID string container *fyne.Container
container *fyne.Container
} }
var _ fyne.Widget = (*BottomPanel)(nil) var _ fyne.Widget = (*BottomPanel)(nil)
func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) *BottomPanel { func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr *controller.Controller) *BottomPanel {
bp := &BottomPanel{} bp := &BottomPanel{}
bp.ExtendBaseWidget(bp) bp.ExtendBaseWidget(bp)
@@ -56,22 +53,30 @@ func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) *
contr.NavigateTo(controller.NowPlayingRoute("")) contr.NavigateTo(controller.NowPlayingRoute(""))
} }
bp.NowPlaying.OnSetFavorite = func(fav bool) { bp.NowPlaying.OnSetFavorite = func(fav bool) {
contr.SetTrackFavorites([]string{pm.NowPlaying().ID}, fav) if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok {
contr.SetTrackFavorites([]string{tr.ID}, fav)
}
} }
bp.NowPlaying.OnSetRating = func(rating int) { bp.NowPlaying.OnSetRating = func(rating int) {
contr.SetTrackRatings([]string{pm.NowPlaying().ID}, rating) if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok {
contr.SetTrackRatings([]string{tr.ID}, rating)
}
} }
bp.NowPlaying.OnAddToPlaylist = func() { bp.NowPlaying.OnAddToPlaylist = func() {
contr.DoAddTracksToPlaylistWorkflow([]string{pm.NowPlaying().ID}) if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok {
contr.DoAddTracksToPlaylistWorkflow([]string{tr.ID})
}
} }
bp.NowPlaying.OnAlbumNameTapped = func() { bp.NowPlaying.OnAlbumNameTapped = func() {
contr.NavigateTo(controller.AlbumRoute(pm.NowPlaying().AlbumID)) if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok {
contr.NavigateTo(controller.AlbumRoute(tr.AlbumID))
}
} }
bp.NowPlaying.OnArtistNameTapped = func(artistID string) { bp.NowPlaying.OnArtistNameTapped = func(artistID string) {
contr.NavigateTo(controller.ArtistRoute(artistID)) contr.NavigateTo(controller.ArtistRoute(artistID))
} }
bp.NowPlaying.OnTrackNameTapped = func() { bp.NowPlaying.OnTrackNameTapped = func() {
contr.NavigateTo(controller.NowPlayingRoute(pm.NowPlaying().ID)) contr.NavigateTo(controller.NowPlayingRoute(pm.NowPlaying().Metadata().ID))
} }
bp.Controls = widgets.NewPlayerControls() bp.Controls = widgets.NewPlayerControls()
bp.Controls.OnPlayPause(func() { bp.Controls.OnPlayPause(func() {
@@ -97,26 +102,20 @@ func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) *
pm.SetNextLoopMode() pm.SetNextLoopMode()
}) })
bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage)
bp.container = container.New(layouts.NewLeftMiddleRightLayout(500), bp.container = container.New(layouts.NewLeftMiddleRightLayout(500),
bp.NowPlaying, bp.Controls, bp.AuxControls) bp.NowPlaying, bp.Controls, bp.AuxControls)
return bp return bp
} }
func (bp *BottomPanel) onSongChange(song, _ *mediaprovider.Track) { func (bp *BottomPanel) onSongChange(song mediaprovider.MediaItem, _ *mediaprovider.Track) {
if song == nil { if song == nil {
bp.NowPlaying.Update(nil, nil) bp.NowPlaying.Update(nil)
bp.imageLoader.Load("")
} else { } else {
bp.coverArtID = song.CoverArtID bp.NowPlaying.Update(song)
var im image.Image bp.imageLoader.Load(song.Metadata().CoverArtID)
if bp.ImageManager != nil {
// set image to expire not long after the length of the song
// if song is played through without much pausing, image will still
// be in cache for the next song if it's from the same album, or
// if the user navigates to the album page for the track
imgTTLSec := song.Duration + 30
im, _ = bp.ImageManager.GetCoverThumbnailWithTTL(song.CoverArtID, time.Duration(imgTTLSec)*time.Second)
}
bp.NowPlaying.Update(song, im)
} }
} }
+5 -3
View File
@@ -130,10 +130,12 @@ func (a *AlbumPage) Route() controller.Route {
return controller.AlbumRoute(a.albumID) return controller.AlbumRoute(a.albumID)
} }
func (a *AlbumPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { var _ CanShowNowPlaying = (*AlbumPage)(nil)
a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track)
func (a *AlbumPage) OnSongChange(track mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(track)
a.tracklist.SetNowPlaying(a.nowPlayingID) a.tracklist.SetNowPlaying(a.nowPlayingID)
a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) a.tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny))
} }
func (a *AlbumPage) Reload() { func (a *AlbumPage) Reload() {
+3 -3
View File
@@ -139,12 +139,12 @@ func (a *ArtistPage) Save() SavedPage {
var _ CanShowNowPlaying = (*ArtistPage)(nil) var _ CanShowNowPlaying = (*ArtistPage)(nil)
func (a *ArtistPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { func (a *ArtistPage) OnSongChange(track mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track) a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(track)
if a.tracklistCtr != nil { if a.tracklistCtr != nil {
tl := a.tracklistCtr.Objects[0].(*widgets.Tracklist) tl := a.tracklistCtr.Objects[0].(*widgets.Tracklist)
tl.SetNowPlaying(a.nowPlayingID) tl.SetNowPlaying(a.nowPlayingID)
tl.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) tl.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny))
} }
} }
+7 -5
View File
@@ -42,7 +42,7 @@ type Scrollable interface {
} }
type CanShowNowPlaying interface { type CanShowNowPlaying interface {
OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track) OnSongChange(playing mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track)
} }
type CanShowPlayTime interface { type CanShowPlayTime interface {
@@ -140,11 +140,13 @@ func (b *BrowsingPane) AddSettingsMenuSeparator() {
fyne.NewMenuItemSeparator()) fyne.NewMenuItemSeparator())
} }
func (b *BrowsingPane) AddNavigationButton(icon fyne.Resource, pageName controller.PageName, action func()) { func (b *BrowsingPane) AddNavigationButton(icon fyne.Resource, pageName controller.PageName, action func()) *widget.Button {
// make a copy of the icon, because it can change the color // make a copy of the icon, because it can change the color
browsingPaneIcon := theme.NewThemedResource(icon) browsingPaneIcon := theme.NewThemedResource(icon)
b.navBtnsContainer.Add(widget.NewButtonWithIcon("", browsingPaneIcon, action)) btn := widget.NewButtonWithIcon("", browsingPaneIcon, action)
b.navBtnsContainer.Add(btn)
b.navBtnsPageMap[pageName] = browsingPaneIcon b.navBtnsPageMap[pageName] = browsingPaneIcon
return btn
} }
func (b *BrowsingPane) DisableNavigationButtons() { func (b *BrowsingPane) DisableNavigationButtons() {
@@ -162,7 +164,7 @@ func (b *BrowsingPane) EnableNavigationButtons() {
func (b *BrowsingPane) ActivateNavigationButton(num int) { func (b *BrowsingPane) ActivateNavigationButton(num int) {
if num < len(b.navBtnsContainer.Objects) { if num < len(b.navBtnsContainer.Objects) {
btn := b.navBtnsContainer.Objects[num].(*widget.Button) btn := b.navBtnsContainer.Objects[num].(*widget.Button)
if !btn.Disabled() { if !btn.Disabled() && !btn.Hidden {
btn.OnTapped() btn.OnTapped()
} }
} }
@@ -213,7 +215,7 @@ func (b *BrowsingPane) doSetPage(p Page) bool {
return true return true
} }
func (b *BrowsingPane) onSongChange(song, lastScrobbledIfAny *mediaprovider.Track) { func (b *BrowsingPane) onSongChange(song mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
if b.curPage == nil { if b.curPage == nil {
return return
} }
+3 -3
View File
@@ -269,11 +269,11 @@ func (a *FavoritesPage) OnSearched(query string) {
var _ CanShowNowPlaying = (*FavoritesPage)(nil) var _ CanShowNowPlaying = (*FavoritesPage)(nil)
func (a *FavoritesPage) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track) { func (a *FavoritesPage) OnSongChange(item mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(song) a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(item)
if tracklist := a.tracklistOrNil(); tracklist != nil { if tracklist := a.tracklistOrNil(); tracklist != nil {
tracklist.SetNowPlaying(a.nowPlayingID) tracklist.SetNowPlaying(a.nowPlayingID)
tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny))
} }
} }
+26 -16
View File
@@ -6,6 +6,7 @@ import (
"image" "image"
"image/color" "image/color"
"log" "log"
"net/url"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@@ -37,13 +38,13 @@ type NowPlayingPage struct {
nowPlayingPageState nowPlayingPageState
// volatile state // volatile state
nowPlaying *mediaprovider.Track nowPlaying mediaprovider.MediaItem
nowPlayingID string nowPlayingID string
curLyricsID string // id of track currently shown in lyrics curLyricsID string // id of track currently shown in lyrics
curRelatedID string // id of track currrently used to populate related list curRelatedID string // id of track currrently used to populate related list
totalTime float64 totalTime float64
lastPlayPos float64 lastPlayPos float64
queue []*mediaprovider.Track queue []mediaprovider.MediaItem
related []*mediaprovider.Track related []*mediaprovider.Track
lyricLock sync.Mutex lyricLock sync.Mutex
@@ -108,11 +109,16 @@ func NewNowPlayingPage(
a.card = widgets.NewLargeNowPlayingCard() a.card = widgets.NewLargeNowPlayingCard()
a.card.OnAlbumNameTapped = func() { a.card.OnAlbumNameTapped = func() {
contr.NavigateTo(controller.AlbumRoute(sharedutil.AlbumIDOrEmptyStr(a.nowPlaying))) contr.NavigateTo(controller.AlbumRoute(a.nowPlaying.Metadata().AlbumID))
} }
a.card.OnArtistNameTapped = func(artistID string) { a.card.OnArtistNameTapped = func(artistID string) {
contr.NavigateTo(controller.ArtistRoute(artistID)) contr.NavigateTo(controller.ArtistRoute(artistID))
} }
a.card.OnRadioURLTapped = func(urlText string) {
if u, err := url.Parse(urlText); err == nil {
fyne.CurrentApp().OpenURL(u)
}
}
a.card.OnSetFavorite = func(fav bool) { a.card.OnSetFavorite = func(fav bool) {
a.contr.SetTrackFavorites([]string{a.nowPlayingID}, fav) a.contr.SetTrackFavorites([]string{a.nowPlayingID}, fav)
} }
@@ -122,7 +128,7 @@ func NewNowPlayingPage(
a.queueList = widgets.NewPlayQueueList(a.im) a.queueList = widgets.NewPlayQueueList(a.im)
a.relatedList = widgets.NewPlayQueueList(a.im) a.relatedList = widgets.NewPlayQueueList(a.im)
a.queueList.OnReorderTracks = a.doSetNewTrackOrder a.queueList.OnReorderItems = a.doSetNewTrackOrder
a.queueList.OnDownload = contr.ShowDownloadDialog a.queueList.OnDownload = contr.ShowDownloadDialog
a.queueList.OnShare = func(tracks []*mediaprovider.Track) { a.queueList.OnShare = func(tracks []*mediaprovider.Track) {
if len(tracks) > 0 { if len(tracks) > 0 {
@@ -245,12 +251,12 @@ func (a *NowPlayingPage) Route() controller.Route {
var _ CanShowNowPlaying = (*NowPlayingPage)(nil) var _ CanShowNowPlaying = (*NowPlayingPage)(nil)
func (a *NowPlayingPage) OnSongChange(song, _ *mediaprovider.Track) { func (a *NowPlayingPage) OnSongChange(song mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
a.nowPlaying = song a.nowPlaying = song
if a.imageLoadCancel != nil { if a.imageLoadCancel != nil {
a.imageLoadCancel() a.imageLoadCancel()
} }
a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(song) a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(song)
a.queueList.SetNowPlaying(a.nowPlayingID) a.queueList.SetNowPlaying(a.nowPlayingID)
a.relatedList.SetNowPlaying(a.nowPlayingID) a.relatedList.SetNowPlaying(a.nowPlayingID)
@@ -258,7 +264,7 @@ func (a *NowPlayingPage) OnSongChange(song, _ *mediaprovider.Track) {
if song == nil { if song == nil {
a.card.SetCoverImage(nil) a.card.SetCoverImage(nil)
} else { } else {
a.imageLoadCancel = a.im.GetFullSizeCoverArtAsync(song.CoverArtID, a.onImageLoaded) a.imageLoadCancel = a.im.GetFullSizeCoverArtAsync(song.Metadata().CoverArtID, a.onImageLoaded)
} }
if a.tabs != nil && a.tabs.SelectedIndex() == 1 /*lyrics*/ { if a.tabs != nil && a.tabs.SelectedIndex() == 1 /*lyrics*/ {
@@ -273,6 +279,9 @@ func (a *NowPlayingPage) onImageLoaded(img image.Image, err error) {
log.Printf("error loading cover art: %v\n", err) log.Printf("error loading cover art: %v\n", err)
return return
} }
if img == nil {
return
}
a.card.SetCoverImage(img) a.card.SetCoverImage(img)
c := dominantcolor.Find(img) c := dominantcolor.Find(img)
if c == a.background.StartColor { if c == a.background.StartColor {
@@ -308,7 +317,7 @@ func (a *NowPlayingPage) updateLyrics() {
return return
} }
} }
if a.nowPlaying == nil { if a.nowPlaying == nil || a.nowPlaying.Metadata().Type == mediaprovider.MediaItemTypeRadioStation {
a.lyricsViewer.SetLyrics(nil) a.lyricsViewer.SetLyrics(nil)
a.curLyricsID = "" a.curLyricsID = ""
return return
@@ -321,19 +330,20 @@ func (a *NowPlayingPage) updateLyrics() {
// to keep it from showing "Lyrics not available" // to keep it from showing "Lyrics not available"
a.lyricsViewer.SetLyrics(&mediaprovider.Lyrics{Synced: true, a.lyricsViewer.SetLyrics(&mediaprovider.Lyrics{Synced: true,
Lines: []mediaprovider.LyricLine{{Text: ""}}}) Lines: []mediaprovider.LyricLine{{Text: ""}}})
go a.fetchLyrics(ctx, a.nowPlaying) tr, _ := a.nowPlaying.(*mediaprovider.Track)
go a.fetchLyrics(ctx, tr)
} }
func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Track) { func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Track) {
var lyrics *mediaprovider.Lyrics var lyrics *mediaprovider.Lyrics
var err error var err error
if lp, ok := a.sm.Server.(mediaprovider.LyricsProvider); ok { if lp, ok := a.sm.Server.(mediaprovider.LyricsProvider); ok {
if lyrics, err = lp.GetLyrics(a.nowPlaying); err != nil { if lyrics, err = lp.GetLyrics(song); err != nil {
log.Printf("Error fetching lyrics: %v", err) log.Printf("Error fetching lyrics: %v", err)
} }
} }
if lyrics == nil { if lyrics == nil {
lyrics, err = backend.FetchLrcLibLyrics(song.Name, song.ArtistNames[0], song.Album, song.Duration) lyrics, err = backend.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, song.Duration)
if err != nil { if err != nil {
log.Println(err.Error()) log.Println(err.Error())
} }
@@ -401,10 +411,10 @@ func (a *NowPlayingPage) Reload() {
a.relatedList.DisableSharing = !a.canShare a.relatedList.DisableSharing = !a.canShare
a.queue = a.pm.GetPlayQueue() a.queue = a.pm.GetPlayQueue()
a.queueList.SetTracks(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.Duration) a.totalTime += float64(tr.Metadata().Duration)
} }
a.formatStatusLine() a.formatStatusLine()
} }
@@ -457,11 +467,11 @@ func (a *NowPlayingPage) doSetNewTrackOrder(trackIDs []string, op sharedutil.Tra
trackIDSet := sharedutil.ToSet(trackIDs) trackIDSet := sharedutil.ToSet(trackIDs)
idxs := make([]int, 0, len(trackIDs)) idxs := make([]int, 0, len(trackIDs))
for i, tr := range a.queue { for i, tr := range a.queue {
if _, ok := trackIDSet[tr.ID]; ok { if _, ok := trackIDSet[tr.Metadata().ID]; ok {
idxs = append(idxs, i) idxs = append(idxs, i)
} }
} }
newTracks := sharedutil.ReorderTracks(a.queue, idxs, op) newTracks := sharedutil.ReorderItems(a.queue, idxs, op)
a.pm.UpdatePlayQueue(newTracks) a.pm.UpdatePlayQueue(newTracks)
} }
@@ -492,7 +502,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.Duration) dur = float64(np.Metadata().Duration)
} }
statusSuffix := "" statusSuffix := ""
trackNum := 0 trackNum := 0
+6 -4
View File
@@ -126,10 +126,12 @@ func (a *PlaylistPage) Route() controller.Route {
return controller.PlaylistRoute(a.playlistID) return controller.PlaylistRoute(a.playlistID)
} }
func (a *PlaylistPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { var _ CanShowNowPlaying = (*PlaylistPage)(nil)
a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track)
func (a *PlaylistPage) OnSongChange(item mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(item)
a.tracklist.SetNowPlaying(a.nowPlayingID) a.tracklist.SetNowPlaying(a.nowPlayingID)
a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) a.tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny))
} }
func (a *PlaylistPage) Reload() { func (a *PlaylistPage) Reload() {
@@ -187,7 +189,7 @@ func (a *PlaylistPage) doSetNewTrackOrder(op sharedutil.TrackReorderOp) {
idxs = append(idxs, i) idxs = append(idxs, i)
} }
} }
newTracks := sharedutil.ReorderTracks(a.tracks, idxs, op) newTracks := sharedutil.ReorderItems(a.tracks, idxs, op)
ids := sharedutil.TracksToIDs(newTracks) ids := sharedutil.TracksToIDs(newTracks)
if err := a.sm.Server.ReplacePlaylistTracks(a.playlistID, ids); err != nil { if err := a.sm.Server.ReplacePlaylistTracks(a.playlistID, ids); err != nil {
log.Printf("error updating playlist: %s", err.Error()) log.Printf("error updating playlist: %s", err.Error())
+372
View File
@@ -0,0 +1,372 @@
package browsing
import (
"log"
"net/url"
"strings"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/widgets"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
var _ fyne.Widget = (*ArtistPage)(nil)
type RadiosPage struct {
widget.BaseWidget
contr *controller.Controller
rp mediaprovider.RadioProvider
pm *backend.PlaybackManager
radios []*mediaprovider.RadioStation
list *RadioList
nowPlayingID string
titleDisp *widget.RichText
noRadiosMsg fyne.CanvasObject
container *fyne.Container
searcher *widgets.SearchEntry
}
func NewRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, pm *backend.PlaybackManager) *RadiosPage {
return newRadiosPage(contr, rp, pm, "", 0)
}
func newRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, pm *backend.PlaybackManager, searchText string, scrollPos float32) *RadiosPage {
a := &RadiosPage{
contr: contr,
rp: rp,
pm: pm,
titleDisp: widget.NewRichTextWithText("Internet Radio Stations"),
}
a.ExtendBaseWidget(a)
a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText
a.list = NewRadioList(&a.nowPlayingID)
a.list.OnPlay = a.onPlay
a.list.OnQueue = a.onQueue
a.searcher = widgets.NewSearchEntry()
a.searcher.PlaceHolder = "Search page"
a.searcher.OnSearched = a.onSearched
a.searcher.Entry.Text = searchText
a.noRadiosMsg = container.NewCenter(
container.New(layout.NewCustomPaddedVBoxLayout(-10),
container.NewCenter(
container.NewBorder(nil, nil,
widget.NewIcon(theme.InfoIcon()), nil,
widget.NewRichText(&widget.TextSegment{
Text: "No radio stations available",
Style: widget.RichTextStyleSubHeading,
}))),
widget.NewLabel("Configure your music server to add radio stations"),
),
)
a.noRadiosMsg.Hide()
a.buildContainer()
go a.load(searchText != "", scrollPos)
return a
}
// should be called asynchronously
func (a *RadiosPage) load(searchOnLoad bool, scrollPos float32) {
radios, err := a.rp.GetRadioStations()
if err != nil {
log.Printf("error loading radios: %v", err.Error())
}
if len(radios) == 0 {
a.noRadiosMsg.Show()
} else {
a.noRadiosMsg.Hide()
}
a.radios = radios
if searchOnLoad {
a.onSearched(a.searcher.Entry.Text)
if scrollPos != 0 {
a.list.list.ScrollToOffset(scrollPos)
}
} else {
a.list.SetRadios(a.radios)
if scrollPos != 0 {
a.list.list.ScrollToOffset(scrollPos)
return
}
a.list.Refresh()
}
}
func (a *RadiosPage) onPlay(station *mediaprovider.RadioStation) {
a.pm.LoadRadioStation(station, backend.Replace)
a.pm.PlayFromBeginning()
}
func (a *RadiosPage) onQueue(station *mediaprovider.RadioStation, next bool) {
queueMode := backend.Append
if next {
queueMode = backend.InsertNext
}
a.pm.LoadRadioStation(station, queueMode)
}
func (a *RadiosPage) onSearched(query string) {
// since the radios list is returned in full non-paginated, we will do our own
// simple search based on the radio name, rather than calling a server API
if query == "" {
a.list.SetRadios(a.radios)
} else {
query = strings.ToLower(query)
result := sharedutil.FilterSlice(a.radios, func(x *mediaprovider.RadioStation) bool {
return strings.Contains(strings.ToLower(x.Name), query)
})
a.list.SetRadios(result)
}
a.list.list.ScrollTo(0)
}
var _ Searchable = (*RadiosPage)(nil)
func (a *RadiosPage) SearchWidget() fyne.Focusable {
return a.searcher
}
var _ Scrollable = (*RadiosPage)(nil)
func (a *RadiosPage) Scroll(amount float32) {
a.list.list.ScrollToOffset(a.list.list.GetScrollOffset() + amount)
}
var _ CanShowNowPlaying = (*RadiosPage)(nil)
func (a *RadiosPage) OnSongChange(playing mediaprovider.MediaItem, _ *mediaprovider.Track) {
if playing != nil {
a.nowPlayingID = playing.Metadata().ID
} else {
a.nowPlayingID = ""
}
a.list.Refresh()
}
func (a *RadiosPage) Route() controller.Route {
return controller.RadiosRoute()
}
func (a *RadiosPage) Reload() {
go a.load(false, 0)
}
func (a *RadiosPage) Save() SavedPage {
return &savedrRadiosPage{
contr: a.contr,
rp: a.rp,
pm: a.pm,
searchText: a.searcher.Entry.Text,
scrollPos: a.list.list.GetScrollOffset(),
}
}
type savedrRadiosPage struct {
contr *controller.Controller
rp mediaprovider.RadioProvider
pm *backend.PlaybackManager
searchText string
scrollPos float32
}
func (s *savedrRadiosPage) Restore() Page {
return newRadiosPage(s.contr, s.rp, s.pm, s.searchText, s.scrollPos)
}
func (a *RadiosPage) buildContainer() {
searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer())
a.container = container.New(&layout.CustomPaddedLayout{LeftPadding: 15, RightPadding: 15, TopPadding: 5, BottomPadding: 15},
container.NewBorder(
container.New(&layout.CustomPaddedLayout{LeftPadding: -5},
container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox)),
nil, nil, nil,
container.NewStack(a.noRadiosMsg, a.list)),
)
}
func (a *RadiosPage) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(a.container)
}
type RadioList struct {
widget.BaseWidget
OnPlay func(*mediaprovider.RadioStation)
OnQueue func(r *mediaprovider.RadioStation, next bool)
radios []*mediaprovider.RadioStation
selected *RadioListRow
columnsLayout *layouts.ColumnsLayout
hdr *widgets.ListHeader
list *widgets.FocusList
container *fyne.Container
playingIcon fyne.CanvasObject
menu *widget.PopUpMenu
}
type RadioListRow struct {
widgets.FocusListRowBase
Item *mediaprovider.RadioStation
IsPlaying bool
OnTappedSecondary func(*fyne.PointEvent)
nameLabel *widget.RichText
homePageLink *widget.Hyperlink
}
func NewRadioListRow(layout *layouts.ColumnsLayout) *RadioListRow {
a := &RadioListRow{
nameLabel: widget.NewRichTextWithText(""),
homePageLink: widget.NewHyperlink("", nil),
}
a.ExtendBaseWidget(a)
a.nameLabel.Truncation = fyne.TextTruncateEllipsis
a.homePageLink.Truncation = fyne.TextTruncateEllipsis
a.Content = container.New(layout, a.nameLabel, a.homePageLink)
return a
}
func (a *RadioListRow) TappedSecondary(e *fyne.PointEvent) {
if a.OnTappedSecondary != nil {
a.OnTappedSecondary(e)
}
}
func NewRadioList(nowPlayingIDPtr *string) *RadioList {
a := &RadioList{
columnsLayout: layouts.NewColumnsLayout([]float32{-1, -1}),
}
playIcon := theme.NewThemedResource(theme.MediaPlayIcon())
playIcon.ColorName = theme.ColorNamePrimary
a.playingIcon = container.NewCenter(widget.NewIcon(playIcon))
a.ExtendBaseWidget(a)
a.hdr = widgets.NewListHeader([]widgets.ListColumn{
{Text: "Name", Alignment: fyne.TextAlignLeading, CanToggleVisible: false},
{Text: "Home Page", Alignment: fyne.TextAlignLeading, CanToggleVisible: false}},
a.columnsLayout)
a.hdr.DisableSorting = true
a.list = widgets.NewFocusList(
func() int { return len(a.radios) },
func() fyne.CanvasObject {
r := NewRadioListRow(a.columnsLayout)
r.OnTapped = func() {
r.Selected = true
if a.selected != nil {
// unselect old row
a.selected.Selected = false
a.selected.Refresh()
}
a.selected = r
r.Refresh()
}
r.OnDoubleTapped = func() { a.onPlayRadio(r.Item) }
r.OnTappedSecondary = func(e *fyne.PointEvent) {
r.OnTapped() // handle selection
a.showMenu(e.AbsolutePosition)
}
r.OnFocusNeighbor = func(up bool) {
a.list.FocusNeighbor(r.ItemID(), up)
}
return r
},
func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*RadioListRow)
a.list.SetItemForID(id, row)
changed := false
if row.Item != a.radios[id] {
row.EnsureUnfocused()
row.ListItemID = id
row.Item = a.radios[id]
row.nameLabel.Segments[0].(*widget.TextSegment).Text = row.Item.Name
row.homePageLink.Text = row.Item.HomePageURL
if u, err := url.Parse(row.Item.HomePageURL); err == nil {
row.homePageLink.URL = u
}
changed = true
}
isPlaying := *nowPlayingIDPtr == row.Item.ID
if row.IsPlaying != isPlaying {
row.IsPlaying = isPlaying
row.nameLabel.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying
if isPlaying {
row.Content.(*fyne.Container).Objects[0] =
container.NewBorder(nil, nil, a.playingIcon, nil,
container.New(layout.NewCustomPaddedLayout(0, 0, -5, 0), row.nameLabel))
} else {
row.Content.(*fyne.Container).Objects[0] = row.nameLabel
}
changed = true
}
if changed {
row.Refresh()
}
},
)
a.container = container.NewBorder(a.hdr, nil, nil, nil, a.list)
return a
}
func (a *RadioList) showMenu(pos fyne.Position) {
if a.menu == nil {
play := fyne.NewMenuItem("Play", func() {
if a.OnPlay != nil {
a.OnPlay(a.selected.Item)
}
})
play.Icon = theme.MediaPlayIcon()
playNext := fyne.NewMenuItem("Play next", func() {
if a.OnQueue != nil {
a.OnQueue(a.selected.Item, true)
}
})
playNext.Icon = myTheme.PlayNextIcon
append := fyne.NewMenuItem("Add to queue", func() {
if a.OnQueue != nil {
a.OnQueue(a.selected.Item, false)
}
})
append.Icon = theme.ContentAddIcon()
a.menu = widget.NewPopUpMenu(fyne.NewMenu("",
play,
playNext,
append,
),
fyne.CurrentApp().Driver().CanvasForObject(a),
)
}
a.menu.ShowAtPosition(pos)
}
func (g *RadioList) SetRadios(radios []*mediaprovider.RadioStation) {
g.radios = radios
g.Refresh()
}
func (a *RadioList) onPlayRadio(item *mediaprovider.RadioStation) {
if a.OnPlay != nil {
a.OnPlay(item)
}
}
func (a *RadioList) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(a.container)
}
+4
View File
@@ -55,6 +55,10 @@ func (r Router) CreatePage(rte controller.Route) Page {
return NewPlaylistsPage(r.Controller, r.widgetPool, &r.App.Config.PlaylistsPage, r.App.ServerManager.Server) return NewPlaylistsPage(r.Controller, r.widgetPool, &r.App.Config.PlaylistsPage, r.App.ServerManager.Server)
case controller.Tracks: case controller.Tracks:
return NewTracksPage(r.Controller, &r.App.Config.TracksPage, r.widgetPool, r.App.ServerManager.Server, r.App.ImageManager) return NewTracksPage(r.Controller, &r.App.Config.TracksPage, r.widgetPool, r.App.ServerManager.Server, r.App.ImageManager)
case controller.Radios:
var rp mediaprovider.RadioProvider
rp, _ = r.App.ServerManager.Server.(mediaprovider.RadioProvider)
return NewRadiosPage(r.Controller, rp, r.App.PlaybackManager)
} }
return nil return nil
} }
+5 -3
View File
@@ -95,13 +95,15 @@ func (t *TracksPage) Reload() {
t.loader = widgets.NewTracklistLoader(t.tracklist, iter) t.loader = widgets.NewTracklistLoader(t.tracklist, iter)
} }
func (t *TracksPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { var _ CanShowNowPlaying = (*TracksPage)(nil)
t.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track)
func (t *TracksPage) OnSongChange(item mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
t.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(item)
t.tracklist.SetNowPlaying(t.nowPlayingID) t.tracklist.SetNowPlaying(t.nowPlayingID)
if t.searchTracklist != nil { if t.searchTracklist != nil {
t.searchTracklist.SetNowPlaying(t.nowPlayingID) t.searchTracklist.SetNowPlaying(t.nowPlayingID)
} }
playedID := sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny) playedID := sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny)
t.tracklist.IncrementPlayCount(playedID) t.tracklist.IncrementPlayCount(playedID)
if t.searchTracklist != nil { if t.searchTracklist != nil {
t.searchTracklist.IncrementPlayCount(playedID) t.searchTracklist.IncrementPlayCount(playedID)
+3 -3
View File
@@ -827,8 +827,8 @@ func (c *Controller) downloadTrack(track *mediaprovider.Track, filePath string)
return return
} }
log.Printf("Saved song %s to: %s\n", track.Name, filePath) log.Printf("Saved song %s to: %s\n", track.Title, filePath)
c.sendNotification(fmt.Sprintf("Download completed: %s", track.Name), fmt.Sprintf("Saved at: %s", filePath)) c.sendNotification(fmt.Sprintf("Download completed: %s", track.Title), fmt.Sprintf("Saved at: %s", filePath))
} }
func (c *Controller) downloadTracks(tracks []*mediaprovider.Track, filePath, downloadName string) { func (c *Controller) downloadTracks(tracks []*mediaprovider.Track, filePath, downloadName string) {
@@ -863,7 +863,7 @@ func (c *Controller) downloadTracks(tracks []*mediaprovider.Track, filePath, dow
continue continue
} }
log.Printf("Saved song %s to: %s\n", track.Name, filePath) log.Printf("Saved song %s to: %s\n", track.Title, filePath)
} }
c.sendNotification(fmt.Sprintf("Download completed: %s", downloadName), fmt.Sprintf("Saved at: %s", filePath)) c.sendNotification(fmt.Sprintf("Download completed: %s", downloadName), fmt.Sprintf("Saved at: %s", filePath))
+5
View File
@@ -15,6 +15,7 @@ const (
Playlist Playlist
Playlists Playlists
Tracks Tracks
Radios
) )
type Route struct { type Route struct {
@@ -61,6 +62,10 @@ func ArtistsRoute() Route {
return Route{Page: Artists} return Route{Page: Artists}
} }
func RadiosRoute() Route {
return Route{Page: Radios}
}
func NowPlayingRoute(highlightedTrackID string) Route { func NowPlayingRoute(highlightedTrackID string) Route {
return Route{Page: NowPlaying, Arg: highlightedTrackID} return Route{Page: NowPlaying, Arg: highlightedTrackID}
} }
+1 -1
View File
@@ -264,7 +264,7 @@ func (s *searchResult) Update(result *mediaprovider.SearchResult) {
} }
s.id = result.ID s.id = result.ID
s.contentType = result.Type s.contentType = result.Type
s.image.CenterIcon = placeholderIconForContentType(result.Type) s.image.PlaceholderIcon = placeholderIconForContentType(result.Type)
s.imageLoader.Load(result.CoverID) s.imageLoader.Load(result.CoverID)
s.title.SetText(result.Name) s.title.SetText(result.Name)
+23 -8
View File
@@ -36,9 +36,10 @@ var (
ShortcutNavFive = desktop.CustomShortcut{KeyName: fyne.Key5, Modifier: os.ControlModifier} ShortcutNavFive = desktop.CustomShortcut{KeyName: fyne.Key5, Modifier: os.ControlModifier}
ShortcutNavSix = desktop.CustomShortcut{KeyName: fyne.Key6, Modifier: os.ControlModifier} ShortcutNavSix = desktop.CustomShortcut{KeyName: fyne.Key6, Modifier: os.ControlModifier}
ShortcutNavSeven = desktop.CustomShortcut{KeyName: fyne.Key7, Modifier: os.ControlModifier} ShortcutNavSeven = desktop.CustomShortcut{KeyName: fyne.Key7, Modifier: os.ControlModifier}
ShortcutNavEight = desktop.CustomShortcut{KeyName: fyne.Key8, Modifier: os.ControlModifier}
NavShortcuts = []desktop.CustomShortcut{ShortcutNavOne, ShortcutNavTwo, ShortcutNavThree, NavShortcuts = []desktop.CustomShortcut{ShortcutNavOne, ShortcutNavTwo, ShortcutNavThree,
ShortcutNavFour, ShortcutNavFive, ShortcutNavSix, ShortcutNavSeven} ShortcutNavFour, ShortcutNavFive, ShortcutNavSix, ShortcutNavSeven, ShortcutNavEight}
) )
type MainWindow struct { type MainWindow struct {
@@ -53,6 +54,9 @@ type MainWindow struct {
theme *theme.MyTheme theme *theme.MyTheme
haveSystemTray bool haveSystemTray bool
container *fyne.Container container *fyne.Container
// needs to bes shown/hidden when switching between servers based on whether they support radio
radioBtn *widget.Button
} }
func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, app *backend.App, size fyne.Size) MainWindow { func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, app *backend.App, size fyne.Size) MainWindow {
@@ -81,22 +85,25 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
m.Controller.ReloadFunc = m.BrowsingPane.Reload m.Controller.ReloadFunc = m.BrowsingPane.Reload
m.Controller.CurPageFunc = m.BrowsingPane.CurrentPage m.Controller.CurPageFunc = m.BrowsingPane.CurrentPage
m.BottomPanel = NewBottomPanel(app.PlaybackManager, m.Controller) m.BottomPanel = NewBottomPanel(app.PlaybackManager, app.ImageManager, m.Controller)
m.BottomPanel.ImageManager = app.ImageManager
m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane) m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane)
m.Window.SetContent(m.container) m.Window.SetContent(m.container)
m.Window.Resize(size) m.Window.Resize(size)
app.PlaybackManager.OnSongChange(func(track, _ *mediaprovider.Track) { app.PlaybackManager.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) {
if track == nil { if item == nil {
m.Window.SetTitle(displayAppName) m.Window.SetTitle(displayAppName)
return return
} }
artistDisp := strings.Join(track.ArtistNames, ", ") meta := item.Metadata()
m.Window.SetTitle(fmt.Sprintf("%s %s · %s", track.Name, artistDisp, displayAppName)) artistDisp := ""
if tr, ok := item.(*mediaprovider.Track); ok {
artistDisp = " " + strings.Join(tr.ArtistNames, ", ")
}
m.Window.SetTitle(fmt.Sprintf("%s%s · %s", meta.Name, artistDisp, displayAppName))
if m.App.Config.Application.ShowTrackChangeNotification { if m.App.Config.Application.ShowTrackChangeNotification {
// TODO: Once Fyne issue #2935 is resolved, show album cover // TODO: Once Fyne issue #2935 is resolved, show album cover
fyne.CurrentApp().SendNotification(&fyne.Notification{ fyne.CurrentApp().SendNotification(&fyne.Notification{
Title: track.Name, Title: meta.Name,
Content: artistDisp, Content: artistDisp,
}) })
} }
@@ -179,6 +186,11 @@ func (m *MainWindow) RunOnServerConnectedTasks(app *backend.App, displayAppName
} }
m.App.Config.Application.LastCheckedVersion = t m.App.Config.Application.LastCheckedVersion = t
} }
_, supportsRadio := m.App.ServerManager.Server.(mediaprovider.RadioProvider)
m.radioBtn.Hidden = !supportsRadio
m.radioBtn.Refresh()
m.App.SaveConfigFile() m.App.SaveConfigFile()
} }
@@ -261,6 +273,9 @@ func (m *MainWindow) addNavigationButtons() {
m.BrowsingPane.AddNavigationButton(theme.TracksIcon, controller.Tracks, func() { m.BrowsingPane.AddNavigationButton(theme.TracksIcon, controller.Tracks, func() {
m.Router.NavigateTo(controller.TracksRoute()) m.Router.NavigateTo(controller.TracksRoute())
}) })
m.radioBtn = m.BrowsingPane.AddNavigationButton(theme.RadioIcon, controller.Radios, func() {
m.Router.NavigateTo(controller.RadiosRoute())
})
} }
func (m *MainWindow) addShortcuts() { func (m *MainWindow) addShortcuts() {
+1 -1
View File
@@ -31,7 +31,7 @@ const (
var ( var (
AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg) AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg)
ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg) ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg)
BroadcastIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg) RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg)
FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg) FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg)
NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg) NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg)
NowPlayingIcon fyne.Resource = theme.NewThemedResource(res.ResHeadphonesSvg) NowPlayingIcon fyne.Resource = theme.NewThemedResource(res.ResHeadphonesSvg)
+38 -26
View File
@@ -8,76 +8,88 @@ import (
) )
type TrackListModel struct { type TrackListModel struct {
Track *mediaprovider.Track Item mediaprovider.MediaItem
Selected bool Selected bool
} }
// Returns the item as a *mediaprovider.Track, or panics if not a Track
// Use for tracklists that can only contain tracks (ie not PlayQueueList)
func (t TrackListModel) Track() *mediaprovider.Track {
return t.Item.(*mediaprovider.Track)
}
func ToTrackListModels(trs []*mediaprovider.Track) []*TrackListModel { func ToTrackListModels(trs []*mediaprovider.Track) []*TrackListModel {
return sharedutil.MapSlice(trs, func(tr *mediaprovider.Track) *TrackListModel { return sharedutil.MapSlice(trs, func(tr *mediaprovider.Track) *TrackListModel {
return &TrackListModel{Track: tr, Selected: false} return &TrackListModel{Item: tr, Selected: false}
}) })
} }
func SelectedTracks(tracks []*TrackListModel) []*mediaprovider.Track { func SelectedTracks(items []*TrackListModel) []*mediaprovider.Track {
return sharedutil.FilterMapSlice(tracks, func(tm *TrackListModel) (*mediaprovider.Track, bool) { return sharedutil.FilterMapSlice(items, func(tm *TrackListModel) (*mediaprovider.Track, bool) {
return tm.Track, tm.Selected return tm.Track(), tm.Selected
}) })
} }
func SelectedTrackIDs(tracks []*TrackListModel) []string { func SelectedItems(items []*TrackListModel) []mediaprovider.MediaItem {
return sharedutil.FilterMapSlice(tracks, func(tm *TrackListModel) (string, bool) { return sharedutil.FilterMapSlice(items, func(tm *TrackListModel) (mediaprovider.MediaItem, bool) {
return tm.Track.ID, tm.Selected return tm.Item, tm.Selected
}) })
} }
func SelectTrack(tracks []*TrackListModel, idx int) { func SelectedItemIDs(items []*TrackListModel) []string {
if tracks[idx].Selected { return sharedutil.FilterMapSlice(items, func(tm *TrackListModel) (string, bool) {
return tm.Item.Metadata().ID, tm.Selected
})
}
func SelectItem(items []*TrackListModel, idx int) {
if items[idx].Selected {
return return
} }
UnselectAllTracks(tracks) UnselectAllItems(items)
tracks[idx].Selected = true items[idx].Selected = true
} }
func SelectAllTracks(tracks []*TrackListModel) { func SelectAllItems(items []*TrackListModel) {
for _, tm := range tracks { for _, tm := range items {
tm.Selected = true tm.Selected = true
} }
} }
func UnselectAllTracks(tracks []*TrackListModel) { func UnselectAllItems(items []*TrackListModel) {
for _, tm := range tracks { for _, tm := range items {
tm.Selected = false tm.Selected = false
} }
} }
func SelectTrackRange(tracks []*TrackListModel, idx int) { func SelectItemRange(items []*TrackListModel, idx int) {
if tracks[idx].Selected { if items[idx].Selected {
return return
} }
lastSelected := -1 lastSelected := -1
for i := len(tracks) - 1; i >= 0; i-- { for i := len(items) - 1; i >= 0; i-- {
if tracks[i].Selected { if items[i].Selected {
lastSelected = i lastSelected = i
break break
} }
} }
if lastSelected < 0 { if lastSelected < 0 {
tracks[idx].Selected = true items[idx].Selected = true
return return
} }
from := min(idx, lastSelected) from := min(idx, lastSelected)
to := max(idx, lastSelected) to := max(idx, lastSelected)
for i := from; i <= to; i++ { for i := from; i <= to; i++ {
tracks[i].Selected = true items[i].Selected = true
} }
} }
func FindTrackByID(tracks []*TrackListModel, id string) (*mediaprovider.Track, int) { func FindItemByID(items []*TrackListModel, id string) (mediaprovider.MediaItem, int) {
idx := slices.IndexFunc(tracks, func(tr *TrackListModel) bool { idx := slices.IndexFunc(items, func(tr *TrackListModel) bool {
return tr.Track.ID == id return tr.Item.Metadata().ID == id
}) })
if idx >= 0 { if idx >= 0 {
return tracks[idx].Track, idx return items[idx].Item, idx
} }
return nil, -1 return nil, -1
} }
+1 -1
View File
@@ -321,7 +321,7 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) {
} }
card.ItemIndex = itemIdx card.ItemIndex = itemIdx
g.itemForIndex[itemIdx] = card g.itemForIndex[itemIdx] = card
card.Cover.Im.CenterIcon = g.Placeholder card.Cover.Im.PlaceholderIcon = g.Placeholder
if !card.NeedsUpdate(item) && card.ItemIndex == itemIdx { if !card.NeedsUpdate(item) && card.ItemIndex == itemIdx {
// nothing to do // nothing to do
g.stateMutex.Unlock() g.stateMutex.Unlock()
+4 -4
View File
@@ -15,8 +15,8 @@ import (
// a placeholder with a rectangular border frame // a placeholder with a rectangular border frame
// and an icon positioned in the center of the frame. // and an icon positioned in the center of the frame.
type ImagePlaceholder struct { type ImagePlaceholder struct {
ScaleMode canvas.ImageScale ScaleMode canvas.ImageScale
CenterIcon fyne.Resource PlaceholderIcon fyne.Resource
widget.BaseWidget widget.BaseWidget
content *fyne.Container content *fyne.Container
@@ -31,7 +31,7 @@ type ImagePlaceholder struct {
} }
func NewImagePlaceholder(centerIcon fyne.Resource, minSize float32) *ImagePlaceholder { func NewImagePlaceholder(centerIcon fyne.Resource, minSize float32) *ImagePlaceholder {
i := &ImagePlaceholder{minSize: minSize, CenterIcon: centerIcon} i := &ImagePlaceholder{minSize: minSize, PlaceholderIcon: centerIcon}
i.ExtendBaseWidget(i) i.ExtendBaseWidget(i)
i.iconImage = canvas.NewImageFromResource(centerIcon) i.iconImage = canvas.NewImageFromResource(centerIcon)
i.iconImage.FillMode = canvas.ImageFillContain i.iconImage.FillMode = canvas.ImageFillContain
@@ -94,7 +94,7 @@ func (i *ImagePlaceholder) MinSize() fyne.Size {
func (i *ImagePlaceholder) Refresh() { func (i *ImagePlaceholder) Refresh() {
i.border.Hidden = i.HaveImage() i.border.Hidden = i.HaveImage()
i.iconImage.Resource = i.CenterIcon i.iconImage.Resource = i.PlaceholderIcon
i.iconImage.Hidden = i.HaveImage() i.iconImage.Hidden = i.HaveImage()
i.imageDisp.Hidden = !i.HaveImage() i.imageDisp.Hidden = !i.HaveImage()
i.imageDisp.ScaleMode = i.ScaleMode i.imageDisp.ScaleMode = i.ScaleMode
+44 -21
View File
@@ -20,15 +20,18 @@ type LargeNowPlayingCard struct {
DisableRating bool DisableRating bool
trackName *widget.RichText isRadio bool
artistName *MultiHyperlink trackName *widget.RichText
albumName *widget.Hyperlink artistName *MultiHyperlink
rating *StarRating albumName *widget.Hyperlink
favorite *FavoriteIcon rating *StarRating
cover *ImagePlaceholder favorite *FavoriteIcon
ratingFavoriteContainer *fyne.Container
cover *ImagePlaceholder
OnArtistNameTapped func(artistID string) OnArtistNameTapped func(artistID string)
OnAlbumNameTapped func() OnAlbumNameTapped func()
OnRadioURLTapped func(url string)
OnSetRating func(rating int) OnSetRating func(rating int)
OnSetFavorite func(favorite bool) OnSetFavorite func(favorite bool)
} }
@@ -49,17 +52,18 @@ func NewLargeNowPlayingCard() *LargeNowPlayingCard {
n.cover.ScaleMode = canvas.ImageScaleFastest n.cover.ScaleMode = canvas.ImageScaleFastest
// set up the layout // set up the layout
n.Content = n.cover n.Content = n.cover
n.ratingFavoriteContainer = container.NewHBox(
layout.NewSpacer(),
n.favorite,
widget.NewLabel("·"),
n.rating,
layout.NewSpacer(),
)
n.Caption = container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-13), n.Caption = container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-13),
n.trackName, n.trackName,
n.artistName, n.artistName,
n.albumName, n.albumName,
container.NewHBox( n.ratingFavoriteContainer,
layout.NewSpacer(),
n.favorite,
widget.NewLabel("·"),
n.rating,
layout.NewSpacer(),
),
) )
n.trackName.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameSubHeadingText n.trackName.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameSubHeadingText
@@ -79,6 +83,12 @@ func (n *LargeNowPlayingCard) onAlbumNameTapped() {
} }
func (n *LargeNowPlayingCard) onArtistNameTapped(artistID string) { func (n *LargeNowPlayingCard) onArtistNameTapped(artistID string) {
if n.isRadio {
if n.OnRadioURLTapped != nil {
n.OnRadioURLTapped(artistID)
}
return
}
if n.OnArtistNameTapped != nil { if n.OnArtistNameTapped != nil {
n.OnArtistNameTapped(artistID) n.OnArtistNameTapped(artistID)
} }
@@ -108,19 +118,32 @@ func (n *LargeNowPlayingCard) SetDisplayedFavorite(favorite bool) {
n.favorite.Refresh() n.favorite.Refresh()
} }
func (n *LargeNowPlayingCard) Update(track *mediaprovider.Track) { func (n *LargeNowPlayingCard) Update(item mediaprovider.MediaItem) {
if track != nil { if item == nil {
n.trackName.Segments[0].(*widget.TextSegment).Text = track.Name
n.artistName.BuildSegments(track.ArtistNames, track.ArtistIDs)
n.albumName.Text = track.Album
n.rating.Rating = track.Rating
n.favorite.Favorite = track.Favorite
} else {
n.trackName.Segments[0].(*widget.TextSegment).Text = "" n.trackName.Segments[0].(*widget.TextSegment).Text = ""
n.artistName.BuildSegments([]string{}, []string{}) n.artistName.BuildSegments([]string{}, []string{})
n.albumName.Text = "" n.albumName.Text = ""
n.rating.Rating = 0 n.rating.Rating = 0
n.favorite.Favorite = false n.favorite.Favorite = false
return
}
meta := item.Metadata()
n.trackName.Segments[0].(*widget.TextSegment).Text = meta.Name
n.albumName.Text = meta.Album
n.albumName.Hidden = meta.AlbumID == ""
if tr, ok := item.(*mediaprovider.Track); ok {
n.artistName.BuildSegments(meta.Artists, meta.ArtistIDs)
n.rating.Rating = tr.Rating
n.favorite.Favorite = tr.Favorite
n.cover.PlaceholderIcon = myTheme.TracksIcon
n.ratingFavoriteContainer.Hidden = false
n.isRadio = false
} else if rd, ok := item.(*mediaprovider.RadioStation); ok {
n.artistName.BuildSegments([]string{rd.HomePageURL}, []string{rd.HomePageURL})
n.ratingFavoriteContainer.Hidden = true
n.cover.PlaceholderIcon = myTheme.RadioIcon
n.isRadio = true
} }
n.Refresh() n.Refresh()
+24 -14
View File
@@ -25,7 +25,7 @@ type NowPlayingCard struct {
trackName *widget.Hyperlink trackName *widget.Hyperlink
artistName *MultiHyperlink artistName *MultiHyperlink
albumName *widget.Hyperlink albumName *widget.Hyperlink
cover *TappableImage cover *ImagePlaceholder
menu *widget.PopUpMenu menu *widget.PopUpMenu
ratingMenu *fyne.MenuItem ratingMenu *fyne.MenuItem
@@ -45,17 +45,16 @@ func NewNowPlayingCard() *NowPlayingCard {
albumName: widget.NewHyperlink("", nil), albumName: widget.NewHyperlink("", nil),
} }
n.ExtendBaseWidget(n) n.ExtendBaseWidget(n)
n.cover = NewTappableImage(n.onShowCoverImage) n.cover = NewImagePlaceholder(myTheme.TracksIcon, 85)
n.cover.OnTapped = n.onShowCoverImage
n.cover.OnTappedSecondary = n.showMenu n.cover.OnTappedSecondary = n.showMenu
n.cover.ScaleMode = canvas.ImageScaleFastest
n.cover.Hidden = true
n.trackName.Hidden = true n.trackName.Hidden = true
n.albumName.Hidden = true n.albumName.Hidden = true
n.albumName.Truncation = fyne.TextTruncateEllipsis n.albumName.Truncation = fyne.TextTruncateEllipsis
n.trackName.Truncation = fyne.TextTruncateEllipsis n.trackName.Truncation = fyne.TextTruncateEllipsis
n.trackName.TextStyle.Bold = true n.trackName.TextStyle.Bold = true
n.cover.SetMinSize(fyne.NewSquareSize(85))
n.cover.FillMode = canvas.ImageFillContain
n.cover.ScaleMode = canvas.ImageScaleFastest
n.cover.Hidden = true
n.albumName.OnTapped = n.onAlbumNameTapped n.albumName.OnTapped = n.onAlbumNameTapped
n.artistName.OnTapped = n.onArtistNameTapped n.artistName.OnTapped = n.onArtistNameTapped
n.trackName.OnTapped = n.onTrackNameTapped n.trackName.OnTapped = n.onTrackNameTapped
@@ -119,24 +118,35 @@ func (n *NowPlayingCard) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(c) return widget.NewSimpleRenderer(c)
} }
func (n *NowPlayingCard) Update(track *mediaprovider.Track, cover image.Image) { func (n *NowPlayingCard) Update(track mediaprovider.MediaItem) {
if track != nil { if track == nil {
n.trackName.SetText(track.Name)
n.artistName.BuildSegments(track.ArtistNames, track.ArtistIDs)
n.albumName.SetText(track.Album)
} else {
n.trackName.SetText("") n.trackName.SetText("")
n.artistName.BuildSegments([]string{}, []string{}) n.artistName.BuildSegments([]string{}, []string{})
n.albumName.SetText("") n.albumName.SetText("")
n.cover.Hidden = true
} else {
n.cover.Hidden = false
n.trackName.SetText(track.Metadata().Name)
if tr, ok := track.(*mediaprovider.Track); ok {
n.artistName.BuildSegments(tr.ArtistNames, tr.ArtistIDs)
n.albumName.SetText(tr.Album)
n.cover.PlaceholderIcon = myTheme.TracksIcon
} else {
n.artistName.BuildSegments([]string{}, []string{})
n.albumName.SetText("")
n.cover.PlaceholderIcon = myTheme.RadioIcon
}
} }
n.trackName.Hidden = n.trackName.Text == "" n.trackName.Hidden = n.trackName.Text == ""
n.artistName.Hidden = len(n.artistName.Segments) == 0 n.artistName.Hidden = len(n.artistName.Segments) == 0
n.albumName.Hidden = n.albumName.Text == "" n.albumName.Hidden = n.albumName.Text == ""
n.cover.Image.Image = cover
n.cover.Hidden = cover == nil
n.Refresh() n.Refresh()
} }
func (n *NowPlayingCard) SetImage(cover image.Image) {
n.cover.SetImage(cover, true)
}
func (n *NowPlayingCard) showMenu(e *fyne.PointEvent) { func (n *NowPlayingCard) showMenu(e *fyne.PointEvent) {
if n.menu == nil { if n.menu == nil {
n.ratingMenu = util.NewRatingSubmenu(n.onSetRating) n.ratingMenu = util.NewRatingSubmenu(n.onSetRating)
+165 -95
View File
@@ -23,6 +23,11 @@ import (
const playQueueListThumbnailSize = 52 const playQueueListThumbnailSize = 52
type PlayQueueListModel struct {
Item mediaprovider.MediaItem
Selected bool
}
type PlayQueueList struct { type PlayQueueList struct {
widget.BaseWidget widget.BaseWidget
@@ -33,15 +38,16 @@ type PlayQueueList struct {
OnAddToPlaylist func(trackIDs []string) OnAddToPlaylist func(trackIDs []string)
OnSetFavorite func(trackIDs []string, fav bool) OnSetFavorite func(trackIDs []string, fav bool)
OnSetRating func(trackIDs []string, rating int) OnSetRating func(trackIDs []string, rating int)
OnRemoveFromQueue func(trackIDs []string) OnRemoveFromQueue func(itemIDs []string)
OnDownload func(tracks []*mediaprovider.Track, downloadName string) OnDownload func(tracks []*mediaprovider.Track, downloadName string)
OnShare func(tracks []*mediaprovider.Track) OnShare func(tracks []*mediaprovider.Track)
OnShowArtistPage func(artistID string) OnShowArtistPage func(artistID string)
OnPlayTrackAt func(idx int) OnPlayTrackAt func(idx int)
OnReorderTracks func(trackIDs []string, op sharedutil.TrackReorderOp) OnReorderItems func(itemIDs []string, op sharedutil.TrackReorderOp)
list *FocusList list *FocusList
menu *widget.PopUpMenu menu *widget.PopUpMenu // ctx menu for when only tracks are selected
radiosMenu *widget.PopUpMenu // ctx menu for when selection contains radios
ratingSubmenu *fyne.MenuItem ratingSubmenu *fyne.MenuItem
shareMenuItem *fyne.MenuItem shareMenuItem *fyne.MenuItem
@@ -49,7 +55,7 @@ type PlayQueueList struct {
colLayout *layouts.ColumnsLayout colLayout *layouts.ColumnsLayout
tracksMutex sync.RWMutex tracksMutex sync.RWMutex
tracks []*util.TrackListModel items []*util.TrackListModel
} }
func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList { func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList {
@@ -78,16 +84,16 @@ func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList {
// we could have removed tracks from the list in between // we could have removed tracks from the list in between
// Fyne calling the length callback and this update callback // Fyne calling the length callback and this update callback
// so the itemID may be out of bounds. if so, do nothing. // so the itemID may be out of bounds. if so, do nothing.
if itemID >= len(p.tracks) { if itemID >= len(p.items) {
p.tracksMutex.RUnlock() p.tracksMutex.RUnlock()
return return
} }
model := p.tracks[itemID] model := p.items[itemID]
p.tracksMutex.RUnlock() p.tracksMutex.RUnlock()
tr := item.(*PlayQueueListRow) tr := item.(*PlayQueueListRow)
p.list.SetItemForID(itemID, tr) p.list.SetItemForID(itemID, tr)
if tr.trackID != model.Track.ID || tr.ListItemID != itemID { if tr.trackID != model.Item.Metadata().ID || tr.ListItemID != itemID {
tr.ListItemID = itemID tr.ListItemID = itemID
} }
tr.Update(model, itemID+1) tr.Update(model, itemID+1)
@@ -100,19 +106,29 @@ func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList {
func (p *PlayQueueList) SetTracks(trs []*mediaprovider.Track) { func (p *PlayQueueList) SetTracks(trs []*mediaprovider.Track) {
p.tracksMutex.Lock() p.tracksMutex.Lock()
p.list.ClearItemForIDMap() p.list.ClearItemForIDMap()
p.tracks = util.ToTrackListModels(trs) p.items = util.ToTrackListModels(trs)
p.tracksMutex.Unlock() p.tracksMutex.Unlock()
p.Refresh() p.Refresh()
} }
// Sets the currently playing track ID and updates the list rendering func (p *PlayQueueList) SetItems(items []mediaprovider.MediaItem) {
func (p *PlayQueueList) SetNowPlaying(trackID string) { p.tracksMutex.Lock()
p.list.ClearItemForIDMap()
p.items = sharedutil.MapSlice(items, func(item mediaprovider.MediaItem) *util.TrackListModel {
return &util.TrackListModel{Item: item}
})
p.tracksMutex.Unlock()
p.Refresh()
}
// Sets the currently playing item ID and updates the list rendering
func (p *PlayQueueList) SetNowPlaying(itemID string) {
prevNowPlaying := p.nowPlayingID prevNowPlaying := p.nowPlayingID
p.tracksMutex.RLock() p.tracksMutex.RLock()
trPrev, idxPrev := util.FindTrackByID(p.tracks, prevNowPlaying) trPrev, idxPrev := util.FindItemByID(p.items, prevNowPlaying)
tr, idx := util.FindTrackByID(p.tracks, trackID) tr, idx := util.FindItemByID(p.items, itemID)
p.tracksMutex.RUnlock() p.tracksMutex.RUnlock()
p.nowPlayingID = trackID p.nowPlayingID = itemID
if trPrev != nil { if trPrev != nil {
p.list.RefreshItem(idxPrev) p.list.RefreshItem(idxPrev)
} }
@@ -123,14 +139,14 @@ func (p *PlayQueueList) SetNowPlaying(trackID string) {
func (p *PlayQueueList) SelectAll() { func (p *PlayQueueList) SelectAll() {
p.tracksMutex.RLock() p.tracksMutex.RLock()
util.SelectAllTracks(p.tracks) util.SelectAllItems(p.items)
p.tracksMutex.RUnlock() p.tracksMutex.RUnlock()
p.list.Refresh() p.list.Refresh()
} }
func (p *PlayQueueList) UnselectAll() { func (p *PlayQueueList) UnselectAll() {
p.tracksMutex.RLock() p.tracksMutex.RLock()
util.UnselectAllTracks(p.tracks) util.UnselectAllItems(p.items)
p.tracksMutex.RUnlock() p.tracksMutex.RUnlock()
p.Refresh() p.Refresh()
} }
@@ -138,7 +154,7 @@ func (p *PlayQueueList) UnselectAll() {
func (p *PlayQueueList) lenTracks() int { func (p *PlayQueueList) lenTracks() int {
p.tracksMutex.RLock() p.tracksMutex.RLock()
defer p.tracksMutex.RUnlock() defer p.tracksMutex.RUnlock()
return len(p.tracks) return len(p.items)
} }
func (t *PlayQueueList) onArtistTapped(artistID string) { func (t *PlayQueueList) onArtistTapped(artistID string) {
@@ -172,103 +188,152 @@ func (p *PlayQueueList) onSelectTrack(idx int) {
func (p *PlayQueueList) selectTrack(idx int) { func (p *PlayQueueList) selectTrack(idx int) {
p.tracksMutex.RLock() p.tracksMutex.RLock()
defer p.tracksMutex.RUnlock() defer p.tracksMutex.RUnlock()
util.SelectTrack(p.tracks, idx) util.SelectItem(p.items, idx)
} }
func (p *PlayQueueList) selectAddOrRemove(idx int) { func (p *PlayQueueList) selectAddOrRemove(idx int) {
p.tracksMutex.RLock() p.tracksMutex.RLock()
defer p.tracksMutex.RUnlock() defer p.tracksMutex.RUnlock()
p.tracks[idx].Selected = !p.tracks[idx].Selected p.items[idx].Selected = !p.items[idx].Selected
} }
func (p *PlayQueueList) selectRange(idx int) { func (p *PlayQueueList) selectRange(idx int) {
p.tracksMutex.RLock() p.tracksMutex.RLock()
defer p.tracksMutex.RUnlock() defer p.tracksMutex.RUnlock()
util.SelectTrackRange(p.tracks, idx) util.SelectItemRange(p.items, idx)
} }
func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
p.selectTrack(trackIdx) p.selectTrack(trackIdx)
p.list.Refresh() p.list.Refresh()
if p.menu == nil { selected := p.selectedItems()
playlist := fyne.NewMenuItem("Add to playlist...", func() {
if p.OnAddToPlaylist != nil {
p.OnAddToPlaylist(p.selectedTrackIDs())
}
})
playlist.Icon = myTheme.PlaylistIcon
download := fyne.NewMenuItem("Download...", func() {
if p.OnDownload != nil {
p.OnDownload(p.selectedTracks(), "Selected tracks")
}
})
download.Icon = theme.DownloadIcon()
p.shareMenuItem = fyne.NewMenuItem("Share...", func() {
if p.OnShare != nil {
p.OnShare(p.selectedTracks())
}
})
p.shareMenuItem.Icon = myTheme.ShareIcon
favorite := fyne.NewMenuItem("Set favorite", func() {
if p.OnSetFavorite != nil {
p.OnSetFavorite(p.selectedTrackIDs(), true)
}
})
favorite.Icon = myTheme.FavoriteIcon
unfavorite := fyne.NewMenuItem("Unset favorite", func() {
if p.OnSetFavorite != nil {
p.OnSetFavorite(p.selectedTrackIDs(), false)
}
})
unfavorite.Icon = myTheme.NotFavoriteIcon
p.ratingSubmenu = util.NewRatingSubmenu(func(rating int) {
if p.OnSetRating != nil {
p.OnSetRating(p.selectedTrackIDs(), rating)
}
})
remove := fyne.NewMenuItem("Remove from queue", func() {
if p.OnRemoveFromQueue != nil {
p.OnRemoveFromQueue(p.selectedTrackIDs())
}
})
remove.Icon = theme.ContentRemoveIcon()
reorder := util.NewReorderTracksSubmenu(func(tro sharedutil.TrackReorderOp) {
if p.OnReorderTracks != nil {
p.OnReorderTracks(p.selectedTrackIDs(), tro)
}
})
p.menu = widget.NewPopUpMenu( allTracks := true
fyne.NewMenu("", for _, item := range selected {
playlist, if item.Metadata().Type == mediaprovider.MediaItemTypeRadioStation {
download, allTracks = false
p.shareMenuItem, break
fyne.NewMenuItemSeparator(), }
favorite,
unfavorite,
p.ratingSubmenu,
fyne.NewMenuItemSeparator(),
reorder,
remove,
),
fyne.CurrentApp().Driver().CanvasForObject(p),
)
} }
p.ratingSubmenu.Disabled = p.DisableRating
p.shareMenuItem.Disabled = p.DisableSharing || len(p.selectedTracks()) != 1 var menu *widget.PopUpMenu
p.menu.ShowAtPosition(e.AbsolutePosition) if allTracks {
p.ensureTracksMenu()
p.ratingSubmenu.Disabled = p.DisableRating
p.shareMenuItem.Disabled = p.DisableSharing || len(selected) != 1
menu = p.menu
} else {
p.ensureRadiosMenu()
menu = p.radiosMenu
}
menu.ShowAtPosition(e.AbsolutePosition)
}
func (p *PlayQueueList) ensureTracksMenu() {
if p.menu != nil {
return
}
playlist := fyne.NewMenuItem("Add to playlist...", func() {
if p.OnAddToPlaylist != nil {
p.OnAddToPlaylist(p.selectedItemIDs())
}
})
playlist.Icon = myTheme.PlaylistIcon
download := fyne.NewMenuItem("Download...", func() {
if p.OnDownload != nil {
p.OnDownload(p.selectedTracks(), "Selected tracks")
}
})
download.Icon = theme.DownloadIcon()
p.shareMenuItem = fyne.NewMenuItem("Share...", func() {
if p.OnShare != nil {
p.OnShare(p.selectedTracks())
}
})
p.shareMenuItem.Icon = myTheme.ShareIcon
favorite := fyne.NewMenuItem("Set favorite", func() {
if p.OnSetFavorite != nil {
p.OnSetFavorite(p.selectedItemIDs(), true)
}
})
favorite.Icon = myTheme.FavoriteIcon
unfavorite := fyne.NewMenuItem("Unset favorite", func() {
if p.OnSetFavorite != nil {
p.OnSetFavorite(p.selectedItemIDs(), false)
}
})
unfavorite.Icon = myTheme.NotFavoriteIcon
p.ratingSubmenu = util.NewRatingSubmenu(func(rating int) {
if p.OnSetRating != nil {
p.OnSetRating(p.selectedItemIDs(), rating)
}
})
remove := fyne.NewMenuItem("Remove from queue", func() {
if p.OnRemoveFromQueue != nil {
p.OnRemoveFromQueue(p.selectedItemIDs())
}
})
remove.Icon = theme.ContentRemoveIcon()
reorder := util.NewReorderTracksSubmenu(func(tro sharedutil.TrackReorderOp) {
if p.OnReorderItems != nil {
p.OnReorderItems(p.selectedItemIDs(), tro)
}
})
p.menu = widget.NewPopUpMenu(
fyne.NewMenu("",
playlist,
download,
p.shareMenuItem,
fyne.NewMenuItemSeparator(),
favorite,
unfavorite,
p.ratingSubmenu,
fyne.NewMenuItemSeparator(),
reorder,
remove,
),
fyne.CurrentApp().Driver().CanvasForObject(p),
)
}
func (p *PlayQueueList) ensureRadiosMenu() {
if p.radiosMenu != nil {
return
}
remove := fyne.NewMenuItem("Remove from queue", func() {
if p.OnRemoveFromQueue != nil {
p.OnRemoveFromQueue(p.selectedItemIDs())
}
})
remove.Icon = theme.ContentRemoveIcon()
reorder := util.NewReorderTracksSubmenu(func(tro sharedutil.TrackReorderOp) {
if p.OnReorderItems != nil {
p.OnReorderItems(p.selectedItemIDs(), tro)
}
})
p.radiosMenu = widget.NewPopUpMenu(
fyne.NewMenu("", remove, reorder),
fyne.CurrentApp().Driver().CanvasForObject(p),
)
}
func (t *PlayQueueList) selectedItems() []mediaprovider.MediaItem {
t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock()
return util.SelectedItems(t.items)
} }
func (t *PlayQueueList) selectedTracks() []*mediaprovider.Track { func (t *PlayQueueList) selectedTracks() []*mediaprovider.Track {
t.tracksMutex.RLock() t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock() defer t.tracksMutex.RUnlock()
return util.SelectedTracks(t.tracks) return util.SelectedTracks(t.items)
} }
func (t *PlayQueueList) selectedTrackIDs() []string { func (t *PlayQueueList) selectedItemIDs() []string {
t.tracksMutex.RLock() t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock() defer t.tracksMutex.RUnlock()
return util.SelectedTrackIDs(t.tracks) return util.SelectedItemIDs(t.items)
} }
func (p *PlayQueueList) CreateRenderer() fyne.WidgetRenderer { func (p *PlayQueueList) CreateRenderer() fyne.WidgetRenderer {
@@ -355,19 +420,24 @@ func (p *PlayQueueListRow) Update(tm *util.TrackListModel, rowNum int) {
// Update info that can change if this row is bound to // Update info that can change if this row is bound to
// a new track (*mediaprovider.Track) // a new track (*mediaprovider.Track)
tr := tm.Track meta := tm.Item.Metadata()
if tr.ID != p.trackID { if meta.ID != p.trackID {
p.imageLoader.Load(tm.Track.CoverArtID) if meta.Type == mediaprovider.MediaItemTypeRadioStation {
p.cover.PlaceholderIcon = myTheme.RadioIcon
} else {
p.cover.PlaceholderIcon = myTheme.TracksIcon
}
p.imageLoader.Load(meta.CoverArtID)
p.EnsureUnfocused() p.EnsureUnfocused()
p.trackID = tr.ID p.trackID = meta.ID
p.title.Text = tr.Name p.title.Text = meta.Name
p.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs) p.artist.BuildSegments(meta.Artists, meta.ArtistIDs)
p.time.Text = util.SecondsToTimeString(float64(tr.Duration)) p.time.Text = util.SecondsToTimeString(float64(meta.Duration))
changed = true changed = true
} }
// Render whether track is playing or not // Render whether track is playing or not
if isPlaying := p.playQueueList.nowPlayingID == tr.ID; isPlaying != p.isPlaying { if isPlaying := p.playQueueList.nowPlayingID == meta.ID; isPlaying != p.isPlaying {
p.isPlaying = isPlaying p.isPlaying = isPlaying
p.title.TextStyle.Bold = isPlaying p.title.TextStyle.Bold = isPlaying
+30 -30
View File
@@ -171,7 +171,7 @@ func NewTracklist(tracks []*mediaprovider.Track, im *backend.ImageManager, useCo
tr := item.(TracklistRow) tr := item.(TracklistRow)
t.list.SetItemForID(itemID, tr) t.list.SetItemForID(itemID, tr)
if tr.TrackID() != model.Track.ID || tr.ItemID() != itemID { if tr.TrackID() != model.Item.Metadata().ID || tr.ItemID() != itemID {
tr.SetItemID(itemID) tr.SetItemID(itemID)
} }
i := -1 // signal that we want to display the actual track num. i := -1 // signal that we want to display the actual track num.
@@ -206,7 +206,7 @@ func (t *Tracklist) TrackAt(idx int) *mediaprovider.Track {
log.Println("error: Tracklist.TrackAt: index out of range") log.Println("error: Tracklist.TrackAt: index out of range")
return nil return nil
} }
return t.tracks[idx].Track return t.tracks[idx].Track()
} }
func (t *Tracklist) SetVisibleColumns(cols []string) { func (t *Tracklist) SetVisibleColumns(cols []string) {
@@ -271,8 +271,8 @@ func (t *Tracklist) SetSorting(sorting TracklistSort) {
func (t *Tracklist) SetNowPlaying(trackID string) { func (t *Tracklist) SetNowPlaying(trackID string) {
prevNowPlaying := t.nowPlayingID prevNowPlaying := t.nowPlayingID
t.tracksMutex.RLock() t.tracksMutex.RLock()
trPrev, idxPrev := util.FindTrackByID(t.tracks, prevNowPlaying) trPrev, idxPrev := util.FindItemByID(t.tracks, prevNowPlaying)
tr, idx := util.FindTrackByID(t.tracks, trackID) tr, idx := util.FindItemByID(t.tracks, trackID)
t.tracksMutex.RUnlock() t.tracksMutex.RUnlock()
t.nowPlayingID = trackID t.nowPlayingID = trackID
if trPrev != nil { if trPrev != nil {
@@ -286,10 +286,10 @@ func (t *Tracklist) SetNowPlaying(trackID string) {
// Increments the play count of the given track and updates the list rendering // Increments the play count of the given track and updates the list rendering
func (t *Tracklist) IncrementPlayCount(trackID string) { func (t *Tracklist) IncrementPlayCount(trackID string) {
t.tracksMutex.RLock() t.tracksMutex.RLock()
tr, idx := util.FindTrackByID(t.tracks, trackID) tr, idx := util.FindItemByID(t.tracks, trackID)
t.tracksMutex.RUnlock() t.tracksMutex.RUnlock()
if tr != nil { if tr != nil {
tr.PlayCount += 1 tr.(*mediaprovider.Track).PlayCount += 1
t.list.RefreshItem(idx) t.list.RefreshItem(idx)
} }
} }
@@ -324,7 +324,7 @@ func (t *Tracklist) GetTracks() []*mediaprovider.Track {
t.tracksMutex.RLock() t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock() defer t.tracksMutex.RUnlock()
return sharedutil.MapSlice(t.tracks, func(tm *util.TrackListModel) *mediaprovider.Track { return sharedutil.MapSlice(t.tracks, func(tm *util.TrackListModel) *mediaprovider.Track {
return tm.Track return tm.Track()
}) })
} }
@@ -338,7 +338,7 @@ func (t *Tracklist) AppendTracks(trs []*mediaprovider.Track) {
func (t *Tracklist) SelectAll() { func (t *Tracklist) SelectAll() {
t.tracksMutex.RLock() t.tracksMutex.RLock()
util.SelectAllTracks(t.tracks) util.SelectAllItems(t.tracks)
t.tracksMutex.RUnlock() t.tracksMutex.RUnlock()
t.list.Refresh() t.list.Refresh()
} }
@@ -350,7 +350,7 @@ func (t *Tracklist) UnselectAll() {
func (t *Tracklist) unselectAll() { func (t *Tracklist) unselectAll() {
t.tracksMutex.RLock() t.tracksMutex.RLock()
util.UnselectAllTracks(t.tracks) util.UnselectAllItems(t.tracks)
t.tracksMutex.RUnlock() t.tracksMutex.RUnlock()
} }
@@ -358,7 +358,7 @@ func (t *Tracklist) SelectAndScrollToTrack(trackID string) {
t.tracksMutex.RLock() t.tracksMutex.RLock()
idx := -1 idx := -1
for i, tr := range t.tracks { for i, tr := range t.tracks {
if tr.Track.ID == trackID { if tr.Item.Metadata().ID == trackID {
idx = i idx = i
tr.Selected = true tr.Selected = true
} else { } else {
@@ -422,30 +422,30 @@ func (t *Tracklist) doSortTracks() {
t.tracks = t.tracksOrigOrder t.tracks = t.tracksOrigOrder
} }
case ColumnTitle: case ColumnTitle:
t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Name }) t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().Title })
case ColumnArtist: case ColumnArtist:
t.stringSort(func(tr *util.TrackListModel) string { return strings.Join(tr.Track.ArtistNames, ", ") }) t.stringSort(func(tr *util.TrackListModel) string { return strings.Join(tr.Track().ArtistNames, ", ") })
case ColumnAlbum: case ColumnAlbum:
t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Album }) t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().Album })
case ColumnPath: case ColumnPath:
t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.FilePath }) t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().FilePath })
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 int64(tr.Track().Duration) })
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:
t.intSort(func(tr *util.TrackListModel) int64 { return tr.Track.Size }) t.intSort(func(tr *util.TrackListModel) int64 { return tr.Track().Size })
case ColumnPlays: case ColumnPlays:
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.PlayCount) }) t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().PlayCount) })
case ColumnComment: case ColumnComment:
t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Comment }) t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().Comment })
case ColumnBitrate: case ColumnBitrate:
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.BitRate) }) t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().BitRate) })
case ColumnFavorite: case ColumnFavorite:
t.intSort(func(tr *util.TrackListModel) int64 { t.intSort(func(tr *util.TrackListModel) int64 {
if tr.Track.Favorite { if tr.Track().Favorite {
return 1 return 1
} }
return 0 return 0
@@ -492,13 +492,13 @@ func (t *Tracklist) selectAddOrRemove(idx int) {
func (t *Tracklist) selectTrack(idx int) { func (t *Tracklist) selectTrack(idx int) {
t.tracksMutex.RLock() t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock() defer t.tracksMutex.RUnlock()
util.SelectTrack(t.tracks, idx) util.SelectItem(t.tracks, idx)
} }
func (t *Tracklist) selectRange(idx int) { func (t *Tracklist) selectRange(idx int) {
t.tracksMutex.RLock() t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock() defer t.tracksMutex.RUnlock()
util.SelectTrackRange(t.tracks, idx) util.SelectItemRange(t.tracks, idx)
} }
func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
@@ -534,7 +534,7 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
t.songRadioMenuItem = fyne.NewMenuItem("Play song radio", func() { t.songRadioMenuItem = fyne.NewMenuItem("Play song radio", func() {
t.onPlaySongRadio(t.selectedTracks()) t.onPlaySongRadio(t.selectedTracks())
}) })
t.songRadioMenuItem.Icon = myTheme.BroadcastIcon t.songRadioMenuItem.Icon = myTheme.RadioIcon
t.ctxMenu.Items = append(t.ctxMenu.Items, t.ctxMenu.Items = append(t.ctxMenu.Items,
play, shuffle, playNext, add, t.songRadioMenuItem) play, shuffle, playNext, add, t.songRadioMenuItem)
} }
@@ -581,9 +581,9 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
func (t *Tracklist) onSetFavorite(trackID string, fav bool) { func (t *Tracklist) onSetFavorite(trackID string, fav bool) {
t.tracksMutex.RLock() t.tracksMutex.RLock()
tr, _ := util.FindTrackByID(t.tracks, trackID) item, _ := util.FindItemByID(t.tracks, trackID)
t.tracksMutex.RUnlock() t.tracksMutex.RUnlock()
t.onSetFavorites([]*mediaprovider.Track{tr}, fav, false) t.onSetFavorites([]*mediaprovider.Track{item.(*mediaprovider.Track)}, fav, false)
} }
func (t *Tracklist) onSetFavorites(tracks []*mediaprovider.Track, fav bool, needRefresh bool) { func (t *Tracklist) onSetFavorites(tracks []*mediaprovider.Track, fav bool, needRefresh bool) {
@@ -602,9 +602,9 @@ func (t *Tracklist) onSetFavorites(tracks []*mediaprovider.Track, fav bool, need
func (t *Tracklist) onSetRating(trackID string, rating int) { func (t *Tracklist) onSetRating(trackID string, rating int) {
// update our own track model // update our own track model
t.tracksMutex.RLock() t.tracksMutex.RLock()
tr, _ := util.FindTrackByID(t.tracks, trackID) item, _ := util.FindItemByID(t.tracks, trackID)
t.tracksMutex.RUnlock() t.tracksMutex.RUnlock()
t.onSetRatings([]*mediaprovider.Track{tr}, rating, false) t.onSetRatings([]*mediaprovider.Track{item.(*mediaprovider.Track)}, rating, false)
} }
func (t *Tracklist) onSetRatings(tracks []*mediaprovider.Track, rating int, needRefresh bool) { func (t *Tracklist) onSetRatings(tracks []*mediaprovider.Track, rating int, needRefresh bool) {
@@ -663,7 +663,7 @@ func (t *Tracklist) selectedTracks() []*mediaprovider.Track {
func (t *Tracklist) SelectedTrackIDs() []string { func (t *Tracklist) SelectedTrackIDs() []string {
t.tracksMutex.RLock() t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock() defer t.tracksMutex.RUnlock()
return util.SelectedTrackIDs(t.tracks) return util.SelectedItemIDs(t.tracks)
} }
func (t *Tracklist) lenTracks() int { func (t *Tracklist) lenTracks() int {
+6 -6
View File
@@ -175,8 +175,8 @@ func NewExpandedTracklistRow(tracklist *Tracklist, im *backend.ImageManager, pla
} }
func (t *ExpandedTracklistRow) Update(tm *util.TrackListModel, rowNum int) { func (t *ExpandedTracklistRow) Update(tm *util.TrackListModel, rowNum int) {
if t.trackID != tm.Track.ID { if t.trackID != tm.Track().ID {
t.imageLoader.Load(tm.Track.CoverArtID) t.imageLoader.Load(tm.Track().CoverArtID)
} }
t.tracklistRowBase.Update(tm, rowNum) t.tracklistRowBase.Update(tm, rowNum)
} }
@@ -256,12 +256,12 @@ func (t *tracklistRowBase) Update(tm *util.TrackListModel, rowNum int) {
// Update info that can change if this row is bound to // Update info that can change if this row is bound to
// a new track (*mediaprovider.Track) // a new track (*mediaprovider.Track)
tr := tm.Track tr := tm.Track()
if tr.ID != t.trackID { if id := tr.ID; id != t.trackID {
t.EnsureUnfocused() t.EnsureUnfocused()
t.trackID = tr.ID t.trackID = id
t.name.Segments[0].(*widget.TextSegment).Text = tr.Name t.name.Segments[0].(*widget.TextSegment).Text = tr.Title
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.dur.Text = util.SecondsToTimeString(float64(tr.Duration)) t.dur.Text = util.SecondsToTimeString(float64(tr.Duration))