Merge pull request #393 from dweymouth/feature/radiostations

Add support for Subsonic internet radio stations
This commit is contained in:
Drew Weymouth
2024-06-02 09:32:10 -07:00
committed by GitHub
38 changed files with 1108 additions and 382 deletions
+1 -1
View File
@@ -378,7 +378,7 @@ func (a *App) LoadSavedPlayQueue() error {
if err := a.PlaybackManager.LoadTracks(queue.Tracks, Replace, false); err != nil {
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()
-9
View File
@@ -120,15 +120,6 @@ func (i *ImageManager) GetCoverThumbnail(coverID string) (image.Image, error) {
return i.fetchAndCacheCoverFromDiskOrServer(context.Background(), coverID, i.thumbnailCache.DefaultTTL, nil)
}
// 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) {
@@ -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,
+4
View File
@@ -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
+69 -1
View File
@@ -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 (
@@ -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,
+16 -9
View File
@@ -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)
+38 -20
View File
@@ -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) {
+94 -30
View File
@@ -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] = &copy
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)
}
}
+11 -7
View File
@@ -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)
}
+13 -3
View File
@@ -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{
+1 -1
View File
@@ -88,7 +88,7 @@ var ResFilterSvg = &fyne.StaticResource{
var ResBroadcastSvg = &fyne.StaticResource{
StaticName: "broadcast.svg",
StaticContent: []byte(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<svg width=\"800px\" height=\"800px\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n <path d=\"M4.92893 2.92896L6.34315 4.34317C4.89543 5.79088 4 7.79088 4 10C4 12.2092 4.89543 14.2092 6.34315 15.6569L4.92893 17.0711C3.11929 15.2614 2 12.7614 2 10C2 7.2386 3.11929 4.7386 4.92893 2.92896ZM19.0711 2.92896C20.8807 4.7386 22 7.2386 22 10C22 12.7614 20.8807 15.2614 19.0711 17.0711L17.6569 15.6569C19.1046 14.2092 20 12.2092 20 10C20 7.79088 19.1046 5.79088 17.6569 4.34317L19.0711 2.92896ZM7.75736 5.75738L9.17157 7.1716C8.44771 7.89545 8 8.89545 8 10C8 11.1046 8.44771 12.1046 9.17157 12.8285L7.75736 14.2427C6.67157 13.1569 6 11.6569 6 10C6 8.34317 6.67157 6.84317 7.75736 5.75738ZM16.2426 5.75738C17.3284 6.84317 18 8.34317 18 10C18 11.6569 17.3284 13.1569 16.2426 14.2427L14.8284 12.8285C15.5523 12.1046 16 11.1046 16 10C16 8.89545 15.5523 7.89545 14.8284 7.1716L16.2426 5.75738ZM12 12C10.8954 12 10 11.1046 10 10C10 8.89545 10.8954 8.00002 12 8.00002C13.1046 8.00002 14 8.89545 14 10C14 11.1046 13.1046 12 12 12ZM12 14C12.5798 14 13.0774 14.413 13.1843 14.9829L14.5 22H9.5L10.8157 14.9829C10.9226 14.413 11.4202 14 12 14Z\"/>\n</svg>"),
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<svg width=\"800px\" height=\"800px\" viewBox=\"2 2 22 22\" xmlns=\"http://www.w3.org/2000/svg\">\n <path d=\"M4.92893 2.92896L6.34315 4.34317C4.89543 5.79088 4 7.79088 4 10C4 12.2092 4.89543 14.2092 6.34315 15.6569L4.92893 17.0711C3.11929 15.2614 2 12.7614 2 10C2 7.2386 3.11929 4.7386 4.92893 2.92896ZM19.0711 2.92896C20.8807 4.7386 22 7.2386 22 10C22 12.7614 20.8807 15.2614 19.0711 17.0711L17.6569 15.6569C19.1046 14.2092 20 12.2092 20 10C20 7.79088 19.1046 5.79088 17.6569 4.34317L19.0711 2.92896ZM7.75736 5.75738L9.17157 7.1716C8.44771 7.89545 8 8.89545 8 10C8 11.1046 8.44771 12.1046 9.17157 12.8285L7.75736 14.2427C6.67157 13.1569 6 11.6569 6 10C6 8.34317 6.67157 6.84317 7.75736 5.75738ZM16.2426 5.75738C17.3284 6.84317 18 8.34317 18 10C18 11.6569 17.3284 13.1569 16.2426 14.2427L14.8284 12.8285C15.5523 12.1046 16 11.1046 16 10C16 8.89545 15.5523 7.89545 14.8284 7.1716L16.2426 5.75738ZM12 12C10.8954 12 10 11.1046 10 10C10 8.89545 10.8954 8.00002 12 8.00002C13.1046 8.00002 14 8.89545 14 10C14 11.1046 13.1046 12 12 12ZM12 14C12.5798 14 13.0774 14.413 13.1843 14.9829L14.5 22H9.5L10.8157 14.9829C10.9226 14.413 11.4202 14 12 14Z\"/>\n</svg>"),
}
var ResRepeatSvg = &fyne.StaticResource{
StaticName: "repeat.svg",
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<svg width="800px" height="800px" viewBox="2 2 22 22" xmlns="http://www.w3.org/2000/svg">
<path d="M4.92893 2.92896L6.34315 4.34317C4.89543 5.79088 4 7.79088 4 10C4 12.2092 4.89543 14.2092 6.34315 15.6569L4.92893 17.0711C3.11929 15.2614 2 12.7614 2 10C2 7.2386 3.11929 4.7386 4.92893 2.92896ZM19.0711 2.92896C20.8807 4.7386 22 7.2386 22 10C22 12.7614 20.8807 15.2614 19.0711 17.0711L17.6569 15.6569C19.1046 14.2092 20 12.2092 20 10C20 7.79088 19.1046 5.79088 17.6569 4.34317L19.0711 2.92896ZM7.75736 5.75738L9.17157 7.1716C8.44771 7.89545 8 8.89545 8 10C8 11.1046 8.44771 12.1046 9.17157 12.8285L7.75736 14.2427C6.67157 13.1569 6 11.6569 6 10C6 8.34317 6.67157 6.84317 7.75736 5.75738ZM16.2426 5.75738C17.3284 6.84317 18 8.34317 18 10C18 11.6569 17.3284 13.1569 16.2426 14.2427L14.8284 12.8285C15.5523 12.1046 16 11.1046 16 10C16 8.89545 15.5523 7.89545 14.8284 7.1716L16.2426 5.75738ZM12 12C10.8954 12 10 11.1046 10 10C10 8.89545 10.8954 8.00002 12 8.00002C13.1046 8.00002 14 8.89545 14 10C14 11.1046 13.1046 12 12 12ZM12 14C12.5798 14 13.0774 14.413 13.1843 14.9829L14.5 22H9.5L10.8157 14.9829C10.9226 14.413 11.4202 14 12 14Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+32 -20
View File
@@ -74,11 +74,23 @@ func FindTrackByID(id string, tracks []*mediaprovider.Track) *mediaprovider.Trac
return nil
}
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 {
+5 -5
View File
@@ -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")
}
+24 -25
View File
@@ -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)
}
}
+5 -3
View File
@@ -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() {
+3 -3
View File
@@ -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))
}
}
+7 -5
View File
@@ -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
}
+3 -3
View File
@@ -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))
}
}
+26 -16
View File
@@ -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
+6 -4
View File
@@ -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())
+372
View File
@@ -0,0 +1,372 @@
package browsing
import (
"log"
"net/url"
"strings"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/widgets"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
var _ fyne.Widget = (*ArtistPage)(nil)
type RadiosPage struct {
widget.BaseWidget
contr *controller.Controller
rp mediaprovider.RadioProvider
pm *backend.PlaybackManager
radios []*mediaprovider.RadioStation
list *RadioList
nowPlayingID string
titleDisp *widget.RichText
noRadiosMsg fyne.CanvasObject
container *fyne.Container
searcher *widgets.SearchEntry
}
func NewRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, pm *backend.PlaybackManager) *RadiosPage {
return newRadiosPage(contr, rp, pm, "", 0)
}
func newRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, pm *backend.PlaybackManager, searchText string, scrollPos float32) *RadiosPage {
a := &RadiosPage{
contr: contr,
rp: rp,
pm: pm,
titleDisp: widget.NewRichTextWithText("Internet Radio Stations"),
}
a.ExtendBaseWidget(a)
a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText
a.list = NewRadioList(&a.nowPlayingID)
a.list.OnPlay = a.onPlay
a.list.OnQueue = a.onQueue
a.searcher = widgets.NewSearchEntry()
a.searcher.PlaceHolder = "Search page"
a.searcher.OnSearched = a.onSearched
a.searcher.Entry.Text = searchText
a.noRadiosMsg = container.NewCenter(
container.New(layout.NewCustomPaddedVBoxLayout(-10),
container.NewCenter(
container.NewBorder(nil, nil,
widget.NewIcon(theme.InfoIcon()), nil,
widget.NewRichText(&widget.TextSegment{
Text: "No radio stations available",
Style: widget.RichTextStyleSubHeading,
}))),
widget.NewLabel("Configure your music server to add radio stations"),
),
)
a.noRadiosMsg.Hide()
a.buildContainer()
go a.load(searchText != "", scrollPos)
return a
}
// should be called asynchronously
func (a *RadiosPage) load(searchOnLoad bool, scrollPos float32) {
radios, err := a.rp.GetRadioStations()
if err != nil {
log.Printf("error loading radios: %v", err.Error())
}
if len(radios) == 0 {
a.noRadiosMsg.Show()
} else {
a.noRadiosMsg.Hide()
}
a.radios = radios
if searchOnLoad {
a.onSearched(a.searcher.Entry.Text)
if scrollPos != 0 {
a.list.list.ScrollToOffset(scrollPos)
}
} else {
a.list.SetRadios(a.radios)
if scrollPos != 0 {
a.list.list.ScrollToOffset(scrollPos)
return
}
a.list.Refresh()
}
}
func (a *RadiosPage) onPlay(station *mediaprovider.RadioStation) {
a.pm.LoadRadioStation(station, backend.Replace)
a.pm.PlayFromBeginning()
}
func (a *RadiosPage) onQueue(station *mediaprovider.RadioStation, next bool) {
queueMode := backend.Append
if next {
queueMode = backend.InsertNext
}
a.pm.LoadRadioStation(station, queueMode)
}
func (a *RadiosPage) onSearched(query string) {
// since the radios list is returned in full non-paginated, we will do our own
// simple search based on the radio name, rather than calling a server API
if query == "" {
a.list.SetRadios(a.radios)
} else {
query = strings.ToLower(query)
result := sharedutil.FilterSlice(a.radios, func(x *mediaprovider.RadioStation) bool {
return strings.Contains(strings.ToLower(x.Name), query)
})
a.list.SetRadios(result)
}
a.list.list.ScrollTo(0)
}
var _ Searchable = (*RadiosPage)(nil)
func (a *RadiosPage) SearchWidget() fyne.Focusable {
return a.searcher
}
var _ Scrollable = (*RadiosPage)(nil)
func (a *RadiosPage) Scroll(amount float32) {
a.list.list.ScrollToOffset(a.list.list.GetScrollOffset() + amount)
}
var _ CanShowNowPlaying = (*RadiosPage)(nil)
func (a *RadiosPage) OnSongChange(playing mediaprovider.MediaItem, _ *mediaprovider.Track) {
if playing != nil {
a.nowPlayingID = playing.Metadata().ID
} else {
a.nowPlayingID = ""
}
a.list.Refresh()
}
func (a *RadiosPage) Route() controller.Route {
return controller.RadiosRoute()
}
func (a *RadiosPage) Reload() {
go a.load(false, 0)
}
func (a *RadiosPage) Save() SavedPage {
return &savedrRadiosPage{
contr: a.contr,
rp: a.rp,
pm: a.pm,
searchText: a.searcher.Entry.Text,
scrollPos: a.list.list.GetScrollOffset(),
}
}
type savedrRadiosPage struct {
contr *controller.Controller
rp mediaprovider.RadioProvider
pm *backend.PlaybackManager
searchText string
scrollPos float32
}
func (s *savedrRadiosPage) Restore() Page {
return newRadiosPage(s.contr, s.rp, s.pm, s.searchText, s.scrollPos)
}
func (a *RadiosPage) buildContainer() {
searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer())
a.container = container.New(&layout.CustomPaddedLayout{LeftPadding: 15, RightPadding: 15, TopPadding: 5, BottomPadding: 15},
container.NewBorder(
container.New(&layout.CustomPaddedLayout{LeftPadding: -5},
container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox)),
nil, nil, nil,
container.NewStack(a.noRadiosMsg, a.list)),
)
}
func (a *RadiosPage) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(a.container)
}
type RadioList struct {
widget.BaseWidget
OnPlay func(*mediaprovider.RadioStation)
OnQueue func(r *mediaprovider.RadioStation, next bool)
radios []*mediaprovider.RadioStation
selected *RadioListRow
columnsLayout *layouts.ColumnsLayout
hdr *widgets.ListHeader
list *widgets.FocusList
container *fyne.Container
playingIcon fyne.CanvasObject
menu *widget.PopUpMenu
}
type RadioListRow struct {
widgets.FocusListRowBase
Item *mediaprovider.RadioStation
IsPlaying bool
OnTappedSecondary func(*fyne.PointEvent)
nameLabel *widget.RichText
homePageLink *widget.Hyperlink
}
func NewRadioListRow(layout *layouts.ColumnsLayout) *RadioListRow {
a := &RadioListRow{
nameLabel: widget.NewRichTextWithText(""),
homePageLink: widget.NewHyperlink("", nil),
}
a.ExtendBaseWidget(a)
a.nameLabel.Truncation = fyne.TextTruncateEllipsis
a.homePageLink.Truncation = fyne.TextTruncateEllipsis
a.Content = container.New(layout, a.nameLabel, a.homePageLink)
return a
}
func (a *RadioListRow) TappedSecondary(e *fyne.PointEvent) {
if a.OnTappedSecondary != nil {
a.OnTappedSecondary(e)
}
}
func NewRadioList(nowPlayingIDPtr *string) *RadioList {
a := &RadioList{
columnsLayout: layouts.NewColumnsLayout([]float32{-1, -1}),
}
playIcon := theme.NewThemedResource(theme.MediaPlayIcon())
playIcon.ColorName = theme.ColorNamePrimary
a.playingIcon = container.NewCenter(widget.NewIcon(playIcon))
a.ExtendBaseWidget(a)
a.hdr = widgets.NewListHeader([]widgets.ListColumn{
{Text: "Name", Alignment: fyne.TextAlignLeading, CanToggleVisible: false},
{Text: "Home Page", Alignment: fyne.TextAlignLeading, CanToggleVisible: false}},
a.columnsLayout)
a.hdr.DisableSorting = true
a.list = widgets.NewFocusList(
func() int { return len(a.radios) },
func() fyne.CanvasObject {
r := NewRadioListRow(a.columnsLayout)
r.OnTapped = func() {
r.Selected = true
if a.selected != nil {
// unselect old row
a.selected.Selected = false
a.selected.Refresh()
}
a.selected = r
r.Refresh()
}
r.OnDoubleTapped = func() { a.onPlayRadio(r.Item) }
r.OnTappedSecondary = func(e *fyne.PointEvent) {
r.OnTapped() // handle selection
a.showMenu(e.AbsolutePosition)
}
r.OnFocusNeighbor = func(up bool) {
a.list.FocusNeighbor(r.ItemID(), up)
}
return r
},
func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*RadioListRow)
a.list.SetItemForID(id, row)
changed := false
if row.Item != a.radios[id] {
row.EnsureUnfocused()
row.ListItemID = id
row.Item = a.radios[id]
row.nameLabel.Segments[0].(*widget.TextSegment).Text = row.Item.Name
row.homePageLink.Text = row.Item.HomePageURL
if u, err := url.Parse(row.Item.HomePageURL); err == nil {
row.homePageLink.URL = u
}
changed = true
}
isPlaying := *nowPlayingIDPtr == row.Item.ID
if row.IsPlaying != isPlaying {
row.IsPlaying = isPlaying
row.nameLabel.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying
if isPlaying {
row.Content.(*fyne.Container).Objects[0] =
container.NewBorder(nil, nil, a.playingIcon, nil,
container.New(layout.NewCustomPaddedLayout(0, 0, -5, 0), row.nameLabel))
} else {
row.Content.(*fyne.Container).Objects[0] = row.nameLabel
}
changed = true
}
if changed {
row.Refresh()
}
},
)
a.container = container.NewBorder(a.hdr, nil, nil, nil, a.list)
return a
}
func (a *RadioList) showMenu(pos fyne.Position) {
if a.menu == nil {
play := fyne.NewMenuItem("Play", func() {
if a.OnPlay != nil {
a.OnPlay(a.selected.Item)
}
})
play.Icon = theme.MediaPlayIcon()
playNext := fyne.NewMenuItem("Play next", func() {
if a.OnQueue != nil {
a.OnQueue(a.selected.Item, true)
}
})
playNext.Icon = myTheme.PlayNextIcon
append := fyne.NewMenuItem("Add to queue", func() {
if a.OnQueue != nil {
a.OnQueue(a.selected.Item, false)
}
})
append.Icon = theme.ContentAddIcon()
a.menu = widget.NewPopUpMenu(fyne.NewMenu("",
play,
playNext,
append,
),
fyne.CurrentApp().Driver().CanvasForObject(a),
)
}
a.menu.ShowAtPosition(pos)
}
func (g *RadioList) SetRadios(radios []*mediaprovider.RadioStation) {
g.radios = radios
g.Refresh()
}
func (a *RadioList) onPlayRadio(item *mediaprovider.RadioStation) {
if a.OnPlay != nil {
a.OnPlay(item)
}
}
func (a *RadioList) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(a.container)
}
+4
View File
@@ -55,6 +55,10 @@ func (r Router) CreatePage(rte controller.Route) Page {
return NewPlaylistsPage(r.Controller, r.widgetPool, &r.App.Config.PlaylistsPage, r.App.ServerManager.Server)
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
}
+5 -3
View File
@@ -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)
+3 -3
View File
@@ -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))
+5
View File
@@ -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}
}
+1 -1
View File
@@ -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)
+23 -8
View File
@@ -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() {
+1 -1
View File
@@ -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)
+38 -26
View File
@@ -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
}
+1 -1
View File
@@ -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()
+4 -4
View File
@@ -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
+44 -21
View File
@@ -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()
+24 -14
View File
@@ -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)
+165 -95
View File
@@ -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
+30 -30
View File
@@ -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 {
+6 -6
View File
@@ -175,8 +175,8 @@ func NewExpandedTracklistRow(tracklist *Tracklist, im *backend.ImageManager, pla
}
func (t *ExpandedTracklistRow) Update(tm *util.TrackListModel, rowNum int) {
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))