Merge pull request #393 from dweymouth/feature/radiostations
Add support for Subsonic internet radio stations
This commit is contained in:
+1
-1
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user