add features

This commit is contained in:
2026-07-29 04:35:25 -05:00
parent 23c6113b33
commit cab5a987b0
29 changed files with 1179 additions and 89 deletions
+11 -4
View File
@@ -25,7 +25,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/20after4/configdir" "github.com/20after4/configdir"
"github.com/zalando/go-keyring"
) )
const ( const (
@@ -57,6 +56,7 @@ type App struct {
LocalPlayer *mpv.Player LocalPlayer *mpv.Player
UpdateChecker UpdateChecker UpdateChecker UpdateChecker
MPRISHandler *MPRISHandler MPRISHandler *MPRISHandler
DiscordPresence *DiscordPresence
WinSMTC *windows.SMTC WinSMTC *windows.SMTC
ipcServer ipc.IPCServer ipcServer ipc.IPCServer
@@ -150,7 +150,8 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
return nil, err return nil, err
} }
a.ServerManager = NewServerManager(appName, appVersion, a.Config, !portableMode && a.Config.Application.EnablePasswordStorage) a.ServerManager = NewServerManager(appName, appVersion, a.Config, confDir,
a.Config.Application.EnablePasswordStorage, !portableMode)
a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, cacheDir) a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, cacheDir)
if a.Config.Playback.UseWaveformSeekbar { if a.Config.Playback.UseWaveformSeekbar {
ac, err := NewAudioCache(a.bgrndCtx, a.ServerManager, filepath.Join(cacheDir, audioCacheSubdir)) ac, err := NewAudioCache(a.bgrndCtx, a.ServerManager, filepath.Join(cacheDir, audioCacheSubdir))
@@ -215,6 +216,9 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
a.PlaybackManager.OnSongChange(func(_ mediaprovider.MediaItem, _ *mediaprovider.Track) { a.PlaybackManager.OnSongChange(func(_ mediaprovider.MediaItem, _ *mediaprovider.Track) {
go a.SavePlayQueueIfEnabled() go a.SavePlayQueueIfEnabled()
}) })
a.DiscordPresence = NewDiscordPresence(a.PlaybackManager, func() mediaprovider.MediaProvider {
return a.ServerManager.GetServer()
}, newMusicBrainzArtworkResolver(a.bgrndCtx, fmt.Sprintf("Supersonic/%s (%s)", appVersion, latestReleaseURL)))
// Start IPC server if another not already running in a different instance // Start IPC server if another not already running in a different instance
if cli == nil { if cli == nil {
@@ -559,9 +563,9 @@ func (a *App) LoginToDefaultServer() error {
if serverCfg == nil { if serverCfg == nil {
return ErrNoServers return ErrNoServers
} }
pass, err := keyring.Get(a.appName, serverCfg.ID.String()) pass, err := a.ServerManager.GetServerPassword(serverCfg.ID)
if err != nil { if err != nil {
return fmt.Errorf("error reading keyring credentials: %v", err) return fmt.Errorf("error reading saved credentials: %v", err)
} }
return a.ServerManager.ConnectToServer(serverCfg, pass) return a.ServerManager.ConnectToServer(serverCfg, pass)
} }
@@ -604,6 +608,9 @@ func (a *App) Shutdown() {
if a.WinSMTC != nil { if a.WinSMTC != nil {
a.WinSMTC.Shutdown() a.WinSMTC.Shutdown()
} }
if a.DiscordPresence != nil {
a.DiscordPresence.Shutdown()
}
a.PlaybackManager.DisableCallbacks() a.PlaybackManager.DisableCallbacks()
a.PlaybackManager.Shutdown() // will trigger scrobble check a.PlaybackManager.Shutdown() // will trigger scrobble check
if a.AudioCache != nil { if a.AudioCache != nil {
+2 -1
View File
@@ -30,6 +30,7 @@ type ServerConfig struct {
Nickname string Nickname string
Default bool Default bool
SelectedLibrary string SelectedLibrary string
EnablePodcasts bool
} }
type AppConfig struct { type AppConfig struct {
@@ -136,7 +137,7 @@ type LocalPlaybackConfig struct {
InMemoryCacheSizeMB int InMemoryCacheSizeMB int
Volume int Volume int
EqualizerEnabled bool EqualizerEnabled bool
EqualizerType string // "ISO10Band" or "ISO15Band" EqualizerType string // "ISO10Band" or "ISO15Band"
EqualizerPreamp float64 EqualizerPreamp float64
GraphicEqualizerBands []float64 GraphicEqualizerBands []float64
ActiveEQPresetName string // Name of currently selected EQ preset ActiveEQPresetName string // Name of currently selected EQ preset
+266
View File
@@ -0,0 +1,266 @@
package backend
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"os"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player"
"github.com/hugolgst/rich-go/ipc"
)
const discordApplicationID = "1531906238919475352"
// DiscordPresence publishes playback information to the local Discord desktop
// client. All RPC failures are intentionally non-fatal: Discord may not be
// installed or running, and that should never affect playback.
type DiscordPresence struct {
pm *PlaybackManager
server func() mediaprovider.MediaProvider
mu sync.Mutex
loggedIn bool
shareURL map[string]string
artURL map[string]string
artwork *musicBrainzArtworkResolver
}
type discordRPCActivity struct {
Type int `json:"type"`
StatusDisplayType int `json:"status_display_type"`
Details string `json:"details,omitempty"`
State string `json:"state,omitempty"`
Timestamps *discordRPCTimestamps `json:"timestamps,omitempty"`
Assets *discordRPCAssets `json:"assets,omitempty"`
Buttons []discordRPCButton `json:"buttons,omitempty"`
}
type discordRPCTimestamps struct {
Start int64 `json:"start,omitempty"`
End int64 `json:"end,omitempty"`
}
type discordRPCAssets struct {
LargeImage string `json:"large_image,omitempty"`
LargeText string `json:"large_text,omitempty"`
}
type discordRPCButton struct {
Label string `json:"label"`
URL string `json:"url"`
}
func NewDiscordPresence(pm *PlaybackManager, server func() mediaprovider.MediaProvider, artwork *musicBrainzArtworkResolver) *DiscordPresence {
d := &DiscordPresence{pm: pm, server: server, shareURL: make(map[string]string), artURL: make(map[string]string), artwork: artwork}
pm.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) {
d.update()
d.createShareURL(item)
d.resolveArtwork(item)
})
pm.OnRadioMetadataChange(func(_, _, _ string) { d.update() })
pm.OnPlaying(d.update)
pm.OnPaused(d.update)
pm.OnSeek(d.update)
pm.OnStopped(d.clear)
return d
}
func (d *DiscordPresence) loginLocked() bool {
if d.loggedIn {
return true
}
if err := ipc.OpenSocket(); err != nil {
return false
}
payload, err := json.Marshal(map[string]string{"v": "1", "client_id": discordApplicationID})
if err != nil {
_ = ipc.CloseSocket()
return false
}
ipc.Send(0, string(payload))
d.loggedIn = true
return true
}
func (d *DiscordPresence) update() {
item := d.pm.NowPlaying()
if item == nil || d.pm.PlaybackStatus().State == player.Stopped {
d.clear()
return
}
metadata := item.Metadata()
status := d.pm.PlaybackStatus()
activity := discordActivity(metadata, status)
d.mu.Lock()
defer d.mu.Unlock()
if shareURL := d.shareURL[metadata.ID]; shareURL != "" && metadata.Type == mediaprovider.MediaItemTypeTrack {
log.Printf("Discord share URL for %q: %s", metadata.Name, shareURL)
activity.Buttons = []discordRPCButton{{Label: "View Song", URL: shareURL}}
}
if url := d.artURL[metadata.ID]; url != "" {
activity.Assets.LargeImage = url
}
if d.loginLocked() {
if err := setDiscordActivity(activity); err != nil {
log.Printf("failed to update Discord Rich Presence: %v", err)
}
}
}
func (d *DiscordPresence) resolveArtwork(item mediaprovider.MediaItem) {
if item == nil || d.artwork == nil {
return
}
metadata := item.Metadata()
if metadata.Album == "" || len(metadata.Artists) == 0 {
return
}
id := metadata.ID
d.mu.Lock()
_, exists := d.artURL[id]
d.mu.Unlock()
if exists {
return
}
go func() {
artURL := d.artwork.Resolve(metadata.Artists[0], metadata.Album)
d.mu.Lock()
d.artURL[id] = artURL
d.mu.Unlock()
if artURL != "" {
if current := d.pm.NowPlaying(); current != nil && current.Metadata().ID == id {
d.update()
}
}
}()
}
func discordActivity(metadata mediaprovider.MediaItemMetadata, status PlaybackStatus) discordRPCActivity {
artist := strings.Join(metadata.Artists, ", ")
state := artist
if metadata.Album != "" {
if state != "" {
state += " — "
}
state += metadata.Album
}
if status.State == player.Paused {
if state != "" {
state += " • Paused"
} else {
state = "Paused"
}
}
activity := discordRPCActivity{
Type: 2, // Listening
StatusDisplayType: 2, // Use Details (the song title) as the profile headline.
Details: discordText(metadata.Name),
State: discordText(state),
Assets: &discordRPCAssets{LargeText: "Listening with Supersonic"},
}
if status.State == player.Playing {
started := time.Now().Add(-time.Duration(status.TimePos * float64(time.Second)))
activity.Timestamps = &discordRPCTimestamps{Start: started.UnixMilli()}
if metadata.Duration > 0 {
activity.Timestamps.End = started.Add(metadata.Duration).UnixMilli()
}
}
return activity
}
func setDiscordActivity(activity discordRPCActivity) error {
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return err
}
payload, err := json.Marshal(struct {
Command string `json:"cmd"`
Args struct {
PID int `json:"pid"`
Activity discordRPCActivity `json:"activity"`
} `json:"args"`
Nonce string `json:"nonce"`
}{
Command: "SET_ACTIVITY",
Args: struct {
PID int `json:"pid"`
Activity discordRPCActivity `json:"activity"`
}{PID: os.Getpid(), Activity: activity},
Nonce: hex.EncodeToString(nonceBytes),
})
if err != nil {
return err
}
ipc.Send(1, string(payload))
return nil
}
// Discord limits details and state to 128 characters. It also rejects a
// non-empty field shorter than two characters.
func discordText(value string) string {
value = strings.TrimSpace(value)
if utf8.RuneCountInString(value) < 2 {
return ""
}
runes := []rune(value)
if len(runes) > 128 {
value = string(runes[:127]) + "…"
}
return value
}
func (d *DiscordPresence) createShareURL(item mediaprovider.MediaItem) {
if item == nil || item.Metadata().Type != mediaprovider.MediaItemTypeTrack {
return
}
id := item.Metadata().ID
d.mu.Lock()
_, exists := d.shareURL[id]
d.mu.Unlock()
if exists {
return
}
go func() {
server := d.server()
sharing, ok := server.(mediaprovider.SupportsSharing)
if !ok {
return
}
url, err := sharing.CreateShareURL(id)
if err != nil {
log.Printf("failed to create Discord download share for track: %v", err)
return
}
d.mu.Lock()
d.shareURL[id] = url.String()
d.mu.Unlock()
log.Printf("Created Discord share URL for %q: %s", item.Metadata().Name, url.String())
if current := d.pm.NowPlaying(); current != nil && current.Metadata().ID == id {
d.update()
}
}()
}
func (d *DiscordPresence) clear() {
d.mu.Lock()
defer d.mu.Unlock()
if d.loggedIn {
// Closing RPC clears this application's activity in Discord.
_ = ipc.CloseSocket()
d.loggedIn = false
}
}
func (d *DiscordPresence) Shutdown() {
d.clear()
}
+7
View File
@@ -28,6 +28,13 @@ func NewLyricsManager(sm *ServerManager, lrclib *LrcLibFetcher) *LyricsManager {
} }
func (lm *LyricsManager) FetchLyricsAsync(song *mediaprovider.Track, cb func(string, *mediaprovider.Lyrics)) { func (lm *LyricsManager) FetchLyricsAsync(song *mediaprovider.Track, cb func(string, *mediaprovider.Lyrics)) {
if song == nil {
if cb != nil {
cb("", nil)
}
return
}
lm.lock.Lock() lm.lock.Lock()
defer lm.lock.Unlock() defer lm.lock.Unlock()
+22
View File
@@ -0,0 +1,22 @@
package backend
import (
"testing"
"github.com/dweymouth/supersonic/backend/mediaprovider"
)
func TestFetchLyricsAsyncWithNilTrack(t *testing.T) {
lm := &LyricsManager{}
called := false
lm.FetchLyricsAsync(nil, func(id string, lyrics *mediaprovider.Lyrics) {
called = true
if id != "" || lyrics != nil {
t.Fatalf("got callback values (%q, %v), want empty ID and nil lyrics", id, lyrics)
}
})
if !called {
t.Fatal("callback was not called")
}
}
+9
View File
@@ -196,6 +196,15 @@ type Server interface {
MediaProvider() MediaProvider MediaProvider() MediaProvider
} }
// PodcastProvider is implemented by Subsonic servers with the standard podcast API.
// Podcast IDs are opaque and must never be converted to numeric IDs.
type PodcastProvider interface {
GetPodcastChannels() ([]*PodcastChannel, error)
GetPodcastChannel(id string) (*PodcastChannel, error)
GetNewestPodcastEpisodes(count int) ([]*PodcastEpisode, error)
GetPodcastEpisode(id string) (*PodcastEpisode, error)
}
type MediaProvider interface { type MediaProvider interface {
SetPrefetchCoverCallback(cb func(coverArtID string)) SetPrefetchCoverCallback(cb func(coverArtID string))
+36
View File
@@ -221,8 +221,44 @@ type MediaItemType int
const ( const (
MediaItemTypeTrack MediaItemType = iota MediaItemTypeTrack MediaItemType = iota
MediaItemTypeRadioStation MediaItemTypeRadioStation
MediaItemTypePodcastEpisode
) )
type PodcastChannel struct {
ID, URL, Title, Description, CoverArtID, OriginalImageURL, Status, ErrorMessage string
Episodes []*PodcastEpisode
}
type PodcastEpisode struct {
ID, StreamID, ChannelID, Title, Description, Status, CoverArtID, OriginalImageURL, ChannelTitle string
PublishDate time.Time
Duration time.Duration
Size int64
BitRate int
ContentType string
}
func (p *PodcastEpisode) Playable() bool {
return p != nil && p.Status == "completed" && p.StreamID != ""
}
func (p *PodcastEpisode) Metadata() MediaItemMetadata {
if p == nil {
return MediaItemMetadata{}
}
return MediaItemMetadata{Type: MediaItemTypePodcastEpisode, MIMEType: p.ContentType,
ID: p.ID, Name: p.Title, Artists: []string{p.ChannelTitle}, Album: p.ChannelTitle,
CoverArtID: p.CoverArtID, Duration: p.Duration, Size: p.Size, BitRate: p.BitRate}
}
func (p *PodcastEpisode) Copy() MediaItem {
if p == nil {
return nil
}
c := *p
return &c
}
type MediaItemMetadata struct { type MediaItemMetadata struct {
Type MediaItemType Type MediaItemType
MIMEType string MIMEType string
+162
View File
@@ -0,0 +1,162 @@
package subsonic
import (
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/dweymouth/supersonic/backend/mediaprovider"
)
type podcastChannelDTO struct {
ID string `xml:"id,attr" json:"id"`
URL string `xml:"url,attr" json:"url"`
Title string `xml:"title,attr" json:"title"`
Description string `xml:"description,attr" json:"description"`
CoverArt string `xml:"coverArt,attr" json:"coverArt"`
OriginalImageURL string `xml:"originalImageUrl,attr" json:"originalImageUrl"`
Status string `xml:"status,attr" json:"status"`
ErrorMessage string `xml:"errorMessage,attr" json:"errorMessage"`
Episodes []podcastEpisodeDTO `xml:"episode" json:"episode"`
}
type podcastEpisodeDTO struct {
ID string `xml:"id,attr" json:"id"`
StreamID string `xml:"streamId,attr" json:"streamId"`
ChannelID string `xml:"channelId,attr" json:"channelId"`
Title string `xml:"title,attr" json:"title"`
Description string `xml:"description,attr" json:"description"`
Status string `xml:"status,attr" json:"status"`
CoverArt string `xml:"coverArt,attr" json:"coverArt"`
PublishDate time.Time `xml:"publishDate,attr" json:"publishDate"`
Duration int `xml:"duration,attr" json:"duration"`
Size int64 `xml:"size,attr" json:"size"`
BitRate int `xml:"bitRate,attr" json:"bitRate"`
ContentType string `xml:"contentType,attr" json:"contentType"`
}
type podcastAPIResponse struct {
XMLName xml.Name `xml:"subsonic-response" json:"-"`
Status string `xml:"status,attr" json:"status"`
Error *struct {
Code int `xml:"code,attr" json:"code"`
Message string `xml:"message,attr" json:"message"`
} `xml:"error" json:"error"`
Podcasts struct {
Channels []podcastChannelDTO `xml:"channel" json:"channel"`
} `xml:"podcasts" json:"podcasts"`
Newest struct {
Episodes []podcastEpisodeDTO `xml:"episode" json:"episode"`
} `xml:"newestPodcasts" json:"newestPodcasts"`
Episode *podcastEpisodeDTO `xml:"podcastEpisode" json:"podcastEpisode"`
}
func (s *subsonicMediaProvider) podcastRequest(endpoint string, params url.Values) (*podcastAPIResponse, error) {
endpoint += ".view"
resp, err := s.client.Request(http.MethodGet, endpoint, params)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("podcast API: HTTP %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var out podcastAPIResponse
if s.client.UseJSON {
var wrapper struct {
Response podcastAPIResponse `json:"subsonic-response"`
}
if err = json.Unmarshal(body, &wrapper); err != nil {
return nil, err
}
out = wrapper.Response
} else if err = xml.Unmarshal(body, &out); err != nil {
return nil, err
}
if out.Status != "ok" {
if out.Error != nil {
return nil, fmt.Errorf("podcast API error %d: %s", out.Error.Code, out.Error.Message)
}
return nil, fmt.Errorf("podcast API returned status %q", out.Status)
}
return &out, nil
}
func episodeFromDTO(e podcastEpisodeDTO, ch *mediaprovider.PodcastChannel) *mediaprovider.PodcastEpisode {
p := &mediaprovider.PodcastEpisode{ID: e.ID, StreamID: e.StreamID, ChannelID: e.ChannelID, Title: e.Title,
Description: e.Description, Status: e.Status, CoverArtID: e.CoverArt, PublishDate: e.PublishDate,
Duration: time.Duration(e.Duration) * time.Second, Size: e.Size, BitRate: e.BitRate, ContentType: e.ContentType}
if ch != nil {
p.ChannelTitle = ch.Title
if p.ChannelID == "" {
p.ChannelID = ch.ID
}
if p.CoverArtID == "" {
p.CoverArtID = ch.CoverArtID
}
p.OriginalImageURL = ch.OriginalImageURL
}
return p
}
func channelFromDTO(c podcastChannelDTO) *mediaprovider.PodcastChannel {
ch := &mediaprovider.PodcastChannel{ID: c.ID, URL: c.URL, Title: c.Title, Description: c.Description,
CoverArtID: c.CoverArt, OriginalImageURL: c.OriginalImageURL, Status: c.Status, ErrorMessage: c.ErrorMessage}
for _, e := range c.Episodes {
ch.Episodes = append(ch.Episodes, episodeFromDTO(e, ch))
}
return ch
}
func (s *subsonicMediaProvider) GetPodcastChannels() ([]*mediaprovider.PodcastChannel, error) {
r, err := s.podcastRequest("getPodcasts", url.Values{"includeEpisodes": {"false"}})
if err != nil {
return nil, err
}
result := make([]*mediaprovider.PodcastChannel, 0, len(r.Podcasts.Channels))
for _, c := range r.Podcasts.Channels {
result = append(result, channelFromDTO(c))
}
return result, nil
}
func (s *subsonicMediaProvider) GetPodcastChannel(id string) (*mediaprovider.PodcastChannel, error) {
r, err := s.podcastRequest("getPodcasts", url.Values{"id": {id}, "includeEpisodes": {"true"}})
if err != nil {
return nil, err
}
if len(r.Podcasts.Channels) == 0 {
return nil, errors.New("podcast channel not found")
}
return channelFromDTO(r.Podcasts.Channels[0]), nil
}
func (s *subsonicMediaProvider) GetNewestPodcastEpisodes(count int) ([]*mediaprovider.PodcastEpisode, error) {
r, err := s.podcastRequest("getNewestPodcasts", url.Values{"count": {strconv.Itoa(count)}})
if err != nil {
return nil, err
}
result := make([]*mediaprovider.PodcastEpisode, 0, len(r.Newest.Episodes))
for _, e := range r.Newest.Episodes {
result = append(result, episodeFromDTO(e, nil))
}
return result, nil
}
func (s *subsonicMediaProvider) GetPodcastEpisode(id string) (*mediaprovider.PodcastEpisode, error) {
r, err := s.podcastRequest("getPodcastEpisode", url.Values{"id": {id}})
if err != nil {
return nil, err
}
if r.Episode == nil {
return nil, errors.New("podcast episode not found")
}
return episodeFromDTO(*r.Episode, nil), nil
}
+147
View File
@@ -0,0 +1,147 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
coverArtAPIBase = "https://coverartarchive.org"
)
// musicBrainzArtworkResolver resolves public album artwork URLs while obeying
// MusicBrainz's one-request-per-second client limit.
type musicBrainzArtworkResolver struct {
ctx context.Context
client *http.Client
userAgent string
mu sync.Mutex
lastCall time.Time
cache map[string]string
inFlight map[string][]chan string
}
func newMusicBrainzArtworkResolver(ctx context.Context, userAgent string) *musicBrainzArtworkResolver {
return &musicBrainzArtworkResolver{
ctx: ctx, client: &http.Client{Timeout: 10 * time.Second}, userAgent: userAgent,
cache: make(map[string]string), inFlight: make(map[string][]chan string),
}
}
func (r *musicBrainzArtworkResolver) Resolve(artist, album string) string {
artist, album = strings.TrimSpace(artist), strings.TrimSpace(album)
if artist == "" || album == "" {
return ""
}
key := strings.ToLower(artist + "\x00" + album)
r.mu.Lock()
if result, ok := r.cache[key]; ok {
r.mu.Unlock()
return result
}
if waiters, ok := r.inFlight[key]; ok {
wait := make(chan string, 1)
r.inFlight[key] = append(waiters, wait)
r.mu.Unlock()
select {
case result := <-wait:
return result
case <-r.ctx.Done():
return ""
}
}
r.inFlight[key] = nil
r.mu.Unlock()
result := r.lookup(artist, album)
r.mu.Lock()
r.cache[key] = result
for _, waiter := range r.inFlight[key] {
waiter <- result
close(waiter)
}
delete(r.inFlight, key)
r.mu.Unlock()
return result
}
func (r *musicBrainzArtworkResolver) lookup(artist, album string) string {
if !r.waitForRateLimit() {
return ""
}
query := fmt.Sprintf(`releasegroup:"%s" AND artist:"%s"`, luceneEscape(album), luceneEscape(artist))
endpoint := musicBrainzAPIBase + "/release-group/?fmt=json&limit=1&query=" + url.QueryEscape(query)
req, err := http.NewRequestWithContext(r.ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ""
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", r.userAgent)
resp, err := r.client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
var result struct {
ReleaseGroups []struct {
ID string `json:"id"`
} `json:"release-groups"`
}
if json.NewDecoder(resp.Body).Decode(&result) != nil || len(result.ReleaseGroups) == 0 {
return ""
}
artURL := coverArtAPIBase + "/release-group/" + result.ReleaseGroups[0].ID + "/front-500"
artReq, err := http.NewRequestWithContext(r.ctx, http.MethodHead, artURL, nil)
if err != nil {
return ""
}
// Validate the Cover Art Archive endpoint itself without following the
// redirect to an archive.org image host. Some archive mirrors reject HEAD
// even though Discord can fetch the redirected image normally.
artClient := *r.client
artClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
artResp, err := artClient.Do(artReq)
if err != nil {
return ""
}
artResp.Body.Close()
validRedirect := artResp.StatusCode >= 300 && artResp.StatusCode < 400 && artResp.Header.Get("Location") != ""
if (artResp.StatusCode < 200 || artResp.StatusCode >= 300) && !validRedirect {
return ""
}
return artURL
}
func (r *musicBrainzArtworkResolver) waitForRateLimit() bool {
r.mu.Lock()
wait := time.Until(r.lastCall.Add(time.Second))
if wait < 0 {
wait = 0
}
r.lastCall = time.Now().Add(wait)
r.mu.Unlock()
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-timer.C:
return true
case <-r.ctx.Done():
return false
}
}
func luceneEscape(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
return strings.ReplaceAll(value, `"`, `\"`)
}
+4
View File
@@ -1043,6 +1043,10 @@ func (p *playbackEngine) getMediaURLForIdx(idx int) string {
} }
} }
url, _ = p.sm.Server.GetStreamURL(tr.ID, ts, p.transcodeCfg.ForceRawFile) url, _ = p.sm.Server.GetStreamURL(tr.ID, ts, p.transcodeCfg.ForceRawFile)
} else if ep, ok := item.(*mediaprovider.PodcastEpisode); ok {
if ep.Playable() {
url, _ = p.sm.Server.GetStreamURL(ep.StreamID, nil, true)
}
} else { } else {
url = item.(*mediaprovider.RadioStation).StreamURL url = item.(*mediaprovider.RadioStation).StreamURL
} }
+120 -13
View File
@@ -3,11 +3,15 @@ package backend
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"strings" "strings"
"sync"
"time" "time"
"github.com/dweymouth/go-jellyfin" "github.com/dweymouth/go-jellyfin"
@@ -26,6 +30,9 @@ type ServerManager struct {
Server mediaprovider.MediaProvider Server mediaprovider.MediaProvider
useKeyring bool useKeyring bool
storePasswords bool
credentialFile string
credentialFileMu sync.Mutex
prefetchCoverCB func(string) prefetchCoverCB func(string)
appName string appName string
appVersion string appVersion string
@@ -36,12 +43,14 @@ type ServerManager struct {
var ErrUnreachable = errors.New("server is unreachable") var ErrUnreachable = errors.New("server is unreachable")
func NewServerManager(appName, appVersion string, config *Config, useKeyring bool) *ServerManager { func NewServerManager(appName, appVersion string, config *Config, configDir string, storePasswords, useKeyring bool) *ServerManager {
return &ServerManager{ return &ServerManager{
appName: appName, appName: appName,
appVersion: appVersion, appVersion: appVersion,
config: config, config: config,
useKeyring: useKeyring, useKeyring: useKeyring,
storePasswords: storePasswords,
credentialFile: filepath.Join(configDir, "credentials.json"),
} }
} }
@@ -147,9 +156,13 @@ func (s *ServerManager) Logout(deletePassword bool) {
} }
func (s *ServerManager) deleteServerPassword(serverID uuid.UUID) { func (s *ServerManager) deleteServerPassword(serverID uuid.UUID) {
if s.useKeyring { if !s.storePasswords {
keyring.Delete(s.appName, s.ServerID.String()) return
} }
if s.useKeyring {
_ = keyring.Delete(s.appName, serverID.String())
}
_ = s.deleteFilePassword(serverID)
} }
// Sets a callback that is invoked when a server is connected to. // Sets a callback that is invoked when a server is connected to.
@@ -163,17 +176,111 @@ func (s *ServerManager) OnLogout(cb func()) {
} }
func (s *ServerManager) GetServerPassword(serverID uuid.UUID) (string, error) { func (s *ServerManager) GetServerPassword(serverID uuid.UUID) (string, error) {
if s.useKeyring { if !s.storePasswords {
return keyring.Get(s.appName, serverID.String()) return "", errors.New("password storage not enabled")
} }
return "", errors.New("keyring not enabled") if s.useKeyring {
if password, err := keyring.Get(s.appName, serverID.String()); err == nil {
return password, nil
} else {
log.Printf("OS keyring unavailable, using local credential store: %v", err)
}
}
return s.getFilePassword(serverID)
} }
func (s *ServerManager) SetServerPassword(server *ServerConfig, password string) error { func (s *ServerManager) SetServerPassword(server *ServerConfig, password string) error {
if s.useKeyring { if !s.storePasswords {
return keyring.Set(s.appName, server.ID.String(), password) return errors.New("password storage not enabled")
} }
return errors.New("keyring not available") if s.useKeyring {
if err := keyring.Set(s.appName, server.ID.String(), password); err == nil {
_ = s.deleteFilePassword(server.ID)
return nil
} else {
log.Printf("OS keyring unavailable, using local credential store: %v", err)
}
}
return s.setFilePassword(server.ID, password)
}
func (s *ServerManager) readFilePasswords() (map[string]string, error) {
passwords := make(map[string]string)
data, err := os.ReadFile(s.credentialFile)
if errors.Is(err, os.ErrNotExist) {
return passwords, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, &passwords); err != nil {
return nil, fmt.Errorf("read saved credentials: %w", err)
}
return passwords, nil
}
func (s *ServerManager) writeFilePasswords(passwords map[string]string) error {
tmp, err := os.CreateTemp(filepath.Dir(s.credentialFile), ".credentials-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return err
}
if err := json.NewEncoder(tmp).Encode(passwords); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, s.credentialFile)
}
func (s *ServerManager) getFilePassword(serverID uuid.UUID) (string, error) {
s.credentialFileMu.Lock()
defer s.credentialFileMu.Unlock()
passwords, err := s.readFilePasswords()
if err != nil {
return "", err
}
password, ok := passwords[serverID.String()]
if !ok {
return "", errors.New("no saved password")
}
return password, nil
}
func (s *ServerManager) setFilePassword(serverID uuid.UUID, password string) error {
s.credentialFileMu.Lock()
defer s.credentialFileMu.Unlock()
passwords, err := s.readFilePasswords()
if err != nil {
return err
}
passwords[serverID.String()] = password
return s.writeFilePasswords(passwords)
}
func (s *ServerManager) deleteFilePassword(serverID uuid.UUID) error {
s.credentialFileMu.Lock()
defer s.credentialFileMu.Unlock()
passwords, err := s.readFilePasswords()
if err != nil {
return err
}
delete(passwords, serverID.String())
if len(passwords) == 0 {
err := os.Remove(s.credentialFile)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
return s.writeFilePasswords(passwords)
} }
func (s *ServerManager) connect(connection ServerConnection, password string) (mediaprovider.Server, error) { func (s *ServerManager) connect(connection ServerConnection, password string) (mediaprovider.Server, error) {
+28 -1
View File
@@ -1,6 +1,33 @@
package backend package backend
import "testing" import (
"os"
"path/filepath"
"testing"
)
func TestServerManagerFilePasswordFallback(t *testing.T) {
configDir := t.TempDir()
manager := NewServerManager("supersonic-test", "test", DefaultConfig("test"), configDir, true, false)
server := manager.AddServer("Test", ServerConnection{})
if err := manager.SetServerPassword(server, "secret"); err != nil {
t.Fatalf("SetServerPassword: %v", err)
}
if got, err := manager.GetServerPassword(server.ID); err != nil || got != "secret" {
t.Fatalf("GetServerPassword = %q, %v; want secret, nil", got, err)
}
if info, err := os.Stat(filepath.Join(configDir, "credentials.json")); err != nil {
t.Fatalf("stat credential file: %v", err)
} else if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("credential file permissions = %o; want 600", got)
}
manager.DeleteServer(server.ID)
if _, err := manager.GetServerPassword(server.ID); err == nil {
t.Fatal("GetServerPassword succeeded after server deletion")
}
}
func TestNormalizeServerURL(t *testing.T) { func TestNormalizeServerURL(t *testing.T) {
tests := []struct { tests := []struct {
+2
View File
@@ -18,6 +18,7 @@ require (
github.com/godbus/dbus/v5 v5.2.2 github.com/godbus/dbus/v5 v5.2.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8 github.com/hashicorp/go-retryablehttp v0.7.8
github.com/hugolgst/rich-go v0.0.0-20240715122152-74618cc1ace2
github.com/pelletier/go-toml/v2 v2.4.2 github.com/pelletier/go-toml/v2 v2.4.2
github.com/quarckster/go-mpris-server v1.0.3 github.com/quarckster/go-mpris-server v1.0.3
github.com/supersonic-app/fyne-lyrics v0.0.0-20250614151306-b1880a70a410 github.com/supersonic-app/fyne-lyrics v0.0.0-20250614151306-b1880a70a410
@@ -67,6 +68,7 @@ require (
github.com/stretchr/testify v1.11.1 // indirect github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/image v0.36.0 // indirect golang.org/x/image v0.36.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+4
View File
@@ -86,6 +86,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hugolgst/rich-go v0.0.0-20240715122152-74618cc1ace2 h1:9qOViOQGFIP5ar+2NorfAIsfuADEKXtklySC0zNnYf4=
github.com/hugolgst/rich-go v0.0.0-20240715122152-74618cc1ace2/go.mod h1:nGaW7CGfNZnhtiFxMpc4OZdqIexGXjUlBnlmpZmjEKA=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
@@ -152,5 +154,7 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+1 -1
View File
@@ -105,7 +105,7 @@ func main() {
} }
} }
fyneApp := app.New() fyneApp := app.NewWithID(res.AppName)
fyneApp.SetIcon(res.ResAppicon256Png) fyneApp.SetIcon(res.ResAppicon256Png)
mainWindow := ui.NewMainWindow(fyneApp, res.AppName, res.DisplayName, res.AppVersion, myApp) mainWindow := ui.NewMainWindow(fyneApp, res.AppName, res.DisplayName, res.AppVersion, myApp)
+9 -2
View File
@@ -53,6 +53,8 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
})) }))
bp.NowPlaying = widgets.NewNowPlayingCard() bp.NowPlaying = widgets.NewNowPlayingCard()
_, canShare := contr.App.ServerManager.Server.(mediaprovider.SupportsSharing)
bp.NowPlaying.DisableSharing = !canShare
bp.NowPlaying.ShowAlbumYear = cfg.AlbumsPage.ShowYears bp.NowPlaying.ShowAlbumYear = cfg.AlbumsPage.ShowYears
bp.NowPlaying.OnCoverTapped = func() { bp.NowPlaying.OnCoverTapped = func() {
contr.NavigateTo(controller.NowPlayingRoute()) contr.NavigateTo(controller.NowPlayingRoute())
@@ -87,8 +89,13 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
} }
} }
bp.NowPlaying.OnShare = func() { bp.NowPlaying.OnShare = func() {
if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok { switch item := pm.NowPlaying().(type) {
contr.ShowShareDialog(tr.ID) case *mediaprovider.Track:
contr.ShowShareDialog(item.ID)
case *mediaprovider.PodcastEpisode:
if item.StreamID != "" {
contr.ShowShareDialog(item.StreamID)
}
} }
} }
bp.Controls = widgets.NewPlayerControls(cfg.Playback.UseWaveformSeekbar, pm.GetLoopMode(), pm.IsShuffle()) bp.Controls = widgets.NewPlayerControls(cfg.Playback.UseWaveformSeekbar, pm.GetLoopMode(), pm.IsShuffle())
+1 -12
View File
@@ -686,7 +686,7 @@ func (a *ArtistPageHeader) Clear() {
a.fullSizeCoverFetching = false a.fullSizeCoverFetching = false
} }
func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums, im *backend.ImageManager) { func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums, _ *backend.ImageManager) {
if artist == nil { if artist == nil {
return return
} }
@@ -695,17 +695,6 @@ func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums, im *ba
a.artistID = artist.ID a.artistID = artist.ID
a.titleDisp.Segments[0].(*widget.TextSegment).Text = artist.Name a.titleDisp.Segments[0].(*widget.TextSegment).Text = artist.Name
a.titleDisp.Refresh() a.titleDisp.Refresh()
if artist.CoverArtID == "" {
return
}
a.artistImageID = artist.CoverArtID
go func() {
if cover, err := im.GetCoverThumbnail(artist.CoverArtID); err == nil {
fyne.Do(func() { a.artistImage.SetImage(cover, true) })
} else {
log.Printf("error fetching cover: %v", err)
}
}()
} }
func (a *ArtistPageHeader) UpdateInfo(info *mediaprovider.ArtistInfo) { func (a *ArtistPageHeader) UpdateInfo(info *mediaprovider.ArtistInfo) {
+4 -3
View File
@@ -402,7 +402,10 @@ func (a *NowPlayingPage) updateLyrics() {
return return
} }
} }
if a.nowPlaying == nil || a.nowPlaying.Metadata().Type == mediaprovider.MediaItemTypeRadioStation { tr, isTrack := a.nowPlaying.(*mediaprovider.Track)
if !isTrack {
a.lyricsLoading.Stop()
a.lyricsViewer.EnableTapToSeek()
a.lyricsViewer.SetLyrics(nil) a.lyricsViewer.SetLyrics(nil)
a.curLyrics = nil a.curLyrics = nil
a.curLyricsID = "" a.curLyricsID = ""
@@ -417,8 +420,6 @@ func (a *NowPlayingPage) updateLyrics() {
Synced: true, Synced: true,
Lines: []mediaprovider.LyricLine{{Text: ""}}, Lines: []mediaprovider.LyricLine{{Text: ""}},
}) })
tr, _ := a.nowPlaying.(*mediaprovider.Track)
a.lm.FetchLyricsAsync(tr, func(id string, lyrics *mediaprovider.Lyrics) { a.lm.FetchLyricsAsync(tr, func(id string, lyrics *mediaprovider.Lyrics) {
if id != a.nowPlayingID { if id != a.nowPlayingID {
return return
+248
View File
@@ -0,0 +1,248 @@
package browsing
import (
"fmt"
"html"
"log"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"github.com/deluan/sanitize"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/ui/controller"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/util"
appWidgets "github.com/dweymouth/supersonic/ui/widgets"
)
type PodcastsPage struct {
widget.BaseWidget
contr *controller.Controller
provider mediaprovider.PodcastProvider
pm *backend.PlaybackManager
channels []*mediaprovider.PodcastChannel
episodes []*mediaprovider.PodcastEpisode
channelList, episodeList *widget.List
channelTitle *widget.Label
content *fyne.Container
}
func NewPodcastsPage(c *controller.Controller, p mediaprovider.PodcastProvider, pm *backend.PlaybackManager) *PodcastsPage {
x := &PodcastsPage{contr: c, provider: p, pm: pm, channelTitle: widget.NewLabel(lang.L("Newest episodes"))}
x.ExtendBaseWidget(x)
x.channelTitle.TextStyle.Bold = true
x.channelList = widget.NewList(func() int { return len(x.channels) }, func() fyne.CanvasObject {
title := widget.NewButton("", nil)
more := appWidgets.NewIconButton(theme.MoreVerticalIcon(), nil)
more.IconSize = appWidgets.IconButtonSizeSmaller
more.SetToolTip(lang.L("More"))
return container.NewBorder(nil, nil, nil, more, title)
}, func(id widget.ListItemID, o fyne.CanvasObject) {
ch := x.channels[id]
row := o.(*fyne.Container)
b := row.Objects[0].(*widget.Button)
more := row.Objects[1].(*appWidgets.IconButton)
b.SetText(ch.Title)
b.OnTapped = func() { go x.loadChannel(ch.ID) }
more.OnTapped = func() {
pos := fyne.CurrentApp().Driver().AbsolutePositionForObject(more)
x.showChannelMenu(ch, pos)
}
})
x.episodeList = widget.NewList(func() int { return len(x.episodes) }, x.newEpisodeRow, x.updateEpisodeRow)
header := container.NewHBox(widget.NewLabelWithStyle(lang.L("Podcasts"), fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), layout.NewSpacer())
left := container.NewBorder(widget.NewLabel(lang.L("Channels")), nil, nil, nil, x.channelList)
right := container.NewBorder(x.channelTitle, nil, nil, nil, x.episodeList)
split := container.NewHSplit(left, right)
split.Offset = .27
x.content = container.NewBorder(header, nil, nil, nil, split)
go x.load()
return x
}
func (p *PodcastsPage) showChannelMenu(ch *mediaprovider.PodcastChannel, pos fyne.Position) {
share := fyne.NewMenuItem(lang.L("Share")+"...", func() {
p.contr.ShowShareDialog(ch.ID)
})
share.Icon = myTheme.ShareIcon
_, canShare := p.provider.(mediaprovider.SupportsSharing)
share.Disabled = ch.ID == "" || !canShare
menu := fyne.NewMenu("", share)
widget.ShowPopUpMenuAtPosition(menu, fyne.CurrentApp().Driver().CanvasForObject(p), pos)
}
func (p *PodcastsPage) newEpisodeRow() fyne.CanvasObject {
title := widget.NewLabel("")
title.TextStyle.Bold = true
desc := widget.NewLabel("")
// List rows are virtualized and have a fixed template height. Letting an
// arbitrary feed description wrap makes it paint over following rows.
desc.Wrapping = fyne.TextWrapOff
desc.Truncation = fyne.TextTruncateEllipsis
play := appWidgets.NewIconButton(theme.MediaPlayIcon(), nil)
play.IconSize = appWidgets.IconButtonSizeSlightlyBigger
play.SetToolTip(lang.L("Play"))
playArea := container.NewPadded(container.NewCenter(play))
more := appWidgets.NewIconButton(theme.MoreVerticalIcon(), nil)
more.IconSize = appWidgets.IconButtonSizeSmaller
more.SetToolTip(lang.L("More"))
moreArea := container.NewPadded(container.NewCenter(more))
texts := container.New(layout.NewCustomPaddedVBoxLayout(0), title, desc)
return container.NewBorder(nil, nil, playArea, moreArea, texts)
}
func (p *PodcastsPage) updateEpisodeRow(id widget.ListItemID, obj fyne.CanvasObject) {
e := p.episodes[id]
border := obj.(*fyne.Container)
texts := border.Objects[0].(*fyne.Container)
playArea := border.Objects[1].(*fyne.Container)
playCenter := playArea.Objects[0].(*fyne.Container)
play := playCenter.Objects[0].(*appWidgets.IconButton)
moreArea := border.Objects[2].(*fyne.Container)
moreCenter := moreArea.Objects[0].(*fyne.Container)
more := moreCenter.Objects[0].(*appWidgets.IconButton)
date := ""
if !e.PublishDate.IsZero() {
date = e.PublishDate.Local().Format("Jan 2, 2006")
}
title := e.Title
if date != "" {
title = fmt.Sprintf("%s (%s)", title, date)
}
texts.Objects[0].(*widget.Label).SetText(title)
texts.Objects[1].(*widget.Label).SetText(podcastDescriptionText(e.Description))
play.OnTapped = func() { p.pm.LoadItems([]mediaprovider.MediaItem{e}, backend.Replace, false); p.pm.PlayTrackAt(0) }
more.OnTapped = func() {
pos := fyne.CurrentApp().Driver().AbsolutePositionForObject(more)
p.showEpisodeMenu(e, pos)
}
if e.Playable() {
play.Enable()
} else {
play.Disable()
}
}
func (p *PodcastsPage) showEpisodeMenu(e *mediaprovider.PodcastEpisode, pos fyne.Position) {
play := fyne.NewMenuItem(lang.L("Play"), func() {
p.pm.LoadItems([]mediaprovider.MediaItem{e}, backend.Replace, false)
p.pm.PlayTrackAt(0)
})
play.Icon = theme.MediaPlayIcon()
playNext := fyne.NewMenuItem(lang.L("Play next"), func() {
p.pm.LoadItems([]mediaprovider.MediaItem{e}, backend.InsertNext, false)
})
playNext.Icon = myTheme.PlayNextIcon
addToQueue := fyne.NewMenuItem(lang.L("Add to queue"), func() {
p.pm.LoadItems([]mediaprovider.MediaItem{e}, backend.Append, false)
})
addToQueue.Icon = theme.ContentAddIcon()
addToPlaylist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() {
p.contr.DoAddTracksToPlaylistWorkflow([]string{e.StreamID})
})
addToPlaylist.Icon = myTheme.PlaylistIcon
share := fyne.NewMenuItem(lang.L("Share")+"...", func() {
p.contr.ShowShareDialog(e.StreamID)
})
share.Icon = myTheme.ShareIcon
favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() {
p.contr.SetTrackFavorites([]string{e.StreamID}, true)
})
favorite.Icon = myTheme.FavoriteIcon
unfavorite := fyne.NewMenuItem(lang.L("Unset favorite"), func() {
p.contr.SetTrackFavorites([]string{e.StreamID}, false)
})
unfavorite.Icon = myTheme.NotFavoriteIcon
rating := util.NewRatingSubmenu(func(value int) {
p.contr.SetTrackRatings([]string{e.StreamID}, value)
})
play.Disabled = !e.Playable()
playNext.Disabled = !e.Playable()
addToQueue.Disabled = !e.Playable()
mediaActionsDisabled := e.StreamID == ""
addToPlaylist.Disabled = mediaActionsDisabled
favorite.Disabled = mediaActionsDisabled
unfavorite.Disabled = mediaActionsDisabled
_, canRate := p.provider.(mediaprovider.SupportsRating)
rating.Disabled = mediaActionsDisabled || !canRate
_, canShare := p.provider.(mediaprovider.SupportsSharing)
share.Disabled = mediaActionsDisabled || !canShare
menu := fyne.NewMenu("", play, playNext, addToQueue, fyne.NewMenuItemSeparator(),
addToPlaylist, share, fyne.NewMenuItemSeparator(), favorite, unfavorite, rating)
widget.ShowPopUpMenuAtPosition(menu, fyne.CurrentApp().Driver().CanvasForObject(p), pos)
}
func podcastDescriptionText(description string) string {
// Some servers return RSS descriptions still wrapped in CDATA and HTML.
// Labels display plain text, so normalize it before rendering the preview.
description = strings.TrimSpace(description)
description = strings.TrimPrefix(description, "<![CDATA[")
description = strings.TrimSuffix(description, "]]>")
description = html.UnescapeString(sanitize.HTML(description))
return strings.Join(strings.Fields(description), " ")
}
func (p *PodcastsPage) load() {
if p.provider == nil {
return
}
channels, err := p.provider.GetPodcastChannels()
if err != nil {
log.Printf("load podcast channels: %v", err)
return
}
episodes, err := p.provider.GetNewestPodcastEpisodes(20)
if err != nil {
log.Printf("load newest podcasts: %v", err)
}
byID := make(map[string]*mediaprovider.PodcastChannel, len(channels))
for _, ch := range channels {
byID[ch.ID] = ch
}
for _, ep := range episodes {
if ch := byID[ep.ChannelID]; ch != nil {
ep.ChannelTitle = ch.Title
if ep.CoverArtID == "" {
ep.CoverArtID = ch.CoverArtID
}
ep.OriginalImageURL = ch.OriginalImageURL
}
}
fyne.Do(func() {
p.channels = channels
p.episodes = episodes
p.channelTitle.SetText(lang.L("Newest episodes"))
p.channelList.Refresh()
p.episodeList.Refresh()
})
}
func (p *PodcastsPage) loadChannel(id string) {
ch, err := p.provider.GetPodcastChannel(id)
if err != nil {
log.Printf("load podcast channel: %v", err)
return
}
fyne.Do(func() { p.episodes = ch.Episodes; p.channelTitle.SetText(ch.Title); p.episodeList.Refresh() })
}
func (p *PodcastsPage) Reload() { go p.load() }
func (p *PodcastsPage) Route() controller.Route { return controller.PodcastsRoute() }
func (p *PodcastsPage) Save() SavedPage { return &savedPodcastsPage{p.contr, p.provider, p.pm} }
func (p *PodcastsPage) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(p.content)
}
type savedPodcastsPage struct {
c *controller.Controller
p mediaprovider.PodcastProvider
pm *backend.PlaybackManager
}
func (s *savedPodcastsPage) Restore() Page { return NewPodcastsPage(s.c, s.p, s.pm) }
+10
View File
@@ -0,0 +1,10 @@
package browsing
import "testing"
func TestPodcastDescriptionText(t *testing.T) {
got := podcastDescriptionText(`<![CDATA[<p>Hello <em>podcast</em> &amp; listeners.</p>]]>`)
if want := "Hello podcast & listeners."; got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
+3
View File
@@ -61,6 +61,9 @@ func (r Router) CreatePage(rte controller.Route) Page {
var rp mediaprovider.RadioProvider var rp mediaprovider.RadioProvider
rp, _ = r.App.ServerManager.Server.(mediaprovider.RadioProvider) rp, _ = r.App.ServerManager.Server.(mediaprovider.RadioProvider)
return NewRadiosPage(r.Controller, rp, r.App.PlaybackManager) return NewRadiosPage(r.Controller, rp, r.App.PlaybackManager)
case controller.Podcasts:
pp, _ := r.App.ServerManager.Server.(mediaprovider.PodcastProvider)
return NewPodcastsPage(r.Controller, pp, r.App.PlaybackManager)
} }
return nil return nil
} }
+5
View File
@@ -16,6 +16,7 @@ const (
Playlists Playlists
Tracks Tracks
Radios Radios
Podcasts
) )
func (p PageName) String() string { func (p PageName) String() string {
@@ -44,6 +45,8 @@ func (p PageName) String() string {
return "All Tracks" return "All Tracks"
case Radios: case Radios:
return "Internet Radio Stations" return "Internet Radio Stations"
case Podcasts:
return "Podcasts"
default: default:
return "" return ""
} }
@@ -102,6 +105,8 @@ func RadiosRoute() Route {
return Route{Page: Radios} return Route{Page: Radios}
} }
func PodcastsRoute() Route { return Route{Page: Podcasts} }
func NowPlayingRoute() Route { func NowPlayingRoute() Route {
return Route{Page: NowPlaying} return Route{Page: NowPlaying}
} }
+9 -32
View File
@@ -35,6 +35,7 @@ func (m *Controller) PromptForFirstServer() {
SkipSSLVerify: d.SkipSSLVerify, SkipSSLVerify: d.SkipSSLVerify,
} }
server := m.App.ServerManager.AddServer(d.Nickname, conn) server := m.App.ServerManager.AddServer(d.Nickname, conn)
server.EnablePodcasts = d.EnablePodcasts
if err := m.trySetPasswordAndConnectToServer(server, d.Password); err != nil { if err := m.trySetPasswordAndConnectToServer(server, d.Password); err != nil {
log.Printf("error connecting to server: %s", err.Error()) log.Printf("error connecting to server: %s", err.Error())
} }
@@ -67,41 +68,15 @@ func (c *Controller) DoConnectToServerWorkflow(server *backend.ServerConfig) {
} }
func (c *Controller) doConnectWithPassword(server *backend.ServerConfig, pass string) { func (c *Controller) doConnectWithPassword(server *backend.ServerConfig, pass string) {
canceled := false
ctx, cancel := context.WithCancel(context.Background())
dlg := dialog.NewCustom(lang.L("Connecting"), lang.L("Cancel"),
widget.NewLabel(fmt.Sprintf(lang.L("Connecting to")+" %s", server.Nickname)), c.MainWindow)
dlg.SetOnClosed(func() {
canceled = true
cancel()
})
c.haveModal = true
dlg.Show()
// try to connect
go func() { go func() {
defer cancel() // make sure to free up ctx resources if user does not cancel if err := c.tryConnectToServer(context.Background(), server, pass); err != nil {
if err := c.tryConnectToServer(ctx, server, pass); err != nil {
fyne.Do(func() { fyne.Do(func() {
dlg.Hide() dlg := dialog.NewError(err, c.MainWindow)
c.haveModal = false dlg.SetOnClosed(func() {
if canceled {
c.PromptForLoginAndConnect() c.PromptForLoginAndConnect()
} else { })
// connection failure c.haveModal = true
dlg := dialog.NewError(err, c.MainWindow) dlg.Show()
dlg.SetOnClosed(func() {
c.PromptForLoginAndConnect()
})
c.haveModal = true
dlg.Show()
}
})
} else {
fyne.Do(func() {
dlg.Hide()
c.haveModal = false
}) })
} }
}() }()
@@ -150,6 +125,7 @@ func (m *Controller) PromptForLoginAndConnect() {
server.Username = editD.Username server.Username = editD.Username
server.LegacyAuth = editD.LegacyAuth server.LegacyAuth = editD.LegacyAuth
server.SkipSSLVerify = editD.SkipSSLVerify server.SkipSSLVerify = editD.SkipSSLVerify
server.EnablePodcasts = editD.EnablePodcasts
m.trySetPasswordAndConnectToServer(server, editD.Password) m.trySetPasswordAndConnectToServer(server, editD.Password)
m.doModalClosed() m.doModalClosed()
} }
@@ -184,6 +160,7 @@ func (m *Controller) PromptForLoginAndConnect() {
SkipSSLVerify: newD.SkipSSLVerify, SkipSSLVerify: newD.SkipSSLVerify,
} }
server := m.App.ServerManager.AddServer(newD.Nickname, conn) server := m.App.ServerManager.AddServer(newD.Nickname, conn)
server.EnablePodcasts = newD.EnablePodcasts
m.trySetPasswordAndConnectToServer(server, newD.Password) m.trySetPasswordAndConnectToServer(server, newD.Password)
m.doModalClosed() m.doModalClosed()
} }
+16 -11
View File
@@ -17,16 +17,17 @@ import (
type AddEditServerDialog struct { type AddEditServerDialog struct {
widget.BaseWidget widget.BaseWidget
ServerType backend.ServerType ServerType backend.ServerType
Nickname string Nickname string
Host string Host string
AltHost string AltHost string
Username string Username string
Password string Password string
LegacyAuth bool LegacyAuth bool
SkipSSLVerify bool SkipSSLVerify bool
OnSubmit func() EnablePodcasts bool
OnCancel func() OnSubmit func()
OnCancel func()
passField *widget.Entry passField *widget.Entry
submitBtn *widget.Button submitBtn *widget.Button
@@ -47,17 +48,21 @@ func NewAddEditServerDialog(title string, cancelable bool, prefillServer *backen
a.Username = prefillServer.Username a.Username = prefillServer.Username
a.LegacyAuth = prefillServer.LegacyAuth a.LegacyAuth = prefillServer.LegacyAuth
a.SkipSSLVerify = prefillServer.SkipSSLVerify a.SkipSSLVerify = prefillServer.SkipSSLVerify
a.EnablePodcasts = prefillServer.EnablePodcasts
} }
titleLabel := widget.NewLabel(title) titleLabel := widget.NewLabel(title)
titleLabel.TextStyle.Bold = true titleLabel.TextStyle.Bold = true
legacyAuthCheck := widget.NewCheckWithData(lang.L("Use legacy authentication"), binding.BindBool(&a.LegacyAuth)) legacyAuthCheck := widget.NewCheckWithData(lang.L("Use legacy authentication"), binding.BindBool(&a.LegacyAuth))
podcastsCheck := widget.NewCheckWithData(lang.L("Enable podcasts"), binding.BindBool(&a.EnablePodcasts))
serverTypeChoice := widget.NewRadioGroup([]string{"Subsonic", "Jellyfin"}, func(s string) { serverTypeChoice := widget.NewRadioGroup([]string{"Subsonic", "Jellyfin"}, func(s string) {
a.ServerType = backend.ServerType(s) a.ServerType = backend.ServerType(s)
if s == string(backend.ServerTypeSubsonic) { if s == string(backend.ServerTypeSubsonic) {
legacyAuthCheck.Show() legacyAuthCheck.Show()
podcastsCheck.Show()
} else { } else {
legacyAuthCheck.Hide() legacyAuthCheck.Hide()
podcastsCheck.Hide()
} }
}) })
skipSSLCheck := widget.NewCheckWithData(lang.L("Skip SSL certificate verification"), binding.BindBool(&a.SkipSSLVerify)) skipSSLCheck := widget.NewCheckWithData(lang.L("Skip SSL certificate verification"), binding.BindBool(&a.SkipSSLVerify))
@@ -116,7 +121,7 @@ func NewAddEditServerDialog(title string, cancelable bool, prefillServer *backen
widget.NewLabel(lang.L("Password")), widget.NewLabel(lang.L("Password")),
a.passField, a.passField,
), ),
container.NewHBox(layout.NewSpacer(), legacyAuthCheck, skipSSLCheck), container.NewVBox(podcastsCheck, legacyAuthCheck, skipSSLCheck),
widget.NewSeparator(), widget.NewSeparator(),
bottomRow, bottomRow,
) )
+6
View File
@@ -801,6 +801,11 @@ func (s *SettingsDialog) createAdvancedTab() *container.TabItem {
multi := widget.NewCheckWithData(lang.L("Allow multiple app instances"), binding.BindBool(&s.config.Application.AllowMultiInstance)) multi := widget.NewCheckWithData(lang.L("Allow multiple app instances"), binding.BindBool(&s.config.Application.AllowMultiInstance))
update := widget.NewCheckWithData(lang.L("Automatically check for updates"), binding.BindBool(&s.config.Application.EnableAutoUpdateChecker)) update := widget.NewCheckWithData(lang.L("Automatically check for updates"), binding.BindBool(&s.config.Application.EnableAutoUpdateChecker))
lrclib := widget.NewCheckWithData(lang.L("Enable LrcLib lyrics fetcher"), binding.BindBool(&s.config.Application.EnableLrcLib)) lrclib := widget.NewCheckWithData(lang.L("Enable LrcLib lyrics fetcher"), binding.BindBool(&s.config.Application.EnableLrcLib))
savePasswords := widget.NewCheck(lang.L("Save passwords on this device"), func(enabled bool) {
s.config.Application.EnablePasswordStorage = enabled
s.setRestartRequired()
})
savePasswords.Checked = s.config.Application.EnablePasswordStorage
threeDigitValidator := func(text, selText string, r rune) bool { threeDigitValidator := func(text, selText string, r rune) bool {
return unicode.IsDigit(r) && len(text)-len(selText) < 3 return unicode.IsDigit(r) && len(text)-len(selText) < 3
@@ -842,6 +847,7 @@ func (s *SettingsDialog) createAdvancedTab() *container.TabItem {
multi, multi,
update, update,
lrclib, lrclib,
savePasswords,
osMediaAPIs, osMediaAPIs,
preventScreensaver, preventScreensaver,
imgCacheCfg, imgCacheCfg,
+2
View File
@@ -369,6 +369,8 @@ func (m *MainWindow) RunOnServerConnectedTasks(serverConf *backend.ServerConfig,
_, supportsRadio := m.App.ServerManager.Server.(mediaprovider.RadioProvider) _, supportsRadio := m.App.ServerManager.Server.(mediaprovider.RadioProvider)
m.Toolbar.SetRadioButtonVisible(supportsRadio) m.Toolbar.SetRadioButtonVisible(supportsRadio)
_, supportsPodcasts := m.App.ServerManager.Server.(mediaprovider.PodcastProvider)
m.Toolbar.SetPodcastButtonVisible(serverConf.EnablePodcasts && supportsPodcasts)
}) })
m.App.SaveConfigFile() m.App.SaveConfigFile()
+4 -3
View File
@@ -106,7 +106,10 @@ func (s *Sidebar) updateLyrics() {
return return
} }
} }
if s.nowPlaying == nil || s.nowPlaying.Metadata().Type == mediaprovider.MediaItemTypeRadioStation { tr, isTrack := s.nowPlaying.(*mediaprovider.Track)
if !isTrack {
s.lyricsLoading.Stop()
s.lyricsViewer.EnableTapToSeek()
s.lyricsViewer.SetLyrics(nil) s.lyricsViewer.SetLyrics(nil)
s.curLyrics = nil s.curLyrics = nil
s.curLyricsID = "" s.curLyricsID = ""
@@ -121,8 +124,6 @@ func (s *Sidebar) updateLyrics() {
Synced: true, Synced: true,
Lines: []mediaprovider.LyricLine{{Text: ""}}, Lines: []mediaprovider.LyricLine{{Text: ""}},
}) })
tr, _ := s.nowPlaying.(*mediaprovider.Track)
s.lm.FetchLyricsAsync(tr, func(id string, lyrics *mediaprovider.Lyrics) { s.lm.FetchLyricsAsync(tr, func(id string, lyrics *mediaprovider.Lyrics) {
if id != s.nowPlayingID { if id != s.nowPlayingID {
return return
+13
View File
@@ -28,6 +28,7 @@ type Toolbar struct {
navBtnsContainer *fyne.Container navBtnsContainer *fyne.Container
navBtnsPageMap map[controller.PageName]fyne.Resource navBtnsPageMap map[controller.PageName]fyne.Resource
radioBtn fyne.CanvasObject radioBtn fyne.CanvasObject
podcastBtn fyne.CanvasObject
quickSearchBtn *ttwidget.Button quickSearchBtn *ttwidget.Button
sidebarBtn *ttwidget.Button sidebarBtn *ttwidget.Button
@@ -82,6 +83,14 @@ func (t *Toolbar) SetRadioButtonVisible(vis bool) {
} }
} }
func (t *Toolbar) SetPodcastButtonVisible(vis bool) {
if vis {
t.podcastBtn.Show()
} else {
t.podcastBtn.Hide()
}
}
// AddSettingsMenuItem adds an item to the Settings menu // AddSettingsMenuItem adds an item to the Settings menu
func (t *Toolbar) AddSettingsMenuItem(label string, icon fyne.Resource, action func()) { func (t *Toolbar) AddSettingsMenuItem(label string, icon fyne.Resource, action func()) {
item := fyne.NewMenuItem(label, action) item := fyne.NewMenuItem(label, action)
@@ -170,6 +179,10 @@ func (t *Toolbar) setupNavigationButtons(navigateFn func(controller.Route)) {
t.radioBtn = t.addNavigationButton(myTheme.RadioIcon, controller.Radios, func() { t.radioBtn = t.addNavigationButton(myTheme.RadioIcon, controller.Radios, func() {
navigateFn(controller.RadiosRoute()) navigateFn(controller.RadiosRoute())
}) })
t.podcastBtn = t.addNavigationButton(myTheme.HeadphonesIcon, controller.Podcasts, func() {
navigateFn(controller.PodcastsRoute())
})
t.podcastBtn.Hide()
} }
func (t *Toolbar) addNavigationButton(icon fyne.Resource, pageName controller.PageName, action func()) *ttwidget.Button { func (t *Toolbar) addNavigationButton(icon fyne.Resource, pageName controller.PageName, action func()) *ttwidget.Button {
+28 -6
View File
@@ -23,8 +23,9 @@ import (
type NowPlayingCard struct { type NowPlayingCard struct {
widget.BaseWidget widget.BaseWidget
DisableRating bool DisableRating bool
ShowAlbumYear bool DisableSharing bool
ShowAlbumYear bool
trackName *OptionHyperlink trackName *OptionHyperlink
artistName *MultiHyperlink artistName *MultiHyperlink
@@ -33,7 +34,9 @@ type NowPlayingCard struct {
menu *widget.PopUpMenu menu *widget.PopUpMenu
ratingMenu *fyne.MenuItem ratingMenu *fyne.MenuItem
albumYear string albumYear string
isPodcast bool
podcastSharingAvailable bool
OnTrackNameTapped func() OnTrackNameTapped func()
OnArtistNameTapped func(artistID string) OnArtistNameTapped func(artistID string)
@@ -144,6 +147,8 @@ func (n *NowPlayingCard) Update(track mediaprovider.MediaItem) {
n.artistName.BuildSegments([]string{}, []string{}) n.artistName.BuildSegments([]string{}, []string{})
n.albumName.BuildSegments([]string{}, []string{}) n.albumName.BuildSegments([]string{}, []string{})
n.albumYear = "" n.albumYear = ""
n.isPodcast = false
n.podcastSharingAvailable = false
n.cover.Hidden = true n.cover.Hidden = true
} else { } else {
n.cover.Hidden = false n.cover.Hidden = false
@@ -153,15 +158,25 @@ func (n *NowPlayingCard) Update(track mediaprovider.MediaItem) {
n.albumName.BuildSegments([]string{tr.Album}, []string{tr.AlbumID}) n.albumName.BuildSegments([]string{tr.Album}, []string{tr.AlbumID})
n.albumYear = strconv.Itoa(tr.Year) n.albumYear = strconv.Itoa(tr.Year)
n.cover.PlaceholderIcon = myTheme.TracksIcon n.cover.PlaceholderIcon = myTheme.TracksIcon
} else if episode, ok := track.(*mediaprovider.PodcastEpisode); ok {
n.artistName.BuildSegments([]string{episode.ChannelTitle}, nil)
n.albumName.BuildSegments([]string{}, []string{})
n.albumYear = ""
n.cover.PlaceholderIcon = myTheme.RadioIcon
n.isPodcast = true
n.podcastSharingAvailable = episode.StreamID != ""
} else { } else {
n.artistName.BuildSegments([]string{}, []string{}) n.artistName.BuildSegments([]string{}, []string{})
n.albumName.BuildSegments([]string{}, []string{}) n.albumName.BuildSegments([]string{}, []string{})
n.albumName.Suffix = "" n.albumName.Suffix = ""
n.cover.PlaceholderIcon = myTheme.RadioIcon n.cover.PlaceholderIcon = myTheme.RadioIcon
n.isPodcast = false
n.podcastSharingAvailable = false
} }
} }
n.trackName.Hidden = n.trackName.Text() == "" n.trackName.Hidden = n.trackName.Text() == ""
n.trackName.SetMenuBtnEnabled(n.cover.PlaceholderIcon != myTheme.RadioIcon) n.trackName.SetMenuBtnEnabled(n.cover.PlaceholderIcon != myTheme.RadioIcon ||
(n.isPodcast && n.podcastSharingAvailable && !n.DisableSharing))
n.artistName.Hidden = len(n.artistName.Segments) == 0 n.artistName.Hidden = len(n.artistName.Segments) == 0
n.albumName.Hidden = len(n.albumName.Segments) == 0 n.albumName.Hidden = len(n.albumName.Segments) == 0
n.Refresh() n.Refresh()
@@ -181,7 +196,11 @@ func (n *NowPlayingCard) Refresh() {
} }
func (n *NowPlayingCard) showMenu(btnPos fyne.Position) { func (n *NowPlayingCard) showMenu(btnPos fyne.Position) {
if n.menu == nil { if n.isPodcast {
share := fyne.NewMenuItem(lang.L("Share")+"...", func() { n.onShare() })
share.Icon = myTheme.ShareIcon
n.menu = widget.NewPopUpMenu(fyne.NewMenu("", share), fyne.CurrentApp().Driver().CanvasForObject(n))
} else {
n.ratingMenu = util.NewRatingSubmenu(n.onSetRating) n.ratingMenu = util.NewRatingSubmenu(n.onSetRating)
favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() { n.onSetFavorite(true) }) favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() { n.onSetFavorite(true) })
favorite.Icon = myTheme.FavoriteIcon favorite.Icon = myTheme.FavoriteIcon
@@ -193,12 +212,15 @@ func (n *NowPlayingCard) showMenu(btnPos fyne.Position) {
info.Icon = theme.InfoIcon() info.Icon = theme.InfoIcon()
share := fyne.NewMenuItem(lang.L("Share")+"...", func() { n.onShare() }) share := fyne.NewMenuItem(lang.L("Share")+"...", func() { n.onShare() })
share.Icon = myTheme.ShareIcon share.Icon = myTheme.ShareIcon
share.Disabled = n.DisableSharing
m := fyne.NewMenu("", favorite, unfavorite, n.ratingMenu, playlist, info, share) m := fyne.NewMenu("", favorite, unfavorite, n.ratingMenu, playlist, info, share)
n.menu = widget.NewPopUpMenu(m, fyne.CurrentApp().Driver().CanvasForObject(n)) n.menu = widget.NewPopUpMenu(m, fyne.CurrentApp().Driver().CanvasForObject(n))
} }
menuSize := n.menu.MinSize() menuSize := n.menu.MinSize()
n.ratingMenu.Disabled = n.DisableRating if !n.isPodcast {
n.ratingMenu.Disabled = n.DisableRating
}
btnPos.Y -= (menuSize.Height + theme.Padding()*3) btnPos.Y -= (menuSize.Height + theme.Padding()*3)
btnPos.X -= menuSize.Width / 2 btnPos.X -= menuSize.Width / 2
n.menu.ShowAtPosition(btnPos) n.menu.ShowAtPosition(btnPos)