add features
This commit is contained in:
+11
-4
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, `"`, `\"`)
|
||||||
|
}
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package browsing
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPodcastDescriptionText(t *testing.T) {
|
||||||
|
got := podcastDescriptionText(`<![CDATA[<p>Hello <em>podcast</em> & listeners.</p>]]>`)
|
||||||
|
if want := "Hello podcast & listeners."; got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user