Merge branch 'dweymouth:main' into main

This commit is contained in:
Benoît Smith
2025-04-09 00:16:00 +02:00
committed by GitHub
56 changed files with 2382 additions and 863 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev desktop-file-utils
- name: Build
run: go build
run: make build
- name: make appimage
run: |
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev desktop-file-utils libmfx1 libdvdnav4
- name: Build
run: go build
run: make build
- name: make appimage
run: |
-3
View File
@@ -31,9 +31,6 @@ jobs:
- name: Setup path
run: export C_INCLUDE_PATH=/usr/local/include:/opt/homebrew/include:$C_INCLUDE_PATH && export LIBRARY_PATH=/usr/local/lib:/opt/homebrew/lib:$LIBRARY_PATH
- name: Build
run: CGO_CFLAGS="-I/usr/local/include -I/opt/homebrew/include" CGO_LDFLAGS="-L/usr/local/lib -L/opt/homebrew/lib" go build
- name: Install Fyne tool
run: go install fyne.io/fyne/v2/cmd/fyne@latest
+1 -4
View File
@@ -31,13 +31,10 @@ jobs:
- name: Setup path
run: export C_INCLUDE_PATH=/usr/local/include:/opt/homebrew/include:$C_INCLUDE_PATH && export LIBRARY_PATH=/usr/local/lib:/opt/homebrew/lib:$LIBRARY_PATH
- name: Build
run: CGO_CFLAGS="-I/usr/local/include -I/opt/homebrew/include" CGO_LDFLAGS="-L/usr/local/lib -L/opt/homebrew/lib" go build
- name: Install Fyne tool
run: go install fyne.io/fyne/v2/cmd/fyne@latest
- name: Package app bundles
- name: Build and package app bundles
run: >
make package_macos &&
cp -r Supersonic.app Supersonic.app.2 &&
-3
View File
@@ -23,9 +23,6 @@ jobs:
- name: Install dependencies
run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev
- name: Build
run: go build
- name: Install Fyne tool
run: go install fyne.io/fyne/v2/cmd/fyne@latest
-3
View File
@@ -23,9 +23,6 @@ jobs:
- name: Install dependencies
run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev
- name: Build
run: go build
- name: Install Fyne tool
run: go install fyne.io/fyne/v2/cmd/fyne@latest
-3
View File
@@ -36,9 +36,6 @@ jobs:
pacman -Syu &&
pacman --noconfirm -S mingw-w64-x86_64-toolchain mingw-w64-x86_64-mpv
- name: Build
run: go build
- name: Install Fyne tool
run: go install fyne.io/fyne/v2/cmd/fyne@latest
+19
View File
@@ -1,5 +1,24 @@
# Change Log
## [0.15.1]
### Fixed
- [#591](https://github.com/dweymouth/supersonic/pull/591) Fix crashing on playback on Windows
- Fix crashing when clicking on album filters button
## [0.15.0]
### Added
- [#582](https://github.com/dweymouth/supersonic/pull/582) Add ability to cast playback to DLNA devices
- [#574](https://github.com/dweymouth/supersonic/pull/574) Add ability to search tracks within playlist
- [#584](https://github.com/dweymouth/supersonic/issues/584) Add context menu actions to items in "Search Everywhere" dialog
- [#571](https://github.com/dweymouth/supersonic/issues/571) Make server requests timeout configurable and increase default timeout
- New overlay animation when showing full size covers
### Fixed
- [#578](https://github.com/dweymouth/supersonic/issues/578),[#530](https://github.com/dweymouth/supersonic/issues/530) Wrong track scrobbled when skipping next
- [#580](https://github.com/dweymouth/supersonic/issues/580) Regression: searching the "All Tracks" page skips some results
## [0.14.0]
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
[Details]
Icon = "res/appicon-512.png"
Name = "Supersonic"
Version = "0.14.0"
Version = "0.15.1"
[LinuxAndBSD]
Categories = ["Audio", "AudioVideo"]
+1 -1
View File
@@ -1,5 +1,5 @@
build:
go build
go build -tags migrated_fynedo
# dylibbundler doesn't seem to pick up on the Python framework dependency,
# so the last 3 cmds move it over manually. This is a bit fragile though
+36 -7
View File
@@ -55,22 +55,51 @@ Supersonic supports any music server with a Subsonic (or OpenSubsonic) API, or J
* [x] Shuffle and repeat playback modes (partial; shuffle album, playlist, artist radio, random songs; repeat one/all)
* [x] Lyrics support
* [x] Internet radio station support (Subsonic)
* [x] Cast to uPnP/DLNA devices
* [ ] Server jukebox control (planned)
* [ ] Browse by folders (planned)
* [ ] Cast to uPnP/DLNA devices (likely planned)
* [ ] Offline mode (eventually planned)
* [ ] iOS/Android support (maybe eventually planned)
## Installation
On Linux, Supersonic is [available as a Flatpak](https://flathub.org/apps/details/io.github.dweymouth.supersonic)! (Thank you @anarcat!) If you prefer to directly install the release build, or build from source, read below.
Platform-specific installation instructions are listed below. In addition to the most recent stable release, you can also download the latest build from the `main` branch via the [Actions](https://github.com/dweymouth/supersonic/actions) tab to get unreleased features and bug fixes (you must be signed in to Github to do this). If you prefer to build from source, or are not running one of these OSes, then see the build instructions for your platform in the next section.
If you are running **Windows**, **Mac OS**, or a **Debian**-based Linux distro, download the [latest release](https://github.com/dweymouth/supersonic/releases) for your operating system. You can also download the latest build from the `main` branch via the [Actions](https://github.com/dweymouth/supersonic/actions) tab to get unreleased features and bug fixes (you must be signed in to Github to do this). If you prefer to build from source, or are not running one of these OSes, then see the build instructions for your platform below.
### Linux
**Packages:** On Linux, Supersonic is [available as a Flatpak](https://flathub.org/apps/details/io.github.dweymouth.supersonic). (Thank you @anarcat!) Third-party packages are also available for Arch and Nix OS.
**AppImage:** On the [latest release](https://github.com/dweymouth/supersonic/releases) page, you can download an AppImage package which supports any OS that can run AppImages. The MPV library is bundled in the AppImage.
**(Debian) .tar.xz:** The.tar.xz builds from the Releases page support Debian-based distros. You **must** have libmpv installed on your system, and choose the correct release build (libmpv2 or libmpv1) based on which is available in your distro's package manager. On apt-based systems, run `sudo apt install libmpv1` (or libmpv2) if it is not already installed. To install the Linux release build, after ensuring the required libmpv is installed, extract the .tar.xz bundle and run `make user-install` or `sudo make install`.
### Windows
Download the [latest release](https://github.com/dweymouth/supersonic/releases). You can choose between the installer, or a standalone zip file which can be extracted and run without requiring system installation.
### Mac OS
Supersonic is available on Homebrew via a custom brew tap, or via downloading the .app bundle from the Releases page.
To install Supersonic with **Homebrew** run:
```sh
brew tap supersonic-app/supersonic
brew install --no-quarantine supersonic
```
The `--no-quarantine` flag is important because Supersonic is distributed without having been [notarized](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution), and therefore will not run without this.
You should also include it when upgrading in future:
```
brew upgrade --no-quarantine supersonic
```
To install the **downloaded .app bundle** from the [Releases page](https://github.com/dweymouth/supersonic/releases), unzip and then drag Supersonic.app to the Applications folder.
**Apple Silicon (M1/M2) Macs:** You will have to remove the "quarantine bit" that Mac will automatically set, being an application downloaded from the internet. After copying the .app bundle to your Applications folder, in the terminal run `sudo xattr -r -d com.apple.quarantine /Applications/Supersonic.app`
**If you are on Linux** and using the build from the Releases page, you must have libmpv installed on your system, and choose the correct release build (libmpv2 or libmpv1) based on which is available in your distro's package manager. On apt-based systems, run `sudo apt install libmpv1` (or libmpv2) if it is not already installed. The Windows and Mac release builds bundle the mpv dependencies. To install the Linux release build, after ensuring the required libmpv is installed, extract the .tar.xz bundle and run `make user-install` or `sudo make install`.
## Build instructions (Linux)
### Ubuntu dependencies
@@ -82,7 +111,7 @@ If you are running **Windows**, **Mac OS**, or a **Debian**-based Linux distro,
* ``sudo dnf install golang mpv-devel libX11-devel libXcursor-devel libXrandr-devel libXinerama-devel libXi-devel libglvnd-devel libXxf86vm-devel``
### Build
* clone the repo, CD into the repo root, and run ``go build``
* clone the repo, CD into the repo root, and run ``make build``
* (note that the first build will take some time as it will download and build the UI library)
### Generate installable .tar.xz bundle
@@ -135,7 +164,7 @@ Supersonic is available in the AUR and can be built either manually with `makepk
- ``export LIBRARY_PATH=/opt/local/lib:$LIBRARY_PATH``
#### Building the application
* clone the repo, CD into the repo root, and run ``go build``
* clone the repo, CD into the repo root, and run ``make build``
* (note that the first build will take some time as it will download and build the UI library)
* run ``make package_macos`` to generate the .app bundle
* **If** you are on Mac OS **High Sierra** through **Catalina**, run ``make bundledeps_macos_highsierra`` and you are done! Otherwise, continue reaading.
+18 -3
View File
@@ -152,6 +152,21 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
a.LrcLibFetcher = NewLrcLibFetcher(a.cacheDir, a.Config.Application.CustomLrcLibUrl, timeout)
}
// Periodically scan for remote players
go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, true /*fastScan*/)
go func() {
t := time.NewTicker(5 * time.Minute)
for {
select {
case <-a.bgrndCtx.Done():
t.Stop()
return
case <-t.C:
a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, false)
}
}
}()
a.PlaybackManager.OnPlaying(func() {
SetSystemSleepDisabled(true)
})
@@ -388,8 +403,8 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) {
}()
})
a.PlaybackManager.OnSeek(func() {
dur := a.PlaybackManager.NowPlaying().Metadata().Duration
smtc.UpdatePosition(int(a.PlaybackManager.CurrentPlayer().GetStatus().TimePos*1000), dur*1000)
playbackStatus := a.PlaybackManager.PlaybackStatus()
smtc.UpdatePosition(int(playbackStatus.TimePos*1000), int(playbackStatus.Duration*1000))
})
a.PlaybackManager.OnPlaying(func() {
smtc.SetEnabled(true)
@@ -448,7 +463,7 @@ func (a *App) Shutdown() {
a.WinSMTC.Shutdown()
}
a.PlaybackManager.DisableCallbacks()
a.PlaybackManager.Stop() // will trigger scrobble check
a.PlaybackManager.Shutdown() // will trigger scrobble check
a.cancel()
a.LocalPlayer.Destroy()
}
+12 -8
View File
@@ -11,7 +11,7 @@ import (
"github.com/dweymouth/supersonic/sharedutil"
)
func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) {
func (j *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) {
limit := maxResults / 3
var wg sync.WaitGroup
var albums []*jellyfin.Album
@@ -22,19 +22,19 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([
wg.Add(1)
go func() {
albumResult, _ := s.client.Search(searchQuery, jellyfin.TypeAlbum, jellyfin.Paging{Limit: limit})
albumResult, _ := j.client.Search(searchQuery, jellyfin.TypeAlbum, jellyfin.Paging{Limit: limit})
albums = albumResult.Albums
wg.Done()
}()
wg.Add(1)
go func() {
artistResult, _ := s.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{Limit: limit})
artistResult, _ := j.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{Limit: limit})
artists = artistResult.Artists
wg.Done()
}()
wg.Add(1)
go func() {
songResult, _ := s.client.Search(searchQuery, jellyfin.TypeSong, jellyfin.Paging{Limit: limit})
songResult, _ := j.client.Search(searchQuery, jellyfin.TypeSong, jellyfin.Paging{Limit: limit})
songs = songResult.Songs
wg.Done()
}()
@@ -44,7 +44,7 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([
wg.Add(1)
go func() {
p, e := s.client.GetPlaylists()
p, e := j.client.GetPlaylists()
if e == nil {
playlists = sharedutil.FilterSlice(p, func(p *jellyfin.Playlist) bool {
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords)
@@ -55,7 +55,7 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([
wg.Add(1)
go func() {
g, e := s.client.GetGenres(jellyfin.Paging{})
g, e := j.client.GetGenres(jellyfin.Paging{})
if e == nil {
genres = sharedutil.FilterSlice(g, func(g jellyfin.NameID) bool {
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords)
@@ -66,13 +66,13 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([
wg.Wait()
results := mergeResults(albums, artists, songs, playlists, genres)
results := j.mergeResults(albums, artists, songs, playlists, genres)
helpers.RankSearchResults(results, searchQuery, queryLowerWords)
return results, nil
}
func mergeResults(
func (j *jellyfinMediaProvider) mergeResults(
albums []*jellyfin.Album,
artists []*jellyfin.Artist,
songs []*jellyfin.Song,
@@ -93,6 +93,7 @@ func mergeResults(
Name: al.Name,
ArtistName: strings.Join(sharedutil.MapSlice(al.Artists, getArtistNames), ","),
Size: al.ChildCount,
Item: toAlbum(al),
})
}
@@ -103,6 +104,7 @@ func mergeResults(
CoverID: ar.ID,
Name: ar.Name,
Size: ar.AlbumCount,
Item: toArtist(ar),
})
}
@@ -114,6 +116,7 @@ func mergeResults(
Name: tr.Name,
ArtistName: strings.Join(sharedutil.MapSlice(tr.Artists, getArtistNames), ","),
Size: int(tr.RunTimeTicks / 10_000_000),
Item: toTrack(tr),
})
}
@@ -124,6 +127,7 @@ func mergeResults(
CoverID: pl.ID,
Name: pl.Name,
Size: pl.SongCount,
Item: j.toPlaylist(pl),
})
}
+6
View File
@@ -183,6 +183,7 @@ const (
type MediaItemMetadata struct {
Type MediaItemType
MIMEType string
ID string
Name string
Artists []string
@@ -204,6 +205,7 @@ func (t *Track) Metadata() MediaItemMetadata {
}
return MediaItemMetadata{
Type: MediaItemTypeTrack,
MIMEType: t.ContentType,
ID: t.ID,
Name: t.Title,
Artists: t.ArtistNames,
@@ -279,4 +281,8 @@ type SearchResult struct {
// Unset for ContentTypes Artist, Playlist, Genre, and RadioStation
ArtistName string
// The actual item corresponding to this search result
// *mediaprovider.Artist for ContentTypeArtist, etc
Item any
}
@@ -101,6 +101,7 @@ func mergeResults(
Name: al.Name,
ArtistName: getNameString(al.Artist, al.Artists),
Size: al.SongCount,
Item: toAlbum(al),
})
}
@@ -111,6 +112,7 @@ func mergeResults(
CoverID: ar.CoverArt,
Name: ar.Name,
Size: ar.AlbumCount,
Item: toArtistFromID3(ar),
})
}
@@ -122,6 +124,7 @@ func mergeResults(
Name: tr.Title,
ArtistName: getNameString(tr.Artist, tr.Artists),
Size: tr.Duration,
Item: toTrack(tr),
})
}
@@ -132,6 +135,7 @@ func mergeResults(
CoverID: pl.CoverArt,
Name: pl.Name,
Size: pl.SongCount,
Item: toPlaylist(pl),
})
}
@@ -149,6 +153,7 @@ func mergeResults(
Type: mediaprovider.ContentTypeRadioStation,
ID: r.ID,
Name: r.Name,
Item: r,
})
}
+3 -3
View File
@@ -84,17 +84,17 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac
})
mp.playbackManager.OnSeek(func() {
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos))
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos))
})
mp.playbackManager.OnPlaying(func() {
C.set_os_playback_state_playing()
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos))
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos))
})
mp.playbackManager.OnPaused(func() {
C.set_os_playback_state_paused()
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos))
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos))
})
return nil
+6 -6
View File
@@ -54,7 +54,7 @@ func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler {
pm.OnSeek(func() {
if m.connErr == nil {
pos := secondsToMicroseconds(pm.PlayerStatus().TimePos)
pos := secondsToMicroseconds(pm.PlaybackStatus().TimePos)
m.evt.Player.OnSeek(pos)
}
})
@@ -160,7 +160,7 @@ func (m *MPRISHandler) Previous() error {
}
func (m *MPRISHandler) Pause() error {
if m.pm.PlayerStatus().State == player.Playing {
if m.pm.PlaybackStatus().State == player.Playing {
m.pm.PlayPause()
}
return nil
@@ -177,7 +177,7 @@ func (m *MPRISHandler) Stop() error {
}
func (m *MPRISHandler) Play() error {
switch m.pm.PlayerStatus().State {
switch m.pm.PlaybackStatus().State {
case player.Paused:
m.pm.PlayPause()
case player.Stopped:
@@ -204,7 +204,7 @@ func (m *MPRISHandler) OpenUri(uri string) error {
}
func (m *MPRISHandler) PlaybackStatus() (types.PlaybackStatus, error) {
switch m.pm.PlayerStatus().State {
switch m.pm.PlaybackStatus().State {
case player.Playing:
return types.PlaybackStatusPlaying, nil
case player.Paused:
@@ -254,7 +254,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) {
if m.curTrackPath != "" {
trackObjPath = m.curTrackPath
}
status := m.pm.PlayerStatus()
status := m.pm.PlaybackStatus()
var meta mediaprovider.MediaItemMetadata
// metadata that can come only from tracks
@@ -307,7 +307,7 @@ func (m *MPRISHandler) SetVolume(v float64) error {
}
func (m *MPRISHandler) Position() (int64, error) {
return int64(secondsToMicroseconds(m.pm.PlayerStatus().TimePos)), nil
return int64(secondsToMicroseconds(m.pm.PlaybackStatus().TimePos)), nil
}
func (m *MPRISHandler) MinimumRate() (float64, error) {
+18 -4
View File
@@ -31,10 +31,11 @@ const (
)
type playbackCommand struct {
Type playbackCommandType
Arg any
Arg2 any
Arg3 any
Type playbackCommandType
Arg any
Arg2 any
Arg3 any
OnDone func()
}
// playbackCommandQueue is a queue to accumulate player commands from the UI
@@ -60,11 +61,24 @@ func (c *playbackCommandQueue) C() <-chan playbackCommand {
return c.nextChan
}
func (c *playbackCommandQueue) Clear() {
c.mutex.Lock()
c.queue = nil
c.mutex.Unlock()
}
func (c *playbackCommandQueue) Stop() {
c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop},
playbackCommand{Type: cmdStop})
}
func (c *playbackCommandQueue) StopAndWait() {
done := make(chan struct{})
c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop},
playbackCommand{Type: cmdStop, OnDone: func() { close(done) }})
<-done
}
func (c *playbackCommandQueue) Continue() {
c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop},
playbackCommand{Type: cmdContinue})
+164 -49
View File
@@ -9,10 +9,13 @@ import (
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player"
"github.com/dweymouth/supersonic/backend/player/mpv"
"github.com/dweymouth/supersonic/backend/util"
"github.com/dweymouth/supersonic/sharedutil"
)
// TODO: make thread-safe
var (
ReplayGainNone = player.ReplayGainNone.String()
ReplayGainAlbum = player.ReplayGainAlbum.String()
@@ -37,6 +40,14 @@ const (
LoopOne
)
type PlaybackState = player.State
type PlaybackStatus struct {
State PlaybackState
TimePos float64
Duration float64
}
type playbackEngine struct {
ctx context.Context
cancelPollPos context.CancelFunc
@@ -48,12 +59,22 @@ type playbackEngine struct {
latestTrackPosition float64 // cleared by checkScrobble
callbacksDisabled bool
playQueue []mediaprovider.MediaItem
nowPlayingIdx int
isRadio bool
wasStopped bool // true iff player was stopped before handleOnTrackChange invocation
noIncrementNextTrackChange bool // true iff the nowPlayingIndex should not be incremente don the next onTrackChange
loopMode LoopMode
playQueue []mediaprovider.MediaItem
nowPlayingIdx int
isRadio bool
loopMode LoopMode
// flags for handleOnTrackChange / handleOnStopped callbacks - reset to false in the callbacks
wasStopped bool // true iff player was stopped before handleOnTrackChange invocation
alreadyScrobbled bool // true iff the previously-playing track was already scrobbled
// if >= 0, track number that was requested by PlayTrackAt
// onTrackChange callback should set nowPlayingIdx to this,
// and reset this to -1
pendingTrackChangeNum int
pendingPlayerChange bool
pendingPlayerChangeStatus player.Status
// to pass to onSongChange listeners; clear once listeners have been called
lastScrobbled *mediaprovider.Track
@@ -99,23 +120,8 @@ func NewPlaybackEngine(
case "One":
pm.loopMode = LoopOne
}
p.OnTrackChange(pm.handleOnTrackChange)
p.OnSeek(func() {
pm.doUpdateTimePos(true)
pm.invokeNoArgCallbacks(pm.onSeek)
})
p.OnStopped(pm.handleOnStopped)
p.OnPaused(func() {
pm.playTimeStopwatch.Stop()
pm.stopPollTimePos()
pm.invokeNoArgCallbacks(pm.onPaused)
})
p.OnPlaying(func() {
pm.playTimeStopwatch.Start()
pm.startPollTimePos()
pm.invokeNoArgCallbacks(pm.onPlaying)
})
pm.registerPlayerCallbacks(p)
s.OnLogout(func() {
pm.StopAndClearPlayQueue()
})
@@ -123,15 +129,95 @@ func NewPlaybackEngine(
return pm
}
func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) {
pl.OnTrackChange(p.handleOnTrackChange)
pl.OnSeek(func() {
p.doUpdateTimePos(true)
p.invokeNoArgCallbacks(p.onSeek)
})
pl.OnStopped(p.handleOnStopped)
pl.OnPaused(func() {
p.playTimeStopwatch.Stop()
p.stopPollTimePos()
p.invokeNoArgCallbacks(p.onPaused)
})
pl.OnPlaying(func() {
p.playTimeStopwatch.Start()
p.startPollTimePos()
p.invokeNoArgCallbacks(p.onPlaying)
})
}
func (p *playbackEngine) unregisterPlayerCallbacks(pl player.BasePlayer) {
pl.OnPaused(nil)
pl.OnPlaying(nil)
pl.OnStopped(nil)
pl.OnSeek(nil)
pl.OnTrackChange(nil)
}
func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error {
// even if we don't successfully change players,
// make sure UI updates if needed (eg enabling cast button)
defer p.invokeNoArgCallbacks(p.onPlayerChange)
needToUnpause := false
stat := p.CurrentPlayer().GetStatus()
if p.pendingPlayerChange {
stat.State = player.Paused
}
switch stat.State {
case player.Stopped:
// nothing
case player.Playing:
needToUnpause = true
p.stopPollTimePos()
fallthrough
case player.Paused:
p.pendingPlayerChangeStatus = stat
p.pendingPlayerChange = true
}
p.unregisterPlayerCallbacks(p.player)
if err := p.player.Stop(true); err != nil {
log.Printf("failed to stop player: %v", err)
}
oldVol := p.player.GetVolume()
if _, isMPV := p.player.(*mpv.Player); !isMPV {
p.player.Destroy()
}
p.player = pl
p.registerPlayerCallbacks(pl)
if needToUnpause {
p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeStatus.TimePos)
p.pendingPlayerChange = false
}
p.invokeNoArgCallbacks(p.onPlayerChange)
vol := pl.GetVolume()
if oldVol != vol {
for _, cb := range p.onVolumeChange {
cb(vol)
}
}
return nil
}
func (p *playbackEngine) PlayTrackAt(idx int) error {
return p.playTrackAt(idx, 0)
}
func (p *playbackEngine) playTrackAt(idx int, startTime float64) error {
if idx < 0 || idx >= len(p.playQueue) {
return errors.New("track index out of range")
}
p.noIncrementNextTrackChange = true
err := p.setTrack(idx, false)
if err == nil {
p.nowPlayingIdx = idx
}
// scrobble current track if needed
p.checkScrobble()
p.alreadyScrobbled = true
p.pendingTrackChangeNum = idx
err := p.setTrack(idx, false, startTime)
return err
}
@@ -162,8 +248,16 @@ func (p *playbackEngine) GetLoopMode() LoopMode {
return p.loopMode
}
func (p *playbackEngine) PlayerStatus() player.Status {
return p.player.GetStatus()
func (p *playbackEngine) PlaybackStatus() PlaybackStatus {
stat := p.pendingPlayerChangeStatus
if !p.pendingPlayerChange {
stat = p.CurrentPlayer().GetStatus()
}
return PlaybackStatus{
State: stat.State,
TimePos: stat.TimePos,
Duration: stat.Duration,
}
}
func (p *playbackEngine) SetVolume(vol int) error {
@@ -182,14 +276,14 @@ func (p *playbackEngine) CurrentPlayer() player.BasePlayer {
}
func (p *playbackEngine) SeekNext() error {
if p.CurrentPlayer().GetStatus().State == player.Stopped {
if p.PlaybackStatus().State == player.Stopped {
return nil
}
return p.PlayTrackAt(p.nowPlayingIdx + 1)
}
func (p *playbackEngine) SeekBackOrPrevious() error {
if p.nowPlayingIdx == 0 || p.player.GetStatus().TimePos > 3 {
if p.nowPlayingIdx == 0 || p.PlaybackStatus().TimePos > 3 {
return p.player.SeekSeconds(0)
}
return p.PlayTrackAt(p.nowPlayingIdx - 1)
@@ -197,7 +291,7 @@ func (p *playbackEngine) SeekBackOrPrevious() error {
func (p *playbackEngine) SeekFwdBackN(n int) error {
idx := p.nowPlayingIdx
if n < 0 && p.player.GetStatus().TimePos > 3 {
if n < 0 && p.PlaybackStatus().TimePos > 3 {
n += 1 // first seek back is just seek to beginning of current
}
if n == 0 || (idx == 0 && n < 0) {
@@ -224,7 +318,7 @@ func (p *playbackEngine) IsSeeking() bool {
}
func (p *playbackEngine) Stop() error {
return p.player.Stop()
return p.player.Stop(false)
}
func (p *playbackEngine) Pause() error {
@@ -232,7 +326,12 @@ func (p *playbackEngine) Pause() error {
}
func (p *playbackEngine) Continue() error {
if p.PlayerStatus().State == player.Stopped {
if p.pendingPlayerChange {
p.pendingPlayerChange = false
return p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeStatus.TimePos)
}
if p.PlaybackStatus().State == player.Stopped {
return p.PlayTrackAt(0)
}
return p.player.Continue()
@@ -254,7 +353,7 @@ func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, insertQueueMo
func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) error {
if insertQueueMode == Replace {
p.player.Stop()
p.player.Stop(false)
p.nowPlayingIdx = -1
p.playQueue = nil
}
@@ -280,7 +379,7 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu
func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) {
if insertMode == Replace {
p.player.Stop()
p.player.Stop(false)
p.nowPlayingIdx = -1
p.playQueue = nil
}
@@ -306,7 +405,7 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins
// Stop playback and clear the play queue.
func (p *playbackEngine) StopAndClearPlayQueue() {
changed := len(p.playQueue) > 0
p.player.Stop()
p.player.Stop(false)
p.playQueue = nil
p.nowPlayingIdx = -1
if changed {
@@ -385,6 +484,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) {
isPlayingTrackRemoved = true
// If we are removing the currently playing track, we need to scrobble it
p.checkScrobble()
p.alreadyScrobbled = true
} else if nowPlaying >= 0 && i == nowPlaying+1 {
isNextPlayingTrackremoved = true
}
@@ -401,7 +501,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) {
p.Stop()
} else {
p.nowPlayingIdx -= 1 // will be incremented in newtrack callback from player
p.setTrack(newNowPlaying, false)
p.setTrack(newNowPlaying, false, 0)
}
// setNextTrack and onSongChange callbacks will be handled
// when we receive new track event from player
@@ -456,21 +556,31 @@ func (p *playbackEngine) SetReplayGainMode(mode player.ReplayGainMode) {
}
func (p *playbackEngine) handleOnTrackChange() {
p.checkScrobble() // scrobble the previous song if needed
if p.player.GetStatus().State == player.Playing {
// scrobble the previous song if needed
if !p.alreadyScrobbled {
p.checkScrobble()
}
if p.PlaybackStatus().State == player.Playing {
p.playTimeStopwatch.Start()
}
if !p.noIncrementNextTrackChange && (p.wasStopped || p.loopMode != LoopOne) {
if p.pendingTrackChangeNum < 0 && (p.wasStopped || p.loopMode != LoopOne) {
p.nowPlayingIdx++
if p.loopMode == LoopAll && p.nowPlayingIdx == len(p.playQueue) {
p.nowPlayingIdx = 0 // wrapped around
}
} else if p.pendingTrackChangeNum >= 0 {
p.nowPlayingIdx = p.pendingTrackChangeNum
p.pendingTrackChangeNum = -1
}
p.noIncrementNextTrackChange = false
nowPlaying := p.playQueue[p.nowPlayingIdx]
_, isRadio := nowPlaying.(*mediaprovider.RadioStation)
p.isRadio = isRadio
// reset flags
p.wasStopped = false
p.alreadyScrobbled = false
p.curTrackDuration = float64(nowPlaying.Metadata().Duration)
p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled
p.invokeOnSongChangeCallbacks()
@@ -480,11 +590,14 @@ func (p *playbackEngine) handleOnTrackChange() {
func (p *playbackEngine) handleOnStopped() {
p.playTimeStopwatch.Stop()
p.checkScrobble()
if !p.alreadyScrobbled {
p.checkScrobble()
}
p.stopPollTimePos()
p.doUpdateTimePos(false)
p.invokeOnSongChangeCallbacks()
p.invokeNoArgCallbacks(p.onStopped)
p.alreadyScrobbled = false
p.wasStopped = true
p.nowPlayingIdx = -1
}
@@ -530,12 +643,14 @@ func (p *playbackEngine) setNextTrackAfterQueueUpdate() {
}
}
func (p *playbackEngine) setTrack(idx int, next bool) error {
func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error {
if urlP, ok := p.player.(player.URLPlayer); ok {
url := ""
var meta mediaprovider.MediaItemMetadata
if idx >= 0 {
var err error
item := p.playQueue[idx]
meta = item.Metadata()
if tr, ok := item.(*mediaprovider.Track); ok {
url, err = p.sm.Server.GetStreamURL(tr.ID, p.transcodeCfg.ForceRawFile)
} else {
@@ -546,9 +661,9 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
}
}
if next {
return urlP.SetNextFile(url)
return urlP.SetNextFile(url, meta)
}
return urlP.PlayFile(url)
return urlP.PlayFile(url, meta, startTime)
} else if trP, ok := p.player.(player.TrackPlayer); ok {
var track *mediaprovider.Track
if idx >= 0 {
@@ -560,13 +675,13 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
if next {
return trP.SetNextTrack(track)
}
return trP.PlayTrack(track)
return trP.PlayTrack(track, startTime)
}
panic("Unsupported player type")
}
func (p *playbackEngine) setNextTrack(idx int) error {
return p.setTrack(idx, true)
return p.setTrack(idx, true, 0)
}
// call BEFORE updating p.nowPlayingIdx
@@ -683,7 +798,7 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) {
if p.callbacksDisabled {
return
}
s := p.player.GetStatus()
s := p.PlaybackStatus()
if s.TimePos > p.latestTrackPosition {
p.latestTrackPosition = s.TimePos
}
+99 -13
View File
@@ -7,12 +7,16 @@ import (
"math/rand"
"runtime"
"slices"
"sync"
"time"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player"
"github.com/dweymouth/supersonic/backend/player/dlna"
"github.com/dweymouth/supersonic/backend/player/mpv"
"github.com/dweymouth/supersonic/sharedutil"
"github.com/supersonic-app/go-upnpcast/device"
"github.com/supersonic-app/go-upnpcast/services"
)
// A high-level MediaProvider-aware playback engine, serves as an
@@ -22,11 +26,23 @@ type PlaybackManager struct {
cmdQueue *playbackCommandQueue
cfg *AppConfig
localPlayer player.BasePlayer
remotePlayersLock sync.Mutex
remotePlayers []RemotePlaybackDevice
currentRemotePlayer *RemotePlaybackDevice
autoplay bool
lastPlayTime float64
}
type RemotePlaybackDevice struct {
Name string
URL string
Protocol string
new func() (player.BasePlayer, error)
}
func NewPlaybackManager(
ctx context.Context,
s *ServerManager,
@@ -39,10 +55,11 @@ func NewPlaybackManager(
e := NewPlaybackEngine(ctx, s, p, playbackCfg, scrobbleCfg, transcodeCfg)
q := NewCommandQueue()
pm := &PlaybackManager{
engine: e,
cmdQueue: q,
cfg: appCfg,
autoplay: playbackCfg.Autoplay,
engine: e,
cmdQueue: q,
cfg: appCfg,
autoplay: playbackCfg.Autoplay,
localPlayer: p,
}
pm.addOnTrackChangeHook()
go pm.runCmdQueue(ctx)
@@ -68,7 +85,7 @@ func (p *PlaybackManager) addOnTrackChangeHook() {
return
}
// workaround for https://github.com/dweymouth/supersonic/issues/483 (see above comment)
if p.NowPlayingIndex() != len(p.engine.playQueue) && p.PlayerStatus().State == player.Playing {
if p.NowPlayingIndex() != len(p.engine.playQueue) && p.PlaybackStatus().State == player.Playing {
p.lastPlayTime = 0
go func() {
time.Sleep(300 * time.Millisecond)
@@ -81,6 +98,68 @@ func (p *PlaybackManager) addOnTrackChangeHook() {
})
}
func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, fastScan bool) {
if fastScan {
p.scanRemotePlayers(ctx, 1 /*waitSec*/)
// continue to slow scan to detect players that take longer to respond
}
p.scanRemotePlayers(ctx, 10 /*waitSec*/)
}
func (p *PlaybackManager) scanRemotePlayers(ctx context.Context, waitSec int) {
devices, _ := device.SearchMediaRenderers(ctx, waitSec, services.AVTransport, services.RenderingControl)
var discovered []RemotePlaybackDevice
for _, d := range devices {
p := RemotePlaybackDevice{
Name: d.FriendlyName,
URL: d.URL,
Protocol: "DLNA",
new: func() (player.BasePlayer, error) {
return dlna.NewDLNAPlayer(d)
},
}
discovered = append(discovered, p)
}
p.remotePlayersLock.Lock()
p.remotePlayers = discovered
p.remotePlayersLock.Unlock()
}
func (p *PlaybackManager) RemotePlayers() []RemotePlaybackDevice {
p.remotePlayersLock.Lock()
players := p.remotePlayers
p.remotePlayersLock.Unlock()
return players
}
func (p *PlaybackManager) CurrentRemotePlayer() *RemotePlaybackDevice {
return p.currentRemotePlayer
}
func (p *PlaybackManager) SetRemotePlayer(rp *RemotePlaybackDevice) error {
p.cmdQueue.Clear()
if rp == nil {
if err := p.engine.SetPlayer(p.localPlayer); err != nil {
return err
}
p.currentRemotePlayer = nil
return nil
}
player, err := rp.new()
if err != nil {
return err
}
if err := p.engine.SetPlayer(player); err != nil {
return err
}
p.currentRemotePlayer = rp
return nil
}
func (p *PlaybackManager) CurrentPlayer() player.BasePlayer {
return p.engine.CurrentPlayer()
}
@@ -397,8 +476,8 @@ func (p *PlaybackManager) IsAutoplay() bool {
return p.autoplay
}
func (p *PlaybackManager) PlayerStatus() player.Status {
return p.engine.PlayerStatus()
func (p *PlaybackManager) PlaybackStatus() PlaybackStatus {
return p.engine.PlaybackStatus()
}
func (p *PlaybackManager) SetVolume(vol int) {
@@ -431,7 +510,7 @@ func (p *PlaybackManager) SeekSeconds(sec float64) {
// Seek by given relative position in the current track by seconds.
func (p *PlaybackManager) SeekBySeconds(sec float64) {
status := p.engine.PlayerStatus()
status := p.engine.PlaybackStatus()
target := status.TimePos + sec
if target < 0 {
target = 0
@@ -456,6 +535,10 @@ func (p *PlaybackManager) Stop() {
p.cmdQueue.Stop()
}
func (p *PlaybackManager) Shutdown() {
p.cmdQueue.StopAndWait()
}
func (p *PlaybackManager) Pause() {
p.cmdQueue.Pause()
}
@@ -465,7 +548,7 @@ func (p *PlaybackManager) Continue() {
}
func (p *PlaybackManager) PlayPause() {
switch p.engine.PlayerStatus().State {
switch p.engine.PlaybackStatus().State {
case player.Playing:
p.Pause()
case player.Paused:
@@ -514,7 +597,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
if len(tr.ArtistIDs) > 0 {
similar, err := s.GetSimilarTracks(tr.ArtistIDs[0], p.cfg.EnqueueBatchSize)
if err != nil {
log.Println("autoplay error: failed to get similar tracks: %v", err)
log.Printf("autoplay error: failed to get similar tracks: %v", err)
}
tracks = filterRecentlyPlayed(similar)
}
@@ -527,7 +610,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
}
byGenre, err := s.GetRandomTracks(g, p.cfg.EnqueueBatchSize)
if err != nil {
log.Println("autoplay error: failed to get tracks by genre: %v", err)
log.Printf("autoplay error: failed to get tracks by genre: %v", err)
}
tracks = filterRecentlyPlayed(byGenre)
if len(tracks) > 0 {
@@ -542,7 +625,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
// fallback to random tracks
random, err := s.GetRandomTracks("", p.cfg.EnqueueBatchSize)
if err != nil {
log.Println("autoplay error: failed to get random tracks: %v", err)
log.Printf("autoplay error: failed to get random tracks: %v", err)
}
tracks = filterRecentlyPlayed(random)
}
@@ -556,7 +639,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
logIfErr := func(action string, err error) {
if err != nil {
log.Println("Playback error (%s): %v", action, err)
log.Printf("Playback error (%s): %v", action, err)
}
}
for {
@@ -606,6 +689,9 @@ func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
mpv.ForceRestartPlayback()
}
}
if c.OnDone != nil {
c.OnDone()
}
}
}
}
+665
View File
@@ -0,0 +1,665 @@
package dlna
import (
"context"
"crypto/md5"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player"
"github.com/dweymouth/supersonic/backend/util"
"github.com/hashicorp/go-retryablehttp"
"github.com/supersonic-app/go-upnpcast/device"
"github.com/supersonic-app/go-upnpcast/services/avtransport"
"github.com/supersonic-app/go-upnpcast/services/renderingcontrol"
)
const (
stopped = 0
playing = 1
paused = 2
)
var unimplemented = errors.New("unimplemented")
type proxyMapEntry struct {
key string
url string
}
type DLNAPlayer struct {
player.BasePlayerCallbackImpl
destroyed bool
cancelRequest context.CancelFunc
avTransport *avtransport.Client
renderControl *renderingcontrol.Client
state int // stopped, playing, paused
seeking bool
metaLock sync.Mutex
curTrackMeta mediaprovider.MediaItemMetadata
nextTrackMeta mediaprovider.MediaItemMetadata
// if true, report playback time 00:00
// pending time sync with player after beginning playback
pendingPlayStart bool
// start playback position in seconds of the last seek/time sync
lastStartTime int
// how long the track has been playing since last time sync
stopwatch util.Stopwatch
proxyServer *http.Server
proxyActive atomic.Bool
localIP string
proxyPort int
pendingSeek bool
pendingSeekSecs float64
// keep in order of most recently accessed at the end
// that way the item in proxyURLs[0] can be kicked out
// when adding a new URL to the proxy, since
// only two will need to be active at any given time
proxyURLs [3]proxyMapEntry
proxyURLLock sync.Mutex
// If SetNextAVTransport fails (e.g. because the device
// does not support the API/gapless), this flag is set
// true, and the next firing of the track change timer
// should clear it to false and use SetAVTransport
// to begin playing the item in nextTrackMeta.
failedToSetNext bool
unsetNextMediaItem *avtransport.MediaItem
timerActive atomic.Bool
timer *time.Timer
resetChan chan (time.Duration)
}
func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) {
retry := retryablehttp.NewClient()
retry.RetryMax = 3
retry.RetryWaitMin = 100 * time.Millisecond
retry.Logger = retryLogger{}
cli := retry.StandardClient()
avt, err := device.AVTransportClient()
if err != nil {
return nil, err
}
avt.HTTPClient = cli
rc, err := device.RenderingControlClient()
if err != nil {
return nil, err
}
rc.HTTPClient = cli
// ping to test connectivity
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := avt.GetTransportInfo(ctx); err != nil {
return nil, fmt.Errorf("failed to connect to %s", device.FriendlyName)
}
return &DLNAPlayer{
avTransport: avt,
renderControl: rc,
resetChan: make(chan time.Duration),
}, nil
}
func (d *DLNAPlayer) SetVolume(vol int) error {
if d.destroyed {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
d.cancelRequest = cancel
defer cancel()
return d.renderControl.SetVolume(ctx, vol)
}
func (d *DLNAPlayer) GetVolume() int {
if d.destroyed {
return 0
}
ctx, cancel := context.WithCancel(context.Background())
d.cancelRequest = cancel
defer cancel()
vol, _ := d.renderControl.GetVolume(ctx)
return vol
}
func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadata, startTime float64) error {
if d.destroyed {
return nil
}
d.ensureSetupProxy()
d.metaLock.Lock()
d.curTrackMeta = meta
d.metaLock.Unlock()
key := d.addURLToProxy(urlstr)
media := avtransport.MediaItem{
URL: d.urlForItem(key),
Title: meta.Name,
ContentType: meta.MIMEType,
Seekable: true,
}
if err := d.playAVTransportMedia(&media); err != nil {
return err
}
d.pendingPlayStart = true
if startTime > 0 {
// TODO: do something better than this!!
time.Sleep(2 * time.Second)
if !d.destroyed {
d.sendSeekCmd(startTime)
}
d.pendingPlayStart = false
} else {
go func() {
time.Sleep(2 * time.Second)
if !d.destroyed {
d.syncPlaybackTime()
}
d.pendingPlayStart = false
}()
}
d.state = playing
remainingDur := meta.Duration - int(startTime)
d.setTrackChangeTimer(time.Duration(remainingDur) * time.Second)
d.stopwatch.Reset()
d.stopwatch.Start()
d.lastStartTime = int(startTime)
d.InvokeOnPlaying()
d.InvokeOnTrackChange()
if startTime > 0 {
d.InvokeOnSeek()
}
return nil
}
func (d *DLNAPlayer) playAVTransportMedia(media *avtransport.MediaItem) error {
ctx, cancel := context.WithCancel(context.Background())
d.cancelRequest = cancel
defer cancel()
err := d.avTransport.SetAVTransportMedia(ctx, media)
if err != nil {
return err
}
if err := d.avTransport.Play(ctx); err != nil {
return err
}
return nil
}
func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadata) error {
if d.destroyed {
return nil
}
var media *avtransport.MediaItem
d.metaLock.Lock()
d.nextTrackMeta = meta
d.metaLock.Unlock()
if url != "" {
d.ensureSetupProxy()
key := d.addURLToProxy(url)
media = &avtransport.MediaItem{
URL: d.urlForItem(key),
ContentType: meta.MIMEType,
Title: meta.Name,
Seekable: true,
}
}
ctx, cancel := context.WithCancel(context.Background())
d.cancelRequest = cancel
defer cancel()
err := d.avTransport.SetNextAVTransportMedia(ctx, media)
if err != nil {
d.metaLock.Lock()
d.failedToSetNext = true
d.unsetNextMediaItem = media
d.metaLock.Unlock()
}
return err
}
func (d *DLNAPlayer) Continue() error {
if d.destroyed || d.state == playing {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
d.cancelRequest = cancel
defer cancel()
if d.pendingSeek {
d.pendingSeek = false
err := d.avTransport.Seek(ctx, int(d.pendingSeekSecs))
if err != nil {
return err
}
}
if err := d.avTransport.Play(ctx); err != nil {
return err
}
d.metaLock.Lock()
nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - d.curPlayPos()
d.metaLock.Unlock()
d.state = playing
d.setTrackChangeTimer(nextTrackChange)
d.stopwatch.Start()
d.InvokeOnPlaying()
return nil
}
func (d *DLNAPlayer) Pause() error {
if d.destroyed || d.state != playing {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
d.cancelRequest = cancel
defer cancel()
if err := d.avTransport.Pause(ctx); err != nil {
return err
}
d.setTrackChangeTimer(0)
d.stopwatch.Stop()
d.state = paused
d.InvokeOnPaused()
return nil
}
func (d *DLNAPlayer) Stop(force bool) error {
if d.destroyed {
return nil
}
if force && d.cancelRequest != nil {
d.cancelRequest()
}
switch d.state {
case stopped:
return nil
case playing:
var ctx context.Context
var cancel context.CancelFunc
if force {
ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second)
} else {
ctx, cancel = context.WithCancel(context.Background())
}
d.cancelRequest = cancel
defer cancel()
if err := d.avTransport.Pause(ctx); err != nil {
return err
}
fallthrough
case paused:
d.setTrackChangeTimer(0)
d.stopwatch.Reset()
d.lastStartTime = 0
d.state = stopped
d.InvokeOnStopped()
return nil
default:
return errors.New("invalid player state")
}
}
func (d *DLNAPlayer) SeekSeconds(secs float64) error {
if d.destroyed {
return nil
}
if d.state == paused {
d.pendingSeek = true
d.pendingSeekSecs = secs
} else {
if err := d.sendSeekCmd(secs); err != nil {
return err
}
}
d.lastStartTime = int(secs)
d.stopwatch.Reset()
if d.state == playing {
d.metaLock.Lock()
nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - time.Duration(secs)*time.Second
d.metaLock.Unlock()
d.setTrackChangeTimer(nextTrackChange)
d.stopwatch.Start()
}
d.InvokeOnSeek()
go func() {
time.Sleep(4 * time.Second)
if !d.destroyed {
d.syncPlaybackTime()
}
}()
return nil
}
func (d *DLNAPlayer) sendSeekCmd(secs float64) error {
d.seeking = true
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := d.avTransport.Seek(ctx, int(secs)); err != nil {
d.seeking = false
return err
}
d.seeking = false
return nil
}
func (d *DLNAPlayer) IsSeeking() bool {
return d.seeking
}
func (d *DLNAPlayer) GetStatus() player.Status {
state := player.Stopped
if d.state == playing {
state = player.Playing
} else if d.state == paused {
state = player.Paused
}
var timePos float64
if !d.pendingPlayStart {
timePos = d.curPlayPos().Seconds()
}
return player.Status{
State: state,
TimePos: timePos,
Duration: float64(d.curTrackMeta.Duration),
}
}
func (d *DLNAPlayer) curPlayPos() time.Duration {
return time.Duration(d.lastStartTime)*time.Second + d.stopwatch.Elapsed()
}
func (d *DLNAPlayer) Destroy() {
d.destroyed = true
d.setTrackChangeTimer(0)
if d.cancelRequest != nil {
d.cancelRequest()
}
if d.proxyServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go d.proxyServer.Shutdown(ctx)
d.proxyServer = nil
}
}
func (d *DLNAPlayer) syncPlaybackTime() {
start := time.Now()
if pos, err := d.avTransport.GetPositionInfo(context.Background()); err == nil {
d.lastStartTime = int(pos.RelTime.Seconds() + (time.Since(start) / 2).Seconds())
d.stopwatch.Reset()
if d.state == playing {
d.stopwatch.Start()
}
d.setTrackChangeTimer(time.Duration(d.curTrackMeta.Duration-d.lastStartTime) * time.Second)
d.InvokeOnSeek()
}
}
func (d *DLNAPlayer) ensureSetupProxy() error {
if d.proxyActive.Swap(true) {
return nil // already active
}
var err error
d.localIP, err = util.GetLocalIP()
if err != nil {
return err
}
listener, err := net.Listen("tcp", ":0")
if err != nil {
return err
}
d.proxyPort = listener.Addr().(*net.TCPAddr).Port
d.proxyServer = &http.Server{
Handler: http.HandlerFunc(d.handleRequest),
}
go d.proxyServer.Serve(listener)
return nil
}
func (d *DLNAPlayer) setTrackChangeTimer(dur time.Duration) {
if d.timerActive.Swap(true) {
// was active
d.resetChan <- dur
return
}
if dur == 0 {
d.timerActive.Store(false)
return
}
d.timer = time.NewTimer(dur)
go func() {
for {
select {
case dur := <-d.resetChan:
if dur == 0 {
d.timerActive.Store(false)
if !d.timer.Stop() {
select {
case <-d.timer.C:
default:
}
}
d.timer = nil
return
}
// reset the timer
if !d.timer.Stop() {
select {
case <-d.timer.C:
default:
}
}
d.timer.Reset(dur)
case <-d.timer.C:
d.timerActive.Store(false)
d.timer = nil
d.handleOnTrackChange()
return
}
}
}()
}
func (d *DLNAPlayer) handleOnTrackChange() {
stopping := false
d.metaLock.Lock()
if d.nextTrackMeta.ID == "" {
stopping = true
}
d.curTrackMeta = d.nextTrackMeta
d.nextTrackMeta = mediaprovider.MediaItemMetadata{}
nextTrackChange := time.Duration(d.curTrackMeta.Duration) * time.Second
d.metaLock.Unlock()
if stopping {
d.lastStartTime = 0
d.stopwatch.Reset()
d.InvokeOnStopped()
} else {
d.metaLock.Lock()
if d.failedToSetNext {
d.failedToSetNext = false
media := d.unsetNextMediaItem
d.unsetNextMediaItem = nil
d.metaLock.Unlock()
d.playAVTransportMedia(media)
} else {
d.metaLock.Unlock()
}
d.lastStartTime = 0
d.stopwatch.Reset()
d.stopwatch.Start()
d.setTrackChangeTimer(nextTrackChange)
d.InvokeOnTrackChange()
go func() {
time.Sleep(5 * time.Second)
if !d.destroyed {
d.syncPlaybackTime()
}
}()
}
}
func (d *DLNAPlayer) urlForItem(key string) string {
return fmt.Sprintf("http://%s:%d/%s", d.localIP, d.proxyPort, key)
}
func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.URL.Path, "/")
url, _ := d.lookupProxyURL(key)
if url == "" {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("404"))
return
}
// Create a new request to the target server
proxyReq, err := http.NewRequest(r.Method, url, r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Copy headers from the original request to the new request
proxyReq.Header = r.Header
// Create an HTTP client and send the request
client := &http.Client{}
resp, err := client.Do(proxyReq)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Copy headers from the response to the writer
for name, values := range resp.Header {
for _, value := range values {
w.Header().Add(name, value)
}
}
// Set the status code
w.WriteHeader(resp.StatusCode)
// Copy the response body to the writer
_, err = io.Copy(w, resp.Body)
if err != nil {
fmt.Fprintln(os.Stderr, "Error copying response body:", err)
}
}
func (d *DLNAPlayer) addURLToProxy(url string) string {
hash := md5.Sum([]byte(url))
key := base64.StdEncoding.EncodeToString(hash[:])
d.proxyURLLock.Lock()
defer d.proxyURLLock.Unlock()
d._updateProxyURL(key, url)
return key
}
// lookupProxyURL finds a URL by key and updates its position to most recently used
func (d *DLNAPlayer) lookupProxyURL(key string) (string, bool) {
d.proxyURLLock.Lock()
defer d.proxyURLLock.Unlock()
for i := 0; i < len(d.proxyURLs); i++ {
if d.proxyURLs[i].key == key {
url := d.proxyURLs[i].url
// Move accessed entry to the most recent position
d._updateProxyURL(key, url)
return url, true
}
}
return "", false
}
func (d *DLNAPlayer) _updateProxyURL(key, url string) {
// Check if the key already exists, and if so, move it to the most recently used position
for i := 0; i < len(d.proxyURLs); i++ {
if d.proxyURLs[i].key == key {
if i < len(d.proxyURLs)-1 {
// Shift elements to the left from found position to the end
copy(d.proxyURLs[i:], d.proxyURLs[i+1:])
}
// Place updated entry at the last position
d.proxyURLs[len(d.proxyURLs)-1] = proxyMapEntry{key: key, url: url}
return
}
}
// Shift all elements left to make room for the new entry at the end
copy(d.proxyURLs[:], d.proxyURLs[1:])
// Insert new element at the most recent position
d.proxyURLs[len(d.proxyURLs)-1] = proxyMapEntry{key: key, url: url}
}
type retryLogger struct{}
func (retryLogger) Error(msg string, keysAndValues ...interface{}) {
log.Println(msg, keysAndValues)
}
func (retryLogger) Info(msg string, keysAndValues ...interface{}) {
log.Println(msg, keysAndValues)
}
func (retryLogger) Warn(msg string, keysAndValues ...interface{}) {
log.Println(msg, keysAndValues)
}
func (retryLogger) Debug(msg string, keysAndValues ...interface{}) {
// log only retries, not every request
if strings.Contains(msg, "retrying request") {
log.Println(msg, keysAndValues)
}
}
+4 -2
View File
@@ -68,7 +68,7 @@ func (j *JukeboxPlayer) Pause() error {
return nil
}
func (j *JukeboxPlayer) Stop() error {
func (j *JukeboxPlayer) Stop(_ bool) error {
if j.state == stopped {
return nil
}
@@ -80,7 +80,7 @@ func (j *JukeboxPlayer) Stop() error {
return nil
}
func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track) error {
func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track, _ float64) error {
if err := j.provider.JukeboxSet(track.ID); err != nil {
return err
}
@@ -140,6 +140,8 @@ func (j *JukeboxPlayer) GetStatus() player.Status {
}
}
func (j *JukeboxPlayer) Destroy() {}
func (j *JukeboxPlayer) startAndUpdateTime() error {
beforeStart := time.Now()
if err := j.provider.JukeboxStart(); err != nil {
+28 -12
View File
@@ -6,7 +6,9 @@ import (
"fmt"
"math"
"strconv"
"sync"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player"
"github.com/supersonic-app/go-mpv"
)
@@ -67,6 +69,9 @@ type Player struct {
equalizer Equalizer
peaksEnabled bool
fileLoadedLock sync.Mutex
fileLoadedSig *sync.Cond
bgCancel context.CancelFunc
}
@@ -79,10 +84,12 @@ func New() *Player {
// Same as New, but sets the application name that mpv
// reports to the system audio API.
func NewWithClientName(c string) *Player {
return &Player{
p := &Player{
vol: -1, // use 100 in Init
clientName: c,
}
p.fileLoadedSig = sync.NewCond(&p.fileLoadedLock)
return p
}
// Initializes the Player and makes it ready for playback.
@@ -133,23 +140,32 @@ func (p *Player) Init(maxCacheMB int) error {
}
// Plays the specified file, clearing the previous play queue, if any.
func (p *Player) PlayFile(url string) error {
func (p *Player) PlayFile(url string, _ mediaprovider.MediaItemMetadata, startTime float64) error {
if !p.initialized {
return ErrUnitialized
}
err := p.mpv.Command([]string{"loadfile", url, "replace"})
if err == nil {
p.lenPlaylist = 1
if p.status.State == player.Paused {
return p.Continue()
}
if err != nil {
return err
}
p.lenPlaylist = 1
if p.status.State == player.Paused {
err = p.Continue()
} else {
p.setState(player.Playing)
}
if startTime > 0 {
p.fileLoadedLock.Lock()
p.fileLoadedSig.Wait()
p.fileLoadedLock.Unlock()
p.SeekSeconds(startTime)
}
return err
}
// Stops playback and clears the play queue.
func (p *Player) Stop() error {
func (p *Player) Stop(_ bool) error {
if !p.initialized {
return ErrUnitialized
}
@@ -169,7 +185,7 @@ func (p *Player) Stop() error {
return err
}
func (p *Player) SetNextFile(url string) error {
func (p *Player) SetNextFile(url string, _ mediaprovider.MediaItemMetadata) error {
if p.lenPlaylist > p.curPlaylistPos+1 {
if err := p.mpv.Command([]string{"playlist-remove", strconv.Itoa(int(p.curPlaylistPos) + 1)}); err != nil {
return err
@@ -480,10 +496,9 @@ func (p *Player) eventHandler(ctx context.Context) {
}
switch e.Event_Id {
case mpv.EVENT_PLAYBACK_RESTART:
if p.seeking {
p.seeking = false
}
fallthrough
case mpv.EVENT_SEEK:
p.seeking = false
p.InvokeOnSeek()
case mpv.EVENT_FILE_LOADED:
p.curPlaylistPos, _ = p.getInt64Property("playlist-pos")
@@ -493,6 +508,7 @@ func (p *Player) eventHandler(ctx context.Context) {
p.InvokeOnSeek()
}
p.InvokeOnTrackChange()
p.fileLoadedSig.Signal()
case mpv.EVENT_IDLE:
p.status.Duration = 0
p.status.TimePos = 0
+32 -28
View File
@@ -1,23 +1,25 @@
package player
import "github.com/dweymouth/supersonic/backend/mediaprovider"
import (
"github.com/dweymouth/supersonic/backend/mediaprovider"
)
type URLPlayer interface {
BasePlayer
PlayFile(url string) error
SetNextFile(url string) error
PlayFile(url string, metadata mediaprovider.MediaItemMetadata, startTime float64) error
SetNextFile(url string, metadata mediaprovider.MediaItemMetadata) error
}
type TrackPlayer interface {
BasePlayer
PlayTrack(track *mediaprovider.Track) error
PlayTrack(track *mediaprovider.Track, startTime float64) error
SetNextTrack(track *mediaprovider.Track) error
}
type BasePlayer interface {
Continue() error
Pause() error
Stop() error
Stop(force bool) error
SeekSeconds(secs float64) error
IsSeeking() bool
@@ -27,6 +29,8 @@ type BasePlayer interface {
GetStatus() Status
Destroy()
// Event API
OnPaused(func())
OnStopped(func())
@@ -84,66 +88,66 @@ func (r ReplayGainMode) String() string {
}
type BasePlayerCallbackImpl struct {
onPaused []func()
onStopped []func()
onPlaying []func()
onSeek []func()
onTrackChange []func()
onPaused func()
onStopped func()
onPlaying func()
onSeek func()
onTrackChange func()
}
// Registers a callback which is invoked when the player transitions to the Paused state.
// Sets a callback which is invoked when the player transitions to the Paused state.
func (p *BasePlayerCallbackImpl) OnPaused(cb func()) {
p.onPaused = append(p.onPaused, cb)
p.onPaused = cb
}
// Registers a callback which is invoked when the player transitions to the Stopped state.
// Sets a callback which is invoked when the player transitions to the Stopped state.
func (p *BasePlayerCallbackImpl) OnStopped(cb func()) {
p.onStopped = append(p.onStopped, cb)
p.onStopped = cb
}
// Registers a callback which is invoked when the player transitions to the Playing state.
// Sets a callback which is invoked when the player transitions to the Playing state.
func (p *BasePlayerCallbackImpl) OnPlaying(cb func()) {
p.onPlaying = append(p.onPlaying, cb)
p.onPlaying = cb
}
// Registers a callback which is invoked whenever a seek event occurs.
func (p *BasePlayerCallbackImpl) OnSeek(cb func()) {
p.onSeek = append(p.onSeek, cb)
p.onSeek = cb
}
// Registers a callback which is invoked when the currently playing track changes,
// or when playback begins at any time from the Stopped state.
// Callback is invoked with the index of the currently playing track (zero-based).
func (p *BasePlayerCallbackImpl) OnTrackChange(cb func()) {
p.onTrackChange = append(p.onTrackChange, cb)
p.onTrackChange = cb
}
func (p *BasePlayerCallbackImpl) InvokeOnPaused() {
for _, cb := range p.onPaused {
cb()
if p.onPaused != nil {
p.onPaused()
}
}
func (p *BasePlayerCallbackImpl) InvokeOnPlaying() {
for _, cb := range p.onPlaying {
cb()
if p.onPlaying != nil {
p.onPlaying()
}
}
func (p *BasePlayerCallbackImpl) InvokeOnStopped() {
for _, cb := range p.onStopped {
cb()
if p.onStopped != nil {
p.onStopped()
}
}
func (p *BasePlayerCallbackImpl) InvokeOnSeek() {
for _, cb := range p.onSeek {
cb()
if p.onSeek != nil {
p.onSeek()
}
}
func (p *BasePlayerCallbackImpl) InvokeOnTrackChange() {
for _, cb := range p.onTrackChange {
cb()
if p.onTrackChange != nil {
p.onTrackChange()
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ type serializedSavedPlayQueue struct {
// If the provided CanSavePlayQueue server is non-nil, it will also save to the server.
func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server mediaprovider.CanSavePlayQueue) error {
queue := pm.GetPlayQueue()
stats := pm.PlayerStatus()
stats := pm.PlaybackStatus()
trackIdx := pm.NowPlayingIndex()
trackIDs := make([]string, 0, len(queue))
+29
View File
@@ -1,7 +1,9 @@
package util
import (
"fmt"
"io"
"net"
"os"
)
@@ -21,3 +23,30 @@ func CopyFile(srcPath, dstPath string) error {
_, err = io.Copy(fout, fin)
return err
}
func GetLocalIP() (string, error) {
interfaces, err := net.Interfaces()
if err != nil {
return "", err
}
for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
return "", err
}
for _, addr := range addrs {
ipnet, ok := addr.(*net.IPNet)
if ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
return ipnet.IP.String(), nil
}
}
}
return "", fmt.Errorf("no suitable interface found")
}
+15 -7
View File
@@ -1,9 +1,9 @@
module github.com/dweymouth/supersonic
go 1.21
go 1.21.9
require (
fyne.io/fyne/v2 v2.6.0-alpha1
fyne.io/fyne/v2 v2.6.0-beta1
github.com/20after4/configdir v0.1.1
github.com/Microsoft/go-winio v0.6.2
github.com/cenkalti/dominantcolor v1.0.3
@@ -14,12 +14,14 @@ require (
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645
github.com/godbus/dbus/v5 v5.1.0
github.com/google/uuid v1.3.0
github.com/hashicorp/go-retryablehttp v0.7.7
github.com/pelletier/go-toml/v2 v2.0.8
github.com/quarckster/go-mpris-server v1.0.3
github.com/supersonic-app/go-mpv v0.1.0
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d
github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5
github.com/zalando/go-keyring v0.2.6
golang.org/x/net v0.25.0
golang.org/x/net v0.35.0
golang.org/x/sys v0.30.0
golang.org/x/text v0.22.0
)
@@ -33,18 +35,24 @@ require (
github.com/fredbi/uri v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fyne-io/gl-js v0.1.0 // indirect
github.com/fyne-io/glfw-js v0.1.0 // indirect
github.com/fyne-io/image v0.1.0 // indirect
github.com/fyne-io/glfw-js v0.2.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.1.0 // indirect
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
github.com/go-text/render v0.2.0 // indirect
github.com/go-text/typesetting v0.2.1 // indirect
github.com/h2non/filetype v1.1.3 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/koron/go-ssdp v0.0.5 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rymdport/portal v0.3.0 // indirect
github.com/rymdport/portal v0.4.1 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.10.0 // indirect
@@ -53,4 +61,4 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace fyne.io/fyne/v2 v2.6.0-alpha1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250222005659-e73d8f178006
replace fyne.io/fyne/v2 v2.6.0-beta1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250406173843-f5cb002423a5
+34 -10
View File
@@ -23,10 +23,12 @@ github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64 h1:RUIrnGY03
github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64/go.mod h1:3YrjFDHMlhCsSZ/OvmJCxWm9QHSgOVWZBxnraZz9Z7c=
github.com/dweymouth/fyne-tooltip v0.3.0 h1:NKCyTkh9NtvnTsiHtTOtaJzRDOFYP8AckQ2tyhOh6JY=
github.com/dweymouth/fyne-tooltip v0.3.0/go.mod h1:m04ShLW/Tp6LXrNieTumApvNgo7YSB+wi+jZTN+kDBU=
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250222005659-e73d8f178006 h1:vsLOmm3C/RLFmWhfv+8MNcjApC9S3SdXZLcIGydiVnI=
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250222005659-e73d8f178006/go.mod h1:Bzv2yK+ncZ8LJbHKjyJJpEAFlbs6oulHgKm04ObOqA8=
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250406173843-f5cb002423a5 h1:/gxlRSmndtF8QVxV3oFjlozm2clkMlLBlfiKFtehH+M=
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250406173843-f5cb002423a5/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU=
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645 h1:KzqSaQwG3HsTZQlEtkp0BeUy9vmYZ0rq0B15qIPSiBs=
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
@@ -35,10 +37,12 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM=
github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.1.0 h1:RGGMmVjcsG17Oifl3X2UJ5vH3PgS4B1UY3ASeN5BXbI=
github.com/fyne-io/glfw-js v0.1.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
github.com/fyne-io/image v0.1.0 h1:Vm2TQJ2PWGHCf3jYi1/XroaNNMu+GfI/O2QpSbZd4XQ=
github.com/fyne-io/image v0.1.0/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM=
github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
@@ -57,12 +61,30 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc=
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/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/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/koron/go-ssdp v0.0.5 h1:E1iSMxIs4WqxTbIBLtmNBeOOC+1sCIXQeqTWVnpmwhk=
github.com/koron/go-ssdp v0.0.5/go.mod h1:Qm59B7hpKpDqfyRNWRNr00jGwLdXjDyZh6y7rH6VS0w=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
@@ -77,8 +99,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quarckster/go-mpris-server v1.0.3 h1:ef6d3DpxlORtdEBHnhQ/j3gS0Z3+YUfXeJhC9L9DZvA=
github.com/quarckster/go-mpris-server v1.0.3/go.mod h1:2b4IdrpnEoEfU+6fQKjYhAgdvsiz4JxmTpDAUrMJVO4=
github.com/rymdport/portal v0.3.0 h1:QRHcwKwx3kY5JTQcsVhmhC3TGqGQb9LFghVNUy8AdB8=
github.com/rymdport/portal v0.3.0/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
@@ -97,6 +119,8 @@ github.com/supersonic-app/go-mpv v0.1.0 h1:U+cCnLQxmpqx5mY6nMlC0J4uIdCCXUbAjpjS0
github.com/supersonic-app/go-mpv v0.1.0/go.mod h1:1bQz6kBQumJopXEbkiqoLxIXLy7F7yWFBvknvpAtIC0=
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4=
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4=
github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5 h1:aoUJKPFD/ZrNZjK6fl2Xhazwsttx42esKxhSSzEE3Bo=
github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5/go.mod h1:ibt19zDV5/vvF14jHJpTv3AOorq1EbmrMAubxnuvR5Y=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
@@ -113,8 +137,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+5
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -3,6 +3,7 @@
fyne bundle -package res -prefix Res appicon-256.png > bundled.go
fyne bundle -append -prefix Res icons/coreui/playlist-add-next.svg >> bundled.go
fyne bundle -append -prefix Res icons/freepik/playbutton.png >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/cast.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/disc.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/headphones.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/heart-filled.svg >> bundled.go
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M1,21 L1,19 C2.1045695,19 3,19.8954305 3,21 L1,21 Z M7,21 L5,21 C5,18.790861 3.209139,17 1,17 L1,15 C4.3137085,15 7,17.6862915 7,21 Z M11,21 L9,21 C9,16.581722 5.418278,13 1,13 L1,11 C6.5228475,11 11,15.4771525 11,21 Z M3,9 L1,9 L1,5 C1,3.8954305 1.8954305,3 3,3 L21,3 C22.1045695,3 23,3.8954305 23,5 L23,19 C23,20.1045695 22.1045695,21 21,21 L13,21 L13,19 L21,19 L21,5 L3,5 L3,9 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 646 B

+6 -30
View File
@@ -33,6 +33,8 @@
<li>Sort tracklist views by column and configure visible tracklist columns</li>
<li>Set/unset favorite and browse by favorite albums, artists, and songs</li>
<li>Shuffle and repeat playback modes (partial; shuffle album, playlist, artist radio, random songs)</li>
<li>Internet radio station support (Subsonic)</li>
<li>Cast to uPnP/DLNA devices</li>
</ul>
</description>
@@ -61,44 +63,18 @@
</screenshots>
<releases>
<release date="2025-02-22" version="0.14.0">
<release date="2025-04-07" version="0.15.1">
<description>
<p>
Version 0.14.0 of Supersonic
Version 0.15.1 of Supersonic
</p>
<p>
Added
</p>
<ul>
<li>New translations: Dutch</li>
<li>Media Key / SMTC support for Windows</li>
<li>Add an Autoplay mode to continually add related songs when nearing the end of queue</li>
<li>Shuffle button added to favorite songs view</li>
<li>Added toast notifications for certain UI actions</li>
<li>Add album shuffle modes to Albums and Genre pages</li>
<li>Caching added for LrcLib lyrics, custom LrcLib URL supported in config file</li>
<li>Use notify-send for notifications on Linux if available</li>
<li>Add setting to disable automatic scaling adjustment for detected DPI</li>
<li>New Advanced settings tab exposing more config file settings to the UI</li>
<li>Favorite/menu icon buttons added to grid cards when hovered</li>
<li>Show full dates from OpenSubsonic servers when available</li>
<li>Log to a file instead of stdout on Windows</li>
</ul>
<p>
Fixed
</p>
<ul>
<li>Persist repeat setting across restarts</li>
<li>Removing track from playlist when sorted removed wrong track</li>
<li>Fullscreen artist images not loading in full resolution</li>
<li>Fix volume slider scrolling too quickly with some mice</li>
<li>Loading dots not stopping if canceling a search before completion</li>
<li>Add list/grid debouncing to improve scrolling smoothness when scrolling very quickly</li>
<li>Image caching broken on some servers due to track IDs not being legal filenames</li>
<li>Migrate to Fyne 2.6 for improved stability and performance</li>
<li>Sometimes crashing on exit</li>
<li>Fix crashing on playback on Windows</li>
<li>Fix crashing when clicking on album filters button</li>
</ul>
</description>
+4 -25
View File
@@ -3,7 +3,7 @@ package res
const (
AppName = "supersonic"
DisplayName = "Supersonic"
AppVersion = "0.14.0"
AppVersion = "0.15.1"
AppVersionTag = "v" + AppVersion
ConfigFile = "config.toml"
GithubURL = "https://github.com/dweymouth/supersonic"
@@ -13,31 +13,10 @@ const (
)
var (
WhatsAdded = `
## Added
* New translations: Dutch
* Media Key / SMTC support for Windows
* Add an Autoplay mode to continually add related songs when nearing the end of queue
* Shuffle button added to favorite songs view
* Added toast notifications for certain UI actions
* Add album shuffle modes to Albums and Genre pages
* Caching added for LrcLib lyrics, custom LrcLib URL supported in config file
* Use notify-send for notifications on Linux if available
* Add setting to disable automatic scaling adjustment for detected DPI
* New Advanced settings tab exposing more config file settings to the UI
* Favorite/menu icon buttons added to grid cards when hovered
* Show full dates from OpenSubsonic servers when available
* Log to a file instead of stdout on Windows`
WhatsAdded = ``
WhatsFixed = `
## Fixed
* Persist repeat setting across restarts
* Removing track from playlist when sorted removed wrong track
* Fullscreen artist images not loading in full resolution
* Fix volume slider scrolling too quickly with some mice
* Loading dots not stopping if canceling a search before completion
* Add list/grid debouncing to improve scrolling smoothness when scrolling very quickly
* Image caching broken on some servers due to track IDs not being legal filenames
* Migrate to Fyne 2.6 for improved stability and performance
* Sometimes crashing on exit`
* Fix crashing on playback on Windows
* Fix crashing when clicking on album filters button`
)
+3 -1
View File
@@ -41,6 +41,7 @@
"BPM": "BPM",
"Broadcast": "Broadcast",
"Cancel": "Cancel",
"Cast to device": "Cast to device",
"Check for Updates": "Check for Updates",
"Close": "Close",
"Close to system tray": "Close to system tray",
@@ -168,6 +169,7 @@
"Save play queue on exit": "Save play queue on exit",
"Saved at": "Saved at",
"Scrobble when": "Scrobble when",
"Search": "Search",
"Search Everywhere": "Search Everywhere",
"Search page": "Search page",
"Search playlists or new playlist name": "Search playlists or new playlist name",
@@ -206,6 +208,7 @@
"Testing connection": "Testing connection",
"The request timed out": "The request timed out",
"Theme": "Theme",
"This computer": "This computer",
"Time": "Time",
"Title": "Title",
"Title (A-Z)": "Title (A-Z)",
@@ -237,7 +240,6 @@
"You are running the latest version of": "You are running the latest version of",
"Quit": "Quit",
"Rescan Library": "Rescan Library",
"Search": "Search",
"Owner": "Owner",
"To server": "To server",
"Fav.": "Fav.",
+6
View File
@@ -3,6 +3,7 @@ package ui
import (
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player/mpv"
"github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts"
"github.com/dweymouth/supersonic/ui/util"
@@ -111,6 +112,10 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
pm.OnVolumeChange(func(vol int) {
fyne.Do(func() { bp.AuxControls.VolumeControl.SetVolume(vol) })
})
pm.OnPlayerChange(func() {
_, local := pm.CurrentPlayer().(*mpv.Player)
fyne.Do(func() { bp.AuxControls.SetIsRemotePlayer(!local) })
})
bp.AuxControls.VolumeControl.OnSetVolume = func(v int) {
pm.SetVolume(v)
}
@@ -121,6 +126,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
pm.SetAutoplay(autoplay)
}
bp.AuxControls.OnShowPlayQueue(contr.ShowPopUpPlayQueue)
bp.AuxControls.OnShowCastMenu(contr.ShowCastMenu)
bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage)
+1 -1
View File
@@ -471,7 +471,7 @@ func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums, im *ba
a.artistImageID = artist.CoverArtID
go func() {
if cover, err := im.GetCoverThumbnail(artist.CoverArtID); err == nil {
a.artistImage.SetImage(cover, true)
fyne.Do(func() { a.artistImage.SetImage(cover, true) })
} else {
log.Printf("error fetching cover: %v", err)
}
+14 -2
View File
@@ -166,6 +166,18 @@ func NewNowPlayingPage(
a.relatedList.OnPlaySelectionNext = func(items []mediaprovider.MediaItem) {
a.pm.LoadItems(items, backend.InsertNext, false)
}
a.relatedList.OnPlaySongRadio = func(track *mediaprovider.Track) {
go func() {
if err := a.pm.PlaySimilarSongs(track.ID); err != nil {
fyne.Do(func() {
a.contr.ToastProvider.ShowErrorToast(lang.L("Unable to play song radio"))
})
}
}()
}
a.relatedList.OnShowTrackInfo = func(track *mediaprovider.Track) {
a.contr.ShowTrackInfoDialog(track)
}
a.lyricsViewer = widgets.NewLyricsViewer()
a.statusLabel = widget.NewLabel(lang.L("Stopped"))
@@ -431,7 +443,7 @@ func (a *NowPlayingPage) Reload() {
}
switch a.tabs.SelectedIndex() {
case 1: /*lyrics*/
a.lastPlayPos = a.pm.CurrentPlayer().GetStatus().TimePos
a.lastPlayPos = a.pm.PlaybackStatus().TimePos
a.updateLyrics()
case 2: /*related*/
a.updateRelatedList()
@@ -515,7 +527,7 @@ func (a *NowPlayingPage) saveSelectedTab(tabNum int) {
func (a *NowPlayingPage) formatStatusLine() {
curPlayer := a.pm.CurrentPlayer()
playerStats := curPlayer.GetStatus()
playerStats := a.pm.PlaybackStatus()
lastStatus := a.statusLabel.Text
stopped := lang.L("Stopped")
state := stopped
+96 -1
View File
@@ -5,6 +5,8 @@ import (
"log"
"strings"
"github.com/deluan/sanitize"
ttwidget "github.com/dweymouth/fyne-tooltip/widget"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil"
@@ -14,6 +16,7 @@ import (
"github.com/dweymouth/supersonic/ui/widgets"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/layout"
@@ -240,6 +243,43 @@ func (a *PlaylistPage) onRemoveSelectedFromPlaylist() {
a.Reload()
}
func (a *PlaylistPage) onSearched(query string) {
if query == "" {
// switch back to full playlist view
ids := a.tracklist.SelectedTrackIDs()
a.tracklist.Options.Reorderable = true
a.tracklist.SetSorting(a.trackSort) // restore old sort order
a.tracklist.SetTracks(a.tracks)
// if a track was selected in the searched view,
// scroll to it when switching back to full playlist
if len(ids) > 0 {
a.tracklist.SelectAndScrollToTrack(ids[0])
}
return
}
// search tracks within the playlist
searched := sharedutil.FilterSlice(a.tracks, func(t *mediaprovider.Track) bool {
sani := func(s string) string {
return strings.ToLower(sanitize.Accents(s))
}
qLower := strings.ToLower(query)
return strings.Contains(sani(t.Title), qLower) ||
strings.Contains(sani(strings.Join(t.ArtistNames, "")), qLower) ||
strings.Contains(sani(t.Album), qLower) ||
strings.Contains(sani(strings.Join(t.Genres, "")), qLower) ||
strings.Contains(sani(strings.Join(t.ComposerNames, "")), qLower) ||
strings.Contains(fmt.Sprintf("%d", t.Year), query) ||
strings.Contains(sani(t.Comment), qLower)
})
a.trackSort = a.tracklist.Sorting() // save old sort order
a.tracklist.Options.Reorderable = false
a.tracklist.SetSorting(widgets.TracklistSort{})
a.tracklist.SetTracks(searched)
}
type PlaylistPageHeader struct {
widget.BaseWidget
@@ -275,6 +315,7 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
a.ownerLabel = util.NewTruncatingLabel()
a.createdAtLabel = widget.NewLabel("")
a.trackTimeLabel = widget.NewLabel("")
a.editButton = widget.NewButtonWithIcon(lang.L("Edit"), theme.DocumentCreateIcon(), func() {
if a.playlistInfo != nil {
a.page.contr.DoEditPlaylistWorkflow(&a.playlistInfo.Playlist)
@@ -289,6 +330,58 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
a.page.pm.LoadTracks(a.page.tracks, backend.Replace, true)
a.page.pm.PlayFromBeginning()
})
// set up search button / entry animation pair
// needs reference to containing HBox container (initialized later)
var buttonRow *fyne.Container
var searchBtn *ttwidget.Button
var searchEntry *widgets.SearchEntry
searchBtn = ttwidget.NewButtonWithIcon("", theme.SearchIcon(), func() {
minW := searchBtn.MinSize().Width
if searchEntry == nil {
searchEntry = widgets.NewSearchEntry()
searchEntry.PlaceHolder = ""
searchEntry.Scroll = container.ScrollNone
searchEntry.OnSearched = func(s string) {
// we can't assign a.page.onSearched directly,
// since widget reuse means a.page could change
a.page.onSearched(s)
}
searchEntry.OnFocusLost = func() {
if searchEntry.Text == "" {
// dismissal animation
searchEntry.Scroll = container.ScrollNone
searchEntry.SetPlaceHolder("")
fyne.NewAnimation(canvas.DurationShort, func(f float32) {
f = 1 - f
w := (200-minW)*f + minW
searchEntry.SetMinWidth(w)
if f == 0 {
buttonRow.Objects[3] = searchBtn
}
// re-layout container (without unneeded full refresh)
buttonRow.Layout.Layout(buttonRow.Objects, buttonRow.Layout.MinSize(buttonRow.Objects))
}).Start()
}
}
}
buttonRow.Objects[3] = searchEntry
searchEntry.SetMinWidth(minW)
fyne.CurrentApp().Driver().CanvasForObject(a).Focus(searchEntry)
fyne.NewAnimation(canvas.DurationShort, func(f float32) {
w := (200-minW)*f + minW
searchEntry.SetMinWidth(w)
// re-layout container (without unneeded full refresh)
buttonRow.Layout.Layout(buttonRow.Objects, buttonRow.Layout.MinSize(buttonRow.Objects))
if f == 1 {
searchEntry.Scroll = container.ScrollHorizontalOnly
searchEntry.Refresh() // needed to initialize widget's scroller
searchEntry.SetPlaceHolder(lang.L("Search"))
}
}).Start()
})
searchBtn.SetToolTip(lang.L("Search"))
var pop *widget.PopUpMenu
menuBtn := widget.NewButtonWithIcon("", theme.MoreHorizontalIcon(), nil)
menuBtn.OnTapped = func() {
@@ -317,13 +410,15 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
pop.ShowAtPosition(fyne.NewPos(pos.X, pos.Y+menuBtn.Size().Height))
}
buttonRow = container.NewHBox(a.editButton, playButton, shuffleBtn, searchBtn, menuBtn)
a.container = util.AddHeaderBackground(
container.NewBorder(nil, nil, a.image, nil,
container.NewVBox(a.titleLabel, container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-10),
a.descriptionLabel,
a.ownerLabel,
a.trackTimeLabel),
container.NewHBox(a.editButton, playButton, shuffleBtn, menuBtn),
buttonRow,
)))
return a
}
+12
View File
@@ -9,6 +9,8 @@ import (
"github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/widgets"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/widget"
)
@@ -179,4 +181,14 @@ func (c *Controller) ConnectPlayQueuelistActions(list *widgets.PlayQueueList) {
}
list.OnSetRating = c.SetTrackRatings
list.OnSetFavorite = c.SetTrackFavorites
list.OnPlaySongRadio = func(track *mediaprovider.Track) {
go func() {
if err := c.App.PlaybackManager.PlaySimilarSongs(track.ID); err != nil {
fyne.Do(func() {
c.ToastProvider.ShowErrorToast(lang.L("Unable to play song radio"))
})
}
}()
}
list.OnShowTrackInfo = c.ShowTrackInfoDialog
}
+46 -408
View File
@@ -2,7 +2,6 @@ package controller
import (
"archive/zip"
"context"
"fmt"
"image"
"image/color"
@@ -11,7 +10,6 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"sync"
"time"
@@ -30,7 +28,6 @@ import (
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
@@ -154,6 +151,50 @@ func (m *Controller) HaveModal() bool {
return m.haveModal
}
func (m *Controller) ShowCastMenu(onPendingPlayerChange func()) {
rp := m.App.PlaybackManager.CurrentRemotePlayer()
devices := m.App.PlaybackManager.RemotePlayers()
local := fyne.NewMenuItem(lang.L("This computer"), func() {
if rp == nil {
return // no-op. already not casting
}
onPendingPlayerChange()
go func() {
if err := m.App.PlaybackManager.SetRemotePlayer(nil); err != nil {
fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to disconnect from remote player") })
}
}()
})
local.Icon = theme.ComputerIcon()
local.Checked = rp == nil
menu := fyne.NewMenu("", local)
for _, d := range devices {
_d := d
isCurrent := rp != nil && _d.URL == rp.URL
item := fyne.NewMenuItem(d.Name, func() {
if isCurrent {
return // no-op.
}
onPendingPlayerChange()
go func() {
if err := m.App.PlaybackManager.SetRemotePlayer(&_d); err != nil {
fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to connect to " + _d.Name) })
}
}()
})
item.Icon = myTheme.CastIcon
item.Checked = isCurrent
menu.Items = append(menu.Items, item)
}
pop := widget.NewPopUpMenu(menu, m.MainWindow.Canvas())
canvasSize := m.MainWindow.Canvas().Size()
pop.ShowAtPosition(fyne.NewPos(
canvasSize.Width-pop.MinSize().Width-10,
canvasSize.Height-pop.MinSize().Height-100,
))
}
func (m *Controller) ShowPopUpPlayQueue() {
m.popUpQueueMutex.Lock()
if m.popUpQueue == nil {
@@ -260,327 +301,6 @@ func (m *Controller) GetArtistTracks(artistID string) []*mediaprovider.Track {
return nil
}
func (m *Controller) PromptForFirstServer() {
d := dialogs.NewAddEditServerDialog(lang.L("Connect to Server"), false, nil, m.MainWindow.Canvas().Focus)
pop := widget.NewModalPopUp(d, m.MainWindow.Canvas())
d.OnSubmit = func() {
d.DisableSubmit()
go func() {
if m.testConnectionAndUpdateDialogText(d) {
// connection is good
pop.Hide()
m.doModalClosed()
conn := backend.ServerConnection{
ServerType: d.ServerType,
Hostname: d.Host,
AltHostname: d.AltHost,
Username: d.Username,
LegacyAuth: d.LegacyAuth,
}
server := m.App.ServerManager.AddServer(d.Nickname, conn)
if err := m.trySetPasswordAndConnectToServer(server, d.Password); err != nil {
log.Printf("error connecting to server: %s", err.Error())
}
}
d.EnableSubmit()
}()
}
m.haveModal = true
pop.Show()
}
// Show dialog to select playlist.
// Depending on the results of that dialog, potentially create a new playlist
// Add tracks to the user-specified playlist
func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
sp := dialogs.NewSelectPlaylistDialog(m.App.ServerManager.Server, m.App.ImageManager,
m.App.ServerManager.LoggedInUser, m.App.Config.Application.AddToPlaylistSkipDuplicates)
pop := widget.NewModalPopUp(sp.SearchDialog, m.MainWindow.Canvas())
sp.SetOnDismiss(func() {
pop.Hide()
m.doModalClosed()
})
sp.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) {
notifySuccess := func(n int) {
fyne.Do(func() {
msg := lang.LocalizePluralKey("playlist.addedtracks",
"Added tracks to playlist", n, map[string]string{"trackCount": strconv.Itoa(n)})
m.ToastProvider.ShowSuccessToast(msg)
})
}
notifyError := func() {
fyne.Do(func() {
m.ToastProvider.ShowErrorToast(
lang.L("An error occurred adding tracks to the playlist"),
)
})
}
pop.Hide()
m.App.Config.Application.AddToPlaylistSkipDuplicates = sp.SkipDuplicates
if id == "" /* creating new playlist */ {
go func() {
err := m.App.ServerManager.Server.CreatePlaylist(sp.SearchDialog.SearchQuery(), trackIDs)
if err == nil {
notifySuccess(len(trackIDs))
} else {
log.Printf("error adding tracks to playlist: %s", err.Error())
notifyError()
}
}()
} else {
m.App.Config.Application.DefaultPlaylistID = id
if sp.SkipDuplicates {
go func() {
currentTrackIDs := make(map[string]struct{})
if selectedPlaylist, err := m.App.ServerManager.Server.GetPlaylist(id); err != nil {
log.Printf("error getting playlist: %s", err.Error())
notifyError()
} else {
for _, track := range selectedPlaylist.Tracks {
currentTrackIDs[track.ID] = struct{}{}
}
filterTrackIDs := sharedutil.FilterSlice(trackIDs, func(trackID string) bool {
_, ok := currentTrackIDs[trackID]
return !ok
})
err := m.App.ServerManager.Server.AddPlaylistTracks(id, filterTrackIDs)
if err == nil {
notifySuccess(len(filterTrackIDs))
} else {
log.Printf("error adding tracks to playlist: %s", err.Error())
notifyError()
}
}
}()
} else {
go func() {
err := m.App.ServerManager.Server.AddPlaylistTracks(id, trackIDs)
if err == nil {
notifySuccess(len(trackIDs))
} else {
log.Printf("error adding tracks to playlist: %s", err.Error())
notifyError()
}
}()
}
}
})
m.ClosePopUpOnEscape(pop)
m.haveModal = true
min := sp.MinSize()
height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, m.MainWindow.Canvas().Size().Height*0.7))
sp.SearchDialog.Show()
pop.Resize(fyne.NewSize(min.Width, height))
pop.Show()
m.MainWindow.Canvas().Focus(sp.GetSearchEntry())
}
func (m *Controller) DoEditPlaylistWorkflow(playlist *mediaprovider.Playlist) {
canMakePublic := m.App.ServerManager.Server.CanMakePublicPlaylist()
dlg := dialogs.NewEditPlaylistDialog(playlist, canMakePublic)
pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas())
m.ClosePopUpOnEscape(pop)
dlg.OnCanceled = func() {
pop.Hide()
m.doModalClosed()
}
dlg.OnDeletePlaylist = func() {
pop.Hide()
dialog.ShowCustomConfirm(lang.L("Confirm Delete Playlist"), lang.L("OK"), lang.L("Cancel"), layout.NewSpacer(), /*custom content*/
func(ok bool) {
if !ok {
pop.Show()
} else {
m.doModalClosed()
go func() {
if err := m.App.ServerManager.Server.DeletePlaylist(playlist.ID); err != nil {
log.Printf("error deleting playlist: %s", err.Error())
} else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID {
// navigate to playlists page if user is still on the page of the deleted playlist
fyne.Do(func() { m.NavigateTo(PlaylistsRoute()) })
}
}()
}
}, m.MainWindow)
}
dlg.OnUpdateMetadata = func() {
pop.Hide()
m.doModalClosed()
go func() {
err := m.App.ServerManager.Server.EditPlaylist(playlist.ID, dlg.Name, dlg.Description, dlg.IsPublic)
if err != nil {
log.Printf("error updating playlist: %s", err.Error())
} else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID {
// if user is on playlist page, reload to get the updates
fyne.Do(m.ReloadFunc)
}
}()
}
m.haveModal = true
pop.Show()
}
// DoConnectToServerWorkflow does the workflow for connecting to the last active server on startup
func (c *Controller) DoConnectToServerWorkflow(server *backend.ServerConfig) {
pass, err := c.App.ServerManager.GetServerPassword(server.ID)
if err != nil {
log.Printf("error getting password from keyring: %v", err)
c.PromptForLoginAndConnect()
return
}
// try connecting to last used server - set up cancelable modal dialog
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 {
fyne.Do(func() {
dlg.Hide()
c.haveModal = false
if canceled {
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
})
}
}()
}
func (m *Controller) PromptForLoginAndConnect() {
d := dialogs.NewLoginDialog(m.App.Config.Servers, m.App.ServerManager.GetServerPassword)
pop := widget.NewModalPopUp(d, m.MainWindow.Canvas())
d.OnSubmit = func(server *backend.ServerConfig, password string) {
d.DisableSubmit()
d.SetInfoText(lang.L("Testing connection") + "...")
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := m.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password)
fyne.Do(func() {
if err == backend.ErrUnreachable {
d.SetErrorText(lang.L("Server unreachable"))
} else if err != nil {
d.SetErrorText(lang.L("Authentication failed"))
} else {
pop.Hide()
m.trySetPasswordAndConnectToServer(server, password)
m.doModalClosed()
}
d.EnableSubmit()
})
}()
}
d.OnEditServer = func(server *backend.ServerConfig) {
pop.Hide()
editD := dialogs.NewAddEditServerDialog(lang.L("Edit server"), true, server, m.MainWindow.Canvas().Focus)
editPop := widget.NewModalPopUp(editD, m.MainWindow.Canvas())
editD.OnSubmit = func() {
d.DisableSubmit()
go func() {
success := m.testConnectionAndUpdateDialogText(editD)
fyne.Do(func() {
if success {
// connection is good
editPop.Hide()
server.Hostname = editD.Host
server.AltHostname = editD.AltHost
server.Nickname = editD.Nickname
server.Username = editD.Username
server.LegacyAuth = editD.LegacyAuth
m.trySetPasswordAndConnectToServer(server, editD.Password)
m.doModalClosed()
}
d.EnableSubmit()
})
}()
}
editD.OnCancel = func() {
editPop.Hide()
pop.Show()
}
editPop.Show()
}
d.OnNewServer = func() {
pop.Hide()
newD := dialogs.NewAddEditServerDialog(lang.L("Add Server"), true, nil, m.MainWindow.Canvas().Focus)
newPop := widget.NewModalPopUp(newD, m.MainWindow.Canvas())
newD.OnSubmit = func() {
d.DisableSubmit()
go func() {
success := m.testConnectionAndUpdateDialogText(newD)
fyne.Do(func() {
if success {
// connection is good
newPop.Hide()
conn := backend.ServerConnection{
ServerType: newD.ServerType,
Hostname: newD.Host,
AltHostname: newD.AltHost,
Username: newD.Username,
LegacyAuth: newD.LegacyAuth,
}
server := m.App.ServerManager.AddServer(newD.Nickname, conn)
m.trySetPasswordAndConnectToServer(server, newD.Password)
m.doModalClosed()
}
d.EnableSubmit()
})
}()
}
newD.OnCancel = func() {
newPop.Hide()
pop.Show()
}
newPop.Show()
}
d.OnDeleteServer = func(server *backend.ServerConfig) {
pop.Hide()
dialog.ShowConfirm(lang.L("Confirm Delete Server"),
fmt.Sprintf(lang.L("Are you sure you want to delete the server")+" %q?", server.Nickname),
func(ok bool) {
if ok {
m.App.ServerManager.DeleteServer(server.ID)
m.App.DeleteServerCacheDir(server.ID)
d.SetServers(m.App.Config.Servers)
}
if len(m.App.Config.Servers) == 0 {
m.PromptForFirstServer()
} else {
pop.Show()
}
}, m.MainWindow)
}
m.haveModal = true
pop.Show()
}
func (c *Controller) ShowAboutDialog() {
dlg := dialogs.NewAboutDialog(c.AppVersion)
pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas())
@@ -594,6 +314,7 @@ func (c *Controller) ShowAboutDialog() {
}
func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map[string]string) {
devs, err := c.App.LocalPlayer.ListAudioDevices()
if err != nil {
log.Printf("error listing audio devices: %v", err)
@@ -606,6 +327,7 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
_, canSavePlayQueue := c.App.ServerManager.Server.(mediaprovider.CanSavePlayQueue)
isLocalPlayer := isEqualizerPlayer
bands := c.App.LocalPlayer.Equalizer().BandFrequencies()
dlg := dialogs.NewSettingsDialog(c.App.Config,
devs, themeFiles, bands,
c.App.ServerManager.Server.ClientDecidesScrobble(),
@@ -641,91 +363,7 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
c.ClosePopUpOnEscape(pop)
c.haveModal = true
pop.Show()
}
func (c *Controller) ShowQuickSearch() {
qs := dialogs.NewQuickSearch(c.App.ServerManager.Server, c.App.ImageManager)
pop := widget.NewModalPopUp(qs.SearchDialog, c.MainWindow.Canvas())
qs.SetOnDismiss(func() {
pop.Hide()
c.doModalClosed()
})
qs.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) {
pop.Hide()
c.doModalClosed()
switch contentType {
case mediaprovider.ContentTypeAlbum:
c.NavigateTo(AlbumRoute(id))
case mediaprovider.ContentTypeArtist:
c.NavigateTo(ArtistRoute(id))
case mediaprovider.ContentTypeTrack:
go c.App.PlaybackManager.PlayTrack(id)
case mediaprovider.ContentTypePlaylist:
c.NavigateTo(PlaylistRoute(id))
case mediaprovider.ContentTypeGenre:
c.NavigateTo(GenreRoute(id))
case mediaprovider.ContentTypeRadioStation:
if rp, ok := c.App.ServerManager.Server.(mediaprovider.RadioProvider); ok {
if radio, err := rp.GetRadioStation(id); err == nil {
go c.App.PlaybackManager.PlayRadioStation(radio)
}
}
}
})
c.ClosePopUpOnEscape(pop)
c.haveModal = true
min := qs.MinSize()
height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, c.MainWindow.Canvas().Size().Height*0.7))
qs.SearchDialog.Show()
pop.Resize(fyne.NewSize(min.Width, height))
pop.Show()
c.MainWindow.Canvas().Focus(qs.GetSearchEntry())
}
func (c *Controller) trySetPasswordAndConnectToServer(server *backend.ServerConfig, password string) error {
if err := c.App.ServerManager.SetServerPassword(server, password); err != nil {
log.Printf("error setting keyring credentials: %v", err)
// Don't return an error; fall back to just using the password in-memory
// User will need to log in with the password on subsequent runs.
}
return c.tryConnectToServer(context.Background(), server, password)
}
// try to connect to the given server, with the configured timeout added to the context
func (c *Controller) tryConnectToServer(ctx context.Context, server *backend.ServerConfig, password string) error {
timeout := time.Duration(c.App.Config.Application.RequestTimeoutSeconds) * time.Second
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := c.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password); err != nil {
return err
}
if err := c.App.ServerManager.ConnectToServer(server, password); err != nil {
log.Printf("error connecting to server: %v", err)
return err
}
return nil
}
func (c *Controller) testConnectionAndUpdateDialogText(dlg *dialogs.AddEditServerDialog) bool {
dlg.SetInfoText(lang.L("Testing connection") + "...")
conn := backend.ServerConnection{
ServerType: dlg.ServerType,
Hostname: dlg.Host,
AltHostname: dlg.AltHost,
Username: dlg.Username,
LegacyAuth: dlg.LegacyAuth,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := c.App.ServerManager.TestConnectionAndAuth(ctx, conn, dlg.Password)
if err == backend.ErrUnreachable {
dlg.SetErrorText(lang.L("Could not reach server") + fmt.Sprintf(" (%s?)", lang.L("wrong URL")))
return false
} else if err != nil {
dlg.SetErrorText(lang.L("Authentication failed") + fmt.Sprintf(" (%s)", lang.L("wrong username/password")))
return false
}
return true
}
func (c *Controller) doModalClosed() {
+147
View File
@@ -0,0 +1,147 @@
package controller
import (
"log"
"strconv"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/dialogs"
)
// Show dialog to select playlist.
// Depending on the results of that dialog, potentially create a new playlist
// Add tracks to the user-specified playlist
func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
sp := dialogs.NewSelectPlaylistDialog(m.App.ServerManager.Server, m.App.ImageManager,
m.App.ServerManager.LoggedInUser, m.App.Config.Application.AddToPlaylistSkipDuplicates)
pop := widget.NewModalPopUp(sp.SearchDialog, m.MainWindow.Canvas())
sp.SetOnDismiss(func() {
pop.Hide()
m.doModalClosed()
})
sp.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) {
notifySuccess := func(n int) {
fyne.Do(func() {
msg := lang.LocalizePluralKey("playlist.addedtracks",
"Added tracks to playlist", n, map[string]string{"trackCount": strconv.Itoa(n)})
m.ToastProvider.ShowSuccessToast(msg)
})
}
notifyError := func() {
fyne.Do(func() {
m.ToastProvider.ShowErrorToast(
lang.L("An error occurred adding tracks to the playlist"),
)
})
}
pop.Hide()
m.App.Config.Application.AddToPlaylistSkipDuplicates = sp.SkipDuplicates
if id == "" /* creating new playlist */ {
go func() {
err := m.App.ServerManager.Server.CreatePlaylist(sp.SearchDialog.SearchQuery(), trackIDs)
if err == nil {
notifySuccess(len(trackIDs))
} else {
log.Printf("error adding tracks to playlist: %s", err.Error())
notifyError()
}
}()
} else {
m.App.Config.Application.DefaultPlaylistID = id
if sp.SkipDuplicates {
go func() {
currentTrackIDs := make(map[string]struct{})
if selectedPlaylist, err := m.App.ServerManager.Server.GetPlaylist(id); err != nil {
log.Printf("error getting playlist: %s", err.Error())
notifyError()
} else {
for _, track := range selectedPlaylist.Tracks {
currentTrackIDs[track.ID] = struct{}{}
}
filterTrackIDs := sharedutil.FilterSlice(trackIDs, func(trackID string) bool {
_, ok := currentTrackIDs[trackID]
return !ok
})
err := m.App.ServerManager.Server.AddPlaylistTracks(id, filterTrackIDs)
if err == nil {
notifySuccess(len(filterTrackIDs))
} else {
log.Printf("error adding tracks to playlist: %s", err.Error())
notifyError()
}
}
}()
} else {
go func() {
err := m.App.ServerManager.Server.AddPlaylistTracks(id, trackIDs)
if err == nil {
notifySuccess(len(trackIDs))
} else {
log.Printf("error adding tracks to playlist: %s", err.Error())
notifyError()
}
}()
}
}
})
m.ClosePopUpOnEscape(pop)
m.haveModal = true
min := sp.MinSize()
height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, m.MainWindow.Canvas().Size().Height*0.7))
sp.SearchDialog.Show()
pop.Resize(fyne.NewSize(min.Width, height))
pop.Show()
m.MainWindow.Canvas().Focus(sp.GetSearchEntry())
}
func (m *Controller) DoEditPlaylistWorkflow(playlist *mediaprovider.Playlist) {
canMakePublic := m.App.ServerManager.Server.CanMakePublicPlaylist()
dlg := dialogs.NewEditPlaylistDialog(playlist, canMakePublic)
pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas())
m.ClosePopUpOnEscape(pop)
dlg.OnCanceled = func() {
pop.Hide()
m.doModalClosed()
}
dlg.OnDeletePlaylist = func() {
pop.Hide()
dialog.ShowCustomConfirm(lang.L("Confirm Delete Playlist"), lang.L("OK"), lang.L("Cancel"), layout.NewSpacer(), /*custom content*/
func(ok bool) {
if !ok {
pop.Show()
} else {
m.doModalClosed()
go func() {
if err := m.App.ServerManager.Server.DeletePlaylist(playlist.ID); err != nil {
log.Printf("error deleting playlist: %s", err.Error())
} else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID {
// navigate to playlists page if user is still on the page of the deleted playlist
fyne.Do(func() { m.NavigateTo(PlaylistsRoute()) })
}
}()
}
}, m.MainWindow)
}
dlg.OnUpdateMetadata = func() {
pop.Hide()
m.doModalClosed()
go func() {
err := m.App.ServerManager.Server.EditPlaylist(playlist.ID, dlg.Name, dlg.Description, dlg.IsPublic)
if err != nil {
log.Printf("error updating playlist: %s", err.Error())
} else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID {
// if user is on playlist page, reload to get the updates
fyne.Do(m.ReloadFunc)
}
}()
}
m.haveModal = true
pop.Show()
}
+178
View File
@@ -0,0 +1,178 @@
package controller
import (
"log"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/dialogs"
)
func (c *Controller) ShowQuickSearch() {
qs := dialogs.NewQuickSearch(c.App.ServerManager.Server, c.App.ImageManager)
pop := widget.NewModalPopUp(qs.SearchDialog, c.MainWindow.Canvas())
qs.SetOnDismiss(func() {
pop.Hide()
c.doModalClosed()
})
qs.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) {
pop.Hide()
c.doModalClosed()
switch contentType {
case mediaprovider.ContentTypeAlbum:
c.NavigateTo(AlbumRoute(id))
case mediaprovider.ContentTypeArtist:
c.NavigateTo(ArtistRoute(id))
case mediaprovider.ContentTypeTrack:
go c.App.PlaybackManager.PlayTrack(id)
case mediaprovider.ContentTypePlaylist:
c.NavigateTo(PlaylistRoute(id))
case mediaprovider.ContentTypeGenre:
c.NavigateTo(GenreRoute(id))
case mediaprovider.ContentTypeRadioStation:
if rp, ok := c.App.ServerManager.Server.(mediaprovider.RadioProvider); ok {
if radio, err := rp.GetRadioStation(id); err == nil {
go c.App.PlaybackManager.PlayRadioStation(radio)
}
}
}
})
qs.OnPlay = func(t mediaprovider.ContentType, id string, item any, shuffle bool) {
switch t {
case mediaprovider.ContentTypeTrack:
c.App.PlaybackManager.LoadTracks(
[]*mediaprovider.Track{item.(*mediaprovider.Track)},
backend.Replace, false /*shuffle*/)
c.App.PlaybackManager.PlayFromBeginning()
case mediaprovider.ContentTypeAlbum:
go c.App.PlaybackManager.PlayAlbum(id, 0, shuffle)
case mediaprovider.ContentTypeArtist:
go c.App.PlaybackManager.PlayArtistDiscography(id, shuffle)
case mediaprovider.ContentTypePlaylist:
go c.App.PlaybackManager.PlayPlaylist(id, 0, shuffle)
case mediaprovider.ContentTypeGenre:
go c.App.PlaybackManager.PlayRandomSongs(id /*genre name*/)
case mediaprovider.ContentTypeRadioStation:
go c.App.PlaybackManager.PlayRadioStation(item.(*mediaprovider.RadioStation))
}
}
qs.OnAddToQueue = c.handleSearchDialogOnAddToQueue
qs.OnAddToPlaylist = c.handleSearchDialogOnAddToPlaylist
qs.OnDownload = func(track *mediaprovider.Track) {
c.ShowDownloadDialog([]*mediaprovider.Track{track}, track.Metadata().Name)
}
qs.OnPlaySongRadio = func(track *mediaprovider.Track) {
go func() {
tracks, err := c.GetSongRadioTracks(track)
if err != nil {
c.App.PlaybackManager.LoadTracks(tracks, backend.Replace, false)
c.App.PlaybackManager.PlayFromBeginning()
}
}()
}
qs.OnSetFavorite = func(trackID string, fav bool) {
go c.SetTrackFavorites([]string{trackID}, fav)
}
qs.OnSetRating = func(trackID string, rating int) {
go c.SetTrackRatings([]string{trackID}, rating)
}
qs.OnShare = func(trackID string) {
c.ShowShareDialog(trackID)
}
qs.OnShowTrackInfo = func(track *mediaprovider.Track) {
c.ShowTrackInfoDialog(track)
}
c.ClosePopUpOnEscape(pop)
c.haveModal = true
min := qs.MinSize()
height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, c.MainWindow.Canvas().Size().Height*0.7))
qs.SearchDialog.Show()
pop.Resize(fyne.NewSize(min.Width, height))
pop.Show()
c.MainWindow.Canvas().Focus(qs.GetSearchEntry())
}
func (c *Controller) handleSearchDialogOnAddToQueue(t mediaprovider.ContentType, id string, item any, next bool) {
insertMode := backend.Append
if next {
insertMode = backend.InsertNext
}
switch t {
case mediaprovider.ContentTypeTrack:
c.App.PlaybackManager.LoadTracks(
[]*mediaprovider.Track{item.(*mediaprovider.Track)},
insertMode, false /*shuffle*/)
case mediaprovider.ContentTypeAlbum:
go c.App.PlaybackManager.LoadAlbum(id, insertMode, false)
case mediaprovider.ContentTypeArtist:
go func() {
tracks := c.GetArtistTracks(id)
c.App.PlaybackManager.LoadTracks(tracks, insertMode, false)
}()
case mediaprovider.ContentTypePlaylist:
go c.App.PlaybackManager.LoadPlaylist(id, insertMode, false)
case mediaprovider.ContentTypeGenre:
go func() {
tr, err := c.App.ServerManager.Server.GetRandomTracks(id /*genre name*/, c.App.Config.Application.EnqueueBatchSize)
if err != nil {
c.App.PlaybackManager.LoadTracks(tr, insertMode, false /*shuffle*/)
}
}()
case mediaprovider.ContentTypeRadioStation:
c.App.PlaybackManager.LoadRadioStation(item.(*mediaprovider.RadioStation), insertMode)
}
}
func (c *Controller) handleSearchDialogOnAddToPlaylist(t mediaprovider.ContentType, id string, item any) {
switch t {
case mediaprovider.ContentTypeTrack:
c.DoAddTracksToPlaylistWorkflow([]string{id})
case mediaprovider.ContentTypeAlbum:
go func() {
album, err := c.App.ServerManager.Server.GetAlbum(id)
if err == nil && len(album.Tracks) > 0 {
trackIDs := sharedutil.MapSlice(album.Tracks, func(t *mediaprovider.Track) string {
return t.ID
})
fyne.Do(func() { c.DoAddTracksToPlaylistWorkflow(trackIDs) })
}
}()
case mediaprovider.ContentTypeArtist:
go func() {
tracks := c.GetArtistTracks(id)
if len(tracks) > 0 {
trackIDs := sharedutil.MapSlice(tracks, func(t *mediaprovider.Track) string {
return t.ID
})
fyne.Do(func() { c.DoAddTracksToPlaylistWorkflow(trackIDs) })
}
}()
case mediaprovider.ContentTypePlaylist:
go func() {
playlist, err := c.App.ServerManager.Server.GetPlaylist(id)
if err == nil && len(playlist.Tracks) > 0 {
trackIDs := sharedutil.MapSlice(playlist.Tracks, func(t *mediaprovider.Track) string {
return t.ID
})
fyne.Do(func() { c.DoAddTracksToPlaylistWorkflow(trackIDs) })
}
}()
case mediaprovider.ContentTypeGenre:
go func() {
tracks, err := c.App.ServerManager.Server.GetRandomTracks(id /*genre name*/, c.App.Config.Application.EnqueueBatchSize)
if err == nil && len(tracks) > 0 {
trackIDs := sharedutil.MapSlice(tracks, func(t *mediaprovider.Track) string {
return t.ID
})
fyne.Do(func() { c.DoAddTracksToPlaylistWorkflow(trackIDs) })
}
}()
case mediaprovider.ContentTypeRadioStation:
log.Println("Cannot add radio station to playlist")
}
}
+250
View File
@@ -0,0 +1,250 @@
package controller
import (
"context"
"fmt"
"log"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/widget"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/ui/dialogs"
)
func (m *Controller) PromptForFirstServer() {
d := dialogs.NewAddEditServerDialog(lang.L("Connect to Server"), false, nil, m.MainWindow.Canvas().Focus)
pop := widget.NewModalPopUp(d, m.MainWindow.Canvas())
d.OnSubmit = func() {
d.DisableSubmit()
go func() {
if m.testConnectionAndUpdateDialogText(d) {
// connection is good
pop.Hide()
m.doModalClosed()
conn := backend.ServerConnection{
ServerType: d.ServerType,
Hostname: d.Host,
AltHostname: d.AltHost,
Username: d.Username,
LegacyAuth: d.LegacyAuth,
}
server := m.App.ServerManager.AddServer(d.Nickname, conn)
if err := m.trySetPasswordAndConnectToServer(server, d.Password); err != nil {
log.Printf("error connecting to server: %s", err.Error())
}
}
d.EnableSubmit()
}()
}
m.haveModal = true
pop.Show()
}
// DoConnectToServerWorkflow does the workflow for connecting to the last active server on startup
func (c *Controller) DoConnectToServerWorkflow(server *backend.ServerConfig) {
pass, err := c.App.ServerManager.GetServerPassword(server.ID)
if err != nil {
log.Printf("error getting password from keyring: %v", err)
c.PromptForLoginAndConnect()
return
}
// try connecting to last used server - set up cancelable modal dialog
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 {
fyne.Do(func() {
dlg.Hide()
c.haveModal = false
if canceled {
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
})
}
}()
}
func (m *Controller) PromptForLoginAndConnect() {
d := dialogs.NewLoginDialog(m.App.Config.Servers, m.App.ServerManager.GetServerPassword)
pop := widget.NewModalPopUp(d, m.MainWindow.Canvas())
d.OnSubmit = func(server *backend.ServerConfig, password string) {
d.DisableSubmit()
d.SetInfoText(lang.L("Testing connection") + "...")
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := m.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password)
fyne.Do(func() {
if err == backend.ErrUnreachable {
d.SetErrorText(lang.L("Server unreachable"))
} else if err != nil {
d.SetErrorText(lang.L("Authentication failed"))
} else {
pop.Hide()
m.trySetPasswordAndConnectToServer(server, password)
m.doModalClosed()
}
d.EnableSubmit()
})
}()
}
d.OnEditServer = func(server *backend.ServerConfig) {
pop.Hide()
editD := dialogs.NewAddEditServerDialog(lang.L("Edit server"), true, server, m.MainWindow.Canvas().Focus)
editPop := widget.NewModalPopUp(editD, m.MainWindow.Canvas())
editD.OnSubmit = func() {
d.DisableSubmit()
go func() {
success := m.testConnectionAndUpdateDialogText(editD)
fyne.Do(func() {
if success {
// connection is good
editPop.Hide()
server.Hostname = editD.Host
server.AltHostname = editD.AltHost
server.Nickname = editD.Nickname
server.Username = editD.Username
server.LegacyAuth = editD.LegacyAuth
m.trySetPasswordAndConnectToServer(server, editD.Password)
m.doModalClosed()
}
d.EnableSubmit()
})
}()
}
editD.OnCancel = func() {
editPop.Hide()
pop.Show()
}
editPop.Show()
}
d.OnNewServer = func() {
pop.Hide()
newD := dialogs.NewAddEditServerDialog(lang.L("Add Server"), true, nil, m.MainWindow.Canvas().Focus)
newPop := widget.NewModalPopUp(newD, m.MainWindow.Canvas())
newD.OnSubmit = func() {
d.DisableSubmit()
go func() {
success := m.testConnectionAndUpdateDialogText(newD)
fyne.Do(func() {
if success {
// connection is good
newPop.Hide()
conn := backend.ServerConnection{
ServerType: newD.ServerType,
Hostname: newD.Host,
AltHostname: newD.AltHost,
Username: newD.Username,
LegacyAuth: newD.LegacyAuth,
}
server := m.App.ServerManager.AddServer(newD.Nickname, conn)
m.trySetPasswordAndConnectToServer(server, newD.Password)
m.doModalClosed()
}
d.EnableSubmit()
})
}()
}
newD.OnCancel = func() {
newPop.Hide()
pop.Show()
}
newPop.Show()
}
d.OnDeleteServer = func(server *backend.ServerConfig) {
pop.Hide()
dialog.ShowConfirm(lang.L("Confirm Delete Server"),
fmt.Sprintf(lang.L("Are you sure you want to delete the server")+" %q?", server.Nickname),
func(ok bool) {
if ok {
m.App.ServerManager.DeleteServer(server.ID)
m.App.DeleteServerCacheDir(server.ID)
d.SetServers(m.App.Config.Servers)
}
if len(m.App.Config.Servers) == 0 {
m.PromptForFirstServer()
} else {
pop.Show()
}
}, m.MainWindow)
}
m.haveModal = true
pop.Show()
}
func (c *Controller) trySetPasswordAndConnectToServer(server *backend.ServerConfig, password string) error {
if err := c.App.ServerManager.SetServerPassword(server, password); err != nil {
log.Printf("error setting keyring credentials: %v", err)
// Don't return an error; fall back to just using the password in-memory
// User will need to log in with the password on subsequent runs.
}
return c.tryConnectToServer(context.Background(), server, password)
}
// try to connect to the given server, with the configured timeout added to the context
func (c *Controller) tryConnectToServer(ctx context.Context, server *backend.ServerConfig, password string) error {
timeout := time.Duration(c.App.Config.Application.RequestTimeoutSeconds) * time.Second
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := c.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password); err != nil {
return err
}
if err := c.App.ServerManager.ConnectToServer(server, password); err != nil {
log.Printf("error connecting to server: %v", err)
return err
}
return nil
}
func (c *Controller) testConnectionAndUpdateDialogText(dlg *dialogs.AddEditServerDialog) bool {
dlg.SetInfoText(lang.L("Testing connection") + "...")
conn := backend.ServerConnection{
ServerType: dlg.ServerType,
Hostname: dlg.Host,
AltHostname: dlg.AltHost,
Username: dlg.Username,
LegacyAuth: dlg.LegacyAuth,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := c.App.ServerManager.TestConnectionAndAuth(ctx, conn, dlg.Password)
if err == backend.ErrUnreachable {
dlg.SetErrorText(lang.L("Could not reach server") + fmt.Sprintf(" (%s?)", lang.L("wrong URL")))
return false
} else if err != nil {
dlg.SetErrorText(lang.L("Authentication failed") + fmt.Sprintf(" (%s)", lang.L("wrong username/password")))
return false
}
return true
}
+12 -9
View File
@@ -7,6 +7,7 @@ import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/lang"
"github.com/dweymouth/supersonic/backend/player"
"github.com/dweymouth/supersonic/backend/player/mpv"
"github.com/dweymouth/supersonic/ui/shortcuts"
"github.com/dweymouth/supersonic/ui/util"
"github.com/dweymouth/supersonic/ui/visualizations"
@@ -21,11 +22,13 @@ type visualizationData struct {
}
func (c *Controller) initVisualizations() {
c.App.LocalPlayer.OnStopped(c.stopVisualizationAnim)
c.App.LocalPlayer.OnPaused(c.stopVisualizationAnim)
c.App.LocalPlayer.OnPlaying(func() {
if c.peakMeter != nil {
c.startVisualizationAnim()
c.App.PlaybackManager.OnStopped(c.stopVisualizationAnim)
c.App.PlaybackManager.OnPaused(c.stopVisualizationAnim)
c.App.PlaybackManager.OnPlaying(func() {
if _, ok := c.App.PlaybackManager.CurrentPlayer().(*mpv.Player); ok {
if c.peakMeter != nil {
c.startVisualizationAnim()
}
}
})
}
@@ -71,13 +74,13 @@ func (c *Controller) stopVisualizationAnim() {
if c.visualizationAnim != nil {
c.visualizationAnim.Stop()
c.visualizationAnim = nil
c.App.LocalPlayer.SetPeaksEnabled(false)
// c.App.LocalPlayer.SetPeaksEnabled(false)
}
}
func (c *Controller) startVisualizationAnim() {
if c.visualizationAnim == nil {
c.App.LocalPlayer.SetPeaksEnabled(true)
//c.App.LocalPlayer.SetPeaksEnabled(true)
c.visualizationAnim = fyne.NewAnimation(
time.Duration(math.MaxInt64), /*until stopped*/
c.tickVisualizations)
@@ -86,8 +89,8 @@ func (c *Controller) startVisualizationAnim() {
}
func (c *Controller) tickVisualizations(_ float32) {
lP, rP, lRMS, rRMS := c.App.LocalPlayer.GetPeaks()
//lP, rP, lRMS, rRMS := c.App.LocalPlayer.GetPeaks()
if c.visualizationData.peakMeter != nil {
c.visualizationData.peakMeter.UpdatePeaks(lP, rP, lRMS, rRMS)
//c.visualizationData.peakMeter.UpdatePeaks(lP, rP, lRMS, rRMS)
}
}
+87 -3
View File
@@ -5,31 +5,115 @@ import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"github.com/dweymouth/supersonic/backend/mediaprovider"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/util"
)
type QuickSearch struct {
SearchDialog *SearchDialog
results []*mediaprovider.SearchResult
mp mediaprovider.MediaProvider
OnPlay func(t mediaprovider.ContentType, id string, item any, shuffle bool)
OnAddToQueue func(t mediaprovider.ContentType, id string, item any, next bool)
OnAddToPlaylist func(t mediaprovider.ContentType, id string, item any)
OnSetFavorite func(trackID string, fav bool)
OnSetRating func(trackID string, rating int)
OnDownload func(track *mediaprovider.Track)
OnShare func(trackID string)
OnPlaySongRadio func(track *mediaprovider.Track)
OnShowTrackInfo func(track *mediaprovider.Track)
}
func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *QuickSearch {
q := &QuickSearch{mp: mp}
q.SearchDialog = NewSearchDialog(im, lang.L("Search Everywhere"), lang.L("Close"), q.onSearched)
q.SearchDialog.OnShowContextMenu = q.showMenu
return q
}
func (q *QuickSearch) onSearched(query string) []*mediaprovider.SearchResult {
var results []*mediaprovider.SearchResult
if query != "" {
if res, err := q.mp.SearchAll(query, 20); err != nil {
q.results = nil
log.Printf("Error searching: %s", err.Error())
} else {
results = res
q.results = res
}
}
return results
return q.results
}
func (q *QuickSearch) showMenu(idx int, pos fyne.Position) {
cType := q.results[idx].Type
id := q.results[idx].ID
item := q.results[idx].Item
canvas := fyne.CurrentApp().Driver().CanvasForObject(q.SearchDialog)
switch cType {
case mediaprovider.ContentTypeTrack:
menu := util.NewTrackContextMenu(false, nil)
menu.OnPlay = func(shuffle bool) {
q.OnPlay(cType, id, item, shuffle)
}
menu.OnAddToQueue = func(next bool) {
q.OnAddToQueue(cType, id, item, next)
}
menu.OnAddToPlaylist = func() {
q.OnAddToPlaylist(cType, id, item)
}
menu.OnDownload = func() {
q.OnDownload(item.(*mediaprovider.Track))
}
menu.OnFavorite = func(fav bool) {
q.OnSetFavorite(id, fav)
}
menu.OnSetRating = func(rating int) {
q.OnSetRating(id, rating)
}
menu.OnPlaySongRadio = func() {
q.OnPlaySongRadio(item.(*mediaprovider.Track))
}
menu.OnShowInfo = func() {
q.OnShowTrackInfo(item.(*mediaprovider.Track))
}
menu.OnShare = func() {
q.OnShare(id)
}
menu.ShowAtPosition(pos, canvas)
default:
play := fyne.NewMenuItem(lang.L("Play"), func() {
q.OnPlay(cType, id, item, false)
})
play.Icon = theme.MediaPlayIcon()
shuffle := fyne.NewMenuItem(lang.L("Shuffle"), func() {
q.OnPlay(cType, id, item, true)
})
shuffle.Icon = myTheme.ShuffleIcon
playNext := fyne.NewMenuItem(lang.L("Play next"), func() {
q.OnAddToQueue(cType, id, item, true)
})
playNext.Icon = myTheme.PlayNextIcon
add := fyne.NewMenuItem(lang.L("Add to queue"), func() {
q.OnAddToQueue(cType, id, item, false)
})
add.Icon = theme.ContentAddIcon()
menu := fyne.NewMenu("", play, shuffle, playNext, add)
if cType != mediaprovider.ContentTypeRadioStation {
playlist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() {
q.OnAddToPlaylist(cType, id, item)
})
playlist.Icon = myTheme.PlaylistIcon
menu.Items = append(menu.Items, playlist)
}
widget.ShowPopUpMenuAtPosition(menu, canvas, pos)
}
}
func (q *QuickSearch) SetOnDismiss(onDismiss func()) {
+10 -3
View File
@@ -30,9 +30,10 @@ type SearchDialog struct {
// of the dismiss buttons
ActionItem fyne.CanvasObject
OnDismiss func()
OnNavigateTo func(mediaprovider.ContentType, string)
OnSearched func(string) []*mediaprovider.SearchResult
OnDismiss func()
OnNavigateTo func(mediaprovider.ContentType, string)
OnShowContextMenu func(itemIdx int, pos fyne.Position)
OnSearched func(string) []*mediaprovider.SearchResult
imgSource util.ImageFetcher
resultsMutex sync.RWMutex
@@ -326,6 +327,12 @@ func (q *searchResult) Tapped(_ *fyne.PointEvent) {
q.parent.onSelected(q.index)
}
func (q *searchResult) TappedSecondary(e *fyne.PointEvent) {
if q.parent.OnShowContextMenu != nil {
q.parent.OnShowContextMenu(q.index, e.AbsolutePosition)
}
}
func (q *searchResult) CreateRenderer() fyne.WidgetRenderer {
if q.content == nil {
q.content = container.NewBorder(nil, nil, container.NewCenter(q.image), nil,
+1
View File
@@ -39,6 +39,7 @@ var (
AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg)
ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg)
AutoplayIcon fyne.Resource = theme.NewThemedResource(res.ResInfinitySvg)
CastIcon fyne.Resource = theme.NewThemedResource(res.ResCastSvg)
RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg)
FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg)
NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg)
+137
View File
@@ -0,0 +1,137 @@
package util
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
myTheme "github.com/dweymouth/supersonic/ui/theme"
)
type TrackContextMenu struct {
ratingSubmenu *fyne.MenuItem
shareMenuItem *fyne.MenuItem
songRadioMenuItem *fyne.MenuItem
infoMenuItem *fyne.MenuItem
OnPlay func(shuffle bool)
OnAddToQueue func(next bool)
OnPlaySongRadio func()
OnDownload func()
OnAddToPlaylist func()
OnShowInfo func()
OnShare func()
OnFavorite func(fav bool)
OnSetRating func(rating int)
menu *fyne.Menu
}
func NewTrackContextMenu(disablePlaybackMenu bool, auxItems []*fyne.MenuItem) *TrackContextMenu {
tcm := &TrackContextMenu{}
tcm.menu = fyne.NewMenu("")
if !disablePlaybackMenu {
play := fyne.NewMenuItem(lang.L("Play"), func() {
if tcm.OnPlay != nil {
tcm.OnPlay(false)
}
})
play.Icon = theme.MediaPlayIcon()
shuffle := fyne.NewMenuItem(lang.L("Shuffle"), func() {
if tcm.OnPlay != nil {
tcm.OnPlay(true)
}
})
shuffle.Icon = myTheme.ShuffleIcon
playNext := fyne.NewMenuItem(lang.L("Play next"), func() {
if tcm.OnAddToQueue != nil {
tcm.OnAddToQueue(true)
}
})
playNext.Icon = myTheme.PlayNextIcon
add := fyne.NewMenuItem(lang.L("Add to queue"), func() {
if tcm.OnAddToQueue != nil {
tcm.OnAddToQueue(false)
}
})
add.Icon = theme.ContentAddIcon()
tcm.songRadioMenuItem = fyne.NewMenuItem(lang.L("Play song radio"), func() {
if tcm.OnPlaySongRadio != nil {
tcm.OnPlaySongRadio()
}
})
tcm.songRadioMenuItem.Icon = myTheme.RadioIcon
tcm.menu.Items = append(tcm.menu.Items,
play, shuffle, playNext, add, tcm.songRadioMenuItem)
}
playlist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() {
if tcm.OnAddToPlaylist != nil {
tcm.OnAddToPlaylist()
}
})
playlist.Icon = myTheme.PlaylistIcon
download := fyne.NewMenuItem(lang.L("Download")+"...", func() {
if tcm.OnDownload != nil {
tcm.OnDownload()
}
})
download.Icon = theme.DownloadIcon()
tcm.infoMenuItem = fyne.NewMenuItem(lang.L("Show info")+"...", func() {
if tcm.OnShowInfo != nil {
tcm.OnShowInfo()
}
})
tcm.infoMenuItem.Icon = theme.InfoIcon()
favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() {
if tcm.OnFavorite != nil {
tcm.OnFavorite(true)
}
})
favorite.Icon = myTheme.FavoriteIcon
unfavorite := fyne.NewMenuItem(lang.L("Unset favorite"), func() {
if tcm.OnFavorite != nil {
tcm.OnFavorite(false)
}
})
unfavorite.Icon = myTheme.NotFavoriteIcon
tcm.menu.Items = append(tcm.menu.Items, fyne.NewMenuItemSeparator(),
playlist, download, tcm.infoMenuItem)
tcm.shareMenuItem = fyne.NewMenuItem(lang.L("Share")+"...", func() {
if tcm.OnShare != nil {
tcm.OnShare()
}
})
tcm.shareMenuItem.Icon = myTheme.ShareIcon
tcm.menu.Items = append(tcm.menu.Items, tcm.shareMenuItem)
tcm.menu.Items = append(tcm.menu.Items, fyne.NewMenuItemSeparator())
tcm.menu.Items = append(tcm.menu.Items, favorite, unfavorite)
tcm.ratingSubmenu = NewRatingSubmenu(func(rating int) {
if tcm.OnSetRating != nil {
tcm.OnSetRating(rating)
}
})
tcm.menu.Items = append(tcm.menu.Items, tcm.ratingSubmenu)
if len(auxItems) > 0 {
tcm.menu.Items = append(tcm.menu.Items, fyne.NewMenuItemSeparator())
tcm.menu.Items = append(tcm.menu.Items, auxItems...)
}
return tcm
}
func (tcm *TrackContextMenu) SetRatingDisabled(disabled bool) {
tcm.ratingSubmenu.Disabled = disabled
}
func (tcm *TrackContextMenu) SetShareDisabled(disabled bool) {
tcm.shareMenuItem.Disabled = disabled
}
func (tcm *TrackContextMenu) SetInfoDisabled(disabled bool) {
tcm.infoMenuItem.Disabled = disabled
}
func (tcm *TrackContextMenu) ShowAtPosition(pos fyne.Position, canvas fyne.Canvas) {
widget.ShowPopUpMenuAtPosition(tcm.menu, canvas, pos)
}
+3 -2
View File
@@ -52,12 +52,13 @@ func SelectedIndexes(items []*TrackListModel) []int {
return selected
}
func SelectItem(items []*TrackListModel, idx int) {
func SelectItem(items []*TrackListModel, idx int) bool {
if items[idx].Selected {
return
return false
}
UnselectAllItems(items)
items[idx].Selected = true
return true
}
func SelectAllItems(items []*TrackListModel) {
+4 -1
View File
@@ -199,7 +199,10 @@ func NewAlbumFilterPopup(filter *AlbumFilterButton) *AlbumFilterPopup {
)
go func() {
a.genreFilter.SetGenreList(<-a.filterBtn.genreListChan)
genres := <-a.filterBtn.genreListChan
fyne.Do(func() {
a.genreFilter.SetGenreList(genres)
})
}()
return a
+48 -2
View File
@@ -2,6 +2,7 @@ package widgets
import (
"math"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
@@ -25,6 +26,7 @@ type AuxControls struct {
VolumeControl *VolumeControl
autoplay *IconButton
loop *IconButton
cast *IconButton
showQueue *IconButton
container *fyne.Container
@@ -35,10 +37,17 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
VolumeControl: NewVolumeControl(initialVolume),
autoplay: NewIconButton(myTheme.AutoplayIcon, nil),
loop: NewIconButton(myTheme.RepeatIcon, nil),
cast: NewIconButton(myTheme.CastIcon, nil),
showQueue: NewIconButton(myTheme.PlayQueueIcon, nil),
}
a.loop.IconSize = IconButtonSizeSmaller
a.loop.SetToolTip(lang.L("Repeat"))
a.SetLoopMode(initialLoopMode)
a.cast.IconSize = IconButtonSizeSmaller
a.cast.SetToolTip(lang.L("Cast to device"))
a.autoplay.Highlighted = initialAutoplay
//a.autoplay.IconSize = IconButtonSizeSmaller
a.autoplay.SetToolTip(lang.L("Autoplay"))
@@ -48,9 +57,10 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
a.OnChangeAutoplay(a.autoplay.Highlighted)
}
}
a.SetLoopMode(initialLoopMode)
a.showQueue.IconSize = IconButtonSizeSmaller
a.showQueue.SetToolTip(lang.L("Show play queue"))
a.container = container.NewHBox(
layout.NewSpacer(),
container.NewVBox(
@@ -58,7 +68,7 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
a.VolumeControl,
container.New(
layout.NewCustomPaddedHBoxLayout(theme.Padding()*1.5),
layout.NewSpacer(), a.autoplay, a.loop, a.showQueue, util.NewHSpace(5)),
layout.NewSpacer(), a.autoplay, a.loop, a.cast, a.showQueue, util.NewHSpace(5)),
layout.NewSpacer(),
),
)
@@ -88,6 +98,16 @@ func (a *AuxControls) SetLoopMode(mode backend.LoopMode) {
}
}
func (a *AuxControls) DisableCastButton() {
a.cast.Disable()
}
func (a *AuxControls) SetIsRemotePlayer(isRemote bool) {
a.cast.Enable()
a.cast.Highlighted = isRemote
a.cast.Refresh()
}
func (a *AuxControls) SetAutoplay(autoplay bool) {
if autoplay == a.autoplay.Highlighted {
return
@@ -100,6 +120,12 @@ func (a *AuxControls) OnShowPlayQueue(f func()) {
a.showQueue.OnTapped = f
}
func (a *AuxControls) OnShowCastMenu(f func(func())) {
a.cast.OnTapped = func() {
f(a.DisableCastButton)
}
}
type volumeSlider struct {
widget.Slider
@@ -145,6 +171,10 @@ type VolumeControl struct {
muted bool
lastVol int
setVolDebouncer func()
delaySetVolume bool
pendingVolume int
container *fyne.Container
}
@@ -160,6 +190,15 @@ func NewVolumeControl(initialVol int) *VolumeControl {
v.slider.Orientation = widget.Horizontal
v.slider.Value = float64(v.lastVol)
v.slider.OnChanged = v.onChanged
// for players that are slow to respond to volume changes
// (e.g. DLNA), delay responding to the SetVolume call
// to avoid hiccuping from callback "echoes"
v.setVolDebouncer = util.NewDebouncer(100*time.Millisecond, func() {
v.delaySetVolume = false
v.SetVolume(v.pendingVolume)
})
v.container = container.NewHBox(container.NewCenter(v.icon), v.slider)
return v
}
@@ -167,6 +206,11 @@ func NewVolumeControl(initialVol int) *VolumeControl {
// Sets the volume that is displayed in the slider.
// Does not invoke OnSetVolume callback.
func (v *VolumeControl) SetVolume(vol int) {
if v.delaySetVolume {
v.pendingVolume = vol
return
}
if (vol == v.lastVol && !v.muted) || (v.muted && vol == 0) {
return
}
@@ -177,6 +221,8 @@ func (v *VolumeControl) SetVolume(vol int) {
func (v *VolumeControl) onChanged(volume float64) {
vol := int(volume)
v.delaySetVolume = true
v.setVolDebouncer()
v.lastVol = vol
v.muted = false
v.updateIconForVolume(vol)
+43 -106
View File
@@ -42,6 +42,7 @@ type PlayQueueList struct {
OnPlayItemAt func(idx int)
OnPlaySelection func(items []mediaprovider.MediaItem, shuffle bool)
OnPlaySelectionNext func(items []mediaprovider.MediaItem)
OnPlaySongRadio func(track *mediaprovider.Track)
OnAddToQueue func(items []mediaprovider.MediaItem)
OnAddToPlaylist func(trackIDs []string)
OnSetFavorite func(trackIDs []string, fav bool)
@@ -54,11 +55,8 @@ type PlayQueueList struct {
OnReorderItems func(idxs []int, reorderTo int)
useNonQueueMenu bool
menu *widget.PopUpMenu // ctx menu for when only tracks are selected
radiosMenu *widget.PopUpMenu // ctx menu for when selection contains radios
ratingSubmenu *fyne.MenuItem
infoMenuItem *fyne.MenuItem
shareMenuItem *fyne.MenuItem
menu *util.TrackContextMenu // ctx menu for when only tracks are selected
radiosMenu *widget.PopUpMenu // ctx menu for when selection contains radios
nowPlayingID string
@@ -256,82 +254,24 @@ func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
}
}
var menu *widget.PopUpMenu
if allTracks {
p.ensureTracksMenu()
p.ratingSubmenu.Disabled = p.DisableRating
p.infoMenuItem.Disabled = len(selected) != 1
p.shareMenuItem.Disabled = p.DisableSharing || len(selected) != 1
menu = p.menu
p.menu.SetRatingDisabled(p.DisableRating)
p.menu.SetInfoDisabled(len(selected) != 1)
p.menu.SetShareDisabled(p.DisableSharing || len(selected) != 1)
p.menu.ShowAtPosition(e.AbsolutePosition, fyne.CurrentApp().Driver().CanvasForObject(p))
} else {
p.ensureRadiosMenu()
menu = p.radiosMenu
p.radiosMenu.ShowAtPosition(e.AbsolutePosition)
}
menu.ShowAtPosition(e.AbsolutePosition)
}
func (p *PlayQueueList) ensureTracksMenu() {
if p.menu != nil {
return
}
var menuItems []*fyne.MenuItem
if p.useNonQueueMenu {
menuItems = append(menuItems, p.createQueueActionMenuItems()...)
menuItems = append(menuItems, fyne.NewMenuItemSeparator())
}
playlist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() {
if p.OnAddToPlaylist != nil {
p.OnAddToPlaylist(p.selectedItemIDs())
}
})
playlist.Icon = myTheme.PlaylistIcon
download := fyne.NewMenuItem(lang.L("Download")+"...", func() {
if p.OnDownload != nil {
p.OnDownload(p.selectedTracks(), "Selected tracks")
}
})
download.Icon = theme.DownloadIcon()
p.infoMenuItem = fyne.NewMenuItem(lang.L("Show info")+"...", func() {
if p.OnShowTrackInfo != nil {
p.OnShowTrackInfo(p.selectedTracks()[0])
}
})
p.infoMenuItem.Icon = theme.InfoIcon()
p.shareMenuItem = fyne.NewMenuItem(lang.L("Share")+"...", func() {
if p.OnShare != nil {
p.OnShare(p.selectedTracks())
}
})
p.shareMenuItem.Icon = myTheme.ShareIcon
favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() {
if p.OnSetFavorite != nil {
p.OnSetFavorite(p.selectedItemIDs(), true)
}
})
favorite.Icon = myTheme.FavoriteIcon
unfavorite := fyne.NewMenuItem(lang.L("Unset favorite"), func() {
if p.OnSetFavorite != nil {
p.OnSetFavorite(p.selectedItemIDs(), false)
}
})
unfavorite.Icon = myTheme.NotFavoriteIcon
p.ratingSubmenu = util.NewRatingSubmenu(func(rating int) {
if p.OnSetRating != nil {
p.OnSetRating(p.selectedItemIDs(), rating)
}
})
menuItems = append(menuItems,
playlist,
download,
p.infoMenuItem,
p.shareMenuItem,
fyne.NewMenuItemSeparator(),
favorite,
unfavorite,
p.ratingSubmenu,
)
var auxItems []*fyne.MenuItem
if !p.useNonQueueMenu {
remove := fyne.NewMenuItem(lang.L("Remove from queue"), func() {
if p.OnRemoveFromQueue != nil {
@@ -339,15 +279,40 @@ func (p *PlayQueueList) ensureTracksMenu() {
}
})
remove.Icon = theme.ContentRemoveIcon()
menuItems = append(menuItems,
fyne.NewMenuItemSeparator(),
remove)
auxItems = append(auxItems, remove)
}
p.menu = util.NewTrackContextMenu(!p.useNonQueueMenu, auxItems)
p.menu.OnPlay = func(shuffle bool) {
p.OnPlaySelection(p.selectedItems(), shuffle)
}
p.menu.OnAddToQueue = func(next bool) {
if next {
p.OnPlaySelectionNext(p.selectedItems())
} else {
p.OnAddToQueue(p.selectedItems())
}
}
p.menu.OnPlaySongRadio = func() {
p.OnPlaySongRadio(p.selectedTracks()[0])
}
p.menu.OnDownload = func() {
p.OnDownload(p.selectedTracks(), "Selected tracks")
}
p.menu.OnAddToPlaylist = func() {
p.OnAddToPlaylist(p.selectedItemIDs())
}
p.menu.OnShowInfo = func() {
p.OnShowTrackInfo(p.selectedTracks()[0])
}
p.menu.OnShare = func() {
p.OnShare(p.selectedTracks())
}
p.menu.OnSetRating = func(rating int) {
p.OnSetRating(p.selectedItemIDs(), rating)
}
p.menu.OnFavorite = func(fav bool) {
p.OnSetFavorite(p.selectedItemIDs(), fav)
}
p.menu = widget.NewPopUpMenu(
fyne.NewMenu("", menuItems...),
fyne.CurrentApp().Driver().CanvasForObject(p),
)
}
func (p *PlayQueueList) ensureRadiosMenu() {
@@ -366,34 +331,6 @@ func (p *PlayQueueList) ensureRadiosMenu() {
)
}
func (p *PlayQueueList) createQueueActionMenuItems() []*fyne.MenuItem {
play := fyne.NewMenuItem(lang.L("Play"), func() {
if p.OnPlaySelection != nil {
p.OnPlaySelection(p.selectedItems(), false)
}
})
play.Icon = theme.MediaPlayIcon()
shuffle := fyne.NewMenuItem(lang.L("Shuffle"), func() {
if p.OnPlaySelection != nil {
p.OnPlaySelection(p.selectedItems(), true)
}
})
shuffle.Icon = myTheme.ShuffleIcon
playNext := fyne.NewMenuItem(lang.L("Play next"), func() {
if p.OnPlaySelection != nil {
p.OnPlaySelectionNext(p.selectedItems())
}
})
playNext.Icon = myTheme.PlayNextIcon
add := fyne.NewMenuItem(lang.L("Add to queue"), func() {
if p.OnPlaySelection != nil {
p.OnAddToQueue(p.selectedItems())
}
})
add.Icon = theme.ContentAddIcon()
return []*fyne.MenuItem{play, shuffle, playNext, add}
}
func (t *PlayQueueList) selectedItems() []mediaprovider.MediaItem {
t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock()
+19 -2
View File
@@ -17,7 +17,9 @@ import (
type SearchEntry struct {
widget.Entry
OnSearched func(string)
minWidth float32
OnSearched func(string)
OnFocusLost func()
}
func NewSearchEntry() *SearchEntry {
@@ -53,13 +55,28 @@ func (s *SearchEntry) TypedKey(e *fyne.KeyEvent) {
s.Entry.TypedKey(e)
}
func (s *SearchEntry) FocusLost() {
s.Entry.FocusLost()
if s.OnFocusLost != nil {
s.OnFocusLost()
}
}
func (s *SearchEntry) Refresh() {
s.updateActionButton()
s.Entry.Refresh()
}
func (s *SearchEntry) SetMinWidth(w float32) {
s.minWidth = w
}
func (s *SearchEntry) MinSize() fyne.Size {
return fyne.NewSize(200, s.Entry.MinSize().Height)
w := s.minWidth
if w == 0 {
w = 200
}
return fyne.NewSize(w, s.Entry.MinSize().Height)
}
func (s *SearchEntry) updateActionButton() bool {
+44 -92
View File
@@ -6,13 +6,10 @@ import (
"sort"
"strings"
"fyne.io/fyne/v2/lang"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/layouts"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/util"
"fyne.io/fyne/v2"
@@ -93,17 +90,13 @@ type Tracklist struct {
tracks []*util.TrackListModel
tracksOrigOrder []*util.TrackListModel
nowPlayingID string
colLayout *layouts.ColumnsLayout
hdr *ListHeader
list *FocusList
ctxMenu *fyne.Menu
ratingSubmenu *fyne.MenuItem
shareMenuItem *fyne.MenuItem
songRadioMenuItem *fyne.MenuItem
infoMenuItem *fyne.MenuItem
loadingDots *LoadingDots
container *fyne.Container
nowPlayingID string
colLayout *layouts.ColumnsLayout
hdr *ListHeader
list *FocusList
ctxMenu *util.TrackContextMenu
loadingDots *LoadingDots
container *fyne.Container
}
func NewTracklist(tracks []*mediaprovider.Track, im *backend.ImageManager, useCompactRows bool) *Tracklist {
@@ -342,7 +335,7 @@ func (t *Tracklist) GetTracks() []*mediaprovider.Track {
// Append more tracks to the tracklist.
func (t *Tracklist) AppendTracks(trs []*mediaprovider.Track) {
t.tracksOrigOrder = append(t.tracks, util.ToTrackListModels(trs)...)
t.tracksOrigOrder = append(t.tracksOrigOrder, util.ToTrackListModels(trs)...)
t.doSortTracks()
t.list.Refresh()
}
@@ -491,8 +484,8 @@ func (t *Tracklist) selectAddOrRemove(idx int) {
t.tracks[idx].Selected = !t.tracks[idx].Selected
}
func (t *Tracklist) selectTrack(idx int) {
util.SelectItem(t.tracks, idx)
func (t *Tracklist) selectTrack(idx int) bool {
return util.SelectItem(t.tracks, idx)
}
func (t *Tracklist) selectRange(idx int) {
@@ -500,88 +493,47 @@ func (t *Tracklist) selectRange(idx int) {
}
func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
t.selectTrack(trackIdx)
t.list.Refresh()
if t.selectTrack(trackIdx) {
t.list.Refresh()
}
if t.ctxMenu == nil {
t.ctxMenu = fyne.NewMenu("")
if !t.Options.DisablePlaybackMenu {
play := fyne.NewMenuItem(lang.L("Play"), func() {
if t.OnPlaySelection != nil {
t.OnPlaySelection(t.selectedTracks(), false)
}
})
play.Icon = theme.MediaPlayIcon()
shuffle := fyne.NewMenuItem(lang.L("Shuffle"), func() {
if t.OnPlaySelection != nil {
t.OnPlaySelection(t.selectedTracks(), true)
}
})
shuffle.Icon = myTheme.ShuffleIcon
playNext := fyne.NewMenuItem(lang.L("Play next"), func() {
if t.OnPlaySelection != nil {
t.OnPlaySelectionNext(t.selectedTracks())
}
})
playNext.Icon = myTheme.PlayNextIcon
add := fyne.NewMenuItem(lang.L("Add to queue"), func() {
if t.OnPlaySelection != nil {
t.OnAddToQueue(t.selectedTracks())
}
})
add.Icon = theme.ContentAddIcon()
t.songRadioMenuItem = fyne.NewMenuItem(lang.L("Play song radio"), func() {
t.onPlaySongRadio(t.selectedTracks())
})
t.songRadioMenuItem.Icon = myTheme.RadioIcon
t.ctxMenu.Items = append(t.ctxMenu.Items,
play, shuffle, playNext, add, t.songRadioMenuItem)
t.ctxMenu = util.NewTrackContextMenu(t.Options.DisablePlaybackMenu, t.Options.AuxiliaryMenuItems)
t.ctxMenu.OnPlay = func(shuffle bool) {
t.OnPlaySelection(t.selectedTracks(), shuffle)
}
playlist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() {
if t.OnAddToPlaylist != nil {
t.OnAddToPlaylist(t.SelectedTrackIDs())
t.ctxMenu.OnAddToQueue = func(next bool) {
if next {
t.OnPlaySelectionNext(t.selectedTracks())
} else {
t.OnAddToQueue(t.selectedTracks())
}
})
playlist.Icon = myTheme.PlaylistIcon
download := fyne.NewMenuItem(lang.L("Download")+"...", func() {
}
t.ctxMenu.OnPlaySongRadio = func() {
t.onPlaySongRadio(t.selectedTracks())
}
t.ctxMenu.OnAddToPlaylist = func() {
t.OnAddToPlaylist(t.SelectedTrackIDs())
}
t.ctxMenu.OnDownload = func() {
t.onDownload(t.selectedTracks(), "Selected tracks")
})
download.Icon = theme.DownloadIcon()
t.infoMenuItem = fyne.NewMenuItem(lang.L("Show info")+"...", func() {
if t.OnShowTrackInfo != nil {
t.OnShowTrackInfo(t.selectedTracks()[0])
}
})
t.infoMenuItem.Icon = theme.InfoIcon()
favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() {
t.onSetFavorites(t.selectedTracks(), true, true)
})
favorite.Icon = myTheme.FavoriteIcon
unfavorite := fyne.NewMenuItem(lang.L("Unset favorite"), func() {
t.onSetFavorites(t.selectedTracks(), false, true)
})
unfavorite.Icon = myTheme.NotFavoriteIcon
t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator(),
playlist, download, t.infoMenuItem)
t.shareMenuItem = fyne.NewMenuItem(lang.L("Share")+"...", func() {
}
t.ctxMenu.OnShowInfo = func() {
t.OnShowTrackInfo(t.selectedTracks()[0])
}
t.ctxMenu.OnFavorite = func(fav bool) {
t.onSetFavorites(t.selectedTracks(), fav, true /*needRefresh*/)
}
t.ctxMenu.OnShare = func() {
t.onShare(t.selectedTracks())
})
t.shareMenuItem.Icon = myTheme.ShareIcon
t.ctxMenu.Items = append(t.ctxMenu.Items, t.shareMenuItem)
t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator())
t.ctxMenu.Items = append(t.ctxMenu.Items, favorite, unfavorite)
t.ratingSubmenu = util.NewRatingSubmenu(func(rating int) {
t.onSetRatings(t.selectedTracks(), rating, true)
})
t.ctxMenu.Items = append(t.ctxMenu.Items, t.ratingSubmenu)
if len(t.Options.AuxiliaryMenuItems) > 0 {
t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator())
t.ctxMenu.Items = append(t.ctxMenu.Items, t.Options.AuxiliaryMenuItems...)
}
t.ctxMenu.OnSetRating = func(rating int) {
t.onSetRatings(t.selectedTracks(), rating, true /*needRefresh*/)
}
}
t.ratingSubmenu.Disabled = t.Options.DisableRating
t.shareMenuItem.Disabled = t.Options.DisableSharing || len(t.selectedTracks()) != 1
t.infoMenuItem.Disabled = len(t.selectedTracks()) != 1
widget.ShowPopUpMenuAtPosition(t.ctxMenu, fyne.CurrentApp().Driver().CanvasForObject(t), e.AbsolutePosition)
t.ctxMenu.SetRatingDisabled(t.Options.DisableRating)
t.ctxMenu.SetShareDisabled(t.Options.DisableSharing || len(t.selectedTracks()) != 1)
t.ctxMenu.SetInfoDisabled(len(t.selectedTracks()) != 1)
t.ctxMenu.ShowAtPosition(e.AbsolutePosition, fyne.CurrentApp().Driver().CanvasForObject(t))
}
func (t *Tracklist) onSetFavorite(trackID string, fav bool) {
+1 -1
View File
@@ -72,7 +72,7 @@ func (t *TracklistLoader) loadMoreTracks(num int) {
if t.disposed.Load() {
return
}
fyne.Do(func() {
fyne.DoAndWait(func() {
t.tracklist.SetLoading(false)
t.tracklist.AppendTracks(t.trackBuffer)
t.len += len(t.trackBuffer)
+1 -1
View File
@@ -2,7 +2,7 @@
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "Supersonic"
#define MyAppVersion "0.14.0"
#define MyAppVersion "0.15.1"
#define MyAppPublisher "Drew Weymouth"
#define MyAppURL "https://github.com/dweymouth/supersonic"
#define MyAppExeName "Supersonic.exe"