diff --git a/backend/app.go b/backend/app.go index 4a3cca4..31a8970 100644 --- a/backend/app.go +++ b/backend/app.go @@ -378,7 +378,7 @@ func (a *App) LoadSavedPlayQueue() error { if err := a.PlaybackManager.LoadTracks(queue.Tracks, Replace, false); err != nil { 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 a.PlaybackManager.PlayTrackAt(queue.TrackIndex) a.PlaybackManager.Pause() diff --git a/backend/imagemanager.go b/backend/imagemanager.go index f8102ee..e9050ce 100644 --- a/backend/imagemanager.go +++ b/backend/imagemanager.go @@ -120,15 +120,6 @@ func (i *ImageManager) GetCoverThumbnail(coverID string) (image.Image, error) { 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. // It blocks until the fetch is complete. func (i *ImageManager) GetFullSizeCoverArt(coverID string) (image.Image, error) { diff --git a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go index 9386052..a303e69 100644 --- a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go +++ b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go @@ -395,7 +395,7 @@ func toTrack(ch *jellyfin.Song) *mediaprovider.Track { ID: ch.Id, CoverArtID: coverArtID, ParentID: ch.AlbumID, - Name: ch.Name, + Title: ch.Name, Duration: int(ch.RunTimeTicks / runTimeTicksPerSecond), TrackNumber: ch.IndexNumber, DiscNumber: ch.DiscNumber, diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index 648a363..c3d97d7 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -267,6 +267,10 @@ type LyricsProvider interface { GetLyrics(track *Track) (*Lyrics, error) } +type RadioProvider interface { + GetRadioStations() ([]*RadioStation, error) +} + type JukeboxProvider interface { JukeboxStart() error JukeboxStop() error diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index c865bce..51c7525 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -86,7 +86,7 @@ type Track struct { ID string CoverArtID string ParentID string - Name string + Title string Duration int TrackNumber int DiscNumber int @@ -139,6 +139,74 @@ type SavedPlayQueue struct { 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 const ( diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index 98b95db..fbef25c 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -367,7 +367,7 @@ func (s *subsonicMediaProvider) GetLyrics(track *mediaprovider.Track) (*mediapro return mpLyrics, nil } // 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 == "" { return nil, err } @@ -386,6 +386,7 @@ func (s *subsonicMediaProvider) GetLyrics(track *mediaprovider.Track) (*mediapro } // CanSavePlayQueue interface +var _ mediaprovider.CanSavePlayQueue = (*subsonicMediaProvider)(nil) func (s *subsonicMediaProvider) SavePlayQueue(trackIDs []string, currentTrackIdx int, timeSeconds int) error { if len(trackIDs) == 0 { @@ -413,6 +414,25 @@ func (s *subsonicMediaProvider) GetPlayQueue() (*mediaprovider.SavedPlayQueue, e 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 { if ch == nil { return nil @@ -433,7 +453,7 @@ func toTrack(ch *subsonic.Child) *mediaprovider.Track { ID: ch.ID, CoverArtID: ch.CoverArt, ParentID: ch.Parent, - Name: ch.Title, + Title: ch.Title, Duration: ch.Duration, TrackNumber: ch.Track, DiscNumber: ch.DiscNumber, diff --git a/backend/mpmedia_mac.go b/backend/mpmedia_mac.go index 9e59056..dff1fcc 100644 --- a/backend/mpmedia_mac.go +++ b/backend/mpmedia_mac.go @@ -69,9 +69,14 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac mpMediaEventRecipient = mp 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 - go mp.updateMetadata(track) + var meta *mediaprovider.MediaItemMetadata + if track != nil { + m := track.Metadata() + meta = &m + } + go mp.updateMetadata(meta) }) mp.playbackManager.OnStopped(func() { @@ -95,17 +100,19 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac return nil } -func (mp *MPMediaHandler) updateMetadata(track *mediaprovider.Track) { +func (mp *MPMediaHandler) updateMetadata(meta *mediaprovider.MediaItemMetadata) { var title, artist, artURL string var duration int - if track != nil && track.ID != "" { + if meta != nil && meta.ID != "" { + title = meta.Name var err error - if artURL, err = mp.artURLLookup(track.CoverArtID); err != nil { - log.Printf("error fetching art url: %s", err.Error()) + if artURL, err = mp.artURLLookup(meta.CoverArtID); err != nil { + if meta.CoverArtID != "" { + log.Printf("error fetching art url: %s", err.Error()) + } } - title = track.Name - artist = strings.Join(track.ArtistNames, ", ") - duration = track.Duration + artist = strings.Join(meta.Artists, ", ") + duration = meta.Duration } cTitle := C.CString(title) diff --git a/backend/mpris.go b/backend/mpris.go index 58a9260..440747c 100644 --- a/backend/mpris.go +++ b/backend/mpris.go @@ -58,11 +58,11 @@ func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler { m.evt.Player.OnSeek(pos) } }) - pm.OnSongChange(func(tr, _ *mediaprovider.Track) { + pm.OnSongChange(func(tr mediaprovider.MediaItem, _ *mediaprovider.Track) { if tr == nil { m.curTrackPath = "" } else { - m.curTrackPath = dbusTrackIDPrefix + encodeTrackId(tr.ID) + m.curTrackPath = dbusTrackIDPrefix + encodeTrackId(tr.Metadata().ID) } if m.connErr == nil { m.evt.Player.OnTitle() @@ -251,30 +251,48 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) { trackObjPath = m.curTrackPath } 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 { - 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 - if tr.ID != "" && m.ArtURLLookup != nil { - if u, err := m.ArtURLLookup(tr.CoverArtID); err == nil { + if meta.ID != "" && m.ArtURLLookup != nil { + if u, err := m.ArtURLLookup(meta.CoverArtID); err == nil { artURL = u } } - return types.Metadata{ - TrackId: dbus.ObjectPath(trackObjPath), - Length: secondsToMicroseconds(status.Duration), - Title: tr.Name, - Album: tr.Album, - Artist: tr.ArtistNames, - DiscNumber: tr.DiscNumber, - TrackNumber: tr.TrackNumber, - Genre: []string{tr.Genre}, - UserRating: float64(tr.Rating) / 5, - ContentCreated: strconv.Itoa(tr.Year), - UseCount: tr.PlayCount, - ArtUrl: artURL, - }, nil + mprisMeta := types.Metadata{ + TrackId: dbus.ObjectPath(trackObjPath), + Length: secondsToMicroseconds(status.Duration), + Title: meta.Name, + Album: meta.Album, + Artist: meta.Artists, + DiscNumber: discNumber, + TrackNumber: trackNumber, + UserRating: float64(userRating) / 5, + UseCount: playCount, + ArtUrl: artURL, + } + if genre != "" { + mprisMeta.Genre = []string{genre} + } + if year != 0 { + mprisMeta.ContentCreated = strconv.Itoa(year) + } + return mprisMeta, nil } func (m *MPRISHandler) Volume() (float64, error) { diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 3a089d4..37f315d 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -44,12 +44,13 @@ type playbackEngine struct { player player.BasePlayer playTimeStopwatch util.Stopwatch - curTrackTime float64 + curTrackDuration float64 latestTrackPosition float64 // cleared by checkScrobble callbacksDisabled bool - playQueue []*mediaprovider.Track + playQueue []mediaprovider.MediaItem nowPlayingIdx int + isRadio bool wasStopped bool // true iff player was stopped before handleOnTrackChange invocation loopMode LoopMode @@ -60,7 +61,7 @@ type playbackEngine struct { replayGainCfg ReplayGainConfig // registered callbacks - onSongChange []func(nowPlaying, justScrobbledIfAny *mediaprovider.Track) + onSongChange []func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track) onPlayTimeUpdate []func(float64, float64, bool) onLoopModeChange []func(LoopMode) onVolumeChange []func(int) @@ -122,8 +123,8 @@ func (p *playbackEngine) PlayTrackAt(idx int) error { return p.setTrack(idx, false) } -// Gets the curently playing song, if any. -func (p *playbackEngine) NowPlaying() *mediaprovider.Track { +// Gets the curently playing media item, if any. +func (p *playbackEngine) NowPlaying() mediaprovider.MediaItem { if p.nowPlayingIdx < 0 || len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped { return nil } @@ -184,6 +185,9 @@ func (p *playbackEngine) SeekBackOrPrevious() error { // Seek to given absolute position in the current track by seconds. func (p *playbackEngine) SeekSeconds(sec float64) error { + if p.isRadio { + return nil // can't seek radio stations + } 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)) - newTracks := p.deepCopyTrackSlice(tracks) + newTracks := p.copyTrackSliceToMediaItemSlice(tracks) if shuffle { 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 } +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. func (p *playbackEngine) StopAndClearPlayQueue() { changed := len(p.playQueue) > 0 @@ -247,36 +276,40 @@ func (p *playbackEngine) StopAndClearPlayQueue() { } } -func (p *playbackEngine) GetPlayQueue() []*mediaprovider.Track { - return p.deepCopyTrackSlice(p.playQueue) +func (p *playbackEngine) GetPlayQueue() []mediaprovider.MediaItem { + return p.deepCopyMediaItemSlice(p.playQueue) } // 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. func (p *playbackEngine) OnTrackFavoriteStatusChanged(id string, fav bool) { - if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil { - tr.Favorite = fav + if item := sharedutil.FindMediaItemByID(id, p.playQueue); item != nil { + if tr, ok := item.(*mediaprovider.Track); ok { + tr.Favorite = fav + } } } // 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. func (p *playbackEngine) OnTrackRatingChanged(id string, rating int) { - if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil { - tr.Rating = rating + if item := sharedutil.FindMediaItemByID(id, p.playQueue); item != nil { + if tr, ok := item.(*mediaprovider.Track); ok { + tr.Rating = rating + } } } // Replaces the play queue with the given set of tracks. // 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. -func (p *playbackEngine) UpdatePlayQueue(tracks []*mediaprovider.Track) error { - newQueue := p.deepCopyTrackSlice(tracks) +func (p *playbackEngine) UpdatePlayQueue(items []mediaprovider.MediaItem) error { + newQueue := p.deepCopyMediaItemSlice(items) newNowPlayingIdx := -1 if p.nowPlayingIdx >= 0 { - nowPlayingID := p.playQueue[p.nowPlayingIdx].ID + nowPlayingID := p.playQueue[p.nowPlayingIdx].Metadata().ID for i, tr := range newQueue { - if tr.ID == nowPlayingID { + if tr.Metadata().ID == nowPlayingID { newNowPlayingIdx = i break } @@ -298,14 +331,14 @@ func (p *playbackEngine) UpdatePlayQueue(tracks []*mediaprovider.Track) error { } 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) isPlayingTrackRemoved := false isNextPlayingTrackremoved := false nowPlaying := p.NowPlayingIndex() newNowPlaying := nowPlaying for i, tr := range p.playQueue { - if _, ok := idSet[tr.ID]; ok { + if _, ok := idSet[tr.Metadata().ID]; ok { if i < nowPlaying { // 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 @@ -395,8 +428,11 @@ func (p *playbackEngine) handleOnTrackChange() { p.nowPlayingIdx = 0 // wrapped around } } + nowPlaying := p.playQueue[p.nowPlayingIdx] + _, isRadio := nowPlaying.(*mediaprovider.RadioStation) + p.isRadio = isRadio 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.invokeOnSongChangeCallbacks() p.doUpdateTimePos(false) @@ -460,7 +496,12 @@ func (p *playbackEngine) setTrack(idx int, next bool) error { url := "" if idx >= 0 { 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 { return err } @@ -472,7 +513,10 @@ func (p *playbackEngine) setTrack(idx int, next bool) error { } else if trP, ok := p.player.(player.TrackPlayer); ok { var track *mediaprovider.Track 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 { return trP.SetNextTrack(track) @@ -491,15 +535,19 @@ func (p *playbackEngine) checkScrobble() { if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 { return } + track, ok := p.playQueue[p.nowPlayingIdx].(*mediaprovider.Track) + if !ok { + return // radio stations are not scrobbled + } + playDur := p.playTimeStopwatch.Elapsed() - if playDur.Seconds() < 0.1 || p.curTrackTime < 0.1 { + if playDur.Seconds() < 0.1 || p.curTrackDuration < 0.1 { return } - pcnt := playDur.Seconds() / p.curTrackTime * 100 + pcnt := playDur.Seconds() / p.curTrackDuration * 100 timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 && playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds) - track := p.playQueue[p.nowPlayingIdx] var submission bool server := p.sm.Server 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 { 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 if !server.ClientDecidesScrobble() { // 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 // (play count increases, favorite, and rating) without messing up other views' track models -func (p *playbackEngine) deepCopyTrackSlice(tracks []*mediaprovider.Track) []*mediaprovider.Track { - newTracks := make([]*mediaprovider.Track, len(tracks)) +func (p *playbackEngine) deepCopyMediaItemSlice(tracks []mediaprovider.MediaItem) []mediaprovider.MediaItem { + newTracks := make([]mediaprovider.MediaItem, len(tracks)) for i, tr := range tracks { - copy := *tr - newTracks[i] = © + newTracks[i] = tr.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 } @@ -589,7 +648,12 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) { if s.TimePos > p.latestTrackPosition { 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 { - cb(s.TimePos, s.Duration, seeked) + cb(s.TimePos, duration, seeked) } } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 2fdab18..9d06e67 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -45,8 +45,8 @@ func (p *PlaybackManager) DisableCallbacks() { p.engine.callbacksDisabled = true } -// Gets the curently playing song, if any. -func (p *PlaybackManager) NowPlaying() *mediaprovider.Track { +// Gets the now playing media item, if any. +func (p *PlaybackManager) NowPlaying() mediaprovider.MediaItem { 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. -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) } @@ -126,8 +126,8 @@ func (p *PlaybackManager) LoadTracks(tracks []*mediaprovider.Track, insertQueueM // Replaces the play queue with the given set of tracks. // 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. -func (p *PlaybackManager) UpdatePlayQueue(tracks []*mediaprovider.Track) error { - return p.engine.UpdatePlayQueue(tracks) +func (p *PlaybackManager) UpdatePlayQueue(items []mediaprovider.MediaItem) error { + return p.engine.UpdatePlayQueue(items) } 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)) { if songs, err := fetchFn(); err != nil { 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() } @@ -281,7 +285,7 @@ func (p *PlaybackManager) SeekFraction(fraction float64) error { } else if fraction > 1 { fraction = 1 } - target := p.engine.curTrackTime * fraction + target := p.engine.curTrackDuration * fraction return p.engine.SeekSeconds(target) } diff --git a/backend/savedplayqueue.go b/backend/savedplayqueue.go index a0baf41..fd6e025 100644 --- a/backend/savedplayqueue.go +++ b/backend/savedplayqueue.go @@ -29,9 +29,19 @@ func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server stats := pm.PlayerStatus() trackIdx := pm.NowPlayingIndex() - trackIDs := make([]string, len(queue)) - for i, tr := range queue { - trackIDs[i] = tr.ID + trackIDs := make([]string, 0, len(queue)) + for _, item := range queue { + 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{ diff --git a/res/bundled.go b/res/bundled.go index d284ce7..3e1b5d0 100644 --- a/res/bundled.go +++ b/res/bundled.go @@ -88,7 +88,7 @@ var ResFilterSvg = &fyne.StaticResource{ var ResBroadcastSvg = &fyne.StaticResource{ StaticName: "broadcast.svg", StaticContent: []byte( - "\n\n \n"), + "\n\n \n"), } var ResRepeatSvg = &fyne.StaticResource{ StaticName: "repeat.svg", diff --git a/res/icons/remix_design/broadcast.svg b/res/icons/remix_design/broadcast.svg index ff53661..ab4b918 100644 --- a/res/icons/remix_design/broadcast.svg +++ b/res/icons/remix_design/broadcast.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go index 0421128..bad0e27 100644 --- a/sharedutil/sharedutil.go +++ b/sharedutil/sharedutil.go @@ -74,11 +74,23 @@ func FindTrackByID(id string, tracks []*mediaprovider.Track) *mediaprovider.Trac return nil } -func TrackIDOrEmptyStr(track *mediaprovider.Track) string { - if track == nil { - return "" +func FindMediaItemByID(id string, items []mediaprovider.MediaItem) mediaprovider.MediaItem { + for _, tr := range items { + 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 { @@ -103,58 +115,58 @@ const ( 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 -func ReorderTracks(tracks []*mediaprovider.Track, idxToMove []int, op TrackReorderOp) []*mediaprovider.Track { - newTracks := make([]*mediaprovider.Track, len(tracks)) +func ReorderItems[T any](items []T, idxToMove []int, op TrackReorderOp) []T { + newItems := make([]T, len(items)) switch op { case MoveToTop: topIdx := 0 botIdx := len(idxToMove) idxToMoveSet := ToSet(idxToMove) - for i, t := range tracks { + for i, t := range items { if _, ok := idxToMoveSet[i]; ok { - newTracks[topIdx] = t + newItems[topIdx] = t topIdx++ } else { - newTracks[botIdx] = t + newItems[botIdx] = t botIdx++ } } case MoveToBottom: topIdx := 0 - botIdx := len(tracks) - len(idxToMove) + botIdx := len(items) - len(idxToMove) idxToMoveSet := ToSet(idxToMove) - for i, t := range tracks { + for i, t := range items { if _, ok := idxToMoveSet[i]; ok { - newTracks[botIdx] = t + newItems[botIdx] = t botIdx++ } else { - newTracks[topIdx] = t + newItems[topIdx] = t topIdx++ } } case MoveUp: first := firstIdxCanMoveUp(idxToMove) - copy(newTracks, tracks) + copy(newItems, items) for _, i := range idxToMove { if i < first { continue } - newTracks[i-1], newTracks[i] = newTracks[i], newTracks[i-1] + newItems[i-1], newItems[i] = newItems[i], newItems[i-1] } case MoveDown: - last := lastIdxCanMoveDown(idxToMove, len(tracks)) - copy(newTracks, tracks) + last := lastIdxCanMoveDown(idxToMove, len(items)) + copy(newItems, items) for i := len(idxToMove) - 1; i >= 0; i-- { idx := idxToMove[i] if idx > last { 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 { diff --git a/sharedutil/sharedutil_test.go b/sharedutil/sharedutil_test.go index 05b5312..4d395e4 100644 --- a/sharedutil/sharedutil_test.go +++ b/sharedutil/sharedutil_test.go @@ -7,7 +7,7 @@ import ( "github.com/dweymouth/supersonic/backend/mediaprovider" ) -func Test_ReorderTracks(t *testing.T) { +func Test_ReorderItems(t *testing.T) { tracks := []*mediaprovider.Track{ {ID: "a"}, // 0 {ID: "b"}, // 1 @@ -27,7 +27,7 @@ func Test_ReorderTracks(t *testing.T) { {ID: "b"}, {ID: "e"}, } - newTracks := ReorderTracks(tracks, idxToMove, MoveToTop) + newTracks := ReorderItems(tracks, idxToMove, MoveToTop) if !tracklistsEqual(t, newTracks, want) { t.Error("ReorderTracks: MoveToTop order incorrect") } @@ -42,7 +42,7 @@ func Test_ReorderTracks(t *testing.T) { {ID: "c"}, {ID: "f"}, } - newTracks = ReorderTracks(tracks, idxToMove, MoveToBottom) + newTracks = ReorderItems(tracks, idxToMove, MoveToBottom) if !tracklistsEqual(t, newTracks, want) { t.Error("ReorderTracks: MoveToBottom order incorrect") } @@ -57,7 +57,7 @@ func Test_ReorderTracks(t *testing.T) { {ID: "f"}, {ID: "e"}, } - newTracks = ReorderTracks(tracks, idxToMove, MoveUp) + newTracks = ReorderItems(tracks, idxToMove, MoveUp) if !tracklistsEqual(t, newTracks, want) { t.Error("ReorderTracks: MoveUp order incorrect") } @@ -72,7 +72,7 @@ func Test_ReorderTracks(t *testing.T) { {ID: "e"}, {ID: "f"}, } - newTracks = ReorderTracks(tracks, idxToMove, MoveDown) + newTracks = ReorderItems(tracks, idxToMove, MoveDown) if !tracklistsEqual(t, newTracks, want) { t.Error("ReorderTracks: MoveDown order incorrect") } diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 7af0d53..58387eb 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -1,13 +1,11 @@ package ui import ( - "image" - "time" - "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/layouts" + "github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/widgets" "fyne.io/fyne/v2" @@ -18,19 +16,18 @@ import ( type BottomPanel struct { widget.BaseWidget - ImageManager *backend.ImageManager + imageLoader util.ThumbnailLoader NowPlaying *widgets.NowPlayingCard Controls *widgets.PlayerControls AuxControls *widgets.AuxControls - coverArtID string - container *fyne.Container + container *fyne.Container } 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.ExtendBaseWidget(bp) @@ -56,22 +53,30 @@ func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) * contr.NavigateTo(controller.NowPlayingRoute("")) } 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) { - 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() { - contr.DoAddTracksToPlaylistWorkflow([]string{pm.NowPlaying().ID}) + if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok { + contr.DoAddTracksToPlaylistWorkflow([]string{tr.ID}) + } } 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) { contr.NavigateTo(controller.ArtistRoute(artistID)) } 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.OnPlayPause(func() { @@ -97,26 +102,20 @@ func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) * pm.SetNextLoopMode() }) + bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage) + bp.container = container.New(layouts.NewLeftMiddleRightLayout(500), bp.NowPlaying, bp.Controls, bp.AuxControls) return bp } -func (bp *BottomPanel) onSongChange(song, _ *mediaprovider.Track) { +func (bp *BottomPanel) onSongChange(song mediaprovider.MediaItem, _ *mediaprovider.Track) { if song == nil { - bp.NowPlaying.Update(nil, nil) + bp.NowPlaying.Update(nil) + bp.imageLoader.Load("") } else { - bp.coverArtID = song.CoverArtID - var im image.Image - 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) + bp.NowPlaying.Update(song) + bp.imageLoader.Load(song.Metadata().CoverArtID) } } diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go index cc6197d..c2e6339 100644 --- a/ui/browsing/albumpage.go +++ b/ui/browsing/albumpage.go @@ -130,10 +130,12 @@ func (a *AlbumPage) Route() controller.Route { return controller.AlbumRoute(a.albumID) } -func (a *AlbumPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { - a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track) +var _ CanShowNowPlaying = (*AlbumPage)(nil) + +func (a *AlbumPage) OnSongChange(track mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { + a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(track) a.tracklist.SetNowPlaying(a.nowPlayingID) - a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) + a.tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny)) } func (a *AlbumPage) Reload() { diff --git a/ui/browsing/artistpage.go b/ui/browsing/artistpage.go index 35439fd..634e304 100644 --- a/ui/browsing/artistpage.go +++ b/ui/browsing/artistpage.go @@ -139,12 +139,12 @@ func (a *ArtistPage) Save() SavedPage { var _ CanShowNowPlaying = (*ArtistPage)(nil) -func (a *ArtistPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { - a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track) +func (a *ArtistPage) OnSongChange(track mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { + a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(track) if a.tracklistCtr != nil { tl := a.tracklistCtr.Objects[0].(*widgets.Tracklist) tl.SetNowPlaying(a.nowPlayingID) - tl.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) + tl.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny)) } } diff --git a/ui/browsing/browsingpane.go b/ui/browsing/browsingpane.go index ca1a802..a5279df 100644 --- a/ui/browsing/browsingpane.go +++ b/ui/browsing/browsingpane.go @@ -42,7 +42,7 @@ type Scrollable interface { } type CanShowNowPlaying interface { - OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track) + OnSongChange(playing mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) } type CanShowPlayTime interface { @@ -140,11 +140,13 @@ func (b *BrowsingPane) AddSettingsMenuSeparator() { 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 browsingPaneIcon := theme.NewThemedResource(icon) - b.navBtnsContainer.Add(widget.NewButtonWithIcon("", browsingPaneIcon, action)) + btn := widget.NewButtonWithIcon("", browsingPaneIcon, action) + b.navBtnsContainer.Add(btn) b.navBtnsPageMap[pageName] = browsingPaneIcon + return btn } func (b *BrowsingPane) DisableNavigationButtons() { @@ -162,7 +164,7 @@ func (b *BrowsingPane) EnableNavigationButtons() { func (b *BrowsingPane) ActivateNavigationButton(num int) { if num < len(b.navBtnsContainer.Objects) { btn := b.navBtnsContainer.Objects[num].(*widget.Button) - if !btn.Disabled() { + if !btn.Disabled() && !btn.Hidden { btn.OnTapped() } } @@ -213,7 +215,7 @@ func (b *BrowsingPane) doSetPage(p Page) bool { return true } -func (b *BrowsingPane) onSongChange(song, lastScrobbledIfAny *mediaprovider.Track) { +func (b *BrowsingPane) onSongChange(song mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { if b.curPage == nil { return } diff --git a/ui/browsing/favoritespage.go b/ui/browsing/favoritespage.go index 48c60d8..a550a3e 100644 --- a/ui/browsing/favoritespage.go +++ b/ui/browsing/favoritespage.go @@ -269,11 +269,11 @@ func (a *FavoritesPage) OnSearched(query string) { var _ CanShowNowPlaying = (*FavoritesPage)(nil) -func (a *FavoritesPage) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track) { - a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(song) +func (a *FavoritesPage) OnSongChange(item mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { + a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(item) if tracklist := a.tracklistOrNil(); tracklist != nil { tracklist.SetNowPlaying(a.nowPlayingID) - tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) + tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny)) } } diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index d8791d1..640f15d 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -6,6 +6,7 @@ import ( "image" "image/color" "log" + "net/url" "slices" "strings" "sync" @@ -37,13 +38,13 @@ type NowPlayingPage struct { nowPlayingPageState // volatile state - nowPlaying *mediaprovider.Track + nowPlaying mediaprovider.MediaItem nowPlayingID string curLyricsID string // id of track currently shown in lyrics curRelatedID string // id of track currrently used to populate related list totalTime float64 lastPlayPos float64 - queue []*mediaprovider.Track + queue []mediaprovider.MediaItem related []*mediaprovider.Track lyricLock sync.Mutex @@ -108,11 +109,16 @@ func NewNowPlayingPage( a.card = widgets.NewLargeNowPlayingCard() 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) { 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.contr.SetTrackFavorites([]string{a.nowPlayingID}, fav) } @@ -122,7 +128,7 @@ func NewNowPlayingPage( a.queueList = 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.OnShare = func(tracks []*mediaprovider.Track) { if len(tracks) > 0 { @@ -245,12 +251,12 @@ func (a *NowPlayingPage) Route() controller.Route { var _ CanShowNowPlaying = (*NowPlayingPage)(nil) -func (a *NowPlayingPage) OnSongChange(song, _ *mediaprovider.Track) { +func (a *NowPlayingPage) OnSongChange(song mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { a.nowPlaying = song if a.imageLoadCancel != nil { a.imageLoadCancel() } - a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(song) + a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(song) a.queueList.SetNowPlaying(a.nowPlayingID) a.relatedList.SetNowPlaying(a.nowPlayingID) @@ -258,7 +264,7 @@ func (a *NowPlayingPage) OnSongChange(song, _ *mediaprovider.Track) { if song == nil { a.card.SetCoverImage(nil) } 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*/ { @@ -273,6 +279,9 @@ func (a *NowPlayingPage) onImageLoaded(img image.Image, err error) { log.Printf("error loading cover art: %v\n", err) return } + if img == nil { + return + } a.card.SetCoverImage(img) c := dominantcolor.Find(img) if c == a.background.StartColor { @@ -308,7 +317,7 @@ func (a *NowPlayingPage) updateLyrics() { return } } - if a.nowPlaying == nil { + if a.nowPlaying == nil || a.nowPlaying.Metadata().Type == mediaprovider.MediaItemTypeRadioStation { a.lyricsViewer.SetLyrics(nil) a.curLyricsID = "" return @@ -321,19 +330,20 @@ func (a *NowPlayingPage) updateLyrics() { // to keep it from showing "Lyrics not available" a.lyricsViewer.SetLyrics(&mediaprovider.Lyrics{Synced: true, 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) { var lyrics *mediaprovider.Lyrics var err error 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) } } 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 { log.Println(err.Error()) } @@ -401,10 +411,10 @@ func (a *NowPlayingPage) Reload() { a.relatedList.DisableSharing = !a.canShare a.queue = a.pm.GetPlayQueue() - a.queueList.SetTracks(a.queue) + a.queueList.SetItems(a.queue) a.totalTime = 0.0 for _, tr := range a.queue { - a.totalTime += float64(tr.Duration) + a.totalTime += float64(tr.Metadata().Duration) } a.formatStatusLine() } @@ -457,11 +467,11 @@ func (a *NowPlayingPage) doSetNewTrackOrder(trackIDs []string, op sharedutil.Tra trackIDSet := sharedutil.ToSet(trackIDs) idxs := make([]int, 0, len(trackIDs)) for i, tr := range a.queue { - if _, ok := trackIDSet[tr.ID]; ok { + if _, ok := trackIDSet[tr.Metadata().ID]; ok { idxs = append(idxs, i) } } - newTracks := sharedutil.ReorderTracks(a.queue, idxs, op) + newTracks := sharedutil.ReorderItems(a.queue, idxs, op) a.pm.UpdatePlayQueue(newTracks) } @@ -492,7 +502,7 @@ func (a *NowPlayingPage) formatStatusLine() { dur := 0.0 if np := a.pm.NowPlaying(); np != nil { - dur = float64(np.Duration) + dur = float64(np.Metadata().Duration) } statusSuffix := "" trackNum := 0 diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index 104e23d..4eb9f4b 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -126,10 +126,12 @@ func (a *PlaylistPage) Route() controller.Route { return controller.PlaylistRoute(a.playlistID) } -func (a *PlaylistPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { - a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track) +var _ CanShowNowPlaying = (*PlaylistPage)(nil) + +func (a *PlaylistPage) OnSongChange(item mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { + a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(item) a.tracklist.SetNowPlaying(a.nowPlayingID) - a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) + a.tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny)) } func (a *PlaylistPage) Reload() { @@ -187,7 +189,7 @@ func (a *PlaylistPage) doSetNewTrackOrder(op sharedutil.TrackReorderOp) { idxs = append(idxs, i) } } - newTracks := sharedutil.ReorderTracks(a.tracks, idxs, op) + newTracks := sharedutil.ReorderItems(a.tracks, idxs, op) ids := sharedutil.TracksToIDs(newTracks) if err := a.sm.Server.ReplacePlaylistTracks(a.playlistID, ids); err != nil { log.Printf("error updating playlist: %s", err.Error()) diff --git a/ui/browsing/radiospage.go b/ui/browsing/radiospage.go new file mode 100644 index 0000000..cdcbf7b --- /dev/null +++ b/ui/browsing/radiospage.go @@ -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) +} diff --git a/ui/browsing/router.go b/ui/browsing/router.go index fd7089a..9252309 100644 --- a/ui/browsing/router.go +++ b/ui/browsing/router.go @@ -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) case controller.Tracks: 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 } diff --git a/ui/browsing/trackspage.go b/ui/browsing/trackspage.go index fea8fdc..2b6b505 100644 --- a/ui/browsing/trackspage.go +++ b/ui/browsing/trackspage.go @@ -95,13 +95,15 @@ func (t *TracksPage) Reload() { t.loader = widgets.NewTracklistLoader(t.tracklist, iter) } -func (t *TracksPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { - t.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track) +var _ CanShowNowPlaying = (*TracksPage)(nil) + +func (t *TracksPage) OnSongChange(item mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { + t.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(item) t.tracklist.SetNowPlaying(t.nowPlayingID) if t.searchTracklist != nil { t.searchTracklist.SetNowPlaying(t.nowPlayingID) } - playedID := sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny) + playedID := sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny) t.tracklist.IncrementPlayCount(playedID) if t.searchTracklist != nil { t.searchTracklist.IncrementPlayCount(playedID) diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 95d149c..d62869e 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -827,8 +827,8 @@ func (c *Controller) downloadTrack(track *mediaprovider.Track, filePath string) return } - log.Printf("Saved song %s to: %s\n", track.Name, filePath) - c.sendNotification(fmt.Sprintf("Download completed: %s", track.Name), fmt.Sprintf("Saved at: %s", filePath)) + log.Printf("Saved song %s to: %s\n", track.Title, 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) { @@ -863,7 +863,7 @@ func (c *Controller) downloadTracks(tracks []*mediaprovider.Track, filePath, dow 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)) diff --git a/ui/controller/routes.go b/ui/controller/routes.go index dc2255a..83b9e22 100644 --- a/ui/controller/routes.go +++ b/ui/controller/routes.go @@ -15,6 +15,7 @@ const ( Playlist Playlists Tracks + Radios ) type Route struct { @@ -61,6 +62,10 @@ func ArtistsRoute() Route { return Route{Page: Artists} } +func RadiosRoute() Route { + return Route{Page: Radios} +} + func NowPlayingRoute(highlightedTrackID string) Route { return Route{Page: NowPlaying, Arg: highlightedTrackID} } diff --git a/ui/dialogs/searchdialog.go b/ui/dialogs/searchdialog.go index 6e140d6..8e24b5e 100644 --- a/ui/dialogs/searchdialog.go +++ b/ui/dialogs/searchdialog.go @@ -264,7 +264,7 @@ func (s *searchResult) Update(result *mediaprovider.SearchResult) { } s.id = result.ID s.contentType = result.Type - s.image.CenterIcon = placeholderIconForContentType(result.Type) + s.image.PlaceholderIcon = placeholderIconForContentType(result.Type) s.imageLoader.Load(result.CoverID) s.title.SetText(result.Name) diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 51b4fce..46ee1bf 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -36,9 +36,10 @@ var ( ShortcutNavFive = desktop.CustomShortcut{KeyName: fyne.Key5, Modifier: os.ControlModifier} ShortcutNavSix = desktop.CustomShortcut{KeyName: fyne.Key6, 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, - ShortcutNavFour, ShortcutNavFive, ShortcutNavSix, ShortcutNavSeven} + ShortcutNavFour, ShortcutNavFive, ShortcutNavSix, ShortcutNavSeven, ShortcutNavEight} ) type MainWindow struct { @@ -53,6 +54,9 @@ type MainWindow struct { theme *theme.MyTheme haveSystemTray bool 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 { @@ -81,22 +85,25 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, m.Controller.ReloadFunc = m.BrowsingPane.Reload m.Controller.CurPageFunc = m.BrowsingPane.CurrentPage - m.BottomPanel = NewBottomPanel(app.PlaybackManager, m.Controller) - m.BottomPanel.ImageManager = app.ImageManager + m.BottomPanel = NewBottomPanel(app.PlaybackManager, app.ImageManager, m.Controller) m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane) m.Window.SetContent(m.container) m.Window.Resize(size) - app.PlaybackManager.OnSongChange(func(track, _ *mediaprovider.Track) { - if track == nil { + app.PlaybackManager.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) { + if item == nil { m.Window.SetTitle(displayAppName) return } - artistDisp := strings.Join(track.ArtistNames, ", ") - m.Window.SetTitle(fmt.Sprintf("%s – %s · %s", track.Name, artistDisp, displayAppName)) + meta := item.Metadata() + 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 { // TODO: Once Fyne issue #2935 is resolved, show album cover fyne.CurrentApp().SendNotification(&fyne.Notification{ - Title: track.Name, + Title: meta.Name, Content: artistDisp, }) } @@ -179,6 +186,11 @@ func (m *MainWindow) RunOnServerConnectedTasks(app *backend.App, displayAppName } m.App.Config.Application.LastCheckedVersion = t } + + _, supportsRadio := m.App.ServerManager.Server.(mediaprovider.RadioProvider) + m.radioBtn.Hidden = !supportsRadio + m.radioBtn.Refresh() + m.App.SaveConfigFile() } @@ -261,6 +273,9 @@ func (m *MainWindow) addNavigationButtons() { m.BrowsingPane.AddNavigationButton(theme.TracksIcon, controller.Tracks, func() { m.Router.NavigateTo(controller.TracksRoute()) }) + m.radioBtn = m.BrowsingPane.AddNavigationButton(theme.RadioIcon, controller.Radios, func() { + m.Router.NavigateTo(controller.RadiosRoute()) + }) } func (m *MainWindow) addShortcuts() { diff --git a/ui/theme/theme.go b/ui/theme/theme.go index f2f93d9..0dab7ee 100644 --- a/ui/theme/theme.go +++ b/ui/theme/theme.go @@ -31,7 +31,7 @@ const ( var ( AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg) 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) NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg) NowPlayingIcon fyne.Resource = theme.NewThemedResource(res.ResHeadphonesSvg) diff --git a/ui/util/tracklistutil.go b/ui/util/tracklistutil.go index 0601329..3b70c65 100644 --- a/ui/util/tracklistutil.go +++ b/ui/util/tracklistutil.go @@ -8,76 +8,88 @@ import ( ) type TrackListModel struct { - Track *mediaprovider.Track + Item mediaprovider.MediaItem 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 { 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 { - return sharedutil.FilterMapSlice(tracks, func(tm *TrackListModel) (*mediaprovider.Track, bool) { - return tm.Track, tm.Selected +func SelectedTracks(items []*TrackListModel) []*mediaprovider.Track { + return sharedutil.FilterMapSlice(items, func(tm *TrackListModel) (*mediaprovider.Track, bool) { + return tm.Track(), tm.Selected }) } -func SelectedTrackIDs(tracks []*TrackListModel) []string { - return sharedutil.FilterMapSlice(tracks, func(tm *TrackListModel) (string, bool) { - return tm.Track.ID, tm.Selected +func SelectedItems(items []*TrackListModel) []mediaprovider.MediaItem { + return sharedutil.FilterMapSlice(items, func(tm *TrackListModel) (mediaprovider.MediaItem, bool) { + return tm.Item, tm.Selected }) } -func SelectTrack(tracks []*TrackListModel, idx int) { - if tracks[idx].Selected { +func SelectedItemIDs(items []*TrackListModel) []string { + 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 } - UnselectAllTracks(tracks) - tracks[idx].Selected = true + UnselectAllItems(items) + items[idx].Selected = true } -func SelectAllTracks(tracks []*TrackListModel) { - for _, tm := range tracks { +func SelectAllItems(items []*TrackListModel) { + for _, tm := range items { tm.Selected = true } } -func UnselectAllTracks(tracks []*TrackListModel) { - for _, tm := range tracks { +func UnselectAllItems(items []*TrackListModel) { + for _, tm := range items { tm.Selected = false } } -func SelectTrackRange(tracks []*TrackListModel, idx int) { - if tracks[idx].Selected { +func SelectItemRange(items []*TrackListModel, idx int) { + if items[idx].Selected { return } lastSelected := -1 - for i := len(tracks) - 1; i >= 0; i-- { - if tracks[i].Selected { + for i := len(items) - 1; i >= 0; i-- { + if items[i].Selected { lastSelected = i break } } if lastSelected < 0 { - tracks[idx].Selected = true + items[idx].Selected = true return } from := min(idx, lastSelected) to := max(idx, lastSelected) for i := from; i <= to; i++ { - tracks[i].Selected = true + items[i].Selected = true } } -func FindTrackByID(tracks []*TrackListModel, id string) (*mediaprovider.Track, int) { - idx := slices.IndexFunc(tracks, func(tr *TrackListModel) bool { - return tr.Track.ID == id +func FindItemByID(items []*TrackListModel, id string) (mediaprovider.MediaItem, int) { + idx := slices.IndexFunc(items, func(tr *TrackListModel) bool { + return tr.Item.Metadata().ID == id }) if idx >= 0 { - return tracks[idx].Track, idx + return items[idx].Item, idx } return nil, -1 } diff --git a/ui/widgets/gridview.go b/ui/widgets/gridview.go index 8d9c8dd..5776413 100644 --- a/ui/widgets/gridview.go +++ b/ui/widgets/gridview.go @@ -321,7 +321,7 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) { } card.ItemIndex = itemIdx g.itemForIndex[itemIdx] = card - card.Cover.Im.CenterIcon = g.Placeholder + card.Cover.Im.PlaceholderIcon = g.Placeholder if !card.NeedsUpdate(item) && card.ItemIndex == itemIdx { // nothing to do g.stateMutex.Unlock() diff --git a/ui/widgets/imageplaceholder.go b/ui/widgets/imageplaceholder.go index de552ae..b7bf6c0 100644 --- a/ui/widgets/imageplaceholder.go +++ b/ui/widgets/imageplaceholder.go @@ -15,8 +15,8 @@ import ( // a placeholder with a rectangular border frame // and an icon positioned in the center of the frame. type ImagePlaceholder struct { - ScaleMode canvas.ImageScale - CenterIcon fyne.Resource + ScaleMode canvas.ImageScale + PlaceholderIcon fyne.Resource widget.BaseWidget content *fyne.Container @@ -31,7 +31,7 @@ type ImagePlaceholder struct { } func NewImagePlaceholder(centerIcon fyne.Resource, minSize float32) *ImagePlaceholder { - i := &ImagePlaceholder{minSize: minSize, CenterIcon: centerIcon} + i := &ImagePlaceholder{minSize: minSize, PlaceholderIcon: centerIcon} i.ExtendBaseWidget(i) i.iconImage = canvas.NewImageFromResource(centerIcon) i.iconImage.FillMode = canvas.ImageFillContain @@ -94,7 +94,7 @@ func (i *ImagePlaceholder) MinSize() fyne.Size { func (i *ImagePlaceholder) Refresh() { i.border.Hidden = i.HaveImage() - i.iconImage.Resource = i.CenterIcon + i.iconImage.Resource = i.PlaceholderIcon i.iconImage.Hidden = i.HaveImage() i.imageDisp.Hidden = !i.HaveImage() i.imageDisp.ScaleMode = i.ScaleMode diff --git a/ui/widgets/largenowplayingcard.go b/ui/widgets/largenowplayingcard.go index f2d97fd..691dbcb 100644 --- a/ui/widgets/largenowplayingcard.go +++ b/ui/widgets/largenowplayingcard.go @@ -20,15 +20,18 @@ type LargeNowPlayingCard struct { DisableRating bool - trackName *widget.RichText - artistName *MultiHyperlink - albumName *widget.Hyperlink - rating *StarRating - favorite *FavoriteIcon - cover *ImagePlaceholder + isRadio bool + trackName *widget.RichText + artistName *MultiHyperlink + albumName *widget.Hyperlink + rating *StarRating + favorite *FavoriteIcon + ratingFavoriteContainer *fyne.Container + cover *ImagePlaceholder OnArtistNameTapped func(artistID string) OnAlbumNameTapped func() + OnRadioURLTapped func(url string) OnSetRating func(rating int) OnSetFavorite func(favorite bool) } @@ -49,17 +52,18 @@ func NewLargeNowPlayingCard() *LargeNowPlayingCard { n.cover.ScaleMode = canvas.ImageScaleFastest // set up the layout 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.trackName, n.artistName, n.albumName, - container.NewHBox( - layout.NewSpacer(), - n.favorite, - widget.NewLabel("·"), - n.rating, - layout.NewSpacer(), - ), + n.ratingFavoriteContainer, ) n.trackName.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameSubHeadingText @@ -79,6 +83,12 @@ func (n *LargeNowPlayingCard) onAlbumNameTapped() { } func (n *LargeNowPlayingCard) onArtistNameTapped(artistID string) { + if n.isRadio { + if n.OnRadioURLTapped != nil { + n.OnRadioURLTapped(artistID) + } + return + } if n.OnArtistNameTapped != nil { n.OnArtistNameTapped(artistID) } @@ -108,19 +118,32 @@ func (n *LargeNowPlayingCard) SetDisplayedFavorite(favorite bool) { n.favorite.Refresh() } -func (n *LargeNowPlayingCard) Update(track *mediaprovider.Track) { - if track != 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 { +func (n *LargeNowPlayingCard) Update(item mediaprovider.MediaItem) { + if item == nil { n.trackName.Segments[0].(*widget.TextSegment).Text = "" n.artistName.BuildSegments([]string{}, []string{}) n.albumName.Text = "" n.rating.Rating = 0 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() diff --git a/ui/widgets/nowplayingcard.go b/ui/widgets/nowplayingcard.go index c097513..9cf2b2b 100644 --- a/ui/widgets/nowplayingcard.go +++ b/ui/widgets/nowplayingcard.go @@ -25,7 +25,7 @@ type NowPlayingCard struct { trackName *widget.Hyperlink artistName *MultiHyperlink albumName *widget.Hyperlink - cover *TappableImage + cover *ImagePlaceholder menu *widget.PopUpMenu ratingMenu *fyne.MenuItem @@ -45,17 +45,16 @@ func NewNowPlayingCard() *NowPlayingCard { albumName: widget.NewHyperlink("", nil), } 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.ScaleMode = canvas.ImageScaleFastest + n.cover.Hidden = true n.trackName.Hidden = true n.albumName.Hidden = true n.albumName.Truncation = fyne.TextTruncateEllipsis n.trackName.Truncation = fyne.TextTruncateEllipsis 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.artistName.OnTapped = n.onArtistNameTapped n.trackName.OnTapped = n.onTrackNameTapped @@ -119,24 +118,35 @@ func (n *NowPlayingCard) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(c) } -func (n *NowPlayingCard) Update(track *mediaprovider.Track, cover image.Image) { - if track != nil { - n.trackName.SetText(track.Name) - n.artistName.BuildSegments(track.ArtistNames, track.ArtistIDs) - n.albumName.SetText(track.Album) - } else { +func (n *NowPlayingCard) Update(track mediaprovider.MediaItem) { + if track == nil { n.trackName.SetText("") n.artistName.BuildSegments([]string{}, []string{}) 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.artistName.Hidden = len(n.artistName.Segments) == 0 n.albumName.Hidden = n.albumName.Text == "" - n.cover.Image.Image = cover - n.cover.Hidden = cover == nil n.Refresh() } +func (n *NowPlayingCard) SetImage(cover image.Image) { + n.cover.SetImage(cover, true) +} + func (n *NowPlayingCard) showMenu(e *fyne.PointEvent) { if n.menu == nil { n.ratingMenu = util.NewRatingSubmenu(n.onSetRating) diff --git a/ui/widgets/playqueuelist.go b/ui/widgets/playqueuelist.go index 2c9d80f..402fb61 100644 --- a/ui/widgets/playqueuelist.go +++ b/ui/widgets/playqueuelist.go @@ -23,6 +23,11 @@ import ( const playQueueListThumbnailSize = 52 +type PlayQueueListModel struct { + Item mediaprovider.MediaItem + Selected bool +} + type PlayQueueList struct { widget.BaseWidget @@ -33,15 +38,16 @@ type PlayQueueList struct { OnAddToPlaylist func(trackIDs []string) OnSetFavorite func(trackIDs []string, fav bool) OnSetRating func(trackIDs []string, rating int) - OnRemoveFromQueue func(trackIDs []string) + OnRemoveFromQueue func(itemIDs []string) OnDownload func(tracks []*mediaprovider.Track, downloadName string) OnShare func(tracks []*mediaprovider.Track) OnShowArtistPage func(artistID string) OnPlayTrackAt func(idx int) - OnReorderTracks func(trackIDs []string, op sharedutil.TrackReorderOp) + OnReorderItems func(itemIDs []string, op sharedutil.TrackReorderOp) 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 shareMenuItem *fyne.MenuItem @@ -49,7 +55,7 @@ type PlayQueueList struct { colLayout *layouts.ColumnsLayout tracksMutex sync.RWMutex - tracks []*util.TrackListModel + items []*util.TrackListModel } 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 // Fyne calling the length callback and this update callback // 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() return } - model := p.tracks[itemID] + model := p.items[itemID] p.tracksMutex.RUnlock() tr := item.(*PlayQueueListRow) 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.Update(model, itemID+1) @@ -100,19 +106,29 @@ func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList { func (p *PlayQueueList) SetTracks(trs []*mediaprovider.Track) { p.tracksMutex.Lock() p.list.ClearItemForIDMap() - p.tracks = util.ToTrackListModels(trs) + p.items = util.ToTrackListModels(trs) p.tracksMutex.Unlock() p.Refresh() } -// Sets the currently playing track ID and updates the list rendering -func (p *PlayQueueList) SetNowPlaying(trackID string) { +func (p *PlayQueueList) SetItems(items []mediaprovider.MediaItem) { + 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 p.tracksMutex.RLock() - trPrev, idxPrev := util.FindTrackByID(p.tracks, prevNowPlaying) - tr, idx := util.FindTrackByID(p.tracks, trackID) + trPrev, idxPrev := util.FindItemByID(p.items, prevNowPlaying) + tr, idx := util.FindItemByID(p.items, itemID) p.tracksMutex.RUnlock() - p.nowPlayingID = trackID + p.nowPlayingID = itemID if trPrev != nil { p.list.RefreshItem(idxPrev) } @@ -123,14 +139,14 @@ func (p *PlayQueueList) SetNowPlaying(trackID string) { func (p *PlayQueueList) SelectAll() { p.tracksMutex.RLock() - util.SelectAllTracks(p.tracks) + util.SelectAllItems(p.items) p.tracksMutex.RUnlock() p.list.Refresh() } func (p *PlayQueueList) UnselectAll() { p.tracksMutex.RLock() - util.UnselectAllTracks(p.tracks) + util.UnselectAllItems(p.items) p.tracksMutex.RUnlock() p.Refresh() } @@ -138,7 +154,7 @@ func (p *PlayQueueList) UnselectAll() { func (p *PlayQueueList) lenTracks() int { p.tracksMutex.RLock() defer p.tracksMutex.RUnlock() - return len(p.tracks) + return len(p.items) } func (t *PlayQueueList) onArtistTapped(artistID string) { @@ -172,103 +188,152 @@ func (p *PlayQueueList) onSelectTrack(idx int) { func (p *PlayQueueList) selectTrack(idx int) { p.tracksMutex.RLock() defer p.tracksMutex.RUnlock() - util.SelectTrack(p.tracks, idx) + util.SelectItem(p.items, idx) } func (p *PlayQueueList) selectAddOrRemove(idx int) { p.tracksMutex.RLock() 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) { p.tracksMutex.RLock() defer p.tracksMutex.RUnlock() - util.SelectTrackRange(p.tracks, idx) + util.SelectItemRange(p.items, idx) } func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { p.selectTrack(trackIdx) p.list.Refresh() - if p.menu == nil { - 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) - } - }) + selected := p.selectedItems() - 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), - ) + allTracks := true + for _, item := range selected { + if item.Metadata().Type == mediaprovider.MediaItemTypeRadioStation { + allTracks = false + break + } } - p.ratingSubmenu.Disabled = p.DisableRating - p.shareMenuItem.Disabled = p.DisableSharing || len(p.selectedTracks()) != 1 - p.menu.ShowAtPosition(e.AbsolutePosition) + + var menu *widget.PopUpMenu + 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 { t.tracksMutex.RLock() 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() defer t.tracksMutex.RUnlock() - return util.SelectedTrackIDs(t.tracks) + return util.SelectedItemIDs(t.items) } 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 // a new track (*mediaprovider.Track) - tr := tm.Track - if tr.ID != p.trackID { - p.imageLoader.Load(tm.Track.CoverArtID) + meta := tm.Item.Metadata() + if meta.ID != p.trackID { + if meta.Type == mediaprovider.MediaItemTypeRadioStation { + p.cover.PlaceholderIcon = myTheme.RadioIcon + } else { + p.cover.PlaceholderIcon = myTheme.TracksIcon + } + p.imageLoader.Load(meta.CoverArtID) p.EnsureUnfocused() - p.trackID = tr.ID - p.title.Text = tr.Name - p.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs) - p.time.Text = util.SecondsToTimeString(float64(tr.Duration)) + p.trackID = meta.ID + p.title.Text = meta.Name + p.artist.BuildSegments(meta.Artists, meta.ArtistIDs) + p.time.Text = util.SecondsToTimeString(float64(meta.Duration)) changed = true } // 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.title.TextStyle.Bold = isPlaying diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index bcdd395..14962f8 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -171,7 +171,7 @@ func NewTracklist(tracks []*mediaprovider.Track, im *backend.ImageManager, useCo tr := item.(TracklistRow) 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) } 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") return nil } - return t.tracks[idx].Track + return t.tracks[idx].Track() } func (t *Tracklist) SetVisibleColumns(cols []string) { @@ -271,8 +271,8 @@ func (t *Tracklist) SetSorting(sorting TracklistSort) { func (t *Tracklist) SetNowPlaying(trackID string) { prevNowPlaying := t.nowPlayingID t.tracksMutex.RLock() - trPrev, idxPrev := util.FindTrackByID(t.tracks, prevNowPlaying) - tr, idx := util.FindTrackByID(t.tracks, trackID) + trPrev, idxPrev := util.FindItemByID(t.tracks, prevNowPlaying) + tr, idx := util.FindItemByID(t.tracks, trackID) t.tracksMutex.RUnlock() t.nowPlayingID = trackID 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 func (t *Tracklist) IncrementPlayCount(trackID string) { t.tracksMutex.RLock() - tr, idx := util.FindTrackByID(t.tracks, trackID) + tr, idx := util.FindItemByID(t.tracks, trackID) t.tracksMutex.RUnlock() if tr != nil { - tr.PlayCount += 1 + tr.(*mediaprovider.Track).PlayCount += 1 t.list.RefreshItem(idx) } } @@ -324,7 +324,7 @@ func (t *Tracklist) GetTracks() []*mediaprovider.Track { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() 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() { t.tracksMutex.RLock() - util.SelectAllTracks(t.tracks) + util.SelectAllItems(t.tracks) t.tracksMutex.RUnlock() t.list.Refresh() } @@ -350,7 +350,7 @@ func (t *Tracklist) UnselectAll() { func (t *Tracklist) unselectAll() { t.tracksMutex.RLock() - util.UnselectAllTracks(t.tracks) + util.UnselectAllItems(t.tracks) t.tracksMutex.RUnlock() } @@ -358,7 +358,7 @@ func (t *Tracklist) SelectAndScrollToTrack(trackID string) { t.tracksMutex.RLock() idx := -1 for i, tr := range t.tracks { - if tr.Track.ID == trackID { + if tr.Item.Metadata().ID == trackID { idx = i tr.Selected = true } else { @@ -422,30 +422,30 @@ func (t *Tracklist) doSortTracks() { t.tracks = t.tracksOrigOrder } 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: - 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: - t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Album }) + t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().Album }) 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: - 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: - 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: - 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: - t.intSort(func(tr *util.TrackListModel) int64 { return tr.Track.Size }) + t.intSort(func(tr *util.TrackListModel) int64 { return tr.Track().Size }) 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: - t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Comment }) + t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().Comment }) 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: t.intSort(func(tr *util.TrackListModel) int64 { - if tr.Track.Favorite { + if tr.Track().Favorite { return 1 } return 0 @@ -492,13 +492,13 @@ func (t *Tracklist) selectAddOrRemove(idx int) { func (t *Tracklist) selectTrack(idx int) { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() - util.SelectTrack(t.tracks, idx) + util.SelectItem(t.tracks, idx) } func (t *Tracklist) selectRange(idx int) { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() - util.SelectTrackRange(t.tracks, idx) + util.SelectItemRange(t.tracks, idx) } 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.onPlaySongRadio(t.selectedTracks()) }) - t.songRadioMenuItem.Icon = myTheme.BroadcastIcon + t.songRadioMenuItem.Icon = myTheme.RadioIcon t.ctxMenu.Items = append(t.ctxMenu.Items, 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) { t.tracksMutex.RLock() - tr, _ := util.FindTrackByID(t.tracks, trackID) + item, _ := util.FindItemByID(t.tracks, trackID) 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) { @@ -602,9 +602,9 @@ func (t *Tracklist) onSetFavorites(tracks []*mediaprovider.Track, fav bool, need func (t *Tracklist) onSetRating(trackID string, rating int) { // update our own track model t.tracksMutex.RLock() - tr, _ := util.FindTrackByID(t.tracks, trackID) + item, _ := util.FindItemByID(t.tracks, trackID) 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) { @@ -663,7 +663,7 @@ func (t *Tracklist) selectedTracks() []*mediaprovider.Track { func (t *Tracklist) SelectedTrackIDs() []string { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() - return util.SelectedTrackIDs(t.tracks) + return util.SelectedItemIDs(t.tracks) } func (t *Tracklist) lenTracks() int { diff --git a/ui/widgets/tracklistrow.go b/ui/widgets/tracklistrow.go index 41654cd..394d0bd 100644 --- a/ui/widgets/tracklistrow.go +++ b/ui/widgets/tracklistrow.go @@ -175,8 +175,8 @@ func NewExpandedTracklistRow(tracklist *Tracklist, im *backend.ImageManager, pla } func (t *ExpandedTracklistRow) Update(tm *util.TrackListModel, rowNum int) { - if t.trackID != tm.Track.ID { - t.imageLoader.Load(tm.Track.CoverArtID) + if t.trackID != tm.Track().ID { + t.imageLoader.Load(tm.Track().CoverArtID) } 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 // a new track (*mediaprovider.Track) - tr := tm.Track - if tr.ID != t.trackID { + tr := tm.Track() + if id := tr.ID; id != t.trackID { 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.album.BuildSegments([]string{tr.Album}, []string{tr.AlbumID}) t.dur.Text = util.SecondsToTimeString(float64(tr.Duration))