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 run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev desktop-file-utils
- name: Build - name: Build
run: go build run: make build
- name: make appimage - name: make appimage
run: | 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 run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev desktop-file-utils libmfx1 libdvdnav4
- name: Build - name: Build
run: go build run: make build
- name: make appimage - name: make appimage
run: | run: |
-3
View File
@@ -31,9 +31,6 @@ jobs:
- name: Setup path - 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 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 - name: Install Fyne tool
run: go install fyne.io/fyne/v2/cmd/fyne@latest run: go install fyne.io/fyne/v2/cmd/fyne@latest
+1 -4
View File
@@ -31,13 +31,10 @@ jobs:
- name: Setup path - 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 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 - name: Install Fyne tool
run: go install fyne.io/fyne/v2/cmd/fyne@latest run: go install fyne.io/fyne/v2/cmd/fyne@latest
- name: Package app bundles - name: Build and package app bundles
run: > run: >
make package_macos && make package_macos &&
cp -r Supersonic.app Supersonic.app.2 && cp -r Supersonic.app Supersonic.app.2 &&
-3
View File
@@ -23,9 +23,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev
- name: Build
run: go build
- name: Install Fyne tool - name: Install Fyne tool
run: go install fyne.io/fyne/v2/cmd/fyne@latest run: go install fyne.io/fyne/v2/cmd/fyne@latest
-3
View File
@@ -23,9 +23,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev
- name: Build
run: go build
- name: Install Fyne tool - name: Install Fyne tool
run: go install fyne.io/fyne/v2/cmd/fyne@latest run: go install fyne.io/fyne/v2/cmd/fyne@latest
-3
View File
@@ -36,9 +36,6 @@ jobs:
pacman -Syu && pacman -Syu &&
pacman --noconfirm -S mingw-w64-x86_64-toolchain mingw-w64-x86_64-mpv pacman --noconfirm -S mingw-w64-x86_64-toolchain mingw-w64-x86_64-mpv
- name: Build
run: go build
- name: Install Fyne tool - name: Install Fyne tool
run: go install fyne.io/fyne/v2/cmd/fyne@latest run: go install fyne.io/fyne/v2/cmd/fyne@latest
+19
View File
@@ -1,5 +1,24 @@
# Change Log # 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] ## [0.14.0]
### Added ### Added
+1 -1
View File
@@ -1,7 +1,7 @@
[Details] [Details]
Icon = "res/appicon-512.png" Icon = "res/appicon-512.png"
Name = "Supersonic" Name = "Supersonic"
Version = "0.14.0" Version = "0.15.1"
[LinuxAndBSD] [LinuxAndBSD]
Categories = ["Audio", "AudioVideo"] Categories = ["Audio", "AudioVideo"]
+1 -1
View File
@@ -1,5 +1,5 @@
build: build:
go build go build -tags migrated_fynedo
# dylibbundler doesn't seem to pick up on the Python framework dependency, # 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 # 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] Shuffle and repeat playback modes (partial; shuffle album, playlist, artist radio, random songs; repeat one/all)
* [x] Lyrics support * [x] Lyrics support
* [x] Internet radio station support (Subsonic) * [x] Internet radio station support (Subsonic)
* [x] Cast to uPnP/DLNA devices
* [ ] Server jukebox control (planned) * [ ] Server jukebox control (planned)
* [ ] Browse by folders (planned) * [ ] Browse by folders (planned)
* [ ] Cast to uPnP/DLNA devices (likely planned)
* [ ] Offline mode (eventually planned) * [ ] Offline mode (eventually planned)
* [ ] iOS/Android support (maybe eventually planned) * [ ] iOS/Android support (maybe eventually planned)
## Installation ## 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` **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) ## Build instructions (Linux)
### Ubuntu dependencies ### 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`` * ``sudo dnf install golang mpv-devel libX11-devel libXcursor-devel libXrandr-devel libXinerama-devel libXi-devel libglvnd-devel libXxf86vm-devel``
### Build ### 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) * (note that the first build will take some time as it will download and build the UI library)
### Generate installable .tar.xz bundle ### 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`` - ``export LIBRARY_PATH=/opt/local/lib:$LIBRARY_PATH``
#### Building the application #### 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) * (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 * 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. * **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) 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() { a.PlaybackManager.OnPlaying(func() {
SetSystemSleepDisabled(true) SetSystemSleepDisabled(true)
}) })
@@ -388,8 +403,8 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) {
}() }()
}) })
a.PlaybackManager.OnSeek(func() { a.PlaybackManager.OnSeek(func() {
dur := a.PlaybackManager.NowPlaying().Metadata().Duration playbackStatus := a.PlaybackManager.PlaybackStatus()
smtc.UpdatePosition(int(a.PlaybackManager.CurrentPlayer().GetStatus().TimePos*1000), dur*1000) smtc.UpdatePosition(int(playbackStatus.TimePos*1000), int(playbackStatus.Duration*1000))
}) })
a.PlaybackManager.OnPlaying(func() { a.PlaybackManager.OnPlaying(func() {
smtc.SetEnabled(true) smtc.SetEnabled(true)
@@ -448,7 +463,7 @@ func (a *App) Shutdown() {
a.WinSMTC.Shutdown() a.WinSMTC.Shutdown()
} }
a.PlaybackManager.DisableCallbacks() a.PlaybackManager.DisableCallbacks()
a.PlaybackManager.Stop() // will trigger scrobble check a.PlaybackManager.Shutdown() // will trigger scrobble check
a.cancel() a.cancel()
a.LocalPlayer.Destroy() a.LocalPlayer.Destroy()
} }
+12 -8
View File
@@ -11,7 +11,7 @@ import (
"github.com/dweymouth/supersonic/sharedutil" "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 limit := maxResults / 3
var wg sync.WaitGroup var wg sync.WaitGroup
var albums []*jellyfin.Album var albums []*jellyfin.Album
@@ -22,19 +22,19 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([
wg.Add(1) wg.Add(1)
go func() { 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 albums = albumResult.Albums
wg.Done() wg.Done()
}() }()
wg.Add(1) wg.Add(1)
go func() { 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 artists = artistResult.Artists
wg.Done() wg.Done()
}() }()
wg.Add(1) wg.Add(1)
go func() { 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 songs = songResult.Songs
wg.Done() wg.Done()
}() }()
@@ -44,7 +44,7 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([
wg.Add(1) wg.Add(1)
go func() { go func() {
p, e := s.client.GetPlaylists() p, e := j.client.GetPlaylists()
if e == nil { if e == nil {
playlists = sharedutil.FilterSlice(p, func(p *jellyfin.Playlist) bool { playlists = sharedutil.FilterSlice(p, func(p *jellyfin.Playlist) bool {
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords) 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) wg.Add(1)
go func() { go func() {
g, e := s.client.GetGenres(jellyfin.Paging{}) g, e := j.client.GetGenres(jellyfin.Paging{})
if e == nil { if e == nil {
genres = sharedutil.FilterSlice(g, func(g jellyfin.NameID) bool { genres = sharedutil.FilterSlice(g, func(g jellyfin.NameID) bool {
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords) return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords)
@@ -66,13 +66,13 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([
wg.Wait() wg.Wait()
results := mergeResults(albums, artists, songs, playlists, genres) results := j.mergeResults(albums, artists, songs, playlists, genres)
helpers.RankSearchResults(results, searchQuery, queryLowerWords) helpers.RankSearchResults(results, searchQuery, queryLowerWords)
return results, nil return results, nil
} }
func mergeResults( func (j *jellyfinMediaProvider) mergeResults(
albums []*jellyfin.Album, albums []*jellyfin.Album,
artists []*jellyfin.Artist, artists []*jellyfin.Artist,
songs []*jellyfin.Song, songs []*jellyfin.Song,
@@ -93,6 +93,7 @@ func mergeResults(
Name: al.Name, Name: al.Name,
ArtistName: strings.Join(sharedutil.MapSlice(al.Artists, getArtistNames), ","), ArtistName: strings.Join(sharedutil.MapSlice(al.Artists, getArtistNames), ","),
Size: al.ChildCount, Size: al.ChildCount,
Item: toAlbum(al),
}) })
} }
@@ -103,6 +104,7 @@ func mergeResults(
CoverID: ar.ID, CoverID: ar.ID,
Name: ar.Name, Name: ar.Name,
Size: ar.AlbumCount, Size: ar.AlbumCount,
Item: toArtist(ar),
}) })
} }
@@ -114,6 +116,7 @@ func mergeResults(
Name: tr.Name, Name: tr.Name,
ArtistName: strings.Join(sharedutil.MapSlice(tr.Artists, getArtistNames), ","), ArtistName: strings.Join(sharedutil.MapSlice(tr.Artists, getArtistNames), ","),
Size: int(tr.RunTimeTicks / 10_000_000), Size: int(tr.RunTimeTicks / 10_000_000),
Item: toTrack(tr),
}) })
} }
@@ -124,6 +127,7 @@ func mergeResults(
CoverID: pl.ID, CoverID: pl.ID,
Name: pl.Name, Name: pl.Name,
Size: pl.SongCount, Size: pl.SongCount,
Item: j.toPlaylist(pl),
}) })
} }
+6
View File
@@ -183,6 +183,7 @@ const (
type MediaItemMetadata struct { type MediaItemMetadata struct {
Type MediaItemType Type MediaItemType
MIMEType string
ID string ID string
Name string Name string
Artists []string Artists []string
@@ -204,6 +205,7 @@ func (t *Track) Metadata() MediaItemMetadata {
} }
return MediaItemMetadata{ return MediaItemMetadata{
Type: MediaItemTypeTrack, Type: MediaItemTypeTrack,
MIMEType: t.ContentType,
ID: t.ID, ID: t.ID,
Name: t.Title, Name: t.Title,
Artists: t.ArtistNames, Artists: t.ArtistNames,
@@ -279,4 +281,8 @@ type SearchResult struct {
// Unset for ContentTypes Artist, Playlist, Genre, and RadioStation // Unset for ContentTypes Artist, Playlist, Genre, and RadioStation
ArtistName string 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, Name: al.Name,
ArtistName: getNameString(al.Artist, al.Artists), ArtistName: getNameString(al.Artist, al.Artists),
Size: al.SongCount, Size: al.SongCount,
Item: toAlbum(al),
}) })
} }
@@ -111,6 +112,7 @@ func mergeResults(
CoverID: ar.CoverArt, CoverID: ar.CoverArt,
Name: ar.Name, Name: ar.Name,
Size: ar.AlbumCount, Size: ar.AlbumCount,
Item: toArtistFromID3(ar),
}) })
} }
@@ -122,6 +124,7 @@ func mergeResults(
Name: tr.Title, Name: tr.Title,
ArtistName: getNameString(tr.Artist, tr.Artists), ArtistName: getNameString(tr.Artist, tr.Artists),
Size: tr.Duration, Size: tr.Duration,
Item: toTrack(tr),
}) })
} }
@@ -132,6 +135,7 @@ func mergeResults(
CoverID: pl.CoverArt, CoverID: pl.CoverArt,
Name: pl.Name, Name: pl.Name,
Size: pl.SongCount, Size: pl.SongCount,
Item: toPlaylist(pl),
}) })
} }
@@ -149,6 +153,7 @@ func mergeResults(
Type: mediaprovider.ContentTypeRadioStation, Type: mediaprovider.ContentTypeRadioStation,
ID: r.ID, ID: r.ID,
Name: r.Name, Name: r.Name,
Item: r,
}) })
} }
+3 -3
View File
@@ -84,17 +84,17 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac
}) })
mp.playbackManager.OnSeek(func() { 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() { mp.playbackManager.OnPlaying(func() {
C.set_os_playback_state_playing() 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() { mp.playbackManager.OnPaused(func() {
C.set_os_playback_state_paused() 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 return nil
+6 -6
View File
@@ -54,7 +54,7 @@ func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler {
pm.OnSeek(func() { pm.OnSeek(func() {
if m.connErr == nil { if m.connErr == nil {
pos := secondsToMicroseconds(pm.PlayerStatus().TimePos) pos := secondsToMicroseconds(pm.PlaybackStatus().TimePos)
m.evt.Player.OnSeek(pos) m.evt.Player.OnSeek(pos)
} }
}) })
@@ -160,7 +160,7 @@ func (m *MPRISHandler) Previous() error {
} }
func (m *MPRISHandler) Pause() error { func (m *MPRISHandler) Pause() error {
if m.pm.PlayerStatus().State == player.Playing { if m.pm.PlaybackStatus().State == player.Playing {
m.pm.PlayPause() m.pm.PlayPause()
} }
return nil return nil
@@ -177,7 +177,7 @@ func (m *MPRISHandler) Stop() error {
} }
func (m *MPRISHandler) Play() error { func (m *MPRISHandler) Play() error {
switch m.pm.PlayerStatus().State { switch m.pm.PlaybackStatus().State {
case player.Paused: case player.Paused:
m.pm.PlayPause() m.pm.PlayPause()
case player.Stopped: case player.Stopped:
@@ -204,7 +204,7 @@ func (m *MPRISHandler) OpenUri(uri string) error {
} }
func (m *MPRISHandler) PlaybackStatus() (types.PlaybackStatus, error) { func (m *MPRISHandler) PlaybackStatus() (types.PlaybackStatus, error) {
switch m.pm.PlayerStatus().State { switch m.pm.PlaybackStatus().State {
case player.Playing: case player.Playing:
return types.PlaybackStatusPlaying, nil return types.PlaybackStatusPlaying, nil
case player.Paused: case player.Paused:
@@ -254,7 +254,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) {
if m.curTrackPath != "" { if m.curTrackPath != "" {
trackObjPath = m.curTrackPath trackObjPath = m.curTrackPath
} }
status := m.pm.PlayerStatus() status := m.pm.PlaybackStatus()
var meta mediaprovider.MediaItemMetadata var meta mediaprovider.MediaItemMetadata
// metadata that can come only from tracks // metadata that can come only from tracks
@@ -307,7 +307,7 @@ func (m *MPRISHandler) SetVolume(v float64) error {
} }
func (m *MPRISHandler) Position() (int64, 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) { func (m *MPRISHandler) MinimumRate() (float64, error) {
+18 -4
View File
@@ -31,10 +31,11 @@ const (
) )
type playbackCommand struct { type playbackCommand struct {
Type playbackCommandType Type playbackCommandType
Arg any Arg any
Arg2 any Arg2 any
Arg3 any Arg3 any
OnDone func()
} }
// playbackCommandQueue is a queue to accumulate player commands from the UI // playbackCommandQueue is a queue to accumulate player commands from the UI
@@ -60,11 +61,24 @@ func (c *playbackCommandQueue) C() <-chan playbackCommand {
return c.nextChan return c.nextChan
} }
func (c *playbackCommandQueue) Clear() {
c.mutex.Lock()
c.queue = nil
c.mutex.Unlock()
}
func (c *playbackCommandQueue) Stop() { func (c *playbackCommandQueue) Stop() {
c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop},
playbackCommand{Type: 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() { func (c *playbackCommandQueue) Continue() {
c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop},
playbackCommand{Type: cmdContinue}) playbackCommand{Type: cmdContinue})
+164 -49
View File
@@ -9,10 +9,13 @@ import (
"github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player"
"github.com/dweymouth/supersonic/backend/player/mpv"
"github.com/dweymouth/supersonic/backend/util" "github.com/dweymouth/supersonic/backend/util"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
) )
// TODO: make thread-safe
var ( var (
ReplayGainNone = player.ReplayGainNone.String() ReplayGainNone = player.ReplayGainNone.String()
ReplayGainAlbum = player.ReplayGainAlbum.String() ReplayGainAlbum = player.ReplayGainAlbum.String()
@@ -37,6 +40,14 @@ const (
LoopOne LoopOne
) )
type PlaybackState = player.State
type PlaybackStatus struct {
State PlaybackState
TimePos float64
Duration float64
}
type playbackEngine struct { type playbackEngine struct {
ctx context.Context ctx context.Context
cancelPollPos context.CancelFunc cancelPollPos context.CancelFunc
@@ -48,12 +59,22 @@ type playbackEngine struct {
latestTrackPosition float64 // cleared by checkScrobble latestTrackPosition float64 // cleared by checkScrobble
callbacksDisabled bool callbacksDisabled bool
playQueue []mediaprovider.MediaItem playQueue []mediaprovider.MediaItem
nowPlayingIdx int nowPlayingIdx int
isRadio bool isRadio bool
wasStopped bool // true iff player was stopped before handleOnTrackChange invocation loopMode LoopMode
noIncrementNextTrackChange bool // true iff the nowPlayingIndex should not be incremente don the next onTrackChange
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 // to pass to onSongChange listeners; clear once listeners have been called
lastScrobbled *mediaprovider.Track lastScrobbled *mediaprovider.Track
@@ -99,23 +120,8 @@ func NewPlaybackEngine(
case "One": case "One":
pm.loopMode = LoopOne 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() { s.OnLogout(func() {
pm.StopAndClearPlayQueue() pm.StopAndClearPlayQueue()
}) })
@@ -123,15 +129,95 @@ func NewPlaybackEngine(
return pm 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 { 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) { if idx < 0 || idx >= len(p.playQueue) {
return errors.New("track index out of range") return errors.New("track index out of range")
} }
p.noIncrementNextTrackChange = true // scrobble current track if needed
err := p.setTrack(idx, false) p.checkScrobble()
if err == nil { p.alreadyScrobbled = true
p.nowPlayingIdx = idx p.pendingTrackChangeNum = idx
} err := p.setTrack(idx, false, startTime)
return err return err
} }
@@ -162,8 +248,16 @@ func (p *playbackEngine) GetLoopMode() LoopMode {
return p.loopMode return p.loopMode
} }
func (p *playbackEngine) PlayerStatus() player.Status { func (p *playbackEngine) PlaybackStatus() PlaybackStatus {
return p.player.GetStatus() 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 { func (p *playbackEngine) SetVolume(vol int) error {
@@ -182,14 +276,14 @@ func (p *playbackEngine) CurrentPlayer() player.BasePlayer {
} }
func (p *playbackEngine) SeekNext() error { func (p *playbackEngine) SeekNext() error {
if p.CurrentPlayer().GetStatus().State == player.Stopped { if p.PlaybackStatus().State == player.Stopped {
return nil return nil
} }
return p.PlayTrackAt(p.nowPlayingIdx + 1) return p.PlayTrackAt(p.nowPlayingIdx + 1)
} }
func (p *playbackEngine) SeekBackOrPrevious() error { 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.player.SeekSeconds(0)
} }
return p.PlayTrackAt(p.nowPlayingIdx - 1) return p.PlayTrackAt(p.nowPlayingIdx - 1)
@@ -197,7 +291,7 @@ func (p *playbackEngine) SeekBackOrPrevious() error {
func (p *playbackEngine) SeekFwdBackN(n int) error { func (p *playbackEngine) SeekFwdBackN(n int) error {
idx := p.nowPlayingIdx 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 n += 1 // first seek back is just seek to beginning of current
} }
if n == 0 || (idx == 0 && n < 0) { if n == 0 || (idx == 0 && n < 0) {
@@ -224,7 +318,7 @@ func (p *playbackEngine) IsSeeking() bool {
} }
func (p *playbackEngine) Stop() error { func (p *playbackEngine) Stop() error {
return p.player.Stop() return p.player.Stop(false)
} }
func (p *playbackEngine) Pause() error { func (p *playbackEngine) Pause() error {
@@ -232,7 +326,12 @@ func (p *playbackEngine) Pause() error {
} }
func (p *playbackEngine) Continue() 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.PlayTrackAt(0)
} }
return p.player.Continue() 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 { func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) error {
if insertQueueMode == Replace { if insertQueueMode == Replace {
p.player.Stop() p.player.Stop(false)
p.nowPlayingIdx = -1 p.nowPlayingIdx = -1
p.playQueue = nil p.playQueue = nil
} }
@@ -280,7 +379,7 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu
func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) { func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) {
if insertMode == Replace { if insertMode == Replace {
p.player.Stop() p.player.Stop(false)
p.nowPlayingIdx = -1 p.nowPlayingIdx = -1
p.playQueue = nil p.playQueue = nil
} }
@@ -306,7 +405,7 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins
// Stop playback and clear the play queue. // Stop playback and clear the play queue.
func (p *playbackEngine) StopAndClearPlayQueue() { func (p *playbackEngine) StopAndClearPlayQueue() {
changed := len(p.playQueue) > 0 changed := len(p.playQueue) > 0
p.player.Stop() p.player.Stop(false)
p.playQueue = nil p.playQueue = nil
p.nowPlayingIdx = -1 p.nowPlayingIdx = -1
if changed { if changed {
@@ -385,6 +484,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) {
isPlayingTrackRemoved = true isPlayingTrackRemoved = true
// If we are removing the currently playing track, we need to scrobble it // If we are removing the currently playing track, we need to scrobble it
p.checkScrobble() p.checkScrobble()
p.alreadyScrobbled = true
} else if nowPlaying >= 0 && i == nowPlaying+1 { } else if nowPlaying >= 0 && i == nowPlaying+1 {
isNextPlayingTrackremoved = true isNextPlayingTrackremoved = true
} }
@@ -401,7 +501,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) {
p.Stop() p.Stop()
} else { } else {
p.nowPlayingIdx -= 1 // will be incremented in newtrack callback from player 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 // setNextTrack and onSongChange callbacks will be handled
// when we receive new track event from player // when we receive new track event from player
@@ -456,21 +556,31 @@ func (p *playbackEngine) SetReplayGainMode(mode player.ReplayGainMode) {
} }
func (p *playbackEngine) handleOnTrackChange() { func (p *playbackEngine) handleOnTrackChange() {
p.checkScrobble() // scrobble the previous song if needed // scrobble the previous song if needed
if p.player.GetStatus().State == player.Playing { if !p.alreadyScrobbled {
p.checkScrobble()
}
if p.PlaybackStatus().State == player.Playing {
p.playTimeStopwatch.Start() p.playTimeStopwatch.Start()
} }
if !p.noIncrementNextTrackChange && (p.wasStopped || p.loopMode != LoopOne) { if p.pendingTrackChangeNum < 0 && (p.wasStopped || p.loopMode != LoopOne) {
p.nowPlayingIdx++ p.nowPlayingIdx++
if p.loopMode == LoopAll && p.nowPlayingIdx == len(p.playQueue) { if p.loopMode == LoopAll && p.nowPlayingIdx == len(p.playQueue) {
p.nowPlayingIdx = 0 // wrapped around 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] nowPlaying := p.playQueue[p.nowPlayingIdx]
_, isRadio := nowPlaying.(*mediaprovider.RadioStation) _, isRadio := nowPlaying.(*mediaprovider.RadioStation)
p.isRadio = isRadio p.isRadio = isRadio
// reset flags
p.wasStopped = false p.wasStopped = false
p.alreadyScrobbled = false
p.curTrackDuration = float64(nowPlaying.Metadata().Duration) p.curTrackDuration = float64(nowPlaying.Metadata().Duration)
p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled
p.invokeOnSongChangeCallbacks() p.invokeOnSongChangeCallbacks()
@@ -480,11 +590,14 @@ func (p *playbackEngine) handleOnTrackChange() {
func (p *playbackEngine) handleOnStopped() { func (p *playbackEngine) handleOnStopped() {
p.playTimeStopwatch.Stop() p.playTimeStopwatch.Stop()
p.checkScrobble() if !p.alreadyScrobbled {
p.checkScrobble()
}
p.stopPollTimePos() p.stopPollTimePos()
p.doUpdateTimePos(false) p.doUpdateTimePos(false)
p.invokeOnSongChangeCallbacks() p.invokeOnSongChangeCallbacks()
p.invokeNoArgCallbacks(p.onStopped) p.invokeNoArgCallbacks(p.onStopped)
p.alreadyScrobbled = false
p.wasStopped = true p.wasStopped = true
p.nowPlayingIdx = -1 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 { if urlP, ok := p.player.(player.URLPlayer); ok {
url := "" url := ""
var meta mediaprovider.MediaItemMetadata
if idx >= 0 { if idx >= 0 {
var err error var err error
item := p.playQueue[idx] item := p.playQueue[idx]
meta = item.Metadata()
if tr, ok := item.(*mediaprovider.Track); ok { if tr, ok := item.(*mediaprovider.Track); ok {
url, err = p.sm.Server.GetStreamURL(tr.ID, p.transcodeCfg.ForceRawFile) url, err = p.sm.Server.GetStreamURL(tr.ID, p.transcodeCfg.ForceRawFile)
} else { } else {
@@ -546,9 +661,9 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
} }
} }
if next { 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 { } else if trP, ok := p.player.(player.TrackPlayer); ok {
var track *mediaprovider.Track var track *mediaprovider.Track
if idx >= 0 { if idx >= 0 {
@@ -560,13 +675,13 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
if next { if next {
return trP.SetNextTrack(track) return trP.SetNextTrack(track)
} }
return trP.PlayTrack(track) return trP.PlayTrack(track, startTime)
} }
panic("Unsupported player type") panic("Unsupported player type")
} }
func (p *playbackEngine) setNextTrack(idx int) error { func (p *playbackEngine) setNextTrack(idx int) error {
return p.setTrack(idx, true) return p.setTrack(idx, true, 0)
} }
// call BEFORE updating p.nowPlayingIdx // call BEFORE updating p.nowPlayingIdx
@@ -683,7 +798,7 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) {
if p.callbacksDisabled { if p.callbacksDisabled {
return return
} }
s := p.player.GetStatus() s := p.PlaybackStatus()
if s.TimePos > p.latestTrackPosition { if s.TimePos > p.latestTrackPosition {
p.latestTrackPosition = s.TimePos p.latestTrackPosition = s.TimePos
} }
+99 -13
View File
@@ -7,12 +7,16 @@ import (
"math/rand" "math/rand"
"runtime" "runtime"
"slices" "slices"
"sync"
"time" "time"
"github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player"
"github.com/dweymouth/supersonic/backend/player/dlna"
"github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/backend/player/mpv"
"github.com/dweymouth/supersonic/sharedutil" "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 // A high-level MediaProvider-aware playback engine, serves as an
@@ -22,11 +26,23 @@ type PlaybackManager struct {
cmdQueue *playbackCommandQueue cmdQueue *playbackCommandQueue
cfg *AppConfig cfg *AppConfig
localPlayer player.BasePlayer
remotePlayersLock sync.Mutex
remotePlayers []RemotePlaybackDevice
currentRemotePlayer *RemotePlaybackDevice
autoplay bool autoplay bool
lastPlayTime float64 lastPlayTime float64
} }
type RemotePlaybackDevice struct {
Name string
URL string
Protocol string
new func() (player.BasePlayer, error)
}
func NewPlaybackManager( func NewPlaybackManager(
ctx context.Context, ctx context.Context,
s *ServerManager, s *ServerManager,
@@ -39,10 +55,11 @@ func NewPlaybackManager(
e := NewPlaybackEngine(ctx, s, p, playbackCfg, scrobbleCfg, transcodeCfg) e := NewPlaybackEngine(ctx, s, p, playbackCfg, scrobbleCfg, transcodeCfg)
q := NewCommandQueue() q := NewCommandQueue()
pm := &PlaybackManager{ pm := &PlaybackManager{
engine: e, engine: e,
cmdQueue: q, cmdQueue: q,
cfg: appCfg, cfg: appCfg,
autoplay: playbackCfg.Autoplay, autoplay: playbackCfg.Autoplay,
localPlayer: p,
} }
pm.addOnTrackChangeHook() pm.addOnTrackChangeHook()
go pm.runCmdQueue(ctx) go pm.runCmdQueue(ctx)
@@ -68,7 +85,7 @@ func (p *PlaybackManager) addOnTrackChangeHook() {
return return
} }
// workaround for https://github.com/dweymouth/supersonic/issues/483 (see above comment) // 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 p.lastPlayTime = 0
go func() { go func() {
time.Sleep(300 * time.Millisecond) 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 { func (p *PlaybackManager) CurrentPlayer() player.BasePlayer {
return p.engine.CurrentPlayer() return p.engine.CurrentPlayer()
} }
@@ -397,8 +476,8 @@ func (p *PlaybackManager) IsAutoplay() bool {
return p.autoplay return p.autoplay
} }
func (p *PlaybackManager) PlayerStatus() player.Status { func (p *PlaybackManager) PlaybackStatus() PlaybackStatus {
return p.engine.PlayerStatus() return p.engine.PlaybackStatus()
} }
func (p *PlaybackManager) SetVolume(vol int) { 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. // Seek by given relative position in the current track by seconds.
func (p *PlaybackManager) SeekBySeconds(sec float64) { func (p *PlaybackManager) SeekBySeconds(sec float64) {
status := p.engine.PlayerStatus() status := p.engine.PlaybackStatus()
target := status.TimePos + sec target := status.TimePos + sec
if target < 0 { if target < 0 {
target = 0 target = 0
@@ -456,6 +535,10 @@ func (p *PlaybackManager) Stop() {
p.cmdQueue.Stop() p.cmdQueue.Stop()
} }
func (p *PlaybackManager) Shutdown() {
p.cmdQueue.StopAndWait()
}
func (p *PlaybackManager) Pause() { func (p *PlaybackManager) Pause() {
p.cmdQueue.Pause() p.cmdQueue.Pause()
} }
@@ -465,7 +548,7 @@ func (p *PlaybackManager) Continue() {
} }
func (p *PlaybackManager) PlayPause() { func (p *PlaybackManager) PlayPause() {
switch p.engine.PlayerStatus().State { switch p.engine.PlaybackStatus().State {
case player.Playing: case player.Playing:
p.Pause() p.Pause()
case player.Paused: case player.Paused:
@@ -514,7 +597,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
if len(tr.ArtistIDs) > 0 { if len(tr.ArtistIDs) > 0 {
similar, err := s.GetSimilarTracks(tr.ArtistIDs[0], p.cfg.EnqueueBatchSize) similar, err := s.GetSimilarTracks(tr.ArtistIDs[0], p.cfg.EnqueueBatchSize)
if err != nil { 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) tracks = filterRecentlyPlayed(similar)
} }
@@ -527,7 +610,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
} }
byGenre, err := s.GetRandomTracks(g, p.cfg.EnqueueBatchSize) byGenre, err := s.GetRandomTracks(g, p.cfg.EnqueueBatchSize)
if err != nil { 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) tracks = filterRecentlyPlayed(byGenre)
if len(tracks) > 0 { if len(tracks) > 0 {
@@ -542,7 +625,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
// fallback to random tracks // fallback to random tracks
random, err := s.GetRandomTracks("", p.cfg.EnqueueBatchSize) random, err := s.GetRandomTracks("", p.cfg.EnqueueBatchSize)
if err != nil { 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) tracks = filterRecentlyPlayed(random)
} }
@@ -556,7 +639,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
func (p *PlaybackManager) runCmdQueue(ctx context.Context) { func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
logIfErr := func(action string, err error) { logIfErr := func(action string, err error) {
if err != nil { if err != nil {
log.Println("Playback error (%s): %v", action, err) log.Printf("Playback error (%s): %v", action, err)
} }
} }
for { for {
@@ -606,6 +689,9 @@ func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
mpv.ForceRestartPlayback() 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 return nil
} }
func (j *JukeboxPlayer) Stop() error { func (j *JukeboxPlayer) Stop(_ bool) error {
if j.state == stopped { if j.state == stopped {
return nil return nil
} }
@@ -80,7 +80,7 @@ func (j *JukeboxPlayer) Stop() error {
return nil 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 { if err := j.provider.JukeboxSet(track.ID); err != nil {
return err return err
} }
@@ -140,6 +140,8 @@ func (j *JukeboxPlayer) GetStatus() player.Status {
} }
} }
func (j *JukeboxPlayer) Destroy() {}
func (j *JukeboxPlayer) startAndUpdateTime() error { func (j *JukeboxPlayer) startAndUpdateTime() error {
beforeStart := time.Now() beforeStart := time.Now()
if err := j.provider.JukeboxStart(); err != nil { if err := j.provider.JukeboxStart(); err != nil {
+28 -12
View File
@@ -6,7 +6,9 @@ import (
"fmt" "fmt"
"math" "math"
"strconv" "strconv"
"sync"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player"
"github.com/supersonic-app/go-mpv" "github.com/supersonic-app/go-mpv"
) )
@@ -67,6 +69,9 @@ type Player struct {
equalizer Equalizer equalizer Equalizer
peaksEnabled bool peaksEnabled bool
fileLoadedLock sync.Mutex
fileLoadedSig *sync.Cond
bgCancel context.CancelFunc bgCancel context.CancelFunc
} }
@@ -79,10 +84,12 @@ func New() *Player {
// Same as New, but sets the application name that mpv // Same as New, but sets the application name that mpv
// reports to the system audio API. // reports to the system audio API.
func NewWithClientName(c string) *Player { func NewWithClientName(c string) *Player {
return &Player{ p := &Player{
vol: -1, // use 100 in Init vol: -1, // use 100 in Init
clientName: c, clientName: c,
} }
p.fileLoadedSig = sync.NewCond(&p.fileLoadedLock)
return p
} }
// Initializes the Player and makes it ready for playback. // 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. // 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 { if !p.initialized {
return ErrUnitialized return ErrUnitialized
} }
err := p.mpv.Command([]string{"loadfile", url, "replace"}) err := p.mpv.Command([]string{"loadfile", url, "replace"})
if err == nil { if err != nil {
p.lenPlaylist = 1 return err
if p.status.State == player.Paused { }
return p.Continue() p.lenPlaylist = 1
} if p.status.State == player.Paused {
err = p.Continue()
} else {
p.setState(player.Playing) p.setState(player.Playing)
} }
if startTime > 0 {
p.fileLoadedLock.Lock()
p.fileLoadedSig.Wait()
p.fileLoadedLock.Unlock()
p.SeekSeconds(startTime)
}
return err return err
} }
// Stops playback and clears the play queue. // Stops playback and clears the play queue.
func (p *Player) Stop() error { func (p *Player) Stop(_ bool) error {
if !p.initialized { if !p.initialized {
return ErrUnitialized return ErrUnitialized
} }
@@ -169,7 +185,7 @@ func (p *Player) Stop() error {
return err 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 p.lenPlaylist > p.curPlaylistPos+1 {
if err := p.mpv.Command([]string{"playlist-remove", strconv.Itoa(int(p.curPlaylistPos) + 1)}); err != nil { if err := p.mpv.Command([]string{"playlist-remove", strconv.Itoa(int(p.curPlaylistPos) + 1)}); err != nil {
return err return err
@@ -480,10 +496,9 @@ func (p *Player) eventHandler(ctx context.Context) {
} }
switch e.Event_Id { switch e.Event_Id {
case mpv.EVENT_PLAYBACK_RESTART: case mpv.EVENT_PLAYBACK_RESTART:
if p.seeking { fallthrough
p.seeking = false
}
case mpv.EVENT_SEEK: case mpv.EVENT_SEEK:
p.seeking = false
p.InvokeOnSeek() p.InvokeOnSeek()
case mpv.EVENT_FILE_LOADED: case mpv.EVENT_FILE_LOADED:
p.curPlaylistPos, _ = p.getInt64Property("playlist-pos") p.curPlaylistPos, _ = p.getInt64Property("playlist-pos")
@@ -493,6 +508,7 @@ func (p *Player) eventHandler(ctx context.Context) {
p.InvokeOnSeek() p.InvokeOnSeek()
} }
p.InvokeOnTrackChange() p.InvokeOnTrackChange()
p.fileLoadedSig.Signal()
case mpv.EVENT_IDLE: case mpv.EVENT_IDLE:
p.status.Duration = 0 p.status.Duration = 0
p.status.TimePos = 0 p.status.TimePos = 0
+32 -28
View File
@@ -1,23 +1,25 @@
package player package player
import "github.com/dweymouth/supersonic/backend/mediaprovider" import (
"github.com/dweymouth/supersonic/backend/mediaprovider"
)
type URLPlayer interface { type URLPlayer interface {
BasePlayer BasePlayer
PlayFile(url string) error PlayFile(url string, metadata mediaprovider.MediaItemMetadata, startTime float64) error
SetNextFile(url string) error SetNextFile(url string, metadata mediaprovider.MediaItemMetadata) error
} }
type TrackPlayer interface { type TrackPlayer interface {
BasePlayer BasePlayer
PlayTrack(track *mediaprovider.Track) error PlayTrack(track *mediaprovider.Track, startTime float64) error
SetNextTrack(track *mediaprovider.Track) error SetNextTrack(track *mediaprovider.Track) error
} }
type BasePlayer interface { type BasePlayer interface {
Continue() error Continue() error
Pause() error Pause() error
Stop() error Stop(force bool) error
SeekSeconds(secs float64) error SeekSeconds(secs float64) error
IsSeeking() bool IsSeeking() bool
@@ -27,6 +29,8 @@ type BasePlayer interface {
GetStatus() Status GetStatus() Status
Destroy()
// Event API // Event API
OnPaused(func()) OnPaused(func())
OnStopped(func()) OnStopped(func())
@@ -84,66 +88,66 @@ func (r ReplayGainMode) String() string {
} }
type BasePlayerCallbackImpl struct { type BasePlayerCallbackImpl struct {
onPaused []func() onPaused func()
onStopped []func() onStopped func()
onPlaying []func() onPlaying func()
onSeek []func() onSeek func()
onTrackChange []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()) { 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()) { 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()) { 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. // Registers a callback which is invoked whenever a seek event occurs.
func (p *BasePlayerCallbackImpl) OnSeek(cb func()) { 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, // Registers a callback which is invoked when the currently playing track changes,
// or when playback begins at any time from the Stopped state. // or when playback begins at any time from the Stopped state.
// Callback is invoked with the index of the currently playing track (zero-based). // Callback is invoked with the index of the currently playing track (zero-based).
func (p *BasePlayerCallbackImpl) OnTrackChange(cb func()) { func (p *BasePlayerCallbackImpl) OnTrackChange(cb func()) {
p.onTrackChange = append(p.onTrackChange, cb) p.onTrackChange = cb
} }
func (p *BasePlayerCallbackImpl) InvokeOnPaused() { func (p *BasePlayerCallbackImpl) InvokeOnPaused() {
for _, cb := range p.onPaused { if p.onPaused != nil {
cb() p.onPaused()
} }
} }
func (p *BasePlayerCallbackImpl) InvokeOnPlaying() { func (p *BasePlayerCallbackImpl) InvokeOnPlaying() {
for _, cb := range p.onPlaying { if p.onPlaying != nil {
cb() p.onPlaying()
} }
} }
func (p *BasePlayerCallbackImpl) InvokeOnStopped() { func (p *BasePlayerCallbackImpl) InvokeOnStopped() {
for _, cb := range p.onStopped { if p.onStopped != nil {
cb() p.onStopped()
} }
} }
func (p *BasePlayerCallbackImpl) InvokeOnSeek() { func (p *BasePlayerCallbackImpl) InvokeOnSeek() {
for _, cb := range p.onSeek { if p.onSeek != nil {
cb() p.onSeek()
} }
} }
func (p *BasePlayerCallbackImpl) InvokeOnTrackChange() { func (p *BasePlayerCallbackImpl) InvokeOnTrackChange() {
for _, cb := range p.onTrackChange { if p.onTrackChange != nil {
cb() 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. // 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 { func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server mediaprovider.CanSavePlayQueue) error {
queue := pm.GetPlayQueue() queue := pm.GetPlayQueue()
stats := pm.PlayerStatus() stats := pm.PlaybackStatus()
trackIdx := pm.NowPlayingIndex() trackIdx := pm.NowPlayingIndex()
trackIDs := make([]string, 0, len(queue)) trackIDs := make([]string, 0, len(queue))
+29
View File
@@ -1,7 +1,9 @@
package util package util
import ( import (
"fmt"
"io" "io"
"net"
"os" "os"
) )
@@ -21,3 +23,30 @@ func CopyFile(srcPath, dstPath string) error {
_, err = io.Copy(fout, fin) _, err = io.Copy(fout, fin)
return err 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 module github.com/dweymouth/supersonic
go 1.21 go 1.21.9
require ( 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/20after4/configdir v0.1.1
github.com/Microsoft/go-winio v0.6.2 github.com/Microsoft/go-winio v0.6.2
github.com/cenkalti/dominantcolor v1.0.3 github.com/cenkalti/dominantcolor v1.0.3
@@ -14,12 +14,14 @@ require (
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645 github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645
github.com/godbus/dbus/v5 v5.1.0 github.com/godbus/dbus/v5 v5.1.0
github.com/google/uuid v1.3.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/pelletier/go-toml/v2 v2.0.8
github.com/quarckster/go-mpris-server v1.0.3 github.com/quarckster/go-mpris-server v1.0.3
github.com/supersonic-app/go-mpv v0.1.0 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-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 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/sys v0.30.0
golang.org/x/text v0.22.0 golang.org/x/text v0.22.0
) )
@@ -33,18 +35,24 @@ require (
github.com/fredbi/uri v1.1.0 // indirect github.com/fredbi/uri v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fyne-io/gl-js v0.1.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/glfw-js v0.2.0 // indirect
github.com/fyne-io/image v0.1.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/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // 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/render v0.2.0 // indirect
github.com/go-text/typesetting v0.2.1 // 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/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // 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/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // 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/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.10.0 // indirect github.com/stretchr/testify v1.10.0 // indirect
@@ -53,4 +61,4 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect 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-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 h1:NKCyTkh9NtvnTsiHtTOtaJzRDOFYP8AckQ2tyhOh6JY=
github.com/dweymouth/fyne-tooltip v0.3.0/go.mod h1:m04ShLW/Tp6LXrNieTumApvNgo7YSB+wi+jZTN+kDBU= 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.20250406173843-f5cb002423a5 h1:/gxlRSmndtF8QVxV3oFjlozm2clkMlLBlfiKFtehH+M=
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/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 h1:KzqSaQwG3HsTZQlEtkp0BeUy9vmYZ0rq0B15qIPSiBs=
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= 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 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= 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/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 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM=
github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= 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.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM=
github.com/fyne-io/glfw-js v0.1.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= github.com/fyne-io/glfw-js v0.2.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.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.0/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= 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 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= 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= 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/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 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc=
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= 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 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 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= 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/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 h1:ef6d3DpxlORtdEBHnhQ/j3gS0Z3+YUfXeJhC9L9DZvA=
github.com/quarckster/go-mpris-server v1.0.3/go.mod h1:2b4IdrpnEoEfU+6fQKjYhAgdvsiz4JxmTpDAUrMJVO4= 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.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
github.com/rymdport/portal v0.3.0/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= 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 h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= 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= 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-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 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-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.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 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 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-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.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.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.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/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 -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/coreui/playlist-add-next.svg >> bundled.go
fyne bundle -append -prefix Res icons/freepik/playbutton.png >> 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/disc.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/headphones.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 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>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>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>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> </ul>
</description> </description>
@@ -61,44 +63,18 @@
</screenshots> </screenshots>
<releases> <releases>
<release date="2025-02-22" version="0.14.0"> <release date="2025-04-07" version="0.15.1">
<description> <description>
<p> <p>
Version 0.14.0 of Supersonic Version 0.15.1 of Supersonic
</p> </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> <p>
Fixed Fixed
</p> </p>
<ul> <ul>
<li>Persist repeat setting across restarts</li> <li>Fix crashing on playback on Windows</li>
<li>Removing track from playlist when sorted removed wrong track</li> <li>Fix crashing when clicking on album filters button</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>
</ul> </ul>
</description> </description>
+4 -25
View File
@@ -3,7 +3,7 @@ package res
const ( const (
AppName = "supersonic" AppName = "supersonic"
DisplayName = "Supersonic" DisplayName = "Supersonic"
AppVersion = "0.14.0" AppVersion = "0.15.1"
AppVersionTag = "v" + AppVersion AppVersionTag = "v" + AppVersion
ConfigFile = "config.toml" ConfigFile = "config.toml"
GithubURL = "https://github.com/dweymouth/supersonic" GithubURL = "https://github.com/dweymouth/supersonic"
@@ -13,31 +13,10 @@ const (
) )
var ( var (
WhatsAdded = ` 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`
WhatsFixed = ` WhatsFixed = `
## Fixed ## Fixed
* Persist repeat setting across restarts * Fix crashing on playback on Windows
* Removing track from playlist when sorted removed wrong track * Fix crashing when clicking on album filters button`
* 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`
) )
+3 -1
View File
@@ -41,6 +41,7 @@
"BPM": "BPM", "BPM": "BPM",
"Broadcast": "Broadcast", "Broadcast": "Broadcast",
"Cancel": "Cancel", "Cancel": "Cancel",
"Cast to device": "Cast to device",
"Check for Updates": "Check for Updates", "Check for Updates": "Check for Updates",
"Close": "Close", "Close": "Close",
"Close to system tray": "Close to system tray", "Close to system tray": "Close to system tray",
@@ -168,6 +169,7 @@
"Save play queue on exit": "Save play queue on exit", "Save play queue on exit": "Save play queue on exit",
"Saved at": "Saved at", "Saved at": "Saved at",
"Scrobble when": "Scrobble when", "Scrobble when": "Scrobble when",
"Search": "Search",
"Search Everywhere": "Search Everywhere", "Search Everywhere": "Search Everywhere",
"Search page": "Search page", "Search page": "Search page",
"Search playlists or new playlist name": "Search playlists or new playlist name", "Search playlists or new playlist name": "Search playlists or new playlist name",
@@ -206,6 +208,7 @@
"Testing connection": "Testing connection", "Testing connection": "Testing connection",
"The request timed out": "The request timed out", "The request timed out": "The request timed out",
"Theme": "Theme", "Theme": "Theme",
"This computer": "This computer",
"Time": "Time", "Time": "Time",
"Title": "Title", "Title": "Title",
"Title (A-Z)": "Title (A-Z)", "Title (A-Z)": "Title (A-Z)",
@@ -237,7 +240,6 @@
"You are running the latest version of": "You are running the latest version of", "You are running the latest version of": "You are running the latest version of",
"Quit": "Quit", "Quit": "Quit",
"Rescan Library": "Rescan Library", "Rescan Library": "Rescan Library",
"Search": "Search",
"Owner": "Owner", "Owner": "Owner",
"To server": "To server", "To server": "To server",
"Fav.": "Fav.", "Fav.": "Fav.",
+6
View File
@@ -3,6 +3,7 @@ package ui
import ( import (
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player/mpv"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/layouts"
"github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/util"
@@ -111,6 +112,10 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
pm.OnVolumeChange(func(vol int) { pm.OnVolumeChange(func(vol int) {
fyne.Do(func() { bp.AuxControls.VolumeControl.SetVolume(vol) }) 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) { bp.AuxControls.VolumeControl.OnSetVolume = func(v int) {
pm.SetVolume(v) pm.SetVolume(v)
} }
@@ -121,6 +126,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
pm.SetAutoplay(autoplay) pm.SetAutoplay(autoplay)
} }
bp.AuxControls.OnShowPlayQueue(contr.ShowPopUpPlayQueue) bp.AuxControls.OnShowPlayQueue(contr.ShowPopUpPlayQueue)
bp.AuxControls.OnShowCastMenu(contr.ShowCastMenu)
bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage) 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 a.artistImageID = artist.CoverArtID
go func() { go func() {
if cover, err := im.GetCoverThumbnail(artist.CoverArtID); err == nil { if cover, err := im.GetCoverThumbnail(artist.CoverArtID); err == nil {
a.artistImage.SetImage(cover, true) fyne.Do(func() { a.artistImage.SetImage(cover, true) })
} else { } else {
log.Printf("error fetching cover: %v", err) 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.relatedList.OnPlaySelectionNext = func(items []mediaprovider.MediaItem) {
a.pm.LoadItems(items, backend.InsertNext, false) 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.lyricsViewer = widgets.NewLyricsViewer()
a.statusLabel = widget.NewLabel(lang.L("Stopped")) a.statusLabel = widget.NewLabel(lang.L("Stopped"))
@@ -431,7 +443,7 @@ func (a *NowPlayingPage) Reload() {
} }
switch a.tabs.SelectedIndex() { switch a.tabs.SelectedIndex() {
case 1: /*lyrics*/ case 1: /*lyrics*/
a.lastPlayPos = a.pm.CurrentPlayer().GetStatus().TimePos a.lastPlayPos = a.pm.PlaybackStatus().TimePos
a.updateLyrics() a.updateLyrics()
case 2: /*related*/ case 2: /*related*/
a.updateRelatedList() a.updateRelatedList()
@@ -515,7 +527,7 @@ func (a *NowPlayingPage) saveSelectedTab(tabNum int) {
func (a *NowPlayingPage) formatStatusLine() { func (a *NowPlayingPage) formatStatusLine() {
curPlayer := a.pm.CurrentPlayer() curPlayer := a.pm.CurrentPlayer()
playerStats := curPlayer.GetStatus() playerStats := a.pm.PlaybackStatus()
lastStatus := a.statusLabel.Text lastStatus := a.statusLabel.Text
stopped := lang.L("Stopped") stopped := lang.L("Stopped")
state := stopped state := stopped
+96 -1
View File
@@ -5,6 +5,8 @@ import (
"log" "log"
"strings" "strings"
"github.com/deluan/sanitize"
ttwidget "github.com/dweymouth/fyne-tooltip/widget"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
@@ -14,6 +16,7 @@ import (
"github.com/dweymouth/supersonic/ui/widgets" "github.com/dweymouth/supersonic/ui/widgets"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/layout"
@@ -240,6 +243,43 @@ func (a *PlaylistPage) onRemoveSelectedFromPlaylist() {
a.Reload() 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 { type PlaylistPageHeader struct {
widget.BaseWidget widget.BaseWidget
@@ -275,6 +315,7 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
a.ownerLabel = util.NewTruncatingLabel() a.ownerLabel = util.NewTruncatingLabel()
a.createdAtLabel = widget.NewLabel("") a.createdAtLabel = widget.NewLabel("")
a.trackTimeLabel = widget.NewLabel("") a.trackTimeLabel = widget.NewLabel("")
a.editButton = widget.NewButtonWithIcon(lang.L("Edit"), theme.DocumentCreateIcon(), func() { a.editButton = widget.NewButtonWithIcon(lang.L("Edit"), theme.DocumentCreateIcon(), func() {
if a.playlistInfo != nil { if a.playlistInfo != nil {
a.page.contr.DoEditPlaylistWorkflow(&a.playlistInfo.Playlist) 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.LoadTracks(a.page.tracks, backend.Replace, true)
a.page.pm.PlayFromBeginning() 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 var pop *widget.PopUpMenu
menuBtn := widget.NewButtonWithIcon("", theme.MoreHorizontalIcon(), nil) menuBtn := widget.NewButtonWithIcon("", theme.MoreHorizontalIcon(), nil)
menuBtn.OnTapped = func() { menuBtn.OnTapped = func() {
@@ -317,13 +410,15 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
pop.ShowAtPosition(fyne.NewPos(pos.X, pos.Y+menuBtn.Size().Height)) pop.ShowAtPosition(fyne.NewPos(pos.X, pos.Y+menuBtn.Size().Height))
} }
buttonRow = container.NewHBox(a.editButton, playButton, shuffleBtn, searchBtn, menuBtn)
a.container = util.AddHeaderBackground( a.container = util.AddHeaderBackground(
container.NewBorder(nil, nil, a.image, nil, container.NewBorder(nil, nil, a.image, nil,
container.NewVBox(a.titleLabel, container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-10), container.NewVBox(a.titleLabel, container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-10),
a.descriptionLabel, a.descriptionLabel,
a.ownerLabel, a.ownerLabel,
a.trackTimeLabel), a.trackTimeLabel),
container.NewHBox(a.editButton, playButton, shuffleBtn, menuBtn), buttonRow,
))) )))
return a return a
} }
+12
View File
@@ -9,6 +9,8 @@ import (
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/widgets" "github.com/dweymouth/supersonic/ui/widgets"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
) )
@@ -179,4 +181,14 @@ func (c *Controller) ConnectPlayQueuelistActions(list *widgets.PlayQueueList) {
} }
list.OnSetRating = c.SetTrackRatings list.OnSetRating = c.SetTrackRatings
list.OnSetFavorite = c.SetTrackFavorites 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 ( import (
"archive/zip" "archive/zip"
"context"
"fmt" "fmt"
"image" "image"
"image/color" "image/color"
@@ -11,7 +10,6 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"sync" "sync"
"time" "time"
@@ -30,7 +28,6 @@ import (
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
) )
@@ -154,6 +151,50 @@ func (m *Controller) HaveModal() bool {
return m.haveModal 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() { func (m *Controller) ShowPopUpPlayQueue() {
m.popUpQueueMutex.Lock() m.popUpQueueMutex.Lock()
if m.popUpQueue == nil { if m.popUpQueue == nil {
@@ -260,327 +301,6 @@ func (m *Controller) GetArtistTracks(artistID string) []*mediaprovider.Track {
return nil 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() { func (c *Controller) ShowAboutDialog() {
dlg := dialogs.NewAboutDialog(c.AppVersion) dlg := dialogs.NewAboutDialog(c.AppVersion)
pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) 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) { func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map[string]string) {
devs, err := c.App.LocalPlayer.ListAudioDevices() devs, err := c.App.LocalPlayer.ListAudioDevices()
if err != nil { if err != nil {
log.Printf("error listing audio devices: %v", err) 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) _, canSavePlayQueue := c.App.ServerManager.Server.(mediaprovider.CanSavePlayQueue)
isLocalPlayer := isEqualizerPlayer isLocalPlayer := isEqualizerPlayer
bands := c.App.LocalPlayer.Equalizer().BandFrequencies() bands := c.App.LocalPlayer.Equalizer().BandFrequencies()
dlg := dialogs.NewSettingsDialog(c.App.Config, dlg := dialogs.NewSettingsDialog(c.App.Config,
devs, themeFiles, bands, devs, themeFiles, bands,
c.App.ServerManager.Server.ClientDecidesScrobble(), c.App.ServerManager.Server.ClientDecidesScrobble(),
@@ -641,91 +363,7 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
c.ClosePopUpOnEscape(pop) c.ClosePopUpOnEscape(pop)
c.haveModal = true c.haveModal = true
pop.Show() 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() { 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"
"fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/lang"
"github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player"
"github.com/dweymouth/supersonic/backend/player/mpv"
"github.com/dweymouth/supersonic/ui/shortcuts" "github.com/dweymouth/supersonic/ui/shortcuts"
"github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/util"
"github.com/dweymouth/supersonic/ui/visualizations" "github.com/dweymouth/supersonic/ui/visualizations"
@@ -21,11 +22,13 @@ type visualizationData struct {
} }
func (c *Controller) initVisualizations() { func (c *Controller) initVisualizations() {
c.App.LocalPlayer.OnStopped(c.stopVisualizationAnim) c.App.PlaybackManager.OnStopped(c.stopVisualizationAnim)
c.App.LocalPlayer.OnPaused(c.stopVisualizationAnim) c.App.PlaybackManager.OnPaused(c.stopVisualizationAnim)
c.App.LocalPlayer.OnPlaying(func() { c.App.PlaybackManager.OnPlaying(func() {
if c.peakMeter != nil { if _, ok := c.App.PlaybackManager.CurrentPlayer().(*mpv.Player); ok {
c.startVisualizationAnim() if c.peakMeter != nil {
c.startVisualizationAnim()
}
} }
}) })
} }
@@ -71,13 +74,13 @@ func (c *Controller) stopVisualizationAnim() {
if c.visualizationAnim != nil { if c.visualizationAnim != nil {
c.visualizationAnim.Stop() c.visualizationAnim.Stop()
c.visualizationAnim = nil c.visualizationAnim = nil
c.App.LocalPlayer.SetPeaksEnabled(false) // c.App.LocalPlayer.SetPeaksEnabled(false)
} }
} }
func (c *Controller) startVisualizationAnim() { func (c *Controller) startVisualizationAnim() {
if c.visualizationAnim == nil { if c.visualizationAnim == nil {
c.App.LocalPlayer.SetPeaksEnabled(true) //c.App.LocalPlayer.SetPeaksEnabled(true)
c.visualizationAnim = fyne.NewAnimation( c.visualizationAnim = fyne.NewAnimation(
time.Duration(math.MaxInt64), /*until stopped*/ time.Duration(math.MaxInt64), /*until stopped*/
c.tickVisualizations) c.tickVisualizations)
@@ -86,8 +89,8 @@ func (c *Controller) startVisualizationAnim() {
} }
func (c *Controller) tickVisualizations(_ float32) { 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 { 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"
"fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/util"
) )
type QuickSearch struct { type QuickSearch struct {
SearchDialog *SearchDialog SearchDialog *SearchDialog
results []*mediaprovider.SearchResult
mp mediaprovider.MediaProvider 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 { func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *QuickSearch {
q := &QuickSearch{mp: mp} q := &QuickSearch{mp: mp}
q.SearchDialog = NewSearchDialog(im, lang.L("Search Everywhere"), lang.L("Close"), q.onSearched) q.SearchDialog = NewSearchDialog(im, lang.L("Search Everywhere"), lang.L("Close"), q.onSearched)
q.SearchDialog.OnShowContextMenu = q.showMenu
return q return q
} }
func (q *QuickSearch) onSearched(query string) []*mediaprovider.SearchResult { func (q *QuickSearch) onSearched(query string) []*mediaprovider.SearchResult {
var results []*mediaprovider.SearchResult
if query != "" { if query != "" {
if res, err := q.mp.SearchAll(query, 20); err != nil { if res, err := q.mp.SearchAll(query, 20); err != nil {
q.results = nil
log.Printf("Error searching: %s", err.Error()) log.Printf("Error searching: %s", err.Error())
} else { } 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()) { func (q *QuickSearch) SetOnDismiss(onDismiss func()) {
+10 -3
View File
@@ -30,9 +30,10 @@ type SearchDialog struct {
// of the dismiss buttons // of the dismiss buttons
ActionItem fyne.CanvasObject ActionItem fyne.CanvasObject
OnDismiss func() OnDismiss func()
OnNavigateTo func(mediaprovider.ContentType, string) OnNavigateTo func(mediaprovider.ContentType, string)
OnSearched func(string) []*mediaprovider.SearchResult OnShowContextMenu func(itemIdx int, pos fyne.Position)
OnSearched func(string) []*mediaprovider.SearchResult
imgSource util.ImageFetcher imgSource util.ImageFetcher
resultsMutex sync.RWMutex resultsMutex sync.RWMutex
@@ -326,6 +327,12 @@ func (q *searchResult) Tapped(_ *fyne.PointEvent) {
q.parent.onSelected(q.index) 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 { func (q *searchResult) CreateRenderer() fyne.WidgetRenderer {
if q.content == nil { if q.content == nil {
q.content = container.NewBorder(nil, nil, container.NewCenter(q.image), 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) AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg)
ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg) ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg)
AutoplayIcon fyne.Resource = theme.NewThemedResource(res.ResInfinitySvg) AutoplayIcon fyne.Resource = theme.NewThemedResource(res.ResInfinitySvg)
CastIcon fyne.Resource = theme.NewThemedResource(res.ResCastSvg)
RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg) RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg)
FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg) FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg)
NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg) NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg)
+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 return selected
} }
func SelectItem(items []*TrackListModel, idx int) { func SelectItem(items []*TrackListModel, idx int) bool {
if items[idx].Selected { if items[idx].Selected {
return return false
} }
UnselectAllItems(items) UnselectAllItems(items)
items[idx].Selected = true items[idx].Selected = true
return true
} }
func SelectAllItems(items []*TrackListModel) { func SelectAllItems(items []*TrackListModel) {
+4 -1
View File
@@ -199,7 +199,10 @@ func NewAlbumFilterPopup(filter *AlbumFilterButton) *AlbumFilterPopup {
) )
go func() { go func() {
a.genreFilter.SetGenreList(<-a.filterBtn.genreListChan) genres := <-a.filterBtn.genreListChan
fyne.Do(func() {
a.genreFilter.SetGenreList(genres)
})
}() }()
return a return a
+48 -2
View File
@@ -2,6 +2,7 @@ package widgets
import ( import (
"math" "math"
"time"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
@@ -25,6 +26,7 @@ type AuxControls struct {
VolumeControl *VolumeControl VolumeControl *VolumeControl
autoplay *IconButton autoplay *IconButton
loop *IconButton loop *IconButton
cast *IconButton
showQueue *IconButton showQueue *IconButton
container *fyne.Container container *fyne.Container
@@ -35,10 +37,17 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
VolumeControl: NewVolumeControl(initialVolume), VolumeControl: NewVolumeControl(initialVolume),
autoplay: NewIconButton(myTheme.AutoplayIcon, nil), autoplay: NewIconButton(myTheme.AutoplayIcon, nil),
loop: NewIconButton(myTheme.RepeatIcon, nil), loop: NewIconButton(myTheme.RepeatIcon, nil),
cast: NewIconButton(myTheme.CastIcon, nil),
showQueue: NewIconButton(myTheme.PlayQueueIcon, nil), showQueue: NewIconButton(myTheme.PlayQueueIcon, nil),
} }
a.loop.IconSize = IconButtonSizeSmaller a.loop.IconSize = IconButtonSizeSmaller
a.loop.SetToolTip(lang.L("Repeat")) 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.Highlighted = initialAutoplay
//a.autoplay.IconSize = IconButtonSizeSmaller //a.autoplay.IconSize = IconButtonSizeSmaller
a.autoplay.SetToolTip(lang.L("Autoplay")) a.autoplay.SetToolTip(lang.L("Autoplay"))
@@ -48,9 +57,10 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
a.OnChangeAutoplay(a.autoplay.Highlighted) a.OnChangeAutoplay(a.autoplay.Highlighted)
} }
} }
a.SetLoopMode(initialLoopMode)
a.showQueue.IconSize = IconButtonSizeSmaller a.showQueue.IconSize = IconButtonSizeSmaller
a.showQueue.SetToolTip(lang.L("Show play queue")) a.showQueue.SetToolTip(lang.L("Show play queue"))
a.container = container.NewHBox( a.container = container.NewHBox(
layout.NewSpacer(), layout.NewSpacer(),
container.NewVBox( container.NewVBox(
@@ -58,7 +68,7 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
a.VolumeControl, a.VolumeControl,
container.New( container.New(
layout.NewCustomPaddedHBoxLayout(theme.Padding()*1.5), 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(), 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) { func (a *AuxControls) SetAutoplay(autoplay bool) {
if autoplay == a.autoplay.Highlighted { if autoplay == a.autoplay.Highlighted {
return return
@@ -100,6 +120,12 @@ func (a *AuxControls) OnShowPlayQueue(f func()) {
a.showQueue.OnTapped = f a.showQueue.OnTapped = f
} }
func (a *AuxControls) OnShowCastMenu(f func(func())) {
a.cast.OnTapped = func() {
f(a.DisableCastButton)
}
}
type volumeSlider struct { type volumeSlider struct {
widget.Slider widget.Slider
@@ -145,6 +171,10 @@ type VolumeControl struct {
muted bool muted bool
lastVol int lastVol int
setVolDebouncer func()
delaySetVolume bool
pendingVolume int
container *fyne.Container container *fyne.Container
} }
@@ -160,6 +190,15 @@ func NewVolumeControl(initialVol int) *VolumeControl {
v.slider.Orientation = widget.Horizontal v.slider.Orientation = widget.Horizontal
v.slider.Value = float64(v.lastVol) v.slider.Value = float64(v.lastVol)
v.slider.OnChanged = v.onChanged 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) v.container = container.NewHBox(container.NewCenter(v.icon), v.slider)
return v return v
} }
@@ -167,6 +206,11 @@ func NewVolumeControl(initialVol int) *VolumeControl {
// Sets the volume that is displayed in the slider. // Sets the volume that is displayed in the slider.
// Does not invoke OnSetVolume callback. // Does not invoke OnSetVolume callback.
func (v *VolumeControl) SetVolume(vol int) { func (v *VolumeControl) SetVolume(vol int) {
if v.delaySetVolume {
v.pendingVolume = vol
return
}
if (vol == v.lastVol && !v.muted) || (v.muted && vol == 0) { if (vol == v.lastVol && !v.muted) || (v.muted && vol == 0) {
return return
} }
@@ -177,6 +221,8 @@ func (v *VolumeControl) SetVolume(vol int) {
func (v *VolumeControl) onChanged(volume float64) { func (v *VolumeControl) onChanged(volume float64) {
vol := int(volume) vol := int(volume)
v.delaySetVolume = true
v.setVolDebouncer()
v.lastVol = vol v.lastVol = vol
v.muted = false v.muted = false
v.updateIconForVolume(vol) v.updateIconForVolume(vol)
+43 -106
View File
@@ -42,6 +42,7 @@ type PlayQueueList struct {
OnPlayItemAt func(idx int) OnPlayItemAt func(idx int)
OnPlaySelection func(items []mediaprovider.MediaItem, shuffle bool) OnPlaySelection func(items []mediaprovider.MediaItem, shuffle bool)
OnPlaySelectionNext func(items []mediaprovider.MediaItem) OnPlaySelectionNext func(items []mediaprovider.MediaItem)
OnPlaySongRadio func(track *mediaprovider.Track)
OnAddToQueue func(items []mediaprovider.MediaItem) OnAddToQueue func(items []mediaprovider.MediaItem)
OnAddToPlaylist func(trackIDs []string) OnAddToPlaylist func(trackIDs []string)
OnSetFavorite func(trackIDs []string, fav bool) OnSetFavorite func(trackIDs []string, fav bool)
@@ -54,11 +55,8 @@ type PlayQueueList struct {
OnReorderItems func(idxs []int, reorderTo int) OnReorderItems func(idxs []int, reorderTo int)
useNonQueueMenu bool useNonQueueMenu bool
menu *widget.PopUpMenu // ctx menu for when only tracks are selected menu *util.TrackContextMenu // ctx menu for when only tracks are selected
radiosMenu *widget.PopUpMenu // ctx menu for when selection contains radios radiosMenu *widget.PopUpMenu // ctx menu for when selection contains radios
ratingSubmenu *fyne.MenuItem
infoMenuItem *fyne.MenuItem
shareMenuItem *fyne.MenuItem
nowPlayingID string nowPlayingID string
@@ -256,82 +254,24 @@ func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
} }
} }
var menu *widget.PopUpMenu
if allTracks { if allTracks {
p.ensureTracksMenu() p.ensureTracksMenu()
p.ratingSubmenu.Disabled = p.DisableRating p.menu.SetRatingDisabled(p.DisableRating)
p.infoMenuItem.Disabled = len(selected) != 1 p.menu.SetInfoDisabled(len(selected) != 1)
p.shareMenuItem.Disabled = p.DisableSharing || len(selected) != 1 p.menu.SetShareDisabled(p.DisableSharing || len(selected) != 1)
menu = p.menu p.menu.ShowAtPosition(e.AbsolutePosition, fyne.CurrentApp().Driver().CanvasForObject(p))
} else { } else {
p.ensureRadiosMenu() p.ensureRadiosMenu()
menu = p.radiosMenu p.radiosMenu.ShowAtPosition(e.AbsolutePosition)
} }
menu.ShowAtPosition(e.AbsolutePosition)
} }
func (p *PlayQueueList) ensureTracksMenu() { func (p *PlayQueueList) ensureTracksMenu() {
if p.menu != nil { if p.menu != nil {
return return
} }
var menuItems []*fyne.MenuItem var auxItems []*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,
)
if !p.useNonQueueMenu { if !p.useNonQueueMenu {
remove := fyne.NewMenuItem(lang.L("Remove from queue"), func() { remove := fyne.NewMenuItem(lang.L("Remove from queue"), func() {
if p.OnRemoveFromQueue != nil { if p.OnRemoveFromQueue != nil {
@@ -339,15 +279,40 @@ func (p *PlayQueueList) ensureTracksMenu() {
} }
}) })
remove.Icon = theme.ContentRemoveIcon() remove.Icon = theme.ContentRemoveIcon()
menuItems = append(menuItems, auxItems = append(auxItems, remove)
fyne.NewMenuItemSeparator(), }
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() { 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 { func (t *PlayQueueList) selectedItems() []mediaprovider.MediaItem {
t.tracksMutex.RLock() t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock() defer t.tracksMutex.RUnlock()
+19 -2
View File
@@ -17,7 +17,9 @@ import (
type SearchEntry struct { type SearchEntry struct {
widget.Entry widget.Entry
OnSearched func(string) minWidth float32
OnSearched func(string)
OnFocusLost func()
} }
func NewSearchEntry() *SearchEntry { func NewSearchEntry() *SearchEntry {
@@ -53,13 +55,28 @@ func (s *SearchEntry) TypedKey(e *fyne.KeyEvent) {
s.Entry.TypedKey(e) s.Entry.TypedKey(e)
} }
func (s *SearchEntry) FocusLost() {
s.Entry.FocusLost()
if s.OnFocusLost != nil {
s.OnFocusLost()
}
}
func (s *SearchEntry) Refresh() { func (s *SearchEntry) Refresh() {
s.updateActionButton() s.updateActionButton()
s.Entry.Refresh() s.Entry.Refresh()
} }
func (s *SearchEntry) SetMinWidth(w float32) {
s.minWidth = w
}
func (s *SearchEntry) MinSize() fyne.Size { 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 { func (s *SearchEntry) updateActionButton() bool {
+44 -92
View File
@@ -6,13 +6,10 @@ import (
"sort" "sort"
"strings" "strings"
"fyne.io/fyne/v2/lang"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/layouts"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/util"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
@@ -93,17 +90,13 @@ type Tracklist struct {
tracks []*util.TrackListModel tracks []*util.TrackListModel
tracksOrigOrder []*util.TrackListModel tracksOrigOrder []*util.TrackListModel
nowPlayingID string nowPlayingID string
colLayout *layouts.ColumnsLayout colLayout *layouts.ColumnsLayout
hdr *ListHeader hdr *ListHeader
list *FocusList list *FocusList
ctxMenu *fyne.Menu ctxMenu *util.TrackContextMenu
ratingSubmenu *fyne.MenuItem loadingDots *LoadingDots
shareMenuItem *fyne.MenuItem container *fyne.Container
songRadioMenuItem *fyne.MenuItem
infoMenuItem *fyne.MenuItem
loadingDots *LoadingDots
container *fyne.Container
} }
func NewTracklist(tracks []*mediaprovider.Track, im *backend.ImageManager, useCompactRows bool) *Tracklist { 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. // Append more tracks to the tracklist.
func (t *Tracklist) AppendTracks(trs []*mediaprovider.Track) { 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.doSortTracks()
t.list.Refresh() t.list.Refresh()
} }
@@ -491,8 +484,8 @@ func (t *Tracklist) selectAddOrRemove(idx int) {
t.tracks[idx].Selected = !t.tracks[idx].Selected t.tracks[idx].Selected = !t.tracks[idx].Selected
} }
func (t *Tracklist) selectTrack(idx int) { func (t *Tracklist) selectTrack(idx int) bool {
util.SelectItem(t.tracks, idx) return util.SelectItem(t.tracks, idx)
} }
func (t *Tracklist) selectRange(idx int) { 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) { func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
t.selectTrack(trackIdx) if t.selectTrack(trackIdx) {
t.list.Refresh() t.list.Refresh()
}
if t.ctxMenu == nil { if t.ctxMenu == nil {
t.ctxMenu = fyne.NewMenu("") t.ctxMenu = util.NewTrackContextMenu(t.Options.DisablePlaybackMenu, t.Options.AuxiliaryMenuItems)
if !t.Options.DisablePlaybackMenu { t.ctxMenu.OnPlay = func(shuffle bool) {
play := fyne.NewMenuItem(lang.L("Play"), func() { t.OnPlaySelection(t.selectedTracks(), shuffle)
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)
} }
playlist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() { t.ctxMenu.OnAddToQueue = func(next bool) {
if t.OnAddToPlaylist != nil { if next {
t.OnAddToPlaylist(t.SelectedTrackIDs()) t.OnPlaySelectionNext(t.selectedTracks())
} else {
t.OnAddToQueue(t.selectedTracks())
} }
}) }
playlist.Icon = myTheme.PlaylistIcon t.ctxMenu.OnPlaySongRadio = func() {
download := fyne.NewMenuItem(lang.L("Download")+"...", func() { t.onPlaySongRadio(t.selectedTracks())
}
t.ctxMenu.OnAddToPlaylist = func() {
t.OnAddToPlaylist(t.SelectedTrackIDs())
}
t.ctxMenu.OnDownload = func() {
t.onDownload(t.selectedTracks(), "Selected tracks") t.onDownload(t.selectedTracks(), "Selected tracks")
}) }
download.Icon = theme.DownloadIcon() t.ctxMenu.OnShowInfo = func() {
t.infoMenuItem = fyne.NewMenuItem(lang.L("Show info")+"...", func() { t.OnShowTrackInfo(t.selectedTracks()[0])
if t.OnShowTrackInfo != nil { }
t.OnShowTrackInfo(t.selectedTracks()[0]) t.ctxMenu.OnFavorite = func(fav bool) {
} t.onSetFavorites(t.selectedTracks(), fav, true /*needRefresh*/)
}) }
t.infoMenuItem.Icon = theme.InfoIcon() t.ctxMenu.OnShare = func() {
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.onShare(t.selectedTracks()) t.onShare(t.selectedTracks())
}) }
t.shareMenuItem.Icon = myTheme.ShareIcon t.ctxMenu.OnSetRating = func(rating int) {
t.ctxMenu.Items = append(t.ctxMenu.Items, t.shareMenuItem) t.onSetRatings(t.selectedTracks(), rating, true /*needRefresh*/)
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.ratingSubmenu.Disabled = t.Options.DisableRating t.ctxMenu.SetRatingDisabled(t.Options.DisableRating)
t.shareMenuItem.Disabled = t.Options.DisableSharing || len(t.selectedTracks()) != 1 t.ctxMenu.SetShareDisabled(t.Options.DisableSharing || len(t.selectedTracks()) != 1)
t.infoMenuItem.Disabled = len(t.selectedTracks()) != 1 t.ctxMenu.SetInfoDisabled(len(t.selectedTracks()) != 1)
widget.ShowPopUpMenuAtPosition(t.ctxMenu, fyne.CurrentApp().Driver().CanvasForObject(t), e.AbsolutePosition) t.ctxMenu.ShowAtPosition(e.AbsolutePosition, fyne.CurrentApp().Driver().CanvasForObject(t))
} }
func (t *Tracklist) onSetFavorite(trackID string, fav bool) { 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() { if t.disposed.Load() {
return return
} }
fyne.Do(func() { fyne.DoAndWait(func() {
t.tracklist.SetLoading(false) t.tracklist.SetLoading(false)
t.tracklist.AppendTracks(t.trackBuffer) t.tracklist.AppendTracks(t.trackBuffer)
t.len += len(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! ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "Supersonic" #define MyAppName "Supersonic"
#define MyAppVersion "0.14.0" #define MyAppVersion "0.15.1"
#define MyAppPublisher "Drew Weymouth" #define MyAppPublisher "Drew Weymouth"
#define MyAppURL "https://github.com/dweymouth/supersonic" #define MyAppURL "https://github.com/dweymouth/supersonic"
#define MyAppExeName "Supersonic.exe" #define MyAppExeName "Supersonic.exe"