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/20after4/configdir"
"github.com/zalando/go-keyring"
)
const (
@@ -57,6 +56,7 @@ type App struct {
LocalPlayer *mpv.Player
UpdateChecker UpdateChecker
MPRISHandler *MPRISHandler
DiscordPresence *DiscordPresence
WinSMTC *windows.SMTC
ipcServer ipc.IPCServer
@@ -150,7 +150,8 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
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)
if a.Config.Playback.UseWaveformSeekbar {
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) {
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
if cli == nil {
@@ -559,9 +563,9 @@ func (a *App) LoginToDefaultServer() error {
if serverCfg == nil {
return ErrNoServers
}
pass, err := keyring.Get(a.appName, serverCfg.ID.String())
pass, err := a.ServerManager.GetServerPassword(serverCfg.ID)
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)
}
@@ -604,6 +608,9 @@ func (a *App) Shutdown() {
if a.WinSMTC != nil {
a.WinSMTC.Shutdown()
}
if a.DiscordPresence != nil {
a.DiscordPresence.Shutdown()
}
a.PlaybackManager.DisableCallbacks()
a.PlaybackManager.Shutdown() // will trigger scrobble check
if a.AudioCache != nil {
+2 -1
View File
@@ -30,6 +30,7 @@ type ServerConfig struct {
Nickname string
Default bool
SelectedLibrary string
EnablePodcasts bool
}
type AppConfig struct {
@@ -136,7 +137,7 @@ type LocalPlaybackConfig struct {
InMemoryCacheSizeMB int
Volume int
EqualizerEnabled bool
EqualizerType string // "ISO10Band" or "ISO15Band"
EqualizerType string // "ISO10Band" or "ISO15Band"
EqualizerPreamp float64
GraphicEqualizerBands []float64
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)) {
if song == nil {
if cb != nil {
cb("", nil)
}
return
}
lm.lock.Lock()
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
}
// 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 {
SetPrefetchCoverCallback(cb func(coverArtID string))
+36
View File
@@ -221,8 +221,44 @@ type MediaItemType int
const (
MediaItemTypeTrack MediaItemType = iota
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 MediaItemType
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)
} else if ep, ok := item.(*mediaprovider.PodcastEpisode); ok {
if ep.Playable() {
url, _ = p.sm.Server.GetStreamURL(ep.StreamID, nil, true)
}
} else {
url = item.(*mediaprovider.RadioStation).StreamURL
}
+120 -13
View File
@@ -3,11 +3,15 @@ package backend
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/dweymouth/go-jellyfin"
@@ -26,6 +30,9 @@ type ServerManager struct {
Server mediaprovider.MediaProvider
useKeyring bool
storePasswords bool
credentialFile string
credentialFileMu sync.Mutex
prefetchCoverCB func(string)
appName string
appVersion string
@@ -36,12 +43,14 @@ type ServerManager struct {
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{
appName: appName,
appVersion: appVersion,
config: config,
useKeyring: useKeyring,
appName: appName,
appVersion: appVersion,
config: config,
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) {
if s.useKeyring {
keyring.Delete(s.appName, s.ServerID.String())
if !s.storePasswords {
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.
@@ -163,17 +176,111 @@ func (s *ServerManager) OnLogout(cb func()) {
}
func (s *ServerManager) GetServerPassword(serverID uuid.UUID) (string, error) {
if s.useKeyring {
return keyring.Get(s.appName, serverID.String())
if !s.storePasswords {
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 {
if s.useKeyring {
return keyring.Set(s.appName, server.ID.String(), password)
if !s.storePasswords {
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) {
+28 -1
View File
@@ -1,6 +1,33 @@
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) {
tests := []struct {
+2
View File
@@ -18,6 +18,7 @@ require (
github.com/godbus/dbus/v5 v5.2.2
github.com/google/uuid v1.6.0
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/quarckster/go-mpris-server v1.0.3
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/yuin/goldmark v1.8.2 // 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
)
+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-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
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/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
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 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/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/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)
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()
_, canShare := contr.App.ServerManager.Server.(mediaprovider.SupportsSharing)
bp.NowPlaying.DisableSharing = !canShare
bp.NowPlaying.ShowAlbumYear = cfg.AlbumsPage.ShowYears
bp.NowPlaying.OnCoverTapped = func() {
contr.NavigateTo(controller.NowPlayingRoute())
@@ -87,8 +89,13 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
}
}
bp.NowPlaying.OnShare = func() {
if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok {
contr.ShowShareDialog(tr.ID)
switch item := pm.NowPlaying().(type) {
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())
+1 -12
View File
@@ -686,7 +686,7 @@ func (a *ArtistPageHeader) Clear() {
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 {
return
}
@@ -695,17 +695,6 @@ func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums, im *ba
a.artistID = artist.ID
a.titleDisp.Segments[0].(*widget.TextSegment).Text = artist.Name
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) {
+4 -3
View File
@@ -402,7 +402,10 @@ func (a *NowPlayingPage) updateLyrics() {
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.curLyrics = nil
a.curLyricsID = ""
@@ -417,8 +420,6 @@ func (a *NowPlayingPage) updateLyrics() {
Synced: true,
Lines: []mediaprovider.LyricLine{{Text: ""}},
})
tr, _ := a.nowPlaying.(*mediaprovider.Track)
a.lm.FetchLyricsAsync(tr, func(id string, lyrics *mediaprovider.Lyrics) {
if id != a.nowPlayingID {
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
rp, _ = r.App.ServerManager.Server.(mediaprovider.RadioProvider)
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
}
+5
View File
@@ -16,6 +16,7 @@ const (
Playlists
Tracks
Radios
Podcasts
)
func (p PageName) String() string {
@@ -44,6 +45,8 @@ func (p PageName) String() string {
return "All Tracks"
case Radios:
return "Internet Radio Stations"
case Podcasts:
return "Podcasts"
default:
return ""
}
@@ -102,6 +105,8 @@ func RadiosRoute() Route {
return Route{Page: Radios}
}
func PodcastsRoute() Route { return Route{Page: Podcasts} }
func NowPlayingRoute() Route {
return Route{Page: NowPlaying}
}
+9 -32
View File
@@ -35,6 +35,7 @@ func (m *Controller) PromptForFirstServer() {
SkipSSLVerify: d.SkipSSLVerify,
}
server := m.App.ServerManager.AddServer(d.Nickname, conn)
server.EnablePodcasts = d.EnablePodcasts
if err := m.trySetPasswordAndConnectToServer(server, d.Password); err != nil {
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) {
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() {
defer cancel() // make sure to free up ctx resources if user does not cancel
if err := c.tryConnectToServer(ctx, server, pass); err != nil {
if err := c.tryConnectToServer(context.Background(), server, pass); err != nil {
fyne.Do(func() {
dlg.Hide()
c.haveModal = false
if canceled {
dlg := dialog.NewError(err, c.MainWindow)
dlg.SetOnClosed(func() {
c.PromptForLoginAndConnect()
} else {
// connection failure
dlg := dialog.NewError(err, c.MainWindow)
dlg.SetOnClosed(func() {
c.PromptForLoginAndConnect()
})
c.haveModal = true
dlg.Show()
}
})
} else {
fyne.Do(func() {
dlg.Hide()
c.haveModal = false
})
c.haveModal = true
dlg.Show()
})
}
}()
@@ -150,6 +125,7 @@ func (m *Controller) PromptForLoginAndConnect() {
server.Username = editD.Username
server.LegacyAuth = editD.LegacyAuth
server.SkipSSLVerify = editD.SkipSSLVerify
server.EnablePodcasts = editD.EnablePodcasts
m.trySetPasswordAndConnectToServer(server, editD.Password)
m.doModalClosed()
}
@@ -184,6 +160,7 @@ func (m *Controller) PromptForLoginAndConnect() {
SkipSSLVerify: newD.SkipSSLVerify,
}
server := m.App.ServerManager.AddServer(newD.Nickname, conn)
server.EnablePodcasts = newD.EnablePodcasts
m.trySetPasswordAndConnectToServer(server, newD.Password)
m.doModalClosed()
}
+16 -11
View File
@@ -17,16 +17,17 @@ import (
type AddEditServerDialog struct {
widget.BaseWidget
ServerType backend.ServerType
Nickname string
Host string
AltHost string
Username string
Password string
LegacyAuth bool
SkipSSLVerify bool
OnSubmit func()
OnCancel func()
ServerType backend.ServerType
Nickname string
Host string
AltHost string
Username string
Password string
LegacyAuth bool
SkipSSLVerify bool
EnablePodcasts bool
OnSubmit func()
OnCancel func()
passField *widget.Entry
submitBtn *widget.Button
@@ -47,17 +48,21 @@ func NewAddEditServerDialog(title string, cancelable bool, prefillServer *backen
a.Username = prefillServer.Username
a.LegacyAuth = prefillServer.LegacyAuth
a.SkipSSLVerify = prefillServer.SkipSSLVerify
a.EnablePodcasts = prefillServer.EnablePodcasts
}
titleLabel := widget.NewLabel(title)
titleLabel.TextStyle.Bold = true
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) {
a.ServerType = backend.ServerType(s)
if s == string(backend.ServerTypeSubsonic) {
legacyAuthCheck.Show()
podcastsCheck.Show()
} else {
legacyAuthCheck.Hide()
podcastsCheck.Hide()
}
})
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")),
a.passField,
),
container.NewHBox(layout.NewSpacer(), legacyAuthCheck, skipSSLCheck),
container.NewVBox(podcastsCheck, legacyAuthCheck, skipSSLCheck),
widget.NewSeparator(),
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))
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))
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 {
return unicode.IsDigit(r) && len(text)-len(selText) < 3
@@ -842,6 +847,7 @@ func (s *SettingsDialog) createAdvancedTab() *container.TabItem {
multi,
update,
lrclib,
savePasswords,
osMediaAPIs,
preventScreensaver,
imgCacheCfg,
+2
View File
@@ -369,6 +369,8 @@ func (m *MainWindow) RunOnServerConnectedTasks(serverConf *backend.ServerConfig,
_, supportsRadio := m.App.ServerManager.Server.(mediaprovider.RadioProvider)
m.Toolbar.SetRadioButtonVisible(supportsRadio)
_, supportsPodcasts := m.App.ServerManager.Server.(mediaprovider.PodcastProvider)
m.Toolbar.SetPodcastButtonVisible(serverConf.EnablePodcasts && supportsPodcasts)
})
m.App.SaveConfigFile()
+4 -3
View File
@@ -106,7 +106,10 @@ func (s *Sidebar) updateLyrics() {
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.curLyrics = nil
s.curLyricsID = ""
@@ -121,8 +124,6 @@ func (s *Sidebar) updateLyrics() {
Synced: true,
Lines: []mediaprovider.LyricLine{{Text: ""}},
})
tr, _ := s.nowPlaying.(*mediaprovider.Track)
s.lm.FetchLyricsAsync(tr, func(id string, lyrics *mediaprovider.Lyrics) {
if id != s.nowPlayingID {
return
+13
View File
@@ -28,6 +28,7 @@ type Toolbar struct {
navBtnsContainer *fyne.Container
navBtnsPageMap map[controller.PageName]fyne.Resource
radioBtn fyne.CanvasObject
podcastBtn fyne.CanvasObject
quickSearchBtn *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
func (t *Toolbar) AddSettingsMenuItem(label string, icon fyne.Resource, action func()) {
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() {
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 {
+28 -6
View File
@@ -23,8 +23,9 @@ import (
type NowPlayingCard struct {
widget.BaseWidget
DisableRating bool
ShowAlbumYear bool
DisableRating bool
DisableSharing bool
ShowAlbumYear bool
trackName *OptionHyperlink
artistName *MultiHyperlink
@@ -33,7 +34,9 @@ type NowPlayingCard struct {
menu *widget.PopUpMenu
ratingMenu *fyne.MenuItem
albumYear string
albumYear string
isPodcast bool
podcastSharingAvailable bool
OnTrackNameTapped func()
OnArtistNameTapped func(artistID string)
@@ -144,6 +147,8 @@ func (n *NowPlayingCard) Update(track mediaprovider.MediaItem) {
n.artistName.BuildSegments([]string{}, []string{})
n.albumName.BuildSegments([]string{}, []string{})
n.albumYear = ""
n.isPodcast = false
n.podcastSharingAvailable = false
n.cover.Hidden = true
} else {
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.albumYear = strconv.Itoa(tr.Year)
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 {
n.artistName.BuildSegments([]string{}, []string{})
n.albumName.BuildSegments([]string{}, []string{})
n.albumName.Suffix = ""
n.cover.PlaceholderIcon = myTheme.RadioIcon
n.isPodcast = false
n.podcastSharingAvailable = false
}
}
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.albumName.Hidden = len(n.albumName.Segments) == 0
n.Refresh()
@@ -181,7 +196,11 @@ func (n *NowPlayingCard) Refresh() {
}
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)
favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() { n.onSetFavorite(true) })
favorite.Icon = myTheme.FavoriteIcon
@@ -193,12 +212,15 @@ func (n *NowPlayingCard) showMenu(btnPos fyne.Position) {
info.Icon = theme.InfoIcon()
share := fyne.NewMenuItem(lang.L("Share")+"...", func() { n.onShare() })
share.Icon = myTheme.ShareIcon
share.Disabled = n.DisableSharing
m := fyne.NewMenu("", favorite, unfavorite, n.ratingMenu, playlist, info, share)
n.menu = widget.NewPopUpMenu(m, fyne.CurrentApp().Driver().CanvasForObject(n))
}
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.X -= menuSize.Width / 2
n.menu.ShowAtPosition(btnPos)