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 {
|
if err := a.PlaybackManager.LoadTracks(queue.Tracks, Replace, false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if queue.TrackIndex >= 0 {
|
if queue.TrackIndex >= 0 && queue.TrackIndex < len(queue.Tracks) {
|
||||||
// TODO: This isn't ideal but doesn't seem to cause an audible play-for-a-split-second artifact
|
// TODO: This isn't ideal but doesn't seem to cause an audible play-for-a-split-second artifact
|
||||||
a.PlaybackManager.PlayTrackAt(queue.TrackIndex)
|
a.PlaybackManager.PlayTrackAt(queue.TrackIndex)
|
||||||
a.PlaybackManager.Pause()
|
a.PlaybackManager.Pause()
|
||||||
|
|||||||
@@ -120,15 +120,6 @@ func (i *ImageManager) GetCoverThumbnail(coverID string) (image.Image, error) {
|
|||||||
return i.fetchAndCacheCoverFromDiskOrServer(context.Background(), coverID, i.thumbnailCache.DefaultTTL, nil)
|
return i.fetchAndCacheCoverFromDiskOrServer(context.Background(), coverID, i.thumbnailCache.DefaultTTL, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCoverThumbnailWithTTL fetches the cover for the given coverID and updates the TTL
|
|
||||||
// in the in-memory image cache. It blocks until the image fetch is complete.
|
|
||||||
func (i *ImageManager) GetCoverThumbnailWithTTL(coverID string, ttl time.Duration) (image.Image, error) {
|
|
||||||
if img, err := i.thumbnailCache.GetWithNewTTL(coverID, ttl); err == nil {
|
|
||||||
return img, nil
|
|
||||||
}
|
|
||||||
return i.fetchAndCacheCoverFromDiskOrServer(context.Background(), coverID, ttl, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFullSizeCoverArt fetches the full size cover image for the given coverID.
|
// GetFullSizeCoverArt fetches the full size cover image for the given coverID.
|
||||||
// It blocks until the fetch is complete.
|
// It blocks until the fetch is complete.
|
||||||
func (i *ImageManager) GetFullSizeCoverArt(coverID string) (image.Image, error) {
|
func (i *ImageManager) GetFullSizeCoverArt(coverID string) (image.Image, error) {
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ func toTrack(ch *jellyfin.Song) *mediaprovider.Track {
|
|||||||
ID: ch.Id,
|
ID: ch.Id,
|
||||||
CoverArtID: coverArtID,
|
CoverArtID: coverArtID,
|
||||||
ParentID: ch.AlbumID,
|
ParentID: ch.AlbumID,
|
||||||
Name: ch.Name,
|
Title: ch.Name,
|
||||||
Duration: int(ch.RunTimeTicks / runTimeTicksPerSecond),
|
Duration: int(ch.RunTimeTicks / runTimeTicksPerSecond),
|
||||||
TrackNumber: ch.IndexNumber,
|
TrackNumber: ch.IndexNumber,
|
||||||
DiscNumber: ch.DiscNumber,
|
DiscNumber: ch.DiscNumber,
|
||||||
|
|||||||
@@ -267,6 +267,10 @@ type LyricsProvider interface {
|
|||||||
GetLyrics(track *Track) (*Lyrics, error)
|
GetLyrics(track *Track) (*Lyrics, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RadioProvider interface {
|
||||||
|
GetRadioStations() ([]*RadioStation, error)
|
||||||
|
}
|
||||||
|
|
||||||
type JukeboxProvider interface {
|
type JukeboxProvider interface {
|
||||||
JukeboxStart() error
|
JukeboxStart() error
|
||||||
JukeboxStop() error
|
JukeboxStop() error
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ type Track struct {
|
|||||||
ID string
|
ID string
|
||||||
CoverArtID string
|
CoverArtID string
|
||||||
ParentID string
|
ParentID string
|
||||||
Name string
|
Title string
|
||||||
Duration int
|
Duration int
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
@@ -139,6 +139,74 @@ type SavedPlayQueue struct {
|
|||||||
TimePos int // seconds
|
TimePos int // seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RadioStation struct {
|
||||||
|
Name string
|
||||||
|
ID string
|
||||||
|
HomePageURL string
|
||||||
|
StreamURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaItemType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
MediaItemTypeTrack MediaItemType = iota
|
||||||
|
MediaItemTypeRadioStation
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaItemMetadata struct {
|
||||||
|
Type MediaItemType
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Artists []string
|
||||||
|
ArtistIDs []string
|
||||||
|
Album string
|
||||||
|
AlbumID string
|
||||||
|
CoverArtID string
|
||||||
|
Duration int
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaItem interface {
|
||||||
|
Metadata() MediaItemMetadata
|
||||||
|
Copy() MediaItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Track) Metadata() MediaItemMetadata {
|
||||||
|
if t == nil {
|
||||||
|
return MediaItemMetadata{}
|
||||||
|
}
|
||||||
|
return MediaItemMetadata{
|
||||||
|
Type: MediaItemTypeTrack,
|
||||||
|
ID: t.ID,
|
||||||
|
Name: t.Title,
|
||||||
|
Artists: t.ArtistNames,
|
||||||
|
ArtistIDs: t.ArtistIDs,
|
||||||
|
Album: t.Album,
|
||||||
|
AlbumID: t.AlbumID,
|
||||||
|
CoverArtID: t.CoverArtID,
|
||||||
|
Duration: t.Duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Track) Copy() MediaItem {
|
||||||
|
new := *t
|
||||||
|
return &new
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RadioStation) Metadata() MediaItemMetadata {
|
||||||
|
if r == nil {
|
||||||
|
return MediaItemMetadata{}
|
||||||
|
}
|
||||||
|
return MediaItemMetadata{
|
||||||
|
Type: MediaItemTypeRadioStation,
|
||||||
|
ID: r.ID,
|
||||||
|
Name: r.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RadioStation) Copy() MediaItem {
|
||||||
|
return r // no need to copy since RadioStations are immutable
|
||||||
|
}
|
||||||
|
|
||||||
type ContentType int
|
type ContentType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ func (s *subsonicMediaProvider) GetLyrics(track *mediaprovider.Track) (*mediapro
|
|||||||
return mpLyrics, nil
|
return mpLyrics, nil
|
||||||
}
|
}
|
||||||
// fallback to legacy getLyrics endpoint
|
// fallback to legacy getLyrics endpoint
|
||||||
lyrics, err := s.client.GetLyrics(track.Name, track.ArtistNames[0])
|
lyrics, err := s.client.GetLyrics(track.Title, track.ArtistNames[0])
|
||||||
if err != nil || lyrics == nil || lyrics.Text == "" {
|
if err != nil || lyrics == nil || lyrics.Text == "" {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -386,6 +386,7 @@ func (s *subsonicMediaProvider) GetLyrics(track *mediaprovider.Track) (*mediapro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CanSavePlayQueue interface
|
// CanSavePlayQueue interface
|
||||||
|
var _ mediaprovider.CanSavePlayQueue = (*subsonicMediaProvider)(nil)
|
||||||
|
|
||||||
func (s *subsonicMediaProvider) SavePlayQueue(trackIDs []string, currentTrackIdx int, timeSeconds int) error {
|
func (s *subsonicMediaProvider) SavePlayQueue(trackIDs []string, currentTrackIdx int, timeSeconds int) error {
|
||||||
if len(trackIDs) == 0 {
|
if len(trackIDs) == 0 {
|
||||||
@@ -413,6 +414,25 @@ func (s *subsonicMediaProvider) GetPlayQueue() (*mediaprovider.SavedPlayQueue, e
|
|||||||
return savedQueue, nil
|
return savedQueue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RadioProvider interface
|
||||||
|
var _ mediaprovider.RadioProvider = (*subsonicMediaProvider)(nil)
|
||||||
|
|
||||||
|
func (s *subsonicMediaProvider) GetRadioStations() ([]*mediaprovider.RadioStation, error) {
|
||||||
|
rs, err := s.client.GetInternetRadioStations()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sharedutil.MapSlice(rs, func(rs *subsonic.InternetRadioStation) *mediaprovider.RadioStation {
|
||||||
|
return &mediaprovider.RadioStation{
|
||||||
|
// TODO - subsonic library is missing ID in its radiostation object. add it
|
||||||
|
ID: "radio-" + strings.ReplaceAll(rs.Name, " ", ""),
|
||||||
|
Name: rs.Name,
|
||||||
|
HomePageURL: rs.HomePageUrl,
|
||||||
|
StreamURL: rs.StreamUrl,
|
||||||
|
}
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
func toTrack(ch *subsonic.Child) *mediaprovider.Track {
|
func toTrack(ch *subsonic.Child) *mediaprovider.Track {
|
||||||
if ch == nil {
|
if ch == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -433,7 +453,7 @@ func toTrack(ch *subsonic.Child) *mediaprovider.Track {
|
|||||||
ID: ch.ID,
|
ID: ch.ID,
|
||||||
CoverArtID: ch.CoverArt,
|
CoverArtID: ch.CoverArt,
|
||||||
ParentID: ch.Parent,
|
ParentID: ch.Parent,
|
||||||
Name: ch.Title,
|
Title: ch.Title,
|
||||||
Duration: ch.Duration,
|
Duration: ch.Duration,
|
||||||
TrackNumber: ch.Track,
|
TrackNumber: ch.Track,
|
||||||
DiscNumber: ch.DiscNumber,
|
DiscNumber: ch.DiscNumber,
|
||||||
|
|||||||
+15
-8
@@ -69,9 +69,14 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac
|
|||||||
mpMediaEventRecipient = mp
|
mpMediaEventRecipient = mp
|
||||||
C.register_os_remote_commands()
|
C.register_os_remote_commands()
|
||||||
|
|
||||||
mp.playbackManager.OnSongChange(func(track, _ *mediaprovider.Track) {
|
mp.playbackManager.OnSongChange(func(track mediaprovider.MediaItem, _ *mediaprovider.Track) {
|
||||||
// Asynchronously because artwork fetching can take time
|
// Asynchronously because artwork fetching can take time
|
||||||
go mp.updateMetadata(track)
|
var meta *mediaprovider.MediaItemMetadata
|
||||||
|
if track != nil {
|
||||||
|
m := track.Metadata()
|
||||||
|
meta = &m
|
||||||
|
}
|
||||||
|
go mp.updateMetadata(meta)
|
||||||
})
|
})
|
||||||
|
|
||||||
mp.playbackManager.OnStopped(func() {
|
mp.playbackManager.OnStopped(func() {
|
||||||
@@ -95,17 +100,19 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mp *MPMediaHandler) updateMetadata(track *mediaprovider.Track) {
|
func (mp *MPMediaHandler) updateMetadata(meta *mediaprovider.MediaItemMetadata) {
|
||||||
var title, artist, artURL string
|
var title, artist, artURL string
|
||||||
var duration int
|
var duration int
|
||||||
if track != nil && track.ID != "" {
|
if meta != nil && meta.ID != "" {
|
||||||
|
title = meta.Name
|
||||||
var err error
|
var err error
|
||||||
if artURL, err = mp.artURLLookup(track.CoverArtID); err != nil {
|
if artURL, err = mp.artURLLookup(meta.CoverArtID); err != nil {
|
||||||
|
if meta.CoverArtID != "" {
|
||||||
log.Printf("error fetching art url: %s", err.Error())
|
log.Printf("error fetching art url: %s", err.Error())
|
||||||
}
|
}
|
||||||
title = track.Name
|
}
|
||||||
artist = strings.Join(track.ArtistNames, ", ")
|
artist = strings.Join(meta.Artists, ", ")
|
||||||
duration = track.Duration
|
duration = meta.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
cTitle := C.CString(title)
|
cTitle := C.CString(title)
|
||||||
|
|||||||
+35
-17
@@ -58,11 +58,11 @@ func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler {
|
|||||||
m.evt.Player.OnSeek(pos)
|
m.evt.Player.OnSeek(pos)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
pm.OnSongChange(func(tr, _ *mediaprovider.Track) {
|
pm.OnSongChange(func(tr mediaprovider.MediaItem, _ *mediaprovider.Track) {
|
||||||
if tr == nil {
|
if tr == nil {
|
||||||
m.curTrackPath = ""
|
m.curTrackPath = ""
|
||||||
} else {
|
} else {
|
||||||
m.curTrackPath = dbusTrackIDPrefix + encodeTrackId(tr.ID)
|
m.curTrackPath = dbusTrackIDPrefix + encodeTrackId(tr.Metadata().ID)
|
||||||
}
|
}
|
||||||
if m.connErr == nil {
|
if m.connErr == nil {
|
||||||
m.evt.Player.OnTitle()
|
m.evt.Player.OnTitle()
|
||||||
@@ -251,30 +251,48 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) {
|
|||||||
trackObjPath = m.curTrackPath
|
trackObjPath = m.curTrackPath
|
||||||
}
|
}
|
||||||
status := m.pm.PlayerStatus()
|
status := m.pm.PlayerStatus()
|
||||||
var tr mediaprovider.Track
|
|
||||||
|
var meta mediaprovider.MediaItemMetadata
|
||||||
|
// metadata that can come only from tracks
|
||||||
|
var discNumber, trackNumber, userRating, playCount, year int
|
||||||
|
var genre string
|
||||||
|
|
||||||
if np := m.pm.NowPlaying(); np != nil && status.State != player.Stopped {
|
if np := m.pm.NowPlaying(); np != nil && status.State != player.Stopped {
|
||||||
tr = *np
|
meta = np.Metadata()
|
||||||
|
if track, ok := np.(*mediaprovider.Track); ok {
|
||||||
|
discNumber = track.DiscNumber
|
||||||
|
trackNumber = track.TrackNumber
|
||||||
|
userRating = track.Rating
|
||||||
|
playCount = track.PlayCount
|
||||||
|
year = track.Year
|
||||||
|
genre = track.Genre
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var artURL string
|
var artURL string
|
||||||
if tr.ID != "" && m.ArtURLLookup != nil {
|
if meta.ID != "" && m.ArtURLLookup != nil {
|
||||||
if u, err := m.ArtURLLookup(tr.CoverArtID); err == nil {
|
if u, err := m.ArtURLLookup(meta.CoverArtID); err == nil {
|
||||||
artURL = u
|
artURL = u
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return types.Metadata{
|
mprisMeta := types.Metadata{
|
||||||
TrackId: dbus.ObjectPath(trackObjPath),
|
TrackId: dbus.ObjectPath(trackObjPath),
|
||||||
Length: secondsToMicroseconds(status.Duration),
|
Length: secondsToMicroseconds(status.Duration),
|
||||||
Title: tr.Name,
|
Title: meta.Name,
|
||||||
Album: tr.Album,
|
Album: meta.Album,
|
||||||
Artist: tr.ArtistNames,
|
Artist: meta.Artists,
|
||||||
DiscNumber: tr.DiscNumber,
|
DiscNumber: discNumber,
|
||||||
TrackNumber: tr.TrackNumber,
|
TrackNumber: trackNumber,
|
||||||
Genre: []string{tr.Genre},
|
UserRating: float64(userRating) / 5,
|
||||||
UserRating: float64(tr.Rating) / 5,
|
UseCount: playCount,
|
||||||
ContentCreated: strconv.Itoa(tr.Year),
|
|
||||||
UseCount: tr.PlayCount,
|
|
||||||
ArtUrl: artURL,
|
ArtUrl: artURL,
|
||||||
}, nil
|
}
|
||||||
|
if genre != "" {
|
||||||
|
mprisMeta.Genre = []string{genre}
|
||||||
|
}
|
||||||
|
if year != 0 {
|
||||||
|
mprisMeta.ContentCreated = strconv.Itoa(year)
|
||||||
|
}
|
||||||
|
return mprisMeta, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MPRISHandler) Volume() (float64, error) {
|
func (m *MPRISHandler) Volume() (float64, error) {
|
||||||
|
|||||||
+92
-28
@@ -44,12 +44,13 @@ type playbackEngine struct {
|
|||||||
player player.BasePlayer
|
player player.BasePlayer
|
||||||
|
|
||||||
playTimeStopwatch util.Stopwatch
|
playTimeStopwatch util.Stopwatch
|
||||||
curTrackTime float64
|
curTrackDuration float64
|
||||||
latestTrackPosition float64 // cleared by checkScrobble
|
latestTrackPosition float64 // cleared by checkScrobble
|
||||||
callbacksDisabled bool
|
callbacksDisabled bool
|
||||||
|
|
||||||
playQueue []*mediaprovider.Track
|
playQueue []mediaprovider.MediaItem
|
||||||
nowPlayingIdx int
|
nowPlayingIdx int
|
||||||
|
isRadio bool
|
||||||
wasStopped bool // true iff player was stopped before handleOnTrackChange invocation
|
wasStopped bool // true iff player was stopped before handleOnTrackChange invocation
|
||||||
loopMode LoopMode
|
loopMode LoopMode
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ type playbackEngine struct {
|
|||||||
replayGainCfg ReplayGainConfig
|
replayGainCfg ReplayGainConfig
|
||||||
|
|
||||||
// registered callbacks
|
// registered callbacks
|
||||||
onSongChange []func(nowPlaying, justScrobbledIfAny *mediaprovider.Track)
|
onSongChange []func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track)
|
||||||
onPlayTimeUpdate []func(float64, float64, bool)
|
onPlayTimeUpdate []func(float64, float64, bool)
|
||||||
onLoopModeChange []func(LoopMode)
|
onLoopModeChange []func(LoopMode)
|
||||||
onVolumeChange []func(int)
|
onVolumeChange []func(int)
|
||||||
@@ -122,8 +123,8 @@ func (p *playbackEngine) PlayTrackAt(idx int) error {
|
|||||||
return p.setTrack(idx, false)
|
return p.setTrack(idx, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gets the curently playing song, if any.
|
// Gets the curently playing media item, if any.
|
||||||
func (p *playbackEngine) NowPlaying() *mediaprovider.Track {
|
func (p *playbackEngine) NowPlaying() mediaprovider.MediaItem {
|
||||||
if p.nowPlayingIdx < 0 || len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped {
|
if p.nowPlayingIdx < 0 || len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -184,6 +185,9 @@ func (p *playbackEngine) SeekBackOrPrevious() error {
|
|||||||
|
|
||||||
// Seek to given absolute position in the current track by seconds.
|
// Seek to given absolute position in the current track by seconds.
|
||||||
func (p *playbackEngine) SeekSeconds(sec float64) error {
|
func (p *playbackEngine) SeekSeconds(sec float64) error {
|
||||||
|
if p.isRadio {
|
||||||
|
return nil // can't seek radio stations
|
||||||
|
}
|
||||||
return p.player.SeekSeconds(sec)
|
return p.player.SeekSeconds(sec)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +220,7 @@ func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, insertQueueMo
|
|||||||
}
|
}
|
||||||
needToSetNext := len(tracks) > 0 && (insertQueueMode == InsertNext || (insertQueueMode == Append && p.nowPlayingIdx == len(p.playQueue)-1))
|
needToSetNext := len(tracks) > 0 && (insertQueueMode == InsertNext || (insertQueueMode == Append && p.nowPlayingIdx == len(p.playQueue)-1))
|
||||||
|
|
||||||
newTracks := p.deepCopyTrackSlice(tracks)
|
newTracks := p.copyTrackSliceToMediaItemSlice(tracks)
|
||||||
if shuffle {
|
if shuffle {
|
||||||
rand.Shuffle(len(newTracks), func(i, j int) { newTracks[i], newTracks[j] = newTracks[j], newTracks[i] })
|
rand.Shuffle(len(newTracks), func(i, j int) { newTracks[i], newTracks[j] = newTracks[j], newTracks[i] })
|
||||||
}
|
}
|
||||||
@@ -235,6 +239,31 @@ func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, insertQueueMo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) {
|
||||||
|
if insertMode == Replace {
|
||||||
|
p.player.Stop()
|
||||||
|
p.nowPlayingIdx = -1
|
||||||
|
p.playQueue = nil
|
||||||
|
}
|
||||||
|
needToSetNext := insertMode == InsertNext || (insertMode == Append && p.nowPlayingIdx == len(p.playQueue)-1)
|
||||||
|
insertIdx := len(p.playQueue)
|
||||||
|
if insertMode == InsertNext {
|
||||||
|
insertIdx = p.nowPlayingIdx + 1
|
||||||
|
}
|
||||||
|
new := make([]mediaprovider.MediaItem, len(p.playQueue)+1)
|
||||||
|
firstHalf := p.playQueue[:insertIdx]
|
||||||
|
copy(new, firstHalf)
|
||||||
|
new[len(firstHalf)] = radio
|
||||||
|
copy(new[len(firstHalf)+1:], p.playQueue[insertIdx:])
|
||||||
|
p.playQueue = new
|
||||||
|
|
||||||
|
if needToSetNext {
|
||||||
|
p.setNextTrack(p.nowPlayingIdx + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.invokeNoArgCallbacks(p.onQueueChange)
|
||||||
|
}
|
||||||
|
|
||||||
// Stop playback and clear the play queue.
|
// Stop playback and clear the play queue.
|
||||||
func (p *playbackEngine) StopAndClearPlayQueue() {
|
func (p *playbackEngine) StopAndClearPlayQueue() {
|
||||||
changed := len(p.playQueue) > 0
|
changed := len(p.playQueue) > 0
|
||||||
@@ -247,36 +276,40 @@ func (p *playbackEngine) StopAndClearPlayQueue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *playbackEngine) GetPlayQueue() []*mediaprovider.Track {
|
func (p *playbackEngine) GetPlayQueue() []mediaprovider.MediaItem {
|
||||||
return p.deepCopyTrackSlice(p.playQueue)
|
return p.deepCopyMediaItemSlice(p.playQueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any time the user changes the favorite status of a track elsewhere in the app,
|
// Any time the user changes the favorite status of a track elsewhere in the app,
|
||||||
// this should be called to ensure the in-memory track model is updated.
|
// this should be called to ensure the in-memory track model is updated.
|
||||||
func (p *playbackEngine) OnTrackFavoriteStatusChanged(id string, fav bool) {
|
func (p *playbackEngine) OnTrackFavoriteStatusChanged(id string, fav bool) {
|
||||||
if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil {
|
if item := sharedutil.FindMediaItemByID(id, p.playQueue); item != nil {
|
||||||
|
if tr, ok := item.(*mediaprovider.Track); ok {
|
||||||
tr.Favorite = fav
|
tr.Favorite = fav
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any time the user changes the rating of a track elsewhere in the app,
|
// Any time the user changes the rating of a track elsewhere in the app,
|
||||||
// this should be called to ensure the in-memory track model is updated.
|
// this should be called to ensure the in-memory track model is updated.
|
||||||
func (p *playbackEngine) OnTrackRatingChanged(id string, rating int) {
|
func (p *playbackEngine) OnTrackRatingChanged(id string, rating int) {
|
||||||
if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil {
|
if item := sharedutil.FindMediaItemByID(id, p.playQueue); item != nil {
|
||||||
|
if tr, ok := item.(*mediaprovider.Track); ok {
|
||||||
tr.Rating = rating
|
tr.Rating = rating
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replaces the play queue with the given set of tracks.
|
// Replaces the play queue with the given set of tracks.
|
||||||
// Does not stop playback if the currently playing track is in the new queue,
|
// Does not stop playback if the currently playing track is in the new queue,
|
||||||
// but updates the now playing index to point to the first instance of the track in the new queue.
|
// but updates the now playing index to point to the first instance of the track in the new queue.
|
||||||
func (p *playbackEngine) UpdatePlayQueue(tracks []*mediaprovider.Track) error {
|
func (p *playbackEngine) UpdatePlayQueue(items []mediaprovider.MediaItem) error {
|
||||||
newQueue := p.deepCopyTrackSlice(tracks)
|
newQueue := p.deepCopyMediaItemSlice(items)
|
||||||
newNowPlayingIdx := -1
|
newNowPlayingIdx := -1
|
||||||
if p.nowPlayingIdx >= 0 {
|
if p.nowPlayingIdx >= 0 {
|
||||||
nowPlayingID := p.playQueue[p.nowPlayingIdx].ID
|
nowPlayingID := p.playQueue[p.nowPlayingIdx].Metadata().ID
|
||||||
for i, tr := range newQueue {
|
for i, tr := range newQueue {
|
||||||
if tr.ID == nowPlayingID {
|
if tr.Metadata().ID == nowPlayingID {
|
||||||
newNowPlayingIdx = i
|
newNowPlayingIdx = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -298,14 +331,14 @@ func (p *playbackEngine) UpdatePlayQueue(tracks []*mediaprovider.Track) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *playbackEngine) RemoveTracksFromQueue(trackIDs []string) {
|
func (p *playbackEngine) RemoveTracksFromQueue(trackIDs []string) {
|
||||||
newQueue := make([]*mediaprovider.Track, 0, len(p.playQueue)-len(trackIDs))
|
newQueue := make([]mediaprovider.MediaItem, 0, len(p.playQueue)-len(trackIDs))
|
||||||
idSet := sharedutil.ToSet(trackIDs)
|
idSet := sharedutil.ToSet(trackIDs)
|
||||||
isPlayingTrackRemoved := false
|
isPlayingTrackRemoved := false
|
||||||
isNextPlayingTrackremoved := false
|
isNextPlayingTrackremoved := false
|
||||||
nowPlaying := p.NowPlayingIndex()
|
nowPlaying := p.NowPlayingIndex()
|
||||||
newNowPlaying := nowPlaying
|
newNowPlaying := nowPlaying
|
||||||
for i, tr := range p.playQueue {
|
for i, tr := range p.playQueue {
|
||||||
if _, ok := idSet[tr.ID]; ok {
|
if _, ok := idSet[tr.Metadata().ID]; ok {
|
||||||
if i < nowPlaying {
|
if i < nowPlaying {
|
||||||
// if removing a track earlier than the currently playing one (if any),
|
// if removing a track earlier than the currently playing one (if any),
|
||||||
// decrement new now playing index by one to account for new position in queue
|
// decrement new now playing index by one to account for new position in queue
|
||||||
@@ -395,8 +428,11 @@ func (p *playbackEngine) handleOnTrackChange() {
|
|||||||
p.nowPlayingIdx = 0 // wrapped around
|
p.nowPlayingIdx = 0 // wrapped around
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
nowPlaying := p.playQueue[p.nowPlayingIdx]
|
||||||
|
_, isRadio := nowPlaying.(*mediaprovider.RadioStation)
|
||||||
|
p.isRadio = isRadio
|
||||||
p.wasStopped = false
|
p.wasStopped = false
|
||||||
p.curTrackTime = float64(p.playQueue[p.nowPlayingIdx].Duration)
|
p.curTrackDuration = float64(nowPlaying.Metadata().Duration)
|
||||||
p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled
|
p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled
|
||||||
p.invokeOnSongChangeCallbacks()
|
p.invokeOnSongChangeCallbacks()
|
||||||
p.doUpdateTimePos(false)
|
p.doUpdateTimePos(false)
|
||||||
@@ -460,7 +496,12 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
|
|||||||
url := ""
|
url := ""
|
||||||
if idx >= 0 {
|
if idx >= 0 {
|
||||||
var err error
|
var err error
|
||||||
url, err = p.sm.Server.GetStreamURL(p.playQueue[idx].ID, p.transcodeCfg.ForceRawFile)
|
item := p.playQueue[idx]
|
||||||
|
if tr, ok := item.(*mediaprovider.Track); ok {
|
||||||
|
url, err = p.sm.Server.GetStreamURL(tr.ID, p.transcodeCfg.ForceRawFile)
|
||||||
|
} else {
|
||||||
|
url = item.(*mediaprovider.RadioStation).StreamURL
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -472,7 +513,10 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
|
|||||||
} else if trP, ok := p.player.(player.TrackPlayer); ok {
|
} else if trP, ok := p.player.(player.TrackPlayer); ok {
|
||||||
var track *mediaprovider.Track
|
var track *mediaprovider.Track
|
||||||
if idx >= 0 {
|
if idx >= 0 {
|
||||||
track = p.playQueue[idx]
|
track, ok = p.playQueue[idx].(*mediaprovider.Track)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("cannot play non-Track media item with TrackPlayer")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if next {
|
if next {
|
||||||
return trP.SetNextTrack(track)
|
return trP.SetNextTrack(track)
|
||||||
@@ -491,15 +535,19 @@ func (p *playbackEngine) checkScrobble() {
|
|||||||
if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 {
|
if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
track, ok := p.playQueue[p.nowPlayingIdx].(*mediaprovider.Track)
|
||||||
|
if !ok {
|
||||||
|
return // radio stations are not scrobbled
|
||||||
|
}
|
||||||
|
|
||||||
playDur := p.playTimeStopwatch.Elapsed()
|
playDur := p.playTimeStopwatch.Elapsed()
|
||||||
if playDur.Seconds() < 0.1 || p.curTrackTime < 0.1 {
|
if playDur.Seconds() < 0.1 || p.curTrackDuration < 0.1 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pcnt := playDur.Seconds() / p.curTrackTime * 100
|
pcnt := playDur.Seconds() / p.curTrackDuration * 100
|
||||||
timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 &&
|
timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 &&
|
||||||
playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds)
|
playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds)
|
||||||
|
|
||||||
track := p.playQueue[p.nowPlayingIdx]
|
|
||||||
var submission bool
|
var submission bool
|
||||||
server := p.sm.Server
|
server := p.sm.Server
|
||||||
if server.ClientDecidesScrobble() && (timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent)) {
|
if server.ClientDecidesScrobble() && (timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent)) {
|
||||||
@@ -516,7 +564,11 @@ func (p *playbackEngine) sendNowPlayingScrobble() {
|
|||||||
if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 {
|
if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
track := p.playQueue[p.nowPlayingIdx]
|
track, ok := p.playQueue[p.nowPlayingIdx].(*mediaprovider.Track)
|
||||||
|
if !ok {
|
||||||
|
return // radio stations are not scrobbled
|
||||||
|
}
|
||||||
|
|
||||||
server := p.sm.Server
|
server := p.sm.Server
|
||||||
if !server.ClientDecidesScrobble() {
|
if !server.ClientDecidesScrobble() {
|
||||||
// server will count track as scrobbled as soon as it starts playing
|
// server will count track as scrobbled as soon as it starts playing
|
||||||
@@ -528,11 +580,18 @@ func (p *playbackEngine) sendNowPlayingScrobble() {
|
|||||||
|
|
||||||
// creates a deep copy of the track info so that we can maintain our own state
|
// creates a deep copy of the track info so that we can maintain our own state
|
||||||
// (play count increases, favorite, and rating) without messing up other views' track models
|
// (play count increases, favorite, and rating) without messing up other views' track models
|
||||||
func (p *playbackEngine) deepCopyTrackSlice(tracks []*mediaprovider.Track) []*mediaprovider.Track {
|
func (p *playbackEngine) deepCopyMediaItemSlice(tracks []mediaprovider.MediaItem) []mediaprovider.MediaItem {
|
||||||
newTracks := make([]*mediaprovider.Track, len(tracks))
|
newTracks := make([]mediaprovider.MediaItem, len(tracks))
|
||||||
for i, tr := range tracks {
|
for i, tr := range tracks {
|
||||||
copy := *tr
|
newTracks[i] = tr.Copy()
|
||||||
newTracks[i] = ©
|
}
|
||||||
|
return newTracks
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *playbackEngine) copyTrackSliceToMediaItemSlice(tracks []*mediaprovider.Track) []mediaprovider.MediaItem {
|
||||||
|
newTracks := make([]mediaprovider.MediaItem, len(tracks))
|
||||||
|
for i, tr := range tracks {
|
||||||
|
newTracks[i] = tr.Copy()
|
||||||
}
|
}
|
||||||
return newTracks
|
return newTracks
|
||||||
}
|
}
|
||||||
@@ -589,7 +648,12 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) {
|
|||||||
if s.TimePos > p.latestTrackPosition {
|
if s.TimePos > p.latestTrackPosition {
|
||||||
p.latestTrackPosition = s.TimePos
|
p.latestTrackPosition = s.TimePos
|
||||||
}
|
}
|
||||||
|
duration := s.Duration
|
||||||
|
if p.isRadio {
|
||||||
|
// MPV reports buffered duration - we don't want to show this
|
||||||
|
duration = 0
|
||||||
|
}
|
||||||
for _, cb := range p.onPlayTimeUpdate {
|
for _, cb := range p.onPlayTimeUpdate {
|
||||||
cb(s.TimePos, s.Duration, seeked)
|
cb(s.TimePos, duration, seeked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ func (p *PlaybackManager) DisableCallbacks() {
|
|||||||
p.engine.callbacksDisabled = true
|
p.engine.callbacksDisabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gets the curently playing song, if any.
|
// Gets the now playing media item, if any.
|
||||||
func (p *PlaybackManager) NowPlaying() *mediaprovider.Track {
|
func (p *PlaybackManager) NowPlaying() mediaprovider.MediaItem {
|
||||||
return p.engine.NowPlaying()
|
return p.engine.NowPlaying()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ func (p *PlaybackManager) NowPlayingIndex() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sets a callback that is notified whenever a new song begins playing.
|
// Sets a callback that is notified whenever a new song begins playing.
|
||||||
func (p *PlaybackManager) OnSongChange(cb func(nowPlaying *mediaprovider.Track, justScrobbledIfAny *mediaprovider.Track)) {
|
func (p *PlaybackManager) OnSongChange(cb func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track)) {
|
||||||
p.engine.onSongChange = append(p.engine.onSongChange, cb)
|
p.engine.onSongChange = append(p.engine.onSongChange, cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +126,8 @@ func (p *PlaybackManager) LoadTracks(tracks []*mediaprovider.Track, insertQueueM
|
|||||||
// Replaces the play queue with the given set of tracks.
|
// Replaces the play queue with the given set of tracks.
|
||||||
// Does not stop playback if the currently playing track is in the new queue,
|
// Does not stop playback if the currently playing track is in the new queue,
|
||||||
// but updates the now playing index to point to the first instance of the track in the new queue.
|
// but updates the now playing index to point to the first instance of the track in the new queue.
|
||||||
func (p *PlaybackManager) UpdatePlayQueue(tracks []*mediaprovider.Track) error {
|
func (p *PlaybackManager) UpdatePlayQueue(items []mediaprovider.MediaItem) error {
|
||||||
return p.engine.UpdatePlayQueue(tracks)
|
return p.engine.UpdatePlayQueue(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int, shuffle bool) error {
|
func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int, shuffle bool) error {
|
||||||
@@ -182,6 +182,10 @@ func (p *PlaybackManager) PlaySimilarSongs(id string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *PlaybackManager) LoadRadioStation(station *mediaprovider.RadioStation, queueMode InsertQueueMode) {
|
||||||
|
p.engine.LoadRadioStation(station, queueMode)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Track, error)) {
|
func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Track, error)) {
|
||||||
if songs, err := fetchFn(); err != nil {
|
if songs, err := fetchFn(); err != nil {
|
||||||
log.Printf("error fetching tracks: %s", err.Error())
|
log.Printf("error fetching tracks: %s", err.Error())
|
||||||
@@ -194,7 +198,7 @@ func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Tr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlaybackManager) GetPlayQueue() []*mediaprovider.Track {
|
func (p *PlaybackManager) GetPlayQueue() []mediaprovider.MediaItem {
|
||||||
return p.engine.GetPlayQueue()
|
return p.engine.GetPlayQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +285,7 @@ func (p *PlaybackManager) SeekFraction(fraction float64) error {
|
|||||||
} else if fraction > 1 {
|
} else if fraction > 1 {
|
||||||
fraction = 1
|
fraction = 1
|
||||||
}
|
}
|
||||||
target := p.engine.curTrackTime * fraction
|
target := p.engine.curTrackDuration * fraction
|
||||||
return p.engine.SeekSeconds(target)
|
return p.engine.SeekSeconds(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,19 @@ func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server
|
|||||||
stats := pm.PlayerStatus()
|
stats := pm.PlayerStatus()
|
||||||
trackIdx := pm.NowPlayingIndex()
|
trackIdx := pm.NowPlayingIndex()
|
||||||
|
|
||||||
trackIDs := make([]string, len(queue))
|
trackIDs := make([]string, 0, len(queue))
|
||||||
for i, tr := range queue {
|
for _, item := range queue {
|
||||||
trackIDs[i] = tr.ID
|
if _, ok := item.(*mediaprovider.Track); ok {
|
||||||
|
// don't save radio stations in play queue
|
||||||
|
trackIDs = append(trackIDs, item.Metadata().ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l := len(trackIDs); trackIdx >= l {
|
||||||
|
if l == 0 {
|
||||||
|
trackIdx = 0
|
||||||
|
} else {
|
||||||
|
trackIdx = l - 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saved := serializedSavedPlayQueue{
|
saved := serializedSavedPlayQueue{
|
||||||
|
|||||||
+1
-1
@@ -88,7 +88,7 @@ var ResFilterSvg = &fyne.StaticResource{
|
|||||||
var ResBroadcastSvg = &fyne.StaticResource{
|
var ResBroadcastSvg = &fyne.StaticResource{
|
||||||
StaticName: "broadcast.svg",
|
StaticName: "broadcast.svg",
|
||||||
StaticContent: []byte(
|
StaticContent: []byte(
|
||||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<svg width=\"800px\" height=\"800px\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n <path d=\"M4.92893 2.92896L6.34315 4.34317C4.89543 5.79088 4 7.79088 4 10C4 12.2092 4.89543 14.2092 6.34315 15.6569L4.92893 17.0711C3.11929 15.2614 2 12.7614 2 10C2 7.2386 3.11929 4.7386 4.92893 2.92896ZM19.0711 2.92896C20.8807 4.7386 22 7.2386 22 10C22 12.7614 20.8807 15.2614 19.0711 17.0711L17.6569 15.6569C19.1046 14.2092 20 12.2092 20 10C20 7.79088 19.1046 5.79088 17.6569 4.34317L19.0711 2.92896ZM7.75736 5.75738L9.17157 7.1716C8.44771 7.89545 8 8.89545 8 10C8 11.1046 8.44771 12.1046 9.17157 12.8285L7.75736 14.2427C6.67157 13.1569 6 11.6569 6 10C6 8.34317 6.67157 6.84317 7.75736 5.75738ZM16.2426 5.75738C17.3284 6.84317 18 8.34317 18 10C18 11.6569 17.3284 13.1569 16.2426 14.2427L14.8284 12.8285C15.5523 12.1046 16 11.1046 16 10C16 8.89545 15.5523 7.89545 14.8284 7.1716L16.2426 5.75738ZM12 12C10.8954 12 10 11.1046 10 10C10 8.89545 10.8954 8.00002 12 8.00002C13.1046 8.00002 14 8.89545 14 10C14 11.1046 13.1046 12 12 12ZM12 14C12.5798 14 13.0774 14.413 13.1843 14.9829L14.5 22H9.5L10.8157 14.9829C10.9226 14.413 11.4202 14 12 14Z\"/>\n</svg>"),
|
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<svg width=\"800px\" height=\"800px\" viewBox=\"2 2 22 22\" xmlns=\"http://www.w3.org/2000/svg\">\n <path d=\"M4.92893 2.92896L6.34315 4.34317C4.89543 5.79088 4 7.79088 4 10C4 12.2092 4.89543 14.2092 6.34315 15.6569L4.92893 17.0711C3.11929 15.2614 2 12.7614 2 10C2 7.2386 3.11929 4.7386 4.92893 2.92896ZM19.0711 2.92896C20.8807 4.7386 22 7.2386 22 10C22 12.7614 20.8807 15.2614 19.0711 17.0711L17.6569 15.6569C19.1046 14.2092 20 12.2092 20 10C20 7.79088 19.1046 5.79088 17.6569 4.34317L19.0711 2.92896ZM7.75736 5.75738L9.17157 7.1716C8.44771 7.89545 8 8.89545 8 10C8 11.1046 8.44771 12.1046 9.17157 12.8285L7.75736 14.2427C6.67157 13.1569 6 11.6569 6 10C6 8.34317 6.67157 6.84317 7.75736 5.75738ZM16.2426 5.75738C17.3284 6.84317 18 8.34317 18 10C18 11.6569 17.3284 13.1569 16.2426 14.2427L14.8284 12.8285C15.5523 12.1046 16 11.1046 16 10C16 8.89545 15.5523 7.89545 14.8284 7.1716L16.2426 5.75738ZM12 12C10.8954 12 10 11.1046 10 10C10 8.89545 10.8954 8.00002 12 8.00002C13.1046 8.00002 14 8.89545 14 10C14 11.1046 13.1046 12 12 12ZM12 14C12.5798 14 13.0774 14.413 13.1843 14.9829L14.5 22H9.5L10.8157 14.9829C10.9226 14.413 11.4202 14 12 14Z\"/>\n</svg>"),
|
||||||
}
|
}
|
||||||
var ResRepeatSvg = &fyne.StaticResource{
|
var ResRepeatSvg = &fyne.StaticResource{
|
||||||
StaticName: "repeat.svg",
|
StaticName: "repeat.svg",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<svg width="800px" height="800px" viewBox="2 2 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M4.92893 2.92896L6.34315 4.34317C4.89543 5.79088 4 7.79088 4 10C4 12.2092 4.89543 14.2092 6.34315 15.6569L4.92893 17.0711C3.11929 15.2614 2 12.7614 2 10C2 7.2386 3.11929 4.7386 4.92893 2.92896ZM19.0711 2.92896C20.8807 4.7386 22 7.2386 22 10C22 12.7614 20.8807 15.2614 19.0711 17.0711L17.6569 15.6569C19.1046 14.2092 20 12.2092 20 10C20 7.79088 19.1046 5.79088 17.6569 4.34317L19.0711 2.92896ZM7.75736 5.75738L9.17157 7.1716C8.44771 7.89545 8 8.89545 8 10C8 11.1046 8.44771 12.1046 9.17157 12.8285L7.75736 14.2427C6.67157 13.1569 6 11.6569 6 10C6 8.34317 6.67157 6.84317 7.75736 5.75738ZM16.2426 5.75738C17.3284 6.84317 18 8.34317 18 10C18 11.6569 17.3284 13.1569 16.2426 14.2427L14.8284 12.8285C15.5523 12.1046 16 11.1046 16 10C16 8.89545 15.5523 7.89545 14.8284 7.1716L16.2426 5.75738ZM12 12C10.8954 12 10 11.1046 10 10C10 8.89545 10.8954 8.00002 12 8.00002C13.1046 8.00002 14 8.89545 14 10C14 11.1046 13.1046 12 12 12ZM12 14C12.5798 14 13.0774 14.413 13.1843 14.9829L14.5 22H9.5L10.8157 14.9829C10.9226 14.413 11.4202 14 12 14Z"/>
|
<path d="M4.92893 2.92896L6.34315 4.34317C4.89543 5.79088 4 7.79088 4 10C4 12.2092 4.89543 14.2092 6.34315 15.6569L4.92893 17.0711C3.11929 15.2614 2 12.7614 2 10C2 7.2386 3.11929 4.7386 4.92893 2.92896ZM19.0711 2.92896C20.8807 4.7386 22 7.2386 22 10C22 12.7614 20.8807 15.2614 19.0711 17.0711L17.6569 15.6569C19.1046 14.2092 20 12.2092 20 10C20 7.79088 19.1046 5.79088 17.6569 4.34317L19.0711 2.92896ZM7.75736 5.75738L9.17157 7.1716C8.44771 7.89545 8 8.89545 8 10C8 11.1046 8.44771 12.1046 9.17157 12.8285L7.75736 14.2427C6.67157 13.1569 6 11.6569 6 10C6 8.34317 6.67157 6.84317 7.75736 5.75738ZM16.2426 5.75738C17.3284 6.84317 18 8.34317 18 10C18 11.6569 17.3284 13.1569 16.2426 14.2427L14.8284 12.8285C15.5523 12.1046 16 11.1046 16 10C16 8.89545 15.5523 7.89545 14.8284 7.1716L16.2426 5.75738ZM12 12C10.8954 12 10 11.1046 10 10C10 8.89545 10.8954 8.00002 12 8.00002C13.1046 8.00002 14 8.89545 14 10C14 11.1046 13.1046 12 12 12ZM12 14C12.5798 14 13.0774 14.413 13.1843 14.9829L14.5 22H9.5L10.8157 14.9829C10.9226 14.413 11.4202 14 12 14Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
+32
-20
@@ -74,11 +74,23 @@ func FindTrackByID(id string, tracks []*mediaprovider.Track) *mediaprovider.Trac
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TrackIDOrEmptyStr(track *mediaprovider.Track) string {
|
func FindMediaItemByID(id string, items []mediaprovider.MediaItem) mediaprovider.MediaItem {
|
||||||
if track == nil {
|
for _, tr := range items {
|
||||||
return ""
|
if id == tr.Metadata().ID {
|
||||||
|
return tr
|
||||||
}
|
}
|
||||||
return track.ID
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MediaItemIDOrEmptyStr(item mediaprovider.MediaItem) string {
|
||||||
|
if tr, ok := item.(*mediaprovider.Track); ok && tr != nil {
|
||||||
|
return tr.ID
|
||||||
|
}
|
||||||
|
if rd, ok := item.(*mediaprovider.RadioStation); ok && rd != nil {
|
||||||
|
return rd.ID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func AlbumIDOrEmptyStr(track *mediaprovider.Track) string {
|
func AlbumIDOrEmptyStr(track *mediaprovider.Track) string {
|
||||||
@@ -103,58 +115,58 @@ const (
|
|||||||
MoveDown
|
MoveDown
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reorder tracks and return a new track slice.
|
// Reorder items and return a new track slice.
|
||||||
// idxToMove must contain only valid indexes into tracks, and no repeats
|
// idxToMove must contain only valid indexes into tracks, and no repeats
|
||||||
func ReorderTracks(tracks []*mediaprovider.Track, idxToMove []int, op TrackReorderOp) []*mediaprovider.Track {
|
func ReorderItems[T any](items []T, idxToMove []int, op TrackReorderOp) []T {
|
||||||
newTracks := make([]*mediaprovider.Track, len(tracks))
|
newItems := make([]T, len(items))
|
||||||
switch op {
|
switch op {
|
||||||
case MoveToTop:
|
case MoveToTop:
|
||||||
topIdx := 0
|
topIdx := 0
|
||||||
botIdx := len(idxToMove)
|
botIdx := len(idxToMove)
|
||||||
idxToMoveSet := ToSet(idxToMove)
|
idxToMoveSet := ToSet(idxToMove)
|
||||||
for i, t := range tracks {
|
for i, t := range items {
|
||||||
if _, ok := idxToMoveSet[i]; ok {
|
if _, ok := idxToMoveSet[i]; ok {
|
||||||
newTracks[topIdx] = t
|
newItems[topIdx] = t
|
||||||
topIdx++
|
topIdx++
|
||||||
} else {
|
} else {
|
||||||
newTracks[botIdx] = t
|
newItems[botIdx] = t
|
||||||
botIdx++
|
botIdx++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case MoveToBottom:
|
case MoveToBottom:
|
||||||
topIdx := 0
|
topIdx := 0
|
||||||
botIdx := len(tracks) - len(idxToMove)
|
botIdx := len(items) - len(idxToMove)
|
||||||
idxToMoveSet := ToSet(idxToMove)
|
idxToMoveSet := ToSet(idxToMove)
|
||||||
for i, t := range tracks {
|
for i, t := range items {
|
||||||
if _, ok := idxToMoveSet[i]; ok {
|
if _, ok := idxToMoveSet[i]; ok {
|
||||||
newTracks[botIdx] = t
|
newItems[botIdx] = t
|
||||||
botIdx++
|
botIdx++
|
||||||
} else {
|
} else {
|
||||||
newTracks[topIdx] = t
|
newItems[topIdx] = t
|
||||||
topIdx++
|
topIdx++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case MoveUp:
|
case MoveUp:
|
||||||
first := firstIdxCanMoveUp(idxToMove)
|
first := firstIdxCanMoveUp(idxToMove)
|
||||||
copy(newTracks, tracks)
|
copy(newItems, items)
|
||||||
for _, i := range idxToMove {
|
for _, i := range idxToMove {
|
||||||
if i < first {
|
if i < first {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
newTracks[i-1], newTracks[i] = newTracks[i], newTracks[i-1]
|
newItems[i-1], newItems[i] = newItems[i], newItems[i-1]
|
||||||
}
|
}
|
||||||
case MoveDown:
|
case MoveDown:
|
||||||
last := lastIdxCanMoveDown(idxToMove, len(tracks))
|
last := lastIdxCanMoveDown(idxToMove, len(items))
|
||||||
copy(newTracks, tracks)
|
copy(newItems, items)
|
||||||
for i := len(idxToMove) - 1; i >= 0; i-- {
|
for i := len(idxToMove) - 1; i >= 0; i-- {
|
||||||
idx := idxToMove[i]
|
idx := idxToMove[i]
|
||||||
if idx > last {
|
if idx > last {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
newTracks[idx+1], newTracks[idx] = newTracks[idx], newTracks[idx+1]
|
newItems[idx+1], newItems[idx] = newItems[idx], newItems[idx+1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newTracks
|
return newItems
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstIdxCanMoveUp(idxs []int) int {
|
func firstIdxCanMoveUp(idxs []int) int {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_ReorderTracks(t *testing.T) {
|
func Test_ReorderItems(t *testing.T) {
|
||||||
tracks := []*mediaprovider.Track{
|
tracks := []*mediaprovider.Track{
|
||||||
{ID: "a"}, // 0
|
{ID: "a"}, // 0
|
||||||
{ID: "b"}, // 1
|
{ID: "b"}, // 1
|
||||||
@@ -27,7 +27,7 @@ func Test_ReorderTracks(t *testing.T) {
|
|||||||
{ID: "b"},
|
{ID: "b"},
|
||||||
{ID: "e"},
|
{ID: "e"},
|
||||||
}
|
}
|
||||||
newTracks := ReorderTracks(tracks, idxToMove, MoveToTop)
|
newTracks := ReorderItems(tracks, idxToMove, MoveToTop)
|
||||||
if !tracklistsEqual(t, newTracks, want) {
|
if !tracklistsEqual(t, newTracks, want) {
|
||||||
t.Error("ReorderTracks: MoveToTop order incorrect")
|
t.Error("ReorderTracks: MoveToTop order incorrect")
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ func Test_ReorderTracks(t *testing.T) {
|
|||||||
{ID: "c"},
|
{ID: "c"},
|
||||||
{ID: "f"},
|
{ID: "f"},
|
||||||
}
|
}
|
||||||
newTracks = ReorderTracks(tracks, idxToMove, MoveToBottom)
|
newTracks = ReorderItems(tracks, idxToMove, MoveToBottom)
|
||||||
if !tracklistsEqual(t, newTracks, want) {
|
if !tracklistsEqual(t, newTracks, want) {
|
||||||
t.Error("ReorderTracks: MoveToBottom order incorrect")
|
t.Error("ReorderTracks: MoveToBottom order incorrect")
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func Test_ReorderTracks(t *testing.T) {
|
|||||||
{ID: "f"},
|
{ID: "f"},
|
||||||
{ID: "e"},
|
{ID: "e"},
|
||||||
}
|
}
|
||||||
newTracks = ReorderTracks(tracks, idxToMove, MoveUp)
|
newTracks = ReorderItems(tracks, idxToMove, MoveUp)
|
||||||
if !tracklistsEqual(t, newTracks, want) {
|
if !tracklistsEqual(t, newTracks, want) {
|
||||||
t.Error("ReorderTracks: MoveUp order incorrect")
|
t.Error("ReorderTracks: MoveUp order incorrect")
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ func Test_ReorderTracks(t *testing.T) {
|
|||||||
{ID: "e"},
|
{ID: "e"},
|
||||||
{ID: "f"},
|
{ID: "f"},
|
||||||
}
|
}
|
||||||
newTracks = ReorderTracks(tracks, idxToMove, MoveDown)
|
newTracks = ReorderItems(tracks, idxToMove, MoveDown)
|
||||||
if !tracklistsEqual(t, newTracks, want) {
|
if !tracklistsEqual(t, newTracks, want) {
|
||||||
t.Error("ReorderTracks: MoveDown order incorrect")
|
t.Error("ReorderTracks: MoveDown order incorrect")
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-24
@@ -1,13 +1,11 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dweymouth/supersonic/backend"
|
"github.com/dweymouth/supersonic/backend"
|
||||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
"github.com/dweymouth/supersonic/ui/controller"
|
"github.com/dweymouth/supersonic/ui/controller"
|
||||||
"github.com/dweymouth/supersonic/ui/layouts"
|
"github.com/dweymouth/supersonic/ui/layouts"
|
||||||
|
"github.com/dweymouth/supersonic/ui/util"
|
||||||
"github.com/dweymouth/supersonic/ui/widgets"
|
"github.com/dweymouth/supersonic/ui/widgets"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
@@ -18,19 +16,18 @@ import (
|
|||||||
type BottomPanel struct {
|
type BottomPanel struct {
|
||||||
widget.BaseWidget
|
widget.BaseWidget
|
||||||
|
|
||||||
ImageManager *backend.ImageManager
|
imageLoader util.ThumbnailLoader
|
||||||
|
|
||||||
NowPlaying *widgets.NowPlayingCard
|
NowPlaying *widgets.NowPlayingCard
|
||||||
Controls *widgets.PlayerControls
|
Controls *widgets.PlayerControls
|
||||||
AuxControls *widgets.AuxControls
|
AuxControls *widgets.AuxControls
|
||||||
|
|
||||||
coverArtID string
|
|
||||||
container *fyne.Container
|
container *fyne.Container
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ fyne.Widget = (*BottomPanel)(nil)
|
var _ fyne.Widget = (*BottomPanel)(nil)
|
||||||
|
|
||||||
func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) *BottomPanel {
|
func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr *controller.Controller) *BottomPanel {
|
||||||
bp := &BottomPanel{}
|
bp := &BottomPanel{}
|
||||||
bp.ExtendBaseWidget(bp)
|
bp.ExtendBaseWidget(bp)
|
||||||
|
|
||||||
@@ -56,22 +53,30 @@ func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) *
|
|||||||
contr.NavigateTo(controller.NowPlayingRoute(""))
|
contr.NavigateTo(controller.NowPlayingRoute(""))
|
||||||
}
|
}
|
||||||
bp.NowPlaying.OnSetFavorite = func(fav bool) {
|
bp.NowPlaying.OnSetFavorite = func(fav bool) {
|
||||||
contr.SetTrackFavorites([]string{pm.NowPlaying().ID}, fav)
|
if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok {
|
||||||
|
contr.SetTrackFavorites([]string{tr.ID}, fav)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
bp.NowPlaying.OnSetRating = func(rating int) {
|
bp.NowPlaying.OnSetRating = func(rating int) {
|
||||||
contr.SetTrackRatings([]string{pm.NowPlaying().ID}, rating)
|
if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok {
|
||||||
|
contr.SetTrackRatings([]string{tr.ID}, rating)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
bp.NowPlaying.OnAddToPlaylist = func() {
|
bp.NowPlaying.OnAddToPlaylist = func() {
|
||||||
contr.DoAddTracksToPlaylistWorkflow([]string{pm.NowPlaying().ID})
|
if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok {
|
||||||
|
contr.DoAddTracksToPlaylistWorkflow([]string{tr.ID})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
bp.NowPlaying.OnAlbumNameTapped = func() {
|
bp.NowPlaying.OnAlbumNameTapped = func() {
|
||||||
contr.NavigateTo(controller.AlbumRoute(pm.NowPlaying().AlbumID))
|
if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok {
|
||||||
|
contr.NavigateTo(controller.AlbumRoute(tr.AlbumID))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
bp.NowPlaying.OnArtistNameTapped = func(artistID string) {
|
bp.NowPlaying.OnArtistNameTapped = func(artistID string) {
|
||||||
contr.NavigateTo(controller.ArtistRoute(artistID))
|
contr.NavigateTo(controller.ArtistRoute(artistID))
|
||||||
}
|
}
|
||||||
bp.NowPlaying.OnTrackNameTapped = func() {
|
bp.NowPlaying.OnTrackNameTapped = func() {
|
||||||
contr.NavigateTo(controller.NowPlayingRoute(pm.NowPlaying().ID))
|
contr.NavigateTo(controller.NowPlayingRoute(pm.NowPlaying().Metadata().ID))
|
||||||
}
|
}
|
||||||
bp.Controls = widgets.NewPlayerControls()
|
bp.Controls = widgets.NewPlayerControls()
|
||||||
bp.Controls.OnPlayPause(func() {
|
bp.Controls.OnPlayPause(func() {
|
||||||
@@ -97,26 +102,20 @@ func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) *
|
|||||||
pm.SetNextLoopMode()
|
pm.SetNextLoopMode()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage)
|
||||||
|
|
||||||
bp.container = container.New(layouts.NewLeftMiddleRightLayout(500),
|
bp.container = container.New(layouts.NewLeftMiddleRightLayout(500),
|
||||||
bp.NowPlaying, bp.Controls, bp.AuxControls)
|
bp.NowPlaying, bp.Controls, bp.AuxControls)
|
||||||
return bp
|
return bp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bp *BottomPanel) onSongChange(song, _ *mediaprovider.Track) {
|
func (bp *BottomPanel) onSongChange(song mediaprovider.MediaItem, _ *mediaprovider.Track) {
|
||||||
if song == nil {
|
if song == nil {
|
||||||
bp.NowPlaying.Update(nil, nil)
|
bp.NowPlaying.Update(nil)
|
||||||
|
bp.imageLoader.Load("")
|
||||||
} else {
|
} else {
|
||||||
bp.coverArtID = song.CoverArtID
|
bp.NowPlaying.Update(song)
|
||||||
var im image.Image
|
bp.imageLoader.Load(song.Metadata().CoverArtID)
|
||||||
if bp.ImageManager != nil {
|
|
||||||
// set image to expire not long after the length of the song
|
|
||||||
// if song is played through without much pausing, image will still
|
|
||||||
// be in cache for the next song if it's from the same album, or
|
|
||||||
// if the user navigates to the album page for the track
|
|
||||||
imgTTLSec := song.Duration + 30
|
|
||||||
im, _ = bp.ImageManager.GetCoverThumbnailWithTTL(song.CoverArtID, time.Duration(imgTTLSec)*time.Second)
|
|
||||||
}
|
|
||||||
bp.NowPlaying.Update(song, im)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,10 +130,12 @@ func (a *AlbumPage) Route() controller.Route {
|
|||||||
return controller.AlbumRoute(a.albumID)
|
return controller.AlbumRoute(a.albumID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AlbumPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) {
|
var _ CanShowNowPlaying = (*AlbumPage)(nil)
|
||||||
a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track)
|
|
||||||
|
func (a *AlbumPage) OnSongChange(track mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
|
||||||
|
a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(track)
|
||||||
a.tracklist.SetNowPlaying(a.nowPlayingID)
|
a.tracklist.SetNowPlaying(a.nowPlayingID)
|
||||||
a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny))
|
a.tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AlbumPage) Reload() {
|
func (a *AlbumPage) Reload() {
|
||||||
|
|||||||
@@ -139,12 +139,12 @@ func (a *ArtistPage) Save() SavedPage {
|
|||||||
|
|
||||||
var _ CanShowNowPlaying = (*ArtistPage)(nil)
|
var _ CanShowNowPlaying = (*ArtistPage)(nil)
|
||||||
|
|
||||||
func (a *ArtistPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) {
|
func (a *ArtistPage) OnSongChange(track mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
|
||||||
a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track)
|
a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(track)
|
||||||
if a.tracklistCtr != nil {
|
if a.tracklistCtr != nil {
|
||||||
tl := a.tracklistCtr.Objects[0].(*widgets.Tracklist)
|
tl := a.tracklistCtr.Objects[0].(*widgets.Tracklist)
|
||||||
tl.SetNowPlaying(a.nowPlayingID)
|
tl.SetNowPlaying(a.nowPlayingID)
|
||||||
tl.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny))
|
tl.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ type Scrollable interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CanShowNowPlaying interface {
|
type CanShowNowPlaying interface {
|
||||||
OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track)
|
OnSongChange(playing mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track)
|
||||||
}
|
}
|
||||||
|
|
||||||
type CanShowPlayTime interface {
|
type CanShowPlayTime interface {
|
||||||
@@ -140,11 +140,13 @@ func (b *BrowsingPane) AddSettingsMenuSeparator() {
|
|||||||
fyne.NewMenuItemSeparator())
|
fyne.NewMenuItemSeparator())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *BrowsingPane) AddNavigationButton(icon fyne.Resource, pageName controller.PageName, action func()) {
|
func (b *BrowsingPane) AddNavigationButton(icon fyne.Resource, pageName controller.PageName, action func()) *widget.Button {
|
||||||
// make a copy of the icon, because it can change the color
|
// make a copy of the icon, because it can change the color
|
||||||
browsingPaneIcon := theme.NewThemedResource(icon)
|
browsingPaneIcon := theme.NewThemedResource(icon)
|
||||||
b.navBtnsContainer.Add(widget.NewButtonWithIcon("", browsingPaneIcon, action))
|
btn := widget.NewButtonWithIcon("", browsingPaneIcon, action)
|
||||||
|
b.navBtnsContainer.Add(btn)
|
||||||
b.navBtnsPageMap[pageName] = browsingPaneIcon
|
b.navBtnsPageMap[pageName] = browsingPaneIcon
|
||||||
|
return btn
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *BrowsingPane) DisableNavigationButtons() {
|
func (b *BrowsingPane) DisableNavigationButtons() {
|
||||||
@@ -162,7 +164,7 @@ func (b *BrowsingPane) EnableNavigationButtons() {
|
|||||||
func (b *BrowsingPane) ActivateNavigationButton(num int) {
|
func (b *BrowsingPane) ActivateNavigationButton(num int) {
|
||||||
if num < len(b.navBtnsContainer.Objects) {
|
if num < len(b.navBtnsContainer.Objects) {
|
||||||
btn := b.navBtnsContainer.Objects[num].(*widget.Button)
|
btn := b.navBtnsContainer.Objects[num].(*widget.Button)
|
||||||
if !btn.Disabled() {
|
if !btn.Disabled() && !btn.Hidden {
|
||||||
btn.OnTapped()
|
btn.OnTapped()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +215,7 @@ func (b *BrowsingPane) doSetPage(p Page) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *BrowsingPane) onSongChange(song, lastScrobbledIfAny *mediaprovider.Track) {
|
func (b *BrowsingPane) onSongChange(song mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
|
||||||
if b.curPage == nil {
|
if b.curPage == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,11 +269,11 @@ func (a *FavoritesPage) OnSearched(query string) {
|
|||||||
|
|
||||||
var _ CanShowNowPlaying = (*FavoritesPage)(nil)
|
var _ CanShowNowPlaying = (*FavoritesPage)(nil)
|
||||||
|
|
||||||
func (a *FavoritesPage) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track) {
|
func (a *FavoritesPage) OnSongChange(item mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
|
||||||
a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(song)
|
a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(item)
|
||||||
if tracklist := a.tracklistOrNil(); tracklist != nil {
|
if tracklist := a.tracklistOrNil(); tracklist != nil {
|
||||||
tracklist.SetNowPlaying(a.nowPlayingID)
|
tracklist.SetNowPlaying(a.nowPlayingID)
|
||||||
tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny))
|
tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"log"
|
"log"
|
||||||
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -37,13 +38,13 @@ type NowPlayingPage struct {
|
|||||||
nowPlayingPageState
|
nowPlayingPageState
|
||||||
|
|
||||||
// volatile state
|
// volatile state
|
||||||
nowPlaying *mediaprovider.Track
|
nowPlaying mediaprovider.MediaItem
|
||||||
nowPlayingID string
|
nowPlayingID string
|
||||||
curLyricsID string // id of track currently shown in lyrics
|
curLyricsID string // id of track currently shown in lyrics
|
||||||
curRelatedID string // id of track currrently used to populate related list
|
curRelatedID string // id of track currrently used to populate related list
|
||||||
totalTime float64
|
totalTime float64
|
||||||
lastPlayPos float64
|
lastPlayPos float64
|
||||||
queue []*mediaprovider.Track
|
queue []mediaprovider.MediaItem
|
||||||
related []*mediaprovider.Track
|
related []*mediaprovider.Track
|
||||||
|
|
||||||
lyricLock sync.Mutex
|
lyricLock sync.Mutex
|
||||||
@@ -108,11 +109,16 @@ func NewNowPlayingPage(
|
|||||||
|
|
||||||
a.card = widgets.NewLargeNowPlayingCard()
|
a.card = widgets.NewLargeNowPlayingCard()
|
||||||
a.card.OnAlbumNameTapped = func() {
|
a.card.OnAlbumNameTapped = func() {
|
||||||
contr.NavigateTo(controller.AlbumRoute(sharedutil.AlbumIDOrEmptyStr(a.nowPlaying)))
|
contr.NavigateTo(controller.AlbumRoute(a.nowPlaying.Metadata().AlbumID))
|
||||||
}
|
}
|
||||||
a.card.OnArtistNameTapped = func(artistID string) {
|
a.card.OnArtistNameTapped = func(artistID string) {
|
||||||
contr.NavigateTo(controller.ArtistRoute(artistID))
|
contr.NavigateTo(controller.ArtistRoute(artistID))
|
||||||
}
|
}
|
||||||
|
a.card.OnRadioURLTapped = func(urlText string) {
|
||||||
|
if u, err := url.Parse(urlText); err == nil {
|
||||||
|
fyne.CurrentApp().OpenURL(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
a.card.OnSetFavorite = func(fav bool) {
|
a.card.OnSetFavorite = func(fav bool) {
|
||||||
a.contr.SetTrackFavorites([]string{a.nowPlayingID}, fav)
|
a.contr.SetTrackFavorites([]string{a.nowPlayingID}, fav)
|
||||||
}
|
}
|
||||||
@@ -122,7 +128,7 @@ func NewNowPlayingPage(
|
|||||||
|
|
||||||
a.queueList = widgets.NewPlayQueueList(a.im)
|
a.queueList = widgets.NewPlayQueueList(a.im)
|
||||||
a.relatedList = widgets.NewPlayQueueList(a.im)
|
a.relatedList = widgets.NewPlayQueueList(a.im)
|
||||||
a.queueList.OnReorderTracks = a.doSetNewTrackOrder
|
a.queueList.OnReorderItems = a.doSetNewTrackOrder
|
||||||
a.queueList.OnDownload = contr.ShowDownloadDialog
|
a.queueList.OnDownload = contr.ShowDownloadDialog
|
||||||
a.queueList.OnShare = func(tracks []*mediaprovider.Track) {
|
a.queueList.OnShare = func(tracks []*mediaprovider.Track) {
|
||||||
if len(tracks) > 0 {
|
if len(tracks) > 0 {
|
||||||
@@ -245,12 +251,12 @@ func (a *NowPlayingPage) Route() controller.Route {
|
|||||||
|
|
||||||
var _ CanShowNowPlaying = (*NowPlayingPage)(nil)
|
var _ CanShowNowPlaying = (*NowPlayingPage)(nil)
|
||||||
|
|
||||||
func (a *NowPlayingPage) OnSongChange(song, _ *mediaprovider.Track) {
|
func (a *NowPlayingPage) OnSongChange(song mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
|
||||||
a.nowPlaying = song
|
a.nowPlaying = song
|
||||||
if a.imageLoadCancel != nil {
|
if a.imageLoadCancel != nil {
|
||||||
a.imageLoadCancel()
|
a.imageLoadCancel()
|
||||||
}
|
}
|
||||||
a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(song)
|
a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(song)
|
||||||
a.queueList.SetNowPlaying(a.nowPlayingID)
|
a.queueList.SetNowPlaying(a.nowPlayingID)
|
||||||
a.relatedList.SetNowPlaying(a.nowPlayingID)
|
a.relatedList.SetNowPlaying(a.nowPlayingID)
|
||||||
|
|
||||||
@@ -258,7 +264,7 @@ func (a *NowPlayingPage) OnSongChange(song, _ *mediaprovider.Track) {
|
|||||||
if song == nil {
|
if song == nil {
|
||||||
a.card.SetCoverImage(nil)
|
a.card.SetCoverImage(nil)
|
||||||
} else {
|
} else {
|
||||||
a.imageLoadCancel = a.im.GetFullSizeCoverArtAsync(song.CoverArtID, a.onImageLoaded)
|
a.imageLoadCancel = a.im.GetFullSizeCoverArtAsync(song.Metadata().CoverArtID, a.onImageLoaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.tabs != nil && a.tabs.SelectedIndex() == 1 /*lyrics*/ {
|
if a.tabs != nil && a.tabs.SelectedIndex() == 1 /*lyrics*/ {
|
||||||
@@ -273,6 +279,9 @@ func (a *NowPlayingPage) onImageLoaded(img image.Image, err error) {
|
|||||||
log.Printf("error loading cover art: %v\n", err)
|
log.Printf("error loading cover art: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if img == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
a.card.SetCoverImage(img)
|
a.card.SetCoverImage(img)
|
||||||
c := dominantcolor.Find(img)
|
c := dominantcolor.Find(img)
|
||||||
if c == a.background.StartColor {
|
if c == a.background.StartColor {
|
||||||
@@ -308,7 +317,7 @@ func (a *NowPlayingPage) updateLyrics() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if a.nowPlaying == nil {
|
if a.nowPlaying == nil || a.nowPlaying.Metadata().Type == mediaprovider.MediaItemTypeRadioStation {
|
||||||
a.lyricsViewer.SetLyrics(nil)
|
a.lyricsViewer.SetLyrics(nil)
|
||||||
a.curLyricsID = ""
|
a.curLyricsID = ""
|
||||||
return
|
return
|
||||||
@@ -321,19 +330,20 @@ func (a *NowPlayingPage) updateLyrics() {
|
|||||||
// to keep it from showing "Lyrics not available"
|
// to keep it from showing "Lyrics not available"
|
||||||
a.lyricsViewer.SetLyrics(&mediaprovider.Lyrics{Synced: true,
|
a.lyricsViewer.SetLyrics(&mediaprovider.Lyrics{Synced: true,
|
||||||
Lines: []mediaprovider.LyricLine{{Text: ""}}})
|
Lines: []mediaprovider.LyricLine{{Text: ""}}})
|
||||||
go a.fetchLyrics(ctx, a.nowPlaying)
|
tr, _ := a.nowPlaying.(*mediaprovider.Track)
|
||||||
|
go a.fetchLyrics(ctx, tr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Track) {
|
func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Track) {
|
||||||
var lyrics *mediaprovider.Lyrics
|
var lyrics *mediaprovider.Lyrics
|
||||||
var err error
|
var err error
|
||||||
if lp, ok := a.sm.Server.(mediaprovider.LyricsProvider); ok {
|
if lp, ok := a.sm.Server.(mediaprovider.LyricsProvider); ok {
|
||||||
if lyrics, err = lp.GetLyrics(a.nowPlaying); err != nil {
|
if lyrics, err = lp.GetLyrics(song); err != nil {
|
||||||
log.Printf("Error fetching lyrics: %v", err)
|
log.Printf("Error fetching lyrics: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if lyrics == nil {
|
if lyrics == nil {
|
||||||
lyrics, err = backend.FetchLrcLibLyrics(song.Name, song.ArtistNames[0], song.Album, song.Duration)
|
lyrics, err = backend.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, song.Duration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err.Error())
|
log.Println(err.Error())
|
||||||
}
|
}
|
||||||
@@ -401,10 +411,10 @@ func (a *NowPlayingPage) Reload() {
|
|||||||
a.relatedList.DisableSharing = !a.canShare
|
a.relatedList.DisableSharing = !a.canShare
|
||||||
|
|
||||||
a.queue = a.pm.GetPlayQueue()
|
a.queue = a.pm.GetPlayQueue()
|
||||||
a.queueList.SetTracks(a.queue)
|
a.queueList.SetItems(a.queue)
|
||||||
a.totalTime = 0.0
|
a.totalTime = 0.0
|
||||||
for _, tr := range a.queue {
|
for _, tr := range a.queue {
|
||||||
a.totalTime += float64(tr.Duration)
|
a.totalTime += float64(tr.Metadata().Duration)
|
||||||
}
|
}
|
||||||
a.formatStatusLine()
|
a.formatStatusLine()
|
||||||
}
|
}
|
||||||
@@ -457,11 +467,11 @@ func (a *NowPlayingPage) doSetNewTrackOrder(trackIDs []string, op sharedutil.Tra
|
|||||||
trackIDSet := sharedutil.ToSet(trackIDs)
|
trackIDSet := sharedutil.ToSet(trackIDs)
|
||||||
idxs := make([]int, 0, len(trackIDs))
|
idxs := make([]int, 0, len(trackIDs))
|
||||||
for i, tr := range a.queue {
|
for i, tr := range a.queue {
|
||||||
if _, ok := trackIDSet[tr.ID]; ok {
|
if _, ok := trackIDSet[tr.Metadata().ID]; ok {
|
||||||
idxs = append(idxs, i)
|
idxs = append(idxs, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
newTracks := sharedutil.ReorderTracks(a.queue, idxs, op)
|
newTracks := sharedutil.ReorderItems(a.queue, idxs, op)
|
||||||
a.pm.UpdatePlayQueue(newTracks)
|
a.pm.UpdatePlayQueue(newTracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,7 +502,7 @@ func (a *NowPlayingPage) formatStatusLine() {
|
|||||||
|
|
||||||
dur := 0.0
|
dur := 0.0
|
||||||
if np := a.pm.NowPlaying(); np != nil {
|
if np := a.pm.NowPlaying(); np != nil {
|
||||||
dur = float64(np.Duration)
|
dur = float64(np.Metadata().Duration)
|
||||||
}
|
}
|
||||||
statusSuffix := ""
|
statusSuffix := ""
|
||||||
trackNum := 0
|
trackNum := 0
|
||||||
|
|||||||
@@ -126,10 +126,12 @@ func (a *PlaylistPage) Route() controller.Route {
|
|||||||
return controller.PlaylistRoute(a.playlistID)
|
return controller.PlaylistRoute(a.playlistID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *PlaylistPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) {
|
var _ CanShowNowPlaying = (*PlaylistPage)(nil)
|
||||||
a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track)
|
|
||||||
|
func (a *PlaylistPage) OnSongChange(item mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
|
||||||
|
a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(item)
|
||||||
a.tracklist.SetNowPlaying(a.nowPlayingID)
|
a.tracklist.SetNowPlaying(a.nowPlayingID)
|
||||||
a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny))
|
a.tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *PlaylistPage) Reload() {
|
func (a *PlaylistPage) Reload() {
|
||||||
@@ -187,7 +189,7 @@ func (a *PlaylistPage) doSetNewTrackOrder(op sharedutil.TrackReorderOp) {
|
|||||||
idxs = append(idxs, i)
|
idxs = append(idxs, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
newTracks := sharedutil.ReorderTracks(a.tracks, idxs, op)
|
newTracks := sharedutil.ReorderItems(a.tracks, idxs, op)
|
||||||
ids := sharedutil.TracksToIDs(newTracks)
|
ids := sharedutil.TracksToIDs(newTracks)
|
||||||
if err := a.sm.Server.ReplacePlaylistTracks(a.playlistID, ids); err != nil {
|
if err := a.sm.Server.ReplacePlaylistTracks(a.playlistID, ids); err != nil {
|
||||||
log.Printf("error updating playlist: %s", err.Error())
|
log.Printf("error updating playlist: %s", err.Error())
|
||||||
|
|||||||
@@ -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)
|
return NewPlaylistsPage(r.Controller, r.widgetPool, &r.App.Config.PlaylistsPage, r.App.ServerManager.Server)
|
||||||
case controller.Tracks:
|
case controller.Tracks:
|
||||||
return NewTracksPage(r.Controller, &r.App.Config.TracksPage, r.widgetPool, r.App.ServerManager.Server, r.App.ImageManager)
|
return NewTracksPage(r.Controller, &r.App.Config.TracksPage, r.widgetPool, r.App.ServerManager.Server, r.App.ImageManager)
|
||||||
|
case controller.Radios:
|
||||||
|
var rp mediaprovider.RadioProvider
|
||||||
|
rp, _ = r.App.ServerManager.Server.(mediaprovider.RadioProvider)
|
||||||
|
return NewRadiosPage(r.Controller, rp, r.App.PlaybackManager)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,13 +95,15 @@ func (t *TracksPage) Reload() {
|
|||||||
t.loader = widgets.NewTracklistLoader(t.tracklist, iter)
|
t.loader = widgets.NewTracklistLoader(t.tracklist, iter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TracksPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) {
|
var _ CanShowNowPlaying = (*TracksPage)(nil)
|
||||||
t.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track)
|
|
||||||
|
func (t *TracksPage) OnSongChange(item mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) {
|
||||||
|
t.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(item)
|
||||||
t.tracklist.SetNowPlaying(t.nowPlayingID)
|
t.tracklist.SetNowPlaying(t.nowPlayingID)
|
||||||
if t.searchTracklist != nil {
|
if t.searchTracklist != nil {
|
||||||
t.searchTracklist.SetNowPlaying(t.nowPlayingID)
|
t.searchTracklist.SetNowPlaying(t.nowPlayingID)
|
||||||
}
|
}
|
||||||
playedID := sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)
|
playedID := sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny)
|
||||||
t.tracklist.IncrementPlayCount(playedID)
|
t.tracklist.IncrementPlayCount(playedID)
|
||||||
if t.searchTracklist != nil {
|
if t.searchTracklist != nil {
|
||||||
t.searchTracklist.IncrementPlayCount(playedID)
|
t.searchTracklist.IncrementPlayCount(playedID)
|
||||||
|
|||||||
@@ -827,8 +827,8 @@ func (c *Controller) downloadTrack(track *mediaprovider.Track, filePath string)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Saved song %s to: %s\n", track.Name, filePath)
|
log.Printf("Saved song %s to: %s\n", track.Title, filePath)
|
||||||
c.sendNotification(fmt.Sprintf("Download completed: %s", track.Name), fmt.Sprintf("Saved at: %s", filePath))
|
c.sendNotification(fmt.Sprintf("Download completed: %s", track.Title), fmt.Sprintf("Saved at: %s", filePath))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) downloadTracks(tracks []*mediaprovider.Track, filePath, downloadName string) {
|
func (c *Controller) downloadTracks(tracks []*mediaprovider.Track, filePath, downloadName string) {
|
||||||
@@ -863,7 +863,7 @@ func (c *Controller) downloadTracks(tracks []*mediaprovider.Track, filePath, dow
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Saved song %s to: %s\n", track.Name, filePath)
|
log.Printf("Saved song %s to: %s\n", track.Title, filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.sendNotification(fmt.Sprintf("Download completed: %s", downloadName), fmt.Sprintf("Saved at: %s", filePath))
|
c.sendNotification(fmt.Sprintf("Download completed: %s", downloadName), fmt.Sprintf("Saved at: %s", filePath))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const (
|
|||||||
Playlist
|
Playlist
|
||||||
Playlists
|
Playlists
|
||||||
Tracks
|
Tracks
|
||||||
|
Radios
|
||||||
)
|
)
|
||||||
|
|
||||||
type Route struct {
|
type Route struct {
|
||||||
@@ -61,6 +62,10 @@ func ArtistsRoute() Route {
|
|||||||
return Route{Page: Artists}
|
return Route{Page: Artists}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RadiosRoute() Route {
|
||||||
|
return Route{Page: Radios}
|
||||||
|
}
|
||||||
|
|
||||||
func NowPlayingRoute(highlightedTrackID string) Route {
|
func NowPlayingRoute(highlightedTrackID string) Route {
|
||||||
return Route{Page: NowPlaying, Arg: highlightedTrackID}
|
return Route{Page: NowPlaying, Arg: highlightedTrackID}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ func (s *searchResult) Update(result *mediaprovider.SearchResult) {
|
|||||||
}
|
}
|
||||||
s.id = result.ID
|
s.id = result.ID
|
||||||
s.contentType = result.Type
|
s.contentType = result.Type
|
||||||
s.image.CenterIcon = placeholderIconForContentType(result.Type)
|
s.image.PlaceholderIcon = placeholderIconForContentType(result.Type)
|
||||||
s.imageLoader.Load(result.CoverID)
|
s.imageLoader.Load(result.CoverID)
|
||||||
s.title.SetText(result.Name)
|
s.title.SetText(result.Name)
|
||||||
|
|
||||||
|
|||||||
+23
-8
@@ -36,9 +36,10 @@ var (
|
|||||||
ShortcutNavFive = desktop.CustomShortcut{KeyName: fyne.Key5, Modifier: os.ControlModifier}
|
ShortcutNavFive = desktop.CustomShortcut{KeyName: fyne.Key5, Modifier: os.ControlModifier}
|
||||||
ShortcutNavSix = desktop.CustomShortcut{KeyName: fyne.Key6, Modifier: os.ControlModifier}
|
ShortcutNavSix = desktop.CustomShortcut{KeyName: fyne.Key6, Modifier: os.ControlModifier}
|
||||||
ShortcutNavSeven = desktop.CustomShortcut{KeyName: fyne.Key7, Modifier: os.ControlModifier}
|
ShortcutNavSeven = desktop.CustomShortcut{KeyName: fyne.Key7, Modifier: os.ControlModifier}
|
||||||
|
ShortcutNavEight = desktop.CustomShortcut{KeyName: fyne.Key8, Modifier: os.ControlModifier}
|
||||||
|
|
||||||
NavShortcuts = []desktop.CustomShortcut{ShortcutNavOne, ShortcutNavTwo, ShortcutNavThree,
|
NavShortcuts = []desktop.CustomShortcut{ShortcutNavOne, ShortcutNavTwo, ShortcutNavThree,
|
||||||
ShortcutNavFour, ShortcutNavFive, ShortcutNavSix, ShortcutNavSeven}
|
ShortcutNavFour, ShortcutNavFive, ShortcutNavSix, ShortcutNavSeven, ShortcutNavEight}
|
||||||
)
|
)
|
||||||
|
|
||||||
type MainWindow struct {
|
type MainWindow struct {
|
||||||
@@ -53,6 +54,9 @@ type MainWindow struct {
|
|||||||
theme *theme.MyTheme
|
theme *theme.MyTheme
|
||||||
haveSystemTray bool
|
haveSystemTray bool
|
||||||
container *fyne.Container
|
container *fyne.Container
|
||||||
|
|
||||||
|
// needs to bes shown/hidden when switching between servers based on whether they support radio
|
||||||
|
radioBtn *widget.Button
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, app *backend.App, size fyne.Size) MainWindow {
|
func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, app *backend.App, size fyne.Size) MainWindow {
|
||||||
@@ -81,22 +85,25 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
|
|||||||
m.Controller.ReloadFunc = m.BrowsingPane.Reload
|
m.Controller.ReloadFunc = m.BrowsingPane.Reload
|
||||||
m.Controller.CurPageFunc = m.BrowsingPane.CurrentPage
|
m.Controller.CurPageFunc = m.BrowsingPane.CurrentPage
|
||||||
|
|
||||||
m.BottomPanel = NewBottomPanel(app.PlaybackManager, m.Controller)
|
m.BottomPanel = NewBottomPanel(app.PlaybackManager, app.ImageManager, m.Controller)
|
||||||
m.BottomPanel.ImageManager = app.ImageManager
|
|
||||||
m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane)
|
m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane)
|
||||||
m.Window.SetContent(m.container)
|
m.Window.SetContent(m.container)
|
||||||
m.Window.Resize(size)
|
m.Window.Resize(size)
|
||||||
app.PlaybackManager.OnSongChange(func(track, _ *mediaprovider.Track) {
|
app.PlaybackManager.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) {
|
||||||
if track == nil {
|
if item == nil {
|
||||||
m.Window.SetTitle(displayAppName)
|
m.Window.SetTitle(displayAppName)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
artistDisp := strings.Join(track.ArtistNames, ", ")
|
meta := item.Metadata()
|
||||||
m.Window.SetTitle(fmt.Sprintf("%s – %s · %s", track.Name, artistDisp, displayAppName))
|
artistDisp := ""
|
||||||
|
if tr, ok := item.(*mediaprovider.Track); ok {
|
||||||
|
artistDisp = " – " + strings.Join(tr.ArtistNames, ", ")
|
||||||
|
}
|
||||||
|
m.Window.SetTitle(fmt.Sprintf("%s%s · %s", meta.Name, artistDisp, displayAppName))
|
||||||
if m.App.Config.Application.ShowTrackChangeNotification {
|
if m.App.Config.Application.ShowTrackChangeNotification {
|
||||||
// TODO: Once Fyne issue #2935 is resolved, show album cover
|
// TODO: Once Fyne issue #2935 is resolved, show album cover
|
||||||
fyne.CurrentApp().SendNotification(&fyne.Notification{
|
fyne.CurrentApp().SendNotification(&fyne.Notification{
|
||||||
Title: track.Name,
|
Title: meta.Name,
|
||||||
Content: artistDisp,
|
Content: artistDisp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -179,6 +186,11 @@ func (m *MainWindow) RunOnServerConnectedTasks(app *backend.App, displayAppName
|
|||||||
}
|
}
|
||||||
m.App.Config.Application.LastCheckedVersion = t
|
m.App.Config.Application.LastCheckedVersion = t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, supportsRadio := m.App.ServerManager.Server.(mediaprovider.RadioProvider)
|
||||||
|
m.radioBtn.Hidden = !supportsRadio
|
||||||
|
m.radioBtn.Refresh()
|
||||||
|
|
||||||
m.App.SaveConfigFile()
|
m.App.SaveConfigFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +273,9 @@ func (m *MainWindow) addNavigationButtons() {
|
|||||||
m.BrowsingPane.AddNavigationButton(theme.TracksIcon, controller.Tracks, func() {
|
m.BrowsingPane.AddNavigationButton(theme.TracksIcon, controller.Tracks, func() {
|
||||||
m.Router.NavigateTo(controller.TracksRoute())
|
m.Router.NavigateTo(controller.TracksRoute())
|
||||||
})
|
})
|
||||||
|
m.radioBtn = m.BrowsingPane.AddNavigationButton(theme.RadioIcon, controller.Radios, func() {
|
||||||
|
m.Router.NavigateTo(controller.RadiosRoute())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MainWindow) addShortcuts() {
|
func (m *MainWindow) addShortcuts() {
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ const (
|
|||||||
var (
|
var (
|
||||||
AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg)
|
AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg)
|
||||||
ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg)
|
ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg)
|
||||||
BroadcastIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg)
|
RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg)
|
||||||
FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg)
|
FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg)
|
||||||
NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg)
|
NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg)
|
||||||
NowPlayingIcon fyne.Resource = theme.NewThemedResource(res.ResHeadphonesSvg)
|
NowPlayingIcon fyne.Resource = theme.NewThemedResource(res.ResHeadphonesSvg)
|
||||||
|
|||||||
+38
-26
@@ -8,76 +8,88 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type TrackListModel struct {
|
type TrackListModel struct {
|
||||||
Track *mediaprovider.Track
|
Item mediaprovider.MediaItem
|
||||||
Selected bool
|
Selected bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the item as a *mediaprovider.Track, or panics if not a Track
|
||||||
|
// Use for tracklists that can only contain tracks (ie not PlayQueueList)
|
||||||
|
func (t TrackListModel) Track() *mediaprovider.Track {
|
||||||
|
return t.Item.(*mediaprovider.Track)
|
||||||
|
}
|
||||||
|
|
||||||
func ToTrackListModels(trs []*mediaprovider.Track) []*TrackListModel {
|
func ToTrackListModels(trs []*mediaprovider.Track) []*TrackListModel {
|
||||||
return sharedutil.MapSlice(trs, func(tr *mediaprovider.Track) *TrackListModel {
|
return sharedutil.MapSlice(trs, func(tr *mediaprovider.Track) *TrackListModel {
|
||||||
return &TrackListModel{Track: tr, Selected: false}
|
return &TrackListModel{Item: tr, Selected: false}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func SelectedTracks(tracks []*TrackListModel) []*mediaprovider.Track {
|
func SelectedTracks(items []*TrackListModel) []*mediaprovider.Track {
|
||||||
return sharedutil.FilterMapSlice(tracks, func(tm *TrackListModel) (*mediaprovider.Track, bool) {
|
return sharedutil.FilterMapSlice(items, func(tm *TrackListModel) (*mediaprovider.Track, bool) {
|
||||||
return tm.Track, tm.Selected
|
return tm.Track(), tm.Selected
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func SelectedTrackIDs(tracks []*TrackListModel) []string {
|
func SelectedItems(items []*TrackListModel) []mediaprovider.MediaItem {
|
||||||
return sharedutil.FilterMapSlice(tracks, func(tm *TrackListModel) (string, bool) {
|
return sharedutil.FilterMapSlice(items, func(tm *TrackListModel) (mediaprovider.MediaItem, bool) {
|
||||||
return tm.Track.ID, tm.Selected
|
return tm.Item, tm.Selected
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func SelectTrack(tracks []*TrackListModel, idx int) {
|
func SelectedItemIDs(items []*TrackListModel) []string {
|
||||||
if tracks[idx].Selected {
|
return sharedutil.FilterMapSlice(items, func(tm *TrackListModel) (string, bool) {
|
||||||
|
return tm.Item.Metadata().ID, tm.Selected
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func SelectItem(items []*TrackListModel, idx int) {
|
||||||
|
if items[idx].Selected {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
UnselectAllTracks(tracks)
|
UnselectAllItems(items)
|
||||||
tracks[idx].Selected = true
|
items[idx].Selected = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func SelectAllTracks(tracks []*TrackListModel) {
|
func SelectAllItems(items []*TrackListModel) {
|
||||||
for _, tm := range tracks {
|
for _, tm := range items {
|
||||||
tm.Selected = true
|
tm.Selected = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func UnselectAllTracks(tracks []*TrackListModel) {
|
func UnselectAllItems(items []*TrackListModel) {
|
||||||
for _, tm := range tracks {
|
for _, tm := range items {
|
||||||
tm.Selected = false
|
tm.Selected = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SelectTrackRange(tracks []*TrackListModel, idx int) {
|
func SelectItemRange(items []*TrackListModel, idx int) {
|
||||||
if tracks[idx].Selected {
|
if items[idx].Selected {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastSelected := -1
|
lastSelected := -1
|
||||||
for i := len(tracks) - 1; i >= 0; i-- {
|
for i := len(items) - 1; i >= 0; i-- {
|
||||||
if tracks[i].Selected {
|
if items[i].Selected {
|
||||||
lastSelected = i
|
lastSelected = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if lastSelected < 0 {
|
if lastSelected < 0 {
|
||||||
tracks[idx].Selected = true
|
items[idx].Selected = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
from := min(idx, lastSelected)
|
from := min(idx, lastSelected)
|
||||||
to := max(idx, lastSelected)
|
to := max(idx, lastSelected)
|
||||||
for i := from; i <= to; i++ {
|
for i := from; i <= to; i++ {
|
||||||
tracks[i].Selected = true
|
items[i].Selected = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func FindTrackByID(tracks []*TrackListModel, id string) (*mediaprovider.Track, int) {
|
func FindItemByID(items []*TrackListModel, id string) (mediaprovider.MediaItem, int) {
|
||||||
idx := slices.IndexFunc(tracks, func(tr *TrackListModel) bool {
|
idx := slices.IndexFunc(items, func(tr *TrackListModel) bool {
|
||||||
return tr.Track.ID == id
|
return tr.Item.Metadata().ID == id
|
||||||
})
|
})
|
||||||
if idx >= 0 {
|
if idx >= 0 {
|
||||||
return tracks[idx].Track, idx
|
return items[idx].Item, idx
|
||||||
}
|
}
|
||||||
return nil, -1
|
return nil, -1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) {
|
|||||||
}
|
}
|
||||||
card.ItemIndex = itemIdx
|
card.ItemIndex = itemIdx
|
||||||
g.itemForIndex[itemIdx] = card
|
g.itemForIndex[itemIdx] = card
|
||||||
card.Cover.Im.CenterIcon = g.Placeholder
|
card.Cover.Im.PlaceholderIcon = g.Placeholder
|
||||||
if !card.NeedsUpdate(item) && card.ItemIndex == itemIdx {
|
if !card.NeedsUpdate(item) && card.ItemIndex == itemIdx {
|
||||||
// nothing to do
|
// nothing to do
|
||||||
g.stateMutex.Unlock()
|
g.stateMutex.Unlock()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
// and an icon positioned in the center of the frame.
|
// and an icon positioned in the center of the frame.
|
||||||
type ImagePlaceholder struct {
|
type ImagePlaceholder struct {
|
||||||
ScaleMode canvas.ImageScale
|
ScaleMode canvas.ImageScale
|
||||||
CenterIcon fyne.Resource
|
PlaceholderIcon fyne.Resource
|
||||||
|
|
||||||
widget.BaseWidget
|
widget.BaseWidget
|
||||||
content *fyne.Container
|
content *fyne.Container
|
||||||
@@ -31,7 +31,7 @@ type ImagePlaceholder struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewImagePlaceholder(centerIcon fyne.Resource, minSize float32) *ImagePlaceholder {
|
func NewImagePlaceholder(centerIcon fyne.Resource, minSize float32) *ImagePlaceholder {
|
||||||
i := &ImagePlaceholder{minSize: minSize, CenterIcon: centerIcon}
|
i := &ImagePlaceholder{minSize: minSize, PlaceholderIcon: centerIcon}
|
||||||
i.ExtendBaseWidget(i)
|
i.ExtendBaseWidget(i)
|
||||||
i.iconImage = canvas.NewImageFromResource(centerIcon)
|
i.iconImage = canvas.NewImageFromResource(centerIcon)
|
||||||
i.iconImage.FillMode = canvas.ImageFillContain
|
i.iconImage.FillMode = canvas.ImageFillContain
|
||||||
@@ -94,7 +94,7 @@ func (i *ImagePlaceholder) MinSize() fyne.Size {
|
|||||||
|
|
||||||
func (i *ImagePlaceholder) Refresh() {
|
func (i *ImagePlaceholder) Refresh() {
|
||||||
i.border.Hidden = i.HaveImage()
|
i.border.Hidden = i.HaveImage()
|
||||||
i.iconImage.Resource = i.CenterIcon
|
i.iconImage.Resource = i.PlaceholderIcon
|
||||||
i.iconImage.Hidden = i.HaveImage()
|
i.iconImage.Hidden = i.HaveImage()
|
||||||
i.imageDisp.Hidden = !i.HaveImage()
|
i.imageDisp.Hidden = !i.HaveImage()
|
||||||
i.imageDisp.ScaleMode = i.ScaleMode
|
i.imageDisp.ScaleMode = i.ScaleMode
|
||||||
|
|||||||
@@ -20,15 +20,18 @@ type LargeNowPlayingCard struct {
|
|||||||
|
|
||||||
DisableRating bool
|
DisableRating bool
|
||||||
|
|
||||||
|
isRadio bool
|
||||||
trackName *widget.RichText
|
trackName *widget.RichText
|
||||||
artistName *MultiHyperlink
|
artistName *MultiHyperlink
|
||||||
albumName *widget.Hyperlink
|
albumName *widget.Hyperlink
|
||||||
rating *StarRating
|
rating *StarRating
|
||||||
favorite *FavoriteIcon
|
favorite *FavoriteIcon
|
||||||
|
ratingFavoriteContainer *fyne.Container
|
||||||
cover *ImagePlaceholder
|
cover *ImagePlaceholder
|
||||||
|
|
||||||
OnArtistNameTapped func(artistID string)
|
OnArtistNameTapped func(artistID string)
|
||||||
OnAlbumNameTapped func()
|
OnAlbumNameTapped func()
|
||||||
|
OnRadioURLTapped func(url string)
|
||||||
OnSetRating func(rating int)
|
OnSetRating func(rating int)
|
||||||
OnSetFavorite func(favorite bool)
|
OnSetFavorite func(favorite bool)
|
||||||
}
|
}
|
||||||
@@ -49,17 +52,18 @@ func NewLargeNowPlayingCard() *LargeNowPlayingCard {
|
|||||||
n.cover.ScaleMode = canvas.ImageScaleFastest
|
n.cover.ScaleMode = canvas.ImageScaleFastest
|
||||||
// set up the layout
|
// set up the layout
|
||||||
n.Content = n.cover
|
n.Content = n.cover
|
||||||
n.Caption = container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-13),
|
n.ratingFavoriteContainer = container.NewHBox(
|
||||||
n.trackName,
|
|
||||||
n.artistName,
|
|
||||||
n.albumName,
|
|
||||||
container.NewHBox(
|
|
||||||
layout.NewSpacer(),
|
layout.NewSpacer(),
|
||||||
n.favorite,
|
n.favorite,
|
||||||
widget.NewLabel("·"),
|
widget.NewLabel("·"),
|
||||||
n.rating,
|
n.rating,
|
||||||
layout.NewSpacer(),
|
layout.NewSpacer(),
|
||||||
),
|
)
|
||||||
|
n.Caption = container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-13),
|
||||||
|
n.trackName,
|
||||||
|
n.artistName,
|
||||||
|
n.albumName,
|
||||||
|
n.ratingFavoriteContainer,
|
||||||
)
|
)
|
||||||
|
|
||||||
n.trackName.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameSubHeadingText
|
n.trackName.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameSubHeadingText
|
||||||
@@ -79,6 +83,12 @@ func (n *LargeNowPlayingCard) onAlbumNameTapped() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (n *LargeNowPlayingCard) onArtistNameTapped(artistID string) {
|
func (n *LargeNowPlayingCard) onArtistNameTapped(artistID string) {
|
||||||
|
if n.isRadio {
|
||||||
|
if n.OnRadioURLTapped != nil {
|
||||||
|
n.OnRadioURLTapped(artistID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if n.OnArtistNameTapped != nil {
|
if n.OnArtistNameTapped != nil {
|
||||||
n.OnArtistNameTapped(artistID)
|
n.OnArtistNameTapped(artistID)
|
||||||
}
|
}
|
||||||
@@ -108,19 +118,32 @@ func (n *LargeNowPlayingCard) SetDisplayedFavorite(favorite bool) {
|
|||||||
n.favorite.Refresh()
|
n.favorite.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *LargeNowPlayingCard) Update(track *mediaprovider.Track) {
|
func (n *LargeNowPlayingCard) Update(item mediaprovider.MediaItem) {
|
||||||
if track != nil {
|
if item == nil {
|
||||||
n.trackName.Segments[0].(*widget.TextSegment).Text = track.Name
|
|
||||||
n.artistName.BuildSegments(track.ArtistNames, track.ArtistIDs)
|
|
||||||
n.albumName.Text = track.Album
|
|
||||||
n.rating.Rating = track.Rating
|
|
||||||
n.favorite.Favorite = track.Favorite
|
|
||||||
} else {
|
|
||||||
n.trackName.Segments[0].(*widget.TextSegment).Text = ""
|
n.trackName.Segments[0].(*widget.TextSegment).Text = ""
|
||||||
n.artistName.BuildSegments([]string{}, []string{})
|
n.artistName.BuildSegments([]string{}, []string{})
|
||||||
n.albumName.Text = ""
|
n.albumName.Text = ""
|
||||||
n.rating.Rating = 0
|
n.rating.Rating = 0
|
||||||
n.favorite.Favorite = false
|
n.favorite.Favorite = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta := item.Metadata()
|
||||||
|
n.trackName.Segments[0].(*widget.TextSegment).Text = meta.Name
|
||||||
|
n.albumName.Text = meta.Album
|
||||||
|
n.albumName.Hidden = meta.AlbumID == ""
|
||||||
|
|
||||||
|
if tr, ok := item.(*mediaprovider.Track); ok {
|
||||||
|
n.artistName.BuildSegments(meta.Artists, meta.ArtistIDs)
|
||||||
|
n.rating.Rating = tr.Rating
|
||||||
|
n.favorite.Favorite = tr.Favorite
|
||||||
|
n.cover.PlaceholderIcon = myTheme.TracksIcon
|
||||||
|
n.ratingFavoriteContainer.Hidden = false
|
||||||
|
n.isRadio = false
|
||||||
|
} else if rd, ok := item.(*mediaprovider.RadioStation); ok {
|
||||||
|
n.artistName.BuildSegments([]string{rd.HomePageURL}, []string{rd.HomePageURL})
|
||||||
|
n.ratingFavoriteContainer.Hidden = true
|
||||||
|
n.cover.PlaceholderIcon = myTheme.RadioIcon
|
||||||
|
n.isRadio = true
|
||||||
}
|
}
|
||||||
|
|
||||||
n.Refresh()
|
n.Refresh()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type NowPlayingCard struct {
|
|||||||
trackName *widget.Hyperlink
|
trackName *widget.Hyperlink
|
||||||
artistName *MultiHyperlink
|
artistName *MultiHyperlink
|
||||||
albumName *widget.Hyperlink
|
albumName *widget.Hyperlink
|
||||||
cover *TappableImage
|
cover *ImagePlaceholder
|
||||||
menu *widget.PopUpMenu
|
menu *widget.PopUpMenu
|
||||||
ratingMenu *fyne.MenuItem
|
ratingMenu *fyne.MenuItem
|
||||||
|
|
||||||
@@ -45,17 +45,16 @@ func NewNowPlayingCard() *NowPlayingCard {
|
|||||||
albumName: widget.NewHyperlink("", nil),
|
albumName: widget.NewHyperlink("", nil),
|
||||||
}
|
}
|
||||||
n.ExtendBaseWidget(n)
|
n.ExtendBaseWidget(n)
|
||||||
n.cover = NewTappableImage(n.onShowCoverImage)
|
n.cover = NewImagePlaceholder(myTheme.TracksIcon, 85)
|
||||||
|
n.cover.OnTapped = n.onShowCoverImage
|
||||||
n.cover.OnTappedSecondary = n.showMenu
|
n.cover.OnTappedSecondary = n.showMenu
|
||||||
|
n.cover.ScaleMode = canvas.ImageScaleFastest
|
||||||
|
n.cover.Hidden = true
|
||||||
n.trackName.Hidden = true
|
n.trackName.Hidden = true
|
||||||
n.albumName.Hidden = true
|
n.albumName.Hidden = true
|
||||||
n.albumName.Truncation = fyne.TextTruncateEllipsis
|
n.albumName.Truncation = fyne.TextTruncateEllipsis
|
||||||
n.trackName.Truncation = fyne.TextTruncateEllipsis
|
n.trackName.Truncation = fyne.TextTruncateEllipsis
|
||||||
n.trackName.TextStyle.Bold = true
|
n.trackName.TextStyle.Bold = true
|
||||||
n.cover.SetMinSize(fyne.NewSquareSize(85))
|
|
||||||
n.cover.FillMode = canvas.ImageFillContain
|
|
||||||
n.cover.ScaleMode = canvas.ImageScaleFastest
|
|
||||||
n.cover.Hidden = true
|
|
||||||
n.albumName.OnTapped = n.onAlbumNameTapped
|
n.albumName.OnTapped = n.onAlbumNameTapped
|
||||||
n.artistName.OnTapped = n.onArtistNameTapped
|
n.artistName.OnTapped = n.onArtistNameTapped
|
||||||
n.trackName.OnTapped = n.onTrackNameTapped
|
n.trackName.OnTapped = n.onTrackNameTapped
|
||||||
@@ -119,24 +118,35 @@ func (n *NowPlayingCard) CreateRenderer() fyne.WidgetRenderer {
|
|||||||
return widget.NewSimpleRenderer(c)
|
return widget.NewSimpleRenderer(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *NowPlayingCard) Update(track *mediaprovider.Track, cover image.Image) {
|
func (n *NowPlayingCard) Update(track mediaprovider.MediaItem) {
|
||||||
if track != nil {
|
if track == nil {
|
||||||
n.trackName.SetText(track.Name)
|
|
||||||
n.artistName.BuildSegments(track.ArtistNames, track.ArtistIDs)
|
|
||||||
n.albumName.SetText(track.Album)
|
|
||||||
} else {
|
|
||||||
n.trackName.SetText("")
|
n.trackName.SetText("")
|
||||||
n.artistName.BuildSegments([]string{}, []string{})
|
n.artistName.BuildSegments([]string{}, []string{})
|
||||||
n.albumName.SetText("")
|
n.albumName.SetText("")
|
||||||
|
n.cover.Hidden = true
|
||||||
|
} else {
|
||||||
|
n.cover.Hidden = false
|
||||||
|
n.trackName.SetText(track.Metadata().Name)
|
||||||
|
if tr, ok := track.(*mediaprovider.Track); ok {
|
||||||
|
n.artistName.BuildSegments(tr.ArtistNames, tr.ArtistIDs)
|
||||||
|
n.albumName.SetText(tr.Album)
|
||||||
|
n.cover.PlaceholderIcon = myTheme.TracksIcon
|
||||||
|
} else {
|
||||||
|
n.artistName.BuildSegments([]string{}, []string{})
|
||||||
|
n.albumName.SetText("")
|
||||||
|
n.cover.PlaceholderIcon = myTheme.RadioIcon
|
||||||
|
}
|
||||||
}
|
}
|
||||||
n.trackName.Hidden = n.trackName.Text == ""
|
n.trackName.Hidden = n.trackName.Text == ""
|
||||||
n.artistName.Hidden = len(n.artistName.Segments) == 0
|
n.artistName.Hidden = len(n.artistName.Segments) == 0
|
||||||
n.albumName.Hidden = n.albumName.Text == ""
|
n.albumName.Hidden = n.albumName.Text == ""
|
||||||
n.cover.Image.Image = cover
|
|
||||||
n.cover.Hidden = cover == nil
|
|
||||||
n.Refresh()
|
n.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *NowPlayingCard) SetImage(cover image.Image) {
|
||||||
|
n.cover.SetImage(cover, true)
|
||||||
|
}
|
||||||
|
|
||||||
func (n *NowPlayingCard) showMenu(e *fyne.PointEvent) {
|
func (n *NowPlayingCard) showMenu(e *fyne.PointEvent) {
|
||||||
if n.menu == nil {
|
if n.menu == nil {
|
||||||
n.ratingMenu = util.NewRatingSubmenu(n.onSetRating)
|
n.ratingMenu = util.NewRatingSubmenu(n.onSetRating)
|
||||||
|
|||||||
+111
-41
@@ -23,6 +23,11 @@ import (
|
|||||||
|
|
||||||
const playQueueListThumbnailSize = 52
|
const playQueueListThumbnailSize = 52
|
||||||
|
|
||||||
|
type PlayQueueListModel struct {
|
||||||
|
Item mediaprovider.MediaItem
|
||||||
|
Selected bool
|
||||||
|
}
|
||||||
|
|
||||||
type PlayQueueList struct {
|
type PlayQueueList struct {
|
||||||
widget.BaseWidget
|
widget.BaseWidget
|
||||||
|
|
||||||
@@ -33,15 +38,16 @@ type PlayQueueList struct {
|
|||||||
OnAddToPlaylist func(trackIDs []string)
|
OnAddToPlaylist func(trackIDs []string)
|
||||||
OnSetFavorite func(trackIDs []string, fav bool)
|
OnSetFavorite func(trackIDs []string, fav bool)
|
||||||
OnSetRating func(trackIDs []string, rating int)
|
OnSetRating func(trackIDs []string, rating int)
|
||||||
OnRemoveFromQueue func(trackIDs []string)
|
OnRemoveFromQueue func(itemIDs []string)
|
||||||
OnDownload func(tracks []*mediaprovider.Track, downloadName string)
|
OnDownload func(tracks []*mediaprovider.Track, downloadName string)
|
||||||
OnShare func(tracks []*mediaprovider.Track)
|
OnShare func(tracks []*mediaprovider.Track)
|
||||||
OnShowArtistPage func(artistID string)
|
OnShowArtistPage func(artistID string)
|
||||||
OnPlayTrackAt func(idx int)
|
OnPlayTrackAt func(idx int)
|
||||||
OnReorderTracks func(trackIDs []string, op sharedutil.TrackReorderOp)
|
OnReorderItems func(itemIDs []string, op sharedutil.TrackReorderOp)
|
||||||
|
|
||||||
list *FocusList
|
list *FocusList
|
||||||
menu *widget.PopUpMenu
|
menu *widget.PopUpMenu // ctx menu for when only tracks are selected
|
||||||
|
radiosMenu *widget.PopUpMenu // ctx menu for when selection contains radios
|
||||||
ratingSubmenu *fyne.MenuItem
|
ratingSubmenu *fyne.MenuItem
|
||||||
shareMenuItem *fyne.MenuItem
|
shareMenuItem *fyne.MenuItem
|
||||||
|
|
||||||
@@ -49,7 +55,7 @@ type PlayQueueList struct {
|
|||||||
colLayout *layouts.ColumnsLayout
|
colLayout *layouts.ColumnsLayout
|
||||||
|
|
||||||
tracksMutex sync.RWMutex
|
tracksMutex sync.RWMutex
|
||||||
tracks []*util.TrackListModel
|
items []*util.TrackListModel
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList {
|
func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList {
|
||||||
@@ -78,16 +84,16 @@ func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList {
|
|||||||
// we could have removed tracks from the list in between
|
// we could have removed tracks from the list in between
|
||||||
// Fyne calling the length callback and this update callback
|
// Fyne calling the length callback and this update callback
|
||||||
// so the itemID may be out of bounds. if so, do nothing.
|
// so the itemID may be out of bounds. if so, do nothing.
|
||||||
if itemID >= len(p.tracks) {
|
if itemID >= len(p.items) {
|
||||||
p.tracksMutex.RUnlock()
|
p.tracksMutex.RUnlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
model := p.tracks[itemID]
|
model := p.items[itemID]
|
||||||
p.tracksMutex.RUnlock()
|
p.tracksMutex.RUnlock()
|
||||||
|
|
||||||
tr := item.(*PlayQueueListRow)
|
tr := item.(*PlayQueueListRow)
|
||||||
p.list.SetItemForID(itemID, tr)
|
p.list.SetItemForID(itemID, tr)
|
||||||
if tr.trackID != model.Track.ID || tr.ListItemID != itemID {
|
if tr.trackID != model.Item.Metadata().ID || tr.ListItemID != itemID {
|
||||||
tr.ListItemID = itemID
|
tr.ListItemID = itemID
|
||||||
}
|
}
|
||||||
tr.Update(model, itemID+1)
|
tr.Update(model, itemID+1)
|
||||||
@@ -100,19 +106,29 @@ func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList {
|
|||||||
func (p *PlayQueueList) SetTracks(trs []*mediaprovider.Track) {
|
func (p *PlayQueueList) SetTracks(trs []*mediaprovider.Track) {
|
||||||
p.tracksMutex.Lock()
|
p.tracksMutex.Lock()
|
||||||
p.list.ClearItemForIDMap()
|
p.list.ClearItemForIDMap()
|
||||||
p.tracks = util.ToTrackListModels(trs)
|
p.items = util.ToTrackListModels(trs)
|
||||||
p.tracksMutex.Unlock()
|
p.tracksMutex.Unlock()
|
||||||
p.Refresh()
|
p.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sets the currently playing track ID and updates the list rendering
|
func (p *PlayQueueList) SetItems(items []mediaprovider.MediaItem) {
|
||||||
func (p *PlayQueueList) SetNowPlaying(trackID string) {
|
p.tracksMutex.Lock()
|
||||||
|
p.list.ClearItemForIDMap()
|
||||||
|
p.items = sharedutil.MapSlice(items, func(item mediaprovider.MediaItem) *util.TrackListModel {
|
||||||
|
return &util.TrackListModel{Item: item}
|
||||||
|
})
|
||||||
|
p.tracksMutex.Unlock()
|
||||||
|
p.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets the currently playing item ID and updates the list rendering
|
||||||
|
func (p *PlayQueueList) SetNowPlaying(itemID string) {
|
||||||
prevNowPlaying := p.nowPlayingID
|
prevNowPlaying := p.nowPlayingID
|
||||||
p.tracksMutex.RLock()
|
p.tracksMutex.RLock()
|
||||||
trPrev, idxPrev := util.FindTrackByID(p.tracks, prevNowPlaying)
|
trPrev, idxPrev := util.FindItemByID(p.items, prevNowPlaying)
|
||||||
tr, idx := util.FindTrackByID(p.tracks, trackID)
|
tr, idx := util.FindItemByID(p.items, itemID)
|
||||||
p.tracksMutex.RUnlock()
|
p.tracksMutex.RUnlock()
|
||||||
p.nowPlayingID = trackID
|
p.nowPlayingID = itemID
|
||||||
if trPrev != nil {
|
if trPrev != nil {
|
||||||
p.list.RefreshItem(idxPrev)
|
p.list.RefreshItem(idxPrev)
|
||||||
}
|
}
|
||||||
@@ -123,14 +139,14 @@ func (p *PlayQueueList) SetNowPlaying(trackID string) {
|
|||||||
|
|
||||||
func (p *PlayQueueList) SelectAll() {
|
func (p *PlayQueueList) SelectAll() {
|
||||||
p.tracksMutex.RLock()
|
p.tracksMutex.RLock()
|
||||||
util.SelectAllTracks(p.tracks)
|
util.SelectAllItems(p.items)
|
||||||
p.tracksMutex.RUnlock()
|
p.tracksMutex.RUnlock()
|
||||||
p.list.Refresh()
|
p.list.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlayQueueList) UnselectAll() {
|
func (p *PlayQueueList) UnselectAll() {
|
||||||
p.tracksMutex.RLock()
|
p.tracksMutex.RLock()
|
||||||
util.UnselectAllTracks(p.tracks)
|
util.UnselectAllItems(p.items)
|
||||||
p.tracksMutex.RUnlock()
|
p.tracksMutex.RUnlock()
|
||||||
p.Refresh()
|
p.Refresh()
|
||||||
}
|
}
|
||||||
@@ -138,7 +154,7 @@ func (p *PlayQueueList) UnselectAll() {
|
|||||||
func (p *PlayQueueList) lenTracks() int {
|
func (p *PlayQueueList) lenTracks() int {
|
||||||
p.tracksMutex.RLock()
|
p.tracksMutex.RLock()
|
||||||
defer p.tracksMutex.RUnlock()
|
defer p.tracksMutex.RUnlock()
|
||||||
return len(p.tracks)
|
return len(p.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *PlayQueueList) onArtistTapped(artistID string) {
|
func (t *PlayQueueList) onArtistTapped(artistID string) {
|
||||||
@@ -172,28 +188,54 @@ func (p *PlayQueueList) onSelectTrack(idx int) {
|
|||||||
func (p *PlayQueueList) selectTrack(idx int) {
|
func (p *PlayQueueList) selectTrack(idx int) {
|
||||||
p.tracksMutex.RLock()
|
p.tracksMutex.RLock()
|
||||||
defer p.tracksMutex.RUnlock()
|
defer p.tracksMutex.RUnlock()
|
||||||
util.SelectTrack(p.tracks, idx)
|
util.SelectItem(p.items, idx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlayQueueList) selectAddOrRemove(idx int) {
|
func (p *PlayQueueList) selectAddOrRemove(idx int) {
|
||||||
p.tracksMutex.RLock()
|
p.tracksMutex.RLock()
|
||||||
defer p.tracksMutex.RUnlock()
|
defer p.tracksMutex.RUnlock()
|
||||||
p.tracks[idx].Selected = !p.tracks[idx].Selected
|
p.items[idx].Selected = !p.items[idx].Selected
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlayQueueList) selectRange(idx int) {
|
func (p *PlayQueueList) selectRange(idx int) {
|
||||||
p.tracksMutex.RLock()
|
p.tracksMutex.RLock()
|
||||||
defer p.tracksMutex.RUnlock()
|
defer p.tracksMutex.RUnlock()
|
||||||
util.SelectTrackRange(p.tracks, idx)
|
util.SelectItemRange(p.items, idx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
|
func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
|
||||||
p.selectTrack(trackIdx)
|
p.selectTrack(trackIdx)
|
||||||
p.list.Refresh()
|
p.list.Refresh()
|
||||||
if p.menu == nil {
|
selected := p.selectedItems()
|
||||||
|
|
||||||
|
allTracks := true
|
||||||
|
for _, item := range selected {
|
||||||
|
if item.Metadata().Type == mediaprovider.MediaItemTypeRadioStation {
|
||||||
|
allTracks = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
playlist := fyne.NewMenuItem("Add to playlist...", func() {
|
||||||
if p.OnAddToPlaylist != nil {
|
if p.OnAddToPlaylist != nil {
|
||||||
p.OnAddToPlaylist(p.selectedTrackIDs())
|
p.OnAddToPlaylist(p.selectedItemIDs())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
playlist.Icon = myTheme.PlaylistIcon
|
playlist.Icon = myTheme.PlaylistIcon
|
||||||
@@ -211,30 +253,30 @@ func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
|
|||||||
p.shareMenuItem.Icon = myTheme.ShareIcon
|
p.shareMenuItem.Icon = myTheme.ShareIcon
|
||||||
favorite := fyne.NewMenuItem("Set favorite", func() {
|
favorite := fyne.NewMenuItem("Set favorite", func() {
|
||||||
if p.OnSetFavorite != nil {
|
if p.OnSetFavorite != nil {
|
||||||
p.OnSetFavorite(p.selectedTrackIDs(), true)
|
p.OnSetFavorite(p.selectedItemIDs(), true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
favorite.Icon = myTheme.FavoriteIcon
|
favorite.Icon = myTheme.FavoriteIcon
|
||||||
unfavorite := fyne.NewMenuItem("Unset favorite", func() {
|
unfavorite := fyne.NewMenuItem("Unset favorite", func() {
|
||||||
if p.OnSetFavorite != nil {
|
if p.OnSetFavorite != nil {
|
||||||
p.OnSetFavorite(p.selectedTrackIDs(), false)
|
p.OnSetFavorite(p.selectedItemIDs(), false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
unfavorite.Icon = myTheme.NotFavoriteIcon
|
unfavorite.Icon = myTheme.NotFavoriteIcon
|
||||||
p.ratingSubmenu = util.NewRatingSubmenu(func(rating int) {
|
p.ratingSubmenu = util.NewRatingSubmenu(func(rating int) {
|
||||||
if p.OnSetRating != nil {
|
if p.OnSetRating != nil {
|
||||||
p.OnSetRating(p.selectedTrackIDs(), rating)
|
p.OnSetRating(p.selectedItemIDs(), rating)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
remove := fyne.NewMenuItem("Remove from queue", func() {
|
remove := fyne.NewMenuItem("Remove from queue", func() {
|
||||||
if p.OnRemoveFromQueue != nil {
|
if p.OnRemoveFromQueue != nil {
|
||||||
p.OnRemoveFromQueue(p.selectedTrackIDs())
|
p.OnRemoveFromQueue(p.selectedItemIDs())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
remove.Icon = theme.ContentRemoveIcon()
|
remove.Icon = theme.ContentRemoveIcon()
|
||||||
reorder := util.NewReorderTracksSubmenu(func(tro sharedutil.TrackReorderOp) {
|
reorder := util.NewReorderTracksSubmenu(func(tro sharedutil.TrackReorderOp) {
|
||||||
if p.OnReorderTracks != nil {
|
if p.OnReorderItems != nil {
|
||||||
p.OnReorderTracks(p.selectedTrackIDs(), tro)
|
p.OnReorderItems(p.selectedItemIDs(), tro)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -253,22 +295,45 @@ func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
|
|||||||
),
|
),
|
||||||
fyne.CurrentApp().Driver().CanvasForObject(p),
|
fyne.CurrentApp().Driver().CanvasForObject(p),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PlayQueueList) ensureRadiosMenu() {
|
||||||
|
if p.radiosMenu != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
p.ratingSubmenu.Disabled = p.DisableRating
|
remove := fyne.NewMenuItem("Remove from queue", func() {
|
||||||
p.shareMenuItem.Disabled = p.DisableSharing || len(p.selectedTracks()) != 1
|
if p.OnRemoveFromQueue != nil {
|
||||||
p.menu.ShowAtPosition(e.AbsolutePosition)
|
p.OnRemoveFromQueue(p.selectedItemIDs())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
remove.Icon = theme.ContentRemoveIcon()
|
||||||
|
reorder := util.NewReorderTracksSubmenu(func(tro sharedutil.TrackReorderOp) {
|
||||||
|
if p.OnReorderItems != nil {
|
||||||
|
p.OnReorderItems(p.selectedItemIDs(), tro)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
p.radiosMenu = widget.NewPopUpMenu(
|
||||||
|
fyne.NewMenu("", remove, reorder),
|
||||||
|
fyne.CurrentApp().Driver().CanvasForObject(p),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *PlayQueueList) selectedItems() []mediaprovider.MediaItem {
|
||||||
|
t.tracksMutex.RLock()
|
||||||
|
defer t.tracksMutex.RUnlock()
|
||||||
|
return util.SelectedItems(t.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *PlayQueueList) selectedTracks() []*mediaprovider.Track {
|
func (t *PlayQueueList) selectedTracks() []*mediaprovider.Track {
|
||||||
t.tracksMutex.RLock()
|
t.tracksMutex.RLock()
|
||||||
defer t.tracksMutex.RUnlock()
|
defer t.tracksMutex.RUnlock()
|
||||||
return util.SelectedTracks(t.tracks)
|
return util.SelectedTracks(t.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *PlayQueueList) selectedTrackIDs() []string {
|
func (t *PlayQueueList) selectedItemIDs() []string {
|
||||||
t.tracksMutex.RLock()
|
t.tracksMutex.RLock()
|
||||||
defer t.tracksMutex.RUnlock()
|
defer t.tracksMutex.RUnlock()
|
||||||
return util.SelectedTrackIDs(t.tracks)
|
return util.SelectedItemIDs(t.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlayQueueList) CreateRenderer() fyne.WidgetRenderer {
|
func (p *PlayQueueList) CreateRenderer() fyne.WidgetRenderer {
|
||||||
@@ -355,19 +420,24 @@ func (p *PlayQueueListRow) Update(tm *util.TrackListModel, rowNum int) {
|
|||||||
|
|
||||||
// Update info that can change if this row is bound to
|
// Update info that can change if this row is bound to
|
||||||
// a new track (*mediaprovider.Track)
|
// a new track (*mediaprovider.Track)
|
||||||
tr := tm.Track
|
meta := tm.Item.Metadata()
|
||||||
if tr.ID != p.trackID {
|
if meta.ID != p.trackID {
|
||||||
p.imageLoader.Load(tm.Track.CoverArtID)
|
if meta.Type == mediaprovider.MediaItemTypeRadioStation {
|
||||||
|
p.cover.PlaceholderIcon = myTheme.RadioIcon
|
||||||
|
} else {
|
||||||
|
p.cover.PlaceholderIcon = myTheme.TracksIcon
|
||||||
|
}
|
||||||
|
p.imageLoader.Load(meta.CoverArtID)
|
||||||
p.EnsureUnfocused()
|
p.EnsureUnfocused()
|
||||||
p.trackID = tr.ID
|
p.trackID = meta.ID
|
||||||
p.title.Text = tr.Name
|
p.title.Text = meta.Name
|
||||||
p.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs)
|
p.artist.BuildSegments(meta.Artists, meta.ArtistIDs)
|
||||||
p.time.Text = util.SecondsToTimeString(float64(tr.Duration))
|
p.time.Text = util.SecondsToTimeString(float64(meta.Duration))
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render whether track is playing or not
|
// Render whether track is playing or not
|
||||||
if isPlaying := p.playQueueList.nowPlayingID == tr.ID; isPlaying != p.isPlaying {
|
if isPlaying := p.playQueueList.nowPlayingID == meta.ID; isPlaying != p.isPlaying {
|
||||||
p.isPlaying = isPlaying
|
p.isPlaying = isPlaying
|
||||||
p.title.TextStyle.Bold = isPlaying
|
p.title.TextStyle.Bold = isPlaying
|
||||||
|
|
||||||
|
|||||||
+30
-30
@@ -171,7 +171,7 @@ func NewTracklist(tracks []*mediaprovider.Track, im *backend.ImageManager, useCo
|
|||||||
|
|
||||||
tr := item.(TracklistRow)
|
tr := item.(TracklistRow)
|
||||||
t.list.SetItemForID(itemID, tr)
|
t.list.SetItemForID(itemID, tr)
|
||||||
if tr.TrackID() != model.Track.ID || tr.ItemID() != itemID {
|
if tr.TrackID() != model.Item.Metadata().ID || tr.ItemID() != itemID {
|
||||||
tr.SetItemID(itemID)
|
tr.SetItemID(itemID)
|
||||||
}
|
}
|
||||||
i := -1 // signal that we want to display the actual track num.
|
i := -1 // signal that we want to display the actual track num.
|
||||||
@@ -206,7 +206,7 @@ func (t *Tracklist) TrackAt(idx int) *mediaprovider.Track {
|
|||||||
log.Println("error: Tracklist.TrackAt: index out of range")
|
log.Println("error: Tracklist.TrackAt: index out of range")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return t.tracks[idx].Track
|
return t.tracks[idx].Track()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tracklist) SetVisibleColumns(cols []string) {
|
func (t *Tracklist) SetVisibleColumns(cols []string) {
|
||||||
@@ -271,8 +271,8 @@ func (t *Tracklist) SetSorting(sorting TracklistSort) {
|
|||||||
func (t *Tracklist) SetNowPlaying(trackID string) {
|
func (t *Tracklist) SetNowPlaying(trackID string) {
|
||||||
prevNowPlaying := t.nowPlayingID
|
prevNowPlaying := t.nowPlayingID
|
||||||
t.tracksMutex.RLock()
|
t.tracksMutex.RLock()
|
||||||
trPrev, idxPrev := util.FindTrackByID(t.tracks, prevNowPlaying)
|
trPrev, idxPrev := util.FindItemByID(t.tracks, prevNowPlaying)
|
||||||
tr, idx := util.FindTrackByID(t.tracks, trackID)
|
tr, idx := util.FindItemByID(t.tracks, trackID)
|
||||||
t.tracksMutex.RUnlock()
|
t.tracksMutex.RUnlock()
|
||||||
t.nowPlayingID = trackID
|
t.nowPlayingID = trackID
|
||||||
if trPrev != nil {
|
if trPrev != nil {
|
||||||
@@ -286,10 +286,10 @@ func (t *Tracklist) SetNowPlaying(trackID string) {
|
|||||||
// Increments the play count of the given track and updates the list rendering
|
// Increments the play count of the given track and updates the list rendering
|
||||||
func (t *Tracklist) IncrementPlayCount(trackID string) {
|
func (t *Tracklist) IncrementPlayCount(trackID string) {
|
||||||
t.tracksMutex.RLock()
|
t.tracksMutex.RLock()
|
||||||
tr, idx := util.FindTrackByID(t.tracks, trackID)
|
tr, idx := util.FindItemByID(t.tracks, trackID)
|
||||||
t.tracksMutex.RUnlock()
|
t.tracksMutex.RUnlock()
|
||||||
if tr != nil {
|
if tr != nil {
|
||||||
tr.PlayCount += 1
|
tr.(*mediaprovider.Track).PlayCount += 1
|
||||||
t.list.RefreshItem(idx)
|
t.list.RefreshItem(idx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,7 +324,7 @@ func (t *Tracklist) GetTracks() []*mediaprovider.Track {
|
|||||||
t.tracksMutex.RLock()
|
t.tracksMutex.RLock()
|
||||||
defer t.tracksMutex.RUnlock()
|
defer t.tracksMutex.RUnlock()
|
||||||
return sharedutil.MapSlice(t.tracks, func(tm *util.TrackListModel) *mediaprovider.Track {
|
return sharedutil.MapSlice(t.tracks, func(tm *util.TrackListModel) *mediaprovider.Track {
|
||||||
return tm.Track
|
return tm.Track()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ func (t *Tracklist) AppendTracks(trs []*mediaprovider.Track) {
|
|||||||
|
|
||||||
func (t *Tracklist) SelectAll() {
|
func (t *Tracklist) SelectAll() {
|
||||||
t.tracksMutex.RLock()
|
t.tracksMutex.RLock()
|
||||||
util.SelectAllTracks(t.tracks)
|
util.SelectAllItems(t.tracks)
|
||||||
t.tracksMutex.RUnlock()
|
t.tracksMutex.RUnlock()
|
||||||
t.list.Refresh()
|
t.list.Refresh()
|
||||||
}
|
}
|
||||||
@@ -350,7 +350,7 @@ func (t *Tracklist) UnselectAll() {
|
|||||||
|
|
||||||
func (t *Tracklist) unselectAll() {
|
func (t *Tracklist) unselectAll() {
|
||||||
t.tracksMutex.RLock()
|
t.tracksMutex.RLock()
|
||||||
util.UnselectAllTracks(t.tracks)
|
util.UnselectAllItems(t.tracks)
|
||||||
t.tracksMutex.RUnlock()
|
t.tracksMutex.RUnlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +358,7 @@ func (t *Tracklist) SelectAndScrollToTrack(trackID string) {
|
|||||||
t.tracksMutex.RLock()
|
t.tracksMutex.RLock()
|
||||||
idx := -1
|
idx := -1
|
||||||
for i, tr := range t.tracks {
|
for i, tr := range t.tracks {
|
||||||
if tr.Track.ID == trackID {
|
if tr.Item.Metadata().ID == trackID {
|
||||||
idx = i
|
idx = i
|
||||||
tr.Selected = true
|
tr.Selected = true
|
||||||
} else {
|
} else {
|
||||||
@@ -422,30 +422,30 @@ func (t *Tracklist) doSortTracks() {
|
|||||||
t.tracks = t.tracksOrigOrder
|
t.tracks = t.tracksOrigOrder
|
||||||
}
|
}
|
||||||
case ColumnTitle:
|
case ColumnTitle:
|
||||||
t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Name })
|
t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().Title })
|
||||||
case ColumnArtist:
|
case ColumnArtist:
|
||||||
t.stringSort(func(tr *util.TrackListModel) string { return strings.Join(tr.Track.ArtistNames, ", ") })
|
t.stringSort(func(tr *util.TrackListModel) string { return strings.Join(tr.Track().ArtistNames, ", ") })
|
||||||
case ColumnAlbum:
|
case ColumnAlbum:
|
||||||
t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Album })
|
t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().Album })
|
||||||
case ColumnPath:
|
case ColumnPath:
|
||||||
t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.FilePath })
|
t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().FilePath })
|
||||||
case ColumnRating:
|
case ColumnRating:
|
||||||
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.Rating) })
|
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Rating) })
|
||||||
case ColumnTime:
|
case ColumnTime:
|
||||||
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.Duration) })
|
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Duration) })
|
||||||
case ColumnYear:
|
case ColumnYear:
|
||||||
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.Year) })
|
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Year) })
|
||||||
case ColumnSize:
|
case ColumnSize:
|
||||||
t.intSort(func(tr *util.TrackListModel) int64 { return tr.Track.Size })
|
t.intSort(func(tr *util.TrackListModel) int64 { return tr.Track().Size })
|
||||||
case ColumnPlays:
|
case ColumnPlays:
|
||||||
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.PlayCount) })
|
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().PlayCount) })
|
||||||
case ColumnComment:
|
case ColumnComment:
|
||||||
t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Comment })
|
t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().Comment })
|
||||||
case ColumnBitrate:
|
case ColumnBitrate:
|
||||||
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.BitRate) })
|
t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().BitRate) })
|
||||||
case ColumnFavorite:
|
case ColumnFavorite:
|
||||||
t.intSort(func(tr *util.TrackListModel) int64 {
|
t.intSort(func(tr *util.TrackListModel) int64 {
|
||||||
if tr.Track.Favorite {
|
if tr.Track().Favorite {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
@@ -492,13 +492,13 @@ func (t *Tracklist) selectAddOrRemove(idx int) {
|
|||||||
func (t *Tracklist) selectTrack(idx int) {
|
func (t *Tracklist) selectTrack(idx int) {
|
||||||
t.tracksMutex.RLock()
|
t.tracksMutex.RLock()
|
||||||
defer t.tracksMutex.RUnlock()
|
defer t.tracksMutex.RUnlock()
|
||||||
util.SelectTrack(t.tracks, idx)
|
util.SelectItem(t.tracks, idx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tracklist) selectRange(idx int) {
|
func (t *Tracklist) selectRange(idx int) {
|
||||||
t.tracksMutex.RLock()
|
t.tracksMutex.RLock()
|
||||||
defer t.tracksMutex.RUnlock()
|
defer t.tracksMutex.RUnlock()
|
||||||
util.SelectTrackRange(t.tracks, idx)
|
util.SelectItemRange(t.tracks, idx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
|
func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
|
||||||
@@ -534,7 +534,7 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
|
|||||||
t.songRadioMenuItem = fyne.NewMenuItem("Play song radio", func() {
|
t.songRadioMenuItem = fyne.NewMenuItem("Play song radio", func() {
|
||||||
t.onPlaySongRadio(t.selectedTracks())
|
t.onPlaySongRadio(t.selectedTracks())
|
||||||
})
|
})
|
||||||
t.songRadioMenuItem.Icon = myTheme.BroadcastIcon
|
t.songRadioMenuItem.Icon = myTheme.RadioIcon
|
||||||
t.ctxMenu.Items = append(t.ctxMenu.Items,
|
t.ctxMenu.Items = append(t.ctxMenu.Items,
|
||||||
play, shuffle, playNext, add, t.songRadioMenuItem)
|
play, shuffle, playNext, add, t.songRadioMenuItem)
|
||||||
}
|
}
|
||||||
@@ -581,9 +581,9 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
|
|||||||
|
|
||||||
func (t *Tracklist) onSetFavorite(trackID string, fav bool) {
|
func (t *Tracklist) onSetFavorite(trackID string, fav bool) {
|
||||||
t.tracksMutex.RLock()
|
t.tracksMutex.RLock()
|
||||||
tr, _ := util.FindTrackByID(t.tracks, trackID)
|
item, _ := util.FindItemByID(t.tracks, trackID)
|
||||||
t.tracksMutex.RUnlock()
|
t.tracksMutex.RUnlock()
|
||||||
t.onSetFavorites([]*mediaprovider.Track{tr}, fav, false)
|
t.onSetFavorites([]*mediaprovider.Track{item.(*mediaprovider.Track)}, fav, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tracklist) onSetFavorites(tracks []*mediaprovider.Track, fav bool, needRefresh bool) {
|
func (t *Tracklist) onSetFavorites(tracks []*mediaprovider.Track, fav bool, needRefresh bool) {
|
||||||
@@ -602,9 +602,9 @@ func (t *Tracklist) onSetFavorites(tracks []*mediaprovider.Track, fav bool, need
|
|||||||
func (t *Tracklist) onSetRating(trackID string, rating int) {
|
func (t *Tracklist) onSetRating(trackID string, rating int) {
|
||||||
// update our own track model
|
// update our own track model
|
||||||
t.tracksMutex.RLock()
|
t.tracksMutex.RLock()
|
||||||
tr, _ := util.FindTrackByID(t.tracks, trackID)
|
item, _ := util.FindItemByID(t.tracks, trackID)
|
||||||
t.tracksMutex.RUnlock()
|
t.tracksMutex.RUnlock()
|
||||||
t.onSetRatings([]*mediaprovider.Track{tr}, rating, false)
|
t.onSetRatings([]*mediaprovider.Track{item.(*mediaprovider.Track)}, rating, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tracklist) onSetRatings(tracks []*mediaprovider.Track, rating int, needRefresh bool) {
|
func (t *Tracklist) onSetRatings(tracks []*mediaprovider.Track, rating int, needRefresh bool) {
|
||||||
@@ -663,7 +663,7 @@ func (t *Tracklist) selectedTracks() []*mediaprovider.Track {
|
|||||||
func (t *Tracklist) SelectedTrackIDs() []string {
|
func (t *Tracklist) SelectedTrackIDs() []string {
|
||||||
t.tracksMutex.RLock()
|
t.tracksMutex.RLock()
|
||||||
defer t.tracksMutex.RUnlock()
|
defer t.tracksMutex.RUnlock()
|
||||||
return util.SelectedTrackIDs(t.tracks)
|
return util.SelectedItemIDs(t.tracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tracklist) lenTracks() int {
|
func (t *Tracklist) lenTracks() int {
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ func NewExpandedTracklistRow(tracklist *Tracklist, im *backend.ImageManager, pla
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *ExpandedTracklistRow) Update(tm *util.TrackListModel, rowNum int) {
|
func (t *ExpandedTracklistRow) Update(tm *util.TrackListModel, rowNum int) {
|
||||||
if t.trackID != tm.Track.ID {
|
if t.trackID != tm.Track().ID {
|
||||||
t.imageLoader.Load(tm.Track.CoverArtID)
|
t.imageLoader.Load(tm.Track().CoverArtID)
|
||||||
}
|
}
|
||||||
t.tracklistRowBase.Update(tm, rowNum)
|
t.tracklistRowBase.Update(tm, rowNum)
|
||||||
}
|
}
|
||||||
@@ -256,12 +256,12 @@ func (t *tracklistRowBase) Update(tm *util.TrackListModel, rowNum int) {
|
|||||||
|
|
||||||
// Update info that can change if this row is bound to
|
// Update info that can change if this row is bound to
|
||||||
// a new track (*mediaprovider.Track)
|
// a new track (*mediaprovider.Track)
|
||||||
tr := tm.Track
|
tr := tm.Track()
|
||||||
if tr.ID != t.trackID {
|
if id := tr.ID; id != t.trackID {
|
||||||
t.EnsureUnfocused()
|
t.EnsureUnfocused()
|
||||||
t.trackID = tr.ID
|
t.trackID = id
|
||||||
|
|
||||||
t.name.Segments[0].(*widget.TextSegment).Text = tr.Name
|
t.name.Segments[0].(*widget.TextSegment).Text = tr.Title
|
||||||
t.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs)
|
t.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs)
|
||||||
t.album.BuildSegments([]string{tr.Album}, []string{tr.AlbumID})
|
t.album.BuildSegments([]string{tr.Album}, []string{tr.AlbumID})
|
||||||
t.dur.Text = util.SecondsToTimeString(float64(tr.Duration))
|
t.dur.Text = util.SecondsToTimeString(float64(tr.Duration))
|
||||||
|
|||||||
Reference in New Issue
Block a user