Merge branch 'dweymouth:main' into main
This commit is contained in:
@@ -24,7 +24,7 @@ jobs:
|
||||
run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev desktop-file-utils
|
||||
|
||||
- name: Build
|
||||
run: go build
|
||||
run: make build
|
||||
|
||||
- name: make appimage
|
||||
run: |
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev desktop-file-utils libmfx1 libdvdnav4
|
||||
|
||||
- name: Build
|
||||
run: go build
|
||||
run: make build
|
||||
|
||||
- name: make appimage
|
||||
run: |
|
||||
|
||||
@@ -31,9 +31,6 @@ jobs:
|
||||
- name: Setup path
|
||||
run: export C_INCLUDE_PATH=/usr/local/include:/opt/homebrew/include:$C_INCLUDE_PATH && export LIBRARY_PATH=/usr/local/lib:/opt/homebrew/lib:$LIBRARY_PATH
|
||||
|
||||
- name: Build
|
||||
run: CGO_CFLAGS="-I/usr/local/include -I/opt/homebrew/include" CGO_LDFLAGS="-L/usr/local/lib -L/opt/homebrew/lib" go build
|
||||
|
||||
- name: Install Fyne tool
|
||||
run: go install fyne.io/fyne/v2/cmd/fyne@latest
|
||||
|
||||
|
||||
@@ -31,13 +31,10 @@ jobs:
|
||||
- name: Setup path
|
||||
run: export C_INCLUDE_PATH=/usr/local/include:/opt/homebrew/include:$C_INCLUDE_PATH && export LIBRARY_PATH=/usr/local/lib:/opt/homebrew/lib:$LIBRARY_PATH
|
||||
|
||||
- name: Build
|
||||
run: CGO_CFLAGS="-I/usr/local/include -I/opt/homebrew/include" CGO_LDFLAGS="-L/usr/local/lib -L/opt/homebrew/lib" go build
|
||||
|
||||
- name: Install Fyne tool
|
||||
run: go install fyne.io/fyne/v2/cmd/fyne@latest
|
||||
|
||||
- name: Package app bundles
|
||||
- name: Build and package app bundles
|
||||
run: >
|
||||
make package_macos &&
|
||||
cp -r Supersonic.app Supersonic.app.2 &&
|
||||
|
||||
@@ -23,9 +23,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev
|
||||
|
||||
- name: Build
|
||||
run: go build
|
||||
|
||||
- name: Install Fyne tool
|
||||
run: go install fyne.io/fyne/v2/cmd/fyne@latest
|
||||
|
||||
|
||||
@@ -23,9 +23,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev
|
||||
|
||||
- name: Build
|
||||
run: go build
|
||||
|
||||
- name: Install Fyne tool
|
||||
run: go install fyne.io/fyne/v2/cmd/fyne@latest
|
||||
|
||||
|
||||
@@ -36,9 +36,6 @@ jobs:
|
||||
pacman -Syu &&
|
||||
pacman --noconfirm -S mingw-w64-x86_64-toolchain mingw-w64-x86_64-mpv
|
||||
|
||||
- name: Build
|
||||
run: go build
|
||||
|
||||
- name: Install Fyne tool
|
||||
run: go install fyne.io/fyne/v2/cmd/fyne@latest
|
||||
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Change Log
|
||||
|
||||
## [0.15.1]
|
||||
|
||||
### Fixed
|
||||
- [#591](https://github.com/dweymouth/supersonic/pull/591) Fix crashing on playback on Windows
|
||||
- Fix crashing when clicking on album filters button
|
||||
|
||||
## [0.15.0]
|
||||
|
||||
### Added
|
||||
- [#582](https://github.com/dweymouth/supersonic/pull/582) Add ability to cast playback to DLNA devices
|
||||
- [#574](https://github.com/dweymouth/supersonic/pull/574) Add ability to search tracks within playlist
|
||||
- [#584](https://github.com/dweymouth/supersonic/issues/584) Add context menu actions to items in "Search Everywhere" dialog
|
||||
- [#571](https://github.com/dweymouth/supersonic/issues/571) Make server requests timeout configurable and increase default timeout
|
||||
- New overlay animation when showing full size covers
|
||||
|
||||
### Fixed
|
||||
- [#578](https://github.com/dweymouth/supersonic/issues/578),[#530](https://github.com/dweymouth/supersonic/issues/530) Wrong track scrobbled when skipping next
|
||||
- [#580](https://github.com/dweymouth/supersonic/issues/580) Regression: searching the "All Tracks" page skips some results
|
||||
|
||||
## [0.14.0]
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[Details]
|
||||
Icon = "res/appicon-512.png"
|
||||
Name = "Supersonic"
|
||||
Version = "0.14.0"
|
||||
Version = "0.15.1"
|
||||
|
||||
[LinuxAndBSD]
|
||||
Categories = ["Audio", "AudioVideo"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
build:
|
||||
go build
|
||||
go build -tags migrated_fynedo
|
||||
|
||||
# dylibbundler doesn't seem to pick up on the Python framework dependency,
|
||||
# so the last 3 cmds move it over manually. This is a bit fragile though
|
||||
|
||||
@@ -55,22 +55,51 @@ Supersonic supports any music server with a Subsonic (or OpenSubsonic) API, or J
|
||||
* [x] Shuffle and repeat playback modes (partial; shuffle album, playlist, artist radio, random songs; repeat one/all)
|
||||
* [x] Lyrics support
|
||||
* [x] Internet radio station support (Subsonic)
|
||||
* [x] Cast to uPnP/DLNA devices
|
||||
* [ ] Server jukebox control (planned)
|
||||
* [ ] Browse by folders (planned)
|
||||
* [ ] Cast to uPnP/DLNA devices (likely planned)
|
||||
* [ ] Offline mode (eventually planned)
|
||||
* [ ] iOS/Android support (maybe eventually planned)
|
||||
|
||||
## Installation
|
||||
|
||||
On Linux, Supersonic is [available as a Flatpak](https://flathub.org/apps/details/io.github.dweymouth.supersonic)! (Thank you @anarcat!) If you prefer to directly install the release build, or build from source, read below.
|
||||
Platform-specific installation instructions are listed below. In addition to the most recent stable release, you can also download the latest build from the `main` branch via the [Actions](https://github.com/dweymouth/supersonic/actions) tab to get unreleased features and bug fixes (you must be signed in to Github to do this). If you prefer to build from source, or are not running one of these OSes, then see the build instructions for your platform in the next section.
|
||||
|
||||
If you are running **Windows**, **Mac OS**, or a **Debian**-based Linux distro, download the [latest release](https://github.com/dweymouth/supersonic/releases) for your operating system. You can also download the latest build from the `main` branch via the [Actions](https://github.com/dweymouth/supersonic/actions) tab to get unreleased features and bug fixes (you must be signed in to Github to do this). If you prefer to build from source, or are not running one of these OSes, then see the build instructions for your platform below.
|
||||
### Linux
|
||||
|
||||
**Packages:** On Linux, Supersonic is [available as a Flatpak](https://flathub.org/apps/details/io.github.dweymouth.supersonic). (Thank you @anarcat!) Third-party packages are also available for Arch and Nix OS.
|
||||
|
||||
**AppImage:** On the [latest release](https://github.com/dweymouth/supersonic/releases) page, you can download an AppImage package which supports any OS that can run AppImages. The MPV library is bundled in the AppImage.
|
||||
|
||||
**(Debian) .tar.xz:** The.tar.xz builds from the Releases page support Debian-based distros. You **must** have libmpv installed on your system, and choose the correct release build (libmpv2 or libmpv1) based on which is available in your distro's package manager. On apt-based systems, run `sudo apt install libmpv1` (or libmpv2) if it is not already installed. To install the Linux release build, after ensuring the required libmpv is installed, extract the .tar.xz bundle and run `make user-install` or `sudo make install`.
|
||||
|
||||
### Windows
|
||||
|
||||
Download the [latest release](https://github.com/dweymouth/supersonic/releases). You can choose between the installer, or a standalone zip file which can be extracted and run without requiring system installation.
|
||||
|
||||
### Mac OS
|
||||
|
||||
Supersonic is available on Homebrew via a custom brew tap, or via downloading the .app bundle from the Releases page.
|
||||
|
||||
To install Supersonic with **Homebrew** run:
|
||||
|
||||
```sh
|
||||
brew tap supersonic-app/supersonic
|
||||
brew install --no-quarantine supersonic
|
||||
```
|
||||
|
||||
The `--no-quarantine` flag is important because Supersonic is distributed without having been [notarized](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution), and therefore will not run without this.
|
||||
|
||||
You should also include it when upgrading in future:
|
||||
|
||||
```
|
||||
brew upgrade --no-quarantine supersonic
|
||||
```
|
||||
|
||||
To install the **downloaded .app bundle** from the [Releases page](https://github.com/dweymouth/supersonic/releases), unzip and then drag Supersonic.app to the Applications folder.
|
||||
|
||||
**Apple Silicon (M1/M2) Macs:** You will have to remove the "quarantine bit" that Mac will automatically set, being an application downloaded from the internet. After copying the .app bundle to your Applications folder, in the terminal run `sudo xattr -r -d com.apple.quarantine /Applications/Supersonic.app`
|
||||
|
||||
**If you are on Linux** and using the build from the Releases page, you must have libmpv installed on your system, and choose the correct release build (libmpv2 or libmpv1) based on which is available in your distro's package manager. On apt-based systems, run `sudo apt install libmpv1` (or libmpv2) if it is not already installed. The Windows and Mac release builds bundle the mpv dependencies. To install the Linux release build, after ensuring the required libmpv is installed, extract the .tar.xz bundle and run `make user-install` or `sudo make install`.
|
||||
|
||||
## Build instructions (Linux)
|
||||
|
||||
### Ubuntu dependencies
|
||||
@@ -82,7 +111,7 @@ If you are running **Windows**, **Mac OS**, or a **Debian**-based Linux distro,
|
||||
* ``sudo dnf install golang mpv-devel libX11-devel libXcursor-devel libXrandr-devel libXinerama-devel libXi-devel libglvnd-devel libXxf86vm-devel``
|
||||
|
||||
### Build
|
||||
* clone the repo, CD into the repo root, and run ``go build``
|
||||
* clone the repo, CD into the repo root, and run ``make build``
|
||||
* (note that the first build will take some time as it will download and build the UI library)
|
||||
|
||||
### Generate installable .tar.xz bundle
|
||||
@@ -135,7 +164,7 @@ Supersonic is available in the AUR and can be built either manually with `makepk
|
||||
- ``export LIBRARY_PATH=/opt/local/lib:$LIBRARY_PATH``
|
||||
|
||||
#### Building the application
|
||||
* clone the repo, CD into the repo root, and run ``go build``
|
||||
* clone the repo, CD into the repo root, and run ``make build``
|
||||
* (note that the first build will take some time as it will download and build the UI library)
|
||||
* run ``make package_macos`` to generate the .app bundle
|
||||
* **If** you are on Mac OS **High Sierra** through **Catalina**, run ``make bundledeps_macos_highsierra`` and you are done! Otherwise, continue reaading.
|
||||
|
||||
+18
-3
@@ -152,6 +152,21 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
|
||||
a.LrcLibFetcher = NewLrcLibFetcher(a.cacheDir, a.Config.Application.CustomLrcLibUrl, timeout)
|
||||
}
|
||||
|
||||
// Periodically scan for remote players
|
||||
go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, true /*fastScan*/)
|
||||
go func() {
|
||||
t := time.NewTicker(5 * time.Minute)
|
||||
for {
|
||||
select {
|
||||
case <-a.bgrndCtx.Done():
|
||||
t.Stop()
|
||||
return
|
||||
case <-t.C:
|
||||
a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, false)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
a.PlaybackManager.OnPlaying(func() {
|
||||
SetSystemSleepDisabled(true)
|
||||
})
|
||||
@@ -388,8 +403,8 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) {
|
||||
}()
|
||||
})
|
||||
a.PlaybackManager.OnSeek(func() {
|
||||
dur := a.PlaybackManager.NowPlaying().Metadata().Duration
|
||||
smtc.UpdatePosition(int(a.PlaybackManager.CurrentPlayer().GetStatus().TimePos*1000), dur*1000)
|
||||
playbackStatus := a.PlaybackManager.PlaybackStatus()
|
||||
smtc.UpdatePosition(int(playbackStatus.TimePos*1000), int(playbackStatus.Duration*1000))
|
||||
})
|
||||
a.PlaybackManager.OnPlaying(func() {
|
||||
smtc.SetEnabled(true)
|
||||
@@ -448,7 +463,7 @@ func (a *App) Shutdown() {
|
||||
a.WinSMTC.Shutdown()
|
||||
}
|
||||
a.PlaybackManager.DisableCallbacks()
|
||||
a.PlaybackManager.Stop() // will trigger scrobble check
|
||||
a.PlaybackManager.Shutdown() // will trigger scrobble check
|
||||
a.cancel()
|
||||
a.LocalPlayer.Destroy()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
)
|
||||
|
||||
func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) {
|
||||
func (j *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) {
|
||||
limit := maxResults / 3
|
||||
var wg sync.WaitGroup
|
||||
var albums []*jellyfin.Album
|
||||
@@ -22,19 +22,19 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
albumResult, _ := s.client.Search(searchQuery, jellyfin.TypeAlbum, jellyfin.Paging{Limit: limit})
|
||||
albumResult, _ := j.client.Search(searchQuery, jellyfin.TypeAlbum, jellyfin.Paging{Limit: limit})
|
||||
albums = albumResult.Albums
|
||||
wg.Done()
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
artistResult, _ := s.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{Limit: limit})
|
||||
artistResult, _ := j.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{Limit: limit})
|
||||
artists = artistResult.Artists
|
||||
wg.Done()
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
songResult, _ := s.client.Search(searchQuery, jellyfin.TypeSong, jellyfin.Paging{Limit: limit})
|
||||
songResult, _ := j.client.Search(searchQuery, jellyfin.TypeSong, jellyfin.Paging{Limit: limit})
|
||||
songs = songResult.Songs
|
||||
wg.Done()
|
||||
}()
|
||||
@@ -44,7 +44,7 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
p, e := s.client.GetPlaylists()
|
||||
p, e := j.client.GetPlaylists()
|
||||
if e == nil {
|
||||
playlists = sharedutil.FilterSlice(p, func(p *jellyfin.Playlist) bool {
|
||||
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords)
|
||||
@@ -55,7 +55,7 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
g, e := s.client.GetGenres(jellyfin.Paging{})
|
||||
g, e := j.client.GetGenres(jellyfin.Paging{})
|
||||
if e == nil {
|
||||
genres = sharedutil.FilterSlice(g, func(g jellyfin.NameID) bool {
|
||||
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords)
|
||||
@@ -66,13 +66,13 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([
|
||||
|
||||
wg.Wait()
|
||||
|
||||
results := mergeResults(albums, artists, songs, playlists, genres)
|
||||
results := j.mergeResults(albums, artists, songs, playlists, genres)
|
||||
helpers.RankSearchResults(results, searchQuery, queryLowerWords)
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func mergeResults(
|
||||
func (j *jellyfinMediaProvider) mergeResults(
|
||||
albums []*jellyfin.Album,
|
||||
artists []*jellyfin.Artist,
|
||||
songs []*jellyfin.Song,
|
||||
@@ -93,6 +93,7 @@ func mergeResults(
|
||||
Name: al.Name,
|
||||
ArtistName: strings.Join(sharedutil.MapSlice(al.Artists, getArtistNames), ","),
|
||||
Size: al.ChildCount,
|
||||
Item: toAlbum(al),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,6 +104,7 @@ func mergeResults(
|
||||
CoverID: ar.ID,
|
||||
Name: ar.Name,
|
||||
Size: ar.AlbumCount,
|
||||
Item: toArtist(ar),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,6 +116,7 @@ func mergeResults(
|
||||
Name: tr.Name,
|
||||
ArtistName: strings.Join(sharedutil.MapSlice(tr.Artists, getArtistNames), ","),
|
||||
Size: int(tr.RunTimeTicks / 10_000_000),
|
||||
Item: toTrack(tr),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -124,6 +127,7 @@ func mergeResults(
|
||||
CoverID: pl.ID,
|
||||
Name: pl.Name,
|
||||
Size: pl.SongCount,
|
||||
Item: j.toPlaylist(pl),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ const (
|
||||
|
||||
type MediaItemMetadata struct {
|
||||
Type MediaItemType
|
||||
MIMEType string
|
||||
ID string
|
||||
Name string
|
||||
Artists []string
|
||||
@@ -204,6 +205,7 @@ func (t *Track) Metadata() MediaItemMetadata {
|
||||
}
|
||||
return MediaItemMetadata{
|
||||
Type: MediaItemTypeTrack,
|
||||
MIMEType: t.ContentType,
|
||||
ID: t.ID,
|
||||
Name: t.Title,
|
||||
Artists: t.ArtistNames,
|
||||
@@ -279,4 +281,8 @@ type SearchResult struct {
|
||||
|
||||
// Unset for ContentTypes Artist, Playlist, Genre, and RadioStation
|
||||
ArtistName string
|
||||
|
||||
// The actual item corresponding to this search result
|
||||
// *mediaprovider.Artist for ContentTypeArtist, etc
|
||||
Item any
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ func mergeResults(
|
||||
Name: al.Name,
|
||||
ArtistName: getNameString(al.Artist, al.Artists),
|
||||
Size: al.SongCount,
|
||||
Item: toAlbum(al),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -111,6 +112,7 @@ func mergeResults(
|
||||
CoverID: ar.CoverArt,
|
||||
Name: ar.Name,
|
||||
Size: ar.AlbumCount,
|
||||
Item: toArtistFromID3(ar),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,6 +124,7 @@ func mergeResults(
|
||||
Name: tr.Title,
|
||||
ArtistName: getNameString(tr.Artist, tr.Artists),
|
||||
Size: tr.Duration,
|
||||
Item: toTrack(tr),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -132,6 +135,7 @@ func mergeResults(
|
||||
CoverID: pl.CoverArt,
|
||||
Name: pl.Name,
|
||||
Size: pl.SongCount,
|
||||
Item: toPlaylist(pl),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -149,6 +153,7 @@ func mergeResults(
|
||||
Type: mediaprovider.ContentTypeRadioStation,
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
Item: r,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -84,17 +84,17 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac
|
||||
})
|
||||
|
||||
mp.playbackManager.OnSeek(func() {
|
||||
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos))
|
||||
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos))
|
||||
})
|
||||
|
||||
mp.playbackManager.OnPlaying(func() {
|
||||
C.set_os_playback_state_playing()
|
||||
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos))
|
||||
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos))
|
||||
})
|
||||
|
||||
mp.playbackManager.OnPaused(func() {
|
||||
C.set_os_playback_state_paused()
|
||||
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos))
|
||||
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos))
|
||||
})
|
||||
|
||||
return nil
|
||||
|
||||
+6
-6
@@ -54,7 +54,7 @@ func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler {
|
||||
|
||||
pm.OnSeek(func() {
|
||||
if m.connErr == nil {
|
||||
pos := secondsToMicroseconds(pm.PlayerStatus().TimePos)
|
||||
pos := secondsToMicroseconds(pm.PlaybackStatus().TimePos)
|
||||
m.evt.Player.OnSeek(pos)
|
||||
}
|
||||
})
|
||||
@@ -160,7 +160,7 @@ func (m *MPRISHandler) Previous() error {
|
||||
}
|
||||
|
||||
func (m *MPRISHandler) Pause() error {
|
||||
if m.pm.PlayerStatus().State == player.Playing {
|
||||
if m.pm.PlaybackStatus().State == player.Playing {
|
||||
m.pm.PlayPause()
|
||||
}
|
||||
return nil
|
||||
@@ -177,7 +177,7 @@ func (m *MPRISHandler) Stop() error {
|
||||
}
|
||||
|
||||
func (m *MPRISHandler) Play() error {
|
||||
switch m.pm.PlayerStatus().State {
|
||||
switch m.pm.PlaybackStatus().State {
|
||||
case player.Paused:
|
||||
m.pm.PlayPause()
|
||||
case player.Stopped:
|
||||
@@ -204,7 +204,7 @@ func (m *MPRISHandler) OpenUri(uri string) error {
|
||||
}
|
||||
|
||||
func (m *MPRISHandler) PlaybackStatus() (types.PlaybackStatus, error) {
|
||||
switch m.pm.PlayerStatus().State {
|
||||
switch m.pm.PlaybackStatus().State {
|
||||
case player.Playing:
|
||||
return types.PlaybackStatusPlaying, nil
|
||||
case player.Paused:
|
||||
@@ -254,7 +254,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) {
|
||||
if m.curTrackPath != "" {
|
||||
trackObjPath = m.curTrackPath
|
||||
}
|
||||
status := m.pm.PlayerStatus()
|
||||
status := m.pm.PlaybackStatus()
|
||||
|
||||
var meta mediaprovider.MediaItemMetadata
|
||||
// metadata that can come only from tracks
|
||||
@@ -307,7 +307,7 @@ func (m *MPRISHandler) SetVolume(v float64) error {
|
||||
}
|
||||
|
||||
func (m *MPRISHandler) Position() (int64, error) {
|
||||
return int64(secondsToMicroseconds(m.pm.PlayerStatus().TimePos)), nil
|
||||
return int64(secondsToMicroseconds(m.pm.PlaybackStatus().TimePos)), nil
|
||||
}
|
||||
|
||||
func (m *MPRISHandler) MinimumRate() (float64, error) {
|
||||
|
||||
@@ -31,10 +31,11 @@ const (
|
||||
)
|
||||
|
||||
type playbackCommand struct {
|
||||
Type playbackCommandType
|
||||
Arg any
|
||||
Arg2 any
|
||||
Arg3 any
|
||||
Type playbackCommandType
|
||||
Arg any
|
||||
Arg2 any
|
||||
Arg3 any
|
||||
OnDone func()
|
||||
}
|
||||
|
||||
// playbackCommandQueue is a queue to accumulate player commands from the UI
|
||||
@@ -60,11 +61,24 @@ func (c *playbackCommandQueue) C() <-chan playbackCommand {
|
||||
return c.nextChan
|
||||
}
|
||||
|
||||
func (c *playbackCommandQueue) Clear() {
|
||||
c.mutex.Lock()
|
||||
c.queue = nil
|
||||
c.mutex.Unlock()
|
||||
}
|
||||
|
||||
func (c *playbackCommandQueue) Stop() {
|
||||
c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop},
|
||||
playbackCommand{Type: cmdStop})
|
||||
}
|
||||
|
||||
func (c *playbackCommandQueue) StopAndWait() {
|
||||
done := make(chan struct{})
|
||||
c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop},
|
||||
playbackCommand{Type: cmdStop, OnDone: func() { close(done) }})
|
||||
<-done
|
||||
}
|
||||
|
||||
func (c *playbackCommandQueue) Continue() {
|
||||
c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop},
|
||||
playbackCommand{Type: cmdContinue})
|
||||
|
||||
+164
-49
@@ -9,10 +9,13 @@ import (
|
||||
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/backend/player"
|
||||
"github.com/dweymouth/supersonic/backend/player/mpv"
|
||||
"github.com/dweymouth/supersonic/backend/util"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
)
|
||||
|
||||
// TODO: make thread-safe
|
||||
|
||||
var (
|
||||
ReplayGainNone = player.ReplayGainNone.String()
|
||||
ReplayGainAlbum = player.ReplayGainAlbum.String()
|
||||
@@ -37,6 +40,14 @@ const (
|
||||
LoopOne
|
||||
)
|
||||
|
||||
type PlaybackState = player.State
|
||||
|
||||
type PlaybackStatus struct {
|
||||
State PlaybackState
|
||||
TimePos float64
|
||||
Duration float64
|
||||
}
|
||||
|
||||
type playbackEngine struct {
|
||||
ctx context.Context
|
||||
cancelPollPos context.CancelFunc
|
||||
@@ -48,12 +59,22 @@ type playbackEngine struct {
|
||||
latestTrackPosition float64 // cleared by checkScrobble
|
||||
callbacksDisabled bool
|
||||
|
||||
playQueue []mediaprovider.MediaItem
|
||||
nowPlayingIdx int
|
||||
isRadio bool
|
||||
wasStopped bool // true iff player was stopped before handleOnTrackChange invocation
|
||||
noIncrementNextTrackChange bool // true iff the nowPlayingIndex should not be incremente don the next onTrackChange
|
||||
loopMode LoopMode
|
||||
playQueue []mediaprovider.MediaItem
|
||||
nowPlayingIdx int
|
||||
isRadio bool
|
||||
loopMode LoopMode
|
||||
|
||||
// flags for handleOnTrackChange / handleOnStopped callbacks - reset to false in the callbacks
|
||||
wasStopped bool // true iff player was stopped before handleOnTrackChange invocation
|
||||
alreadyScrobbled bool // true iff the previously-playing track was already scrobbled
|
||||
|
||||
// if >= 0, track number that was requested by PlayTrackAt
|
||||
// onTrackChange callback should set nowPlayingIdx to this,
|
||||
// and reset this to -1
|
||||
pendingTrackChangeNum int
|
||||
|
||||
pendingPlayerChange bool
|
||||
pendingPlayerChangeStatus player.Status
|
||||
|
||||
// to pass to onSongChange listeners; clear once listeners have been called
|
||||
lastScrobbled *mediaprovider.Track
|
||||
@@ -99,23 +120,8 @@ func NewPlaybackEngine(
|
||||
case "One":
|
||||
pm.loopMode = LoopOne
|
||||
}
|
||||
p.OnTrackChange(pm.handleOnTrackChange)
|
||||
p.OnSeek(func() {
|
||||
pm.doUpdateTimePos(true)
|
||||
pm.invokeNoArgCallbacks(pm.onSeek)
|
||||
})
|
||||
p.OnStopped(pm.handleOnStopped)
|
||||
p.OnPaused(func() {
|
||||
pm.playTimeStopwatch.Stop()
|
||||
pm.stopPollTimePos()
|
||||
pm.invokeNoArgCallbacks(pm.onPaused)
|
||||
})
|
||||
p.OnPlaying(func() {
|
||||
pm.playTimeStopwatch.Start()
|
||||
pm.startPollTimePos()
|
||||
pm.invokeNoArgCallbacks(pm.onPlaying)
|
||||
})
|
||||
|
||||
pm.registerPlayerCallbacks(p)
|
||||
s.OnLogout(func() {
|
||||
pm.StopAndClearPlayQueue()
|
||||
})
|
||||
@@ -123,15 +129,95 @@ func NewPlaybackEngine(
|
||||
return pm
|
||||
}
|
||||
|
||||
func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) {
|
||||
pl.OnTrackChange(p.handleOnTrackChange)
|
||||
pl.OnSeek(func() {
|
||||
p.doUpdateTimePos(true)
|
||||
p.invokeNoArgCallbacks(p.onSeek)
|
||||
})
|
||||
pl.OnStopped(p.handleOnStopped)
|
||||
pl.OnPaused(func() {
|
||||
p.playTimeStopwatch.Stop()
|
||||
p.stopPollTimePos()
|
||||
p.invokeNoArgCallbacks(p.onPaused)
|
||||
})
|
||||
pl.OnPlaying(func() {
|
||||
p.playTimeStopwatch.Start()
|
||||
p.startPollTimePos()
|
||||
p.invokeNoArgCallbacks(p.onPlaying)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *playbackEngine) unregisterPlayerCallbacks(pl player.BasePlayer) {
|
||||
pl.OnPaused(nil)
|
||||
pl.OnPlaying(nil)
|
||||
pl.OnStopped(nil)
|
||||
pl.OnSeek(nil)
|
||||
pl.OnTrackChange(nil)
|
||||
}
|
||||
|
||||
func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error {
|
||||
// even if we don't successfully change players,
|
||||
// make sure UI updates if needed (eg enabling cast button)
|
||||
defer p.invokeNoArgCallbacks(p.onPlayerChange)
|
||||
|
||||
needToUnpause := false
|
||||
|
||||
stat := p.CurrentPlayer().GetStatus()
|
||||
if p.pendingPlayerChange {
|
||||
stat.State = player.Paused
|
||||
}
|
||||
|
||||
switch stat.State {
|
||||
case player.Stopped:
|
||||
// nothing
|
||||
case player.Playing:
|
||||
needToUnpause = true
|
||||
p.stopPollTimePos()
|
||||
fallthrough
|
||||
case player.Paused:
|
||||
p.pendingPlayerChangeStatus = stat
|
||||
p.pendingPlayerChange = true
|
||||
}
|
||||
p.unregisterPlayerCallbacks(p.player)
|
||||
if err := p.player.Stop(true); err != nil {
|
||||
log.Printf("failed to stop player: %v", err)
|
||||
}
|
||||
|
||||
oldVol := p.player.GetVolume()
|
||||
if _, isMPV := p.player.(*mpv.Player); !isMPV {
|
||||
p.player.Destroy()
|
||||
}
|
||||
p.player = pl
|
||||
p.registerPlayerCallbacks(pl)
|
||||
|
||||
if needToUnpause {
|
||||
p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeStatus.TimePos)
|
||||
p.pendingPlayerChange = false
|
||||
}
|
||||
p.invokeNoArgCallbacks(p.onPlayerChange)
|
||||
vol := pl.GetVolume()
|
||||
if oldVol != vol {
|
||||
for _, cb := range p.onVolumeChange {
|
||||
cb(vol)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *playbackEngine) PlayTrackAt(idx int) error {
|
||||
return p.playTrackAt(idx, 0)
|
||||
}
|
||||
|
||||
func (p *playbackEngine) playTrackAt(idx int, startTime float64) error {
|
||||
if idx < 0 || idx >= len(p.playQueue) {
|
||||
return errors.New("track index out of range")
|
||||
}
|
||||
p.noIncrementNextTrackChange = true
|
||||
err := p.setTrack(idx, false)
|
||||
if err == nil {
|
||||
p.nowPlayingIdx = idx
|
||||
}
|
||||
// scrobble current track if needed
|
||||
p.checkScrobble()
|
||||
p.alreadyScrobbled = true
|
||||
p.pendingTrackChangeNum = idx
|
||||
err := p.setTrack(idx, false, startTime)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -162,8 +248,16 @@ func (p *playbackEngine) GetLoopMode() LoopMode {
|
||||
return p.loopMode
|
||||
}
|
||||
|
||||
func (p *playbackEngine) PlayerStatus() player.Status {
|
||||
return p.player.GetStatus()
|
||||
func (p *playbackEngine) PlaybackStatus() PlaybackStatus {
|
||||
stat := p.pendingPlayerChangeStatus
|
||||
if !p.pendingPlayerChange {
|
||||
stat = p.CurrentPlayer().GetStatus()
|
||||
}
|
||||
return PlaybackStatus{
|
||||
State: stat.State,
|
||||
TimePos: stat.TimePos,
|
||||
Duration: stat.Duration,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playbackEngine) SetVolume(vol int) error {
|
||||
@@ -182,14 +276,14 @@ func (p *playbackEngine) CurrentPlayer() player.BasePlayer {
|
||||
}
|
||||
|
||||
func (p *playbackEngine) SeekNext() error {
|
||||
if p.CurrentPlayer().GetStatus().State == player.Stopped {
|
||||
if p.PlaybackStatus().State == player.Stopped {
|
||||
return nil
|
||||
}
|
||||
return p.PlayTrackAt(p.nowPlayingIdx + 1)
|
||||
}
|
||||
|
||||
func (p *playbackEngine) SeekBackOrPrevious() error {
|
||||
if p.nowPlayingIdx == 0 || p.player.GetStatus().TimePos > 3 {
|
||||
if p.nowPlayingIdx == 0 || p.PlaybackStatus().TimePos > 3 {
|
||||
return p.player.SeekSeconds(0)
|
||||
}
|
||||
return p.PlayTrackAt(p.nowPlayingIdx - 1)
|
||||
@@ -197,7 +291,7 @@ func (p *playbackEngine) SeekBackOrPrevious() error {
|
||||
|
||||
func (p *playbackEngine) SeekFwdBackN(n int) error {
|
||||
idx := p.nowPlayingIdx
|
||||
if n < 0 && p.player.GetStatus().TimePos > 3 {
|
||||
if n < 0 && p.PlaybackStatus().TimePos > 3 {
|
||||
n += 1 // first seek back is just seek to beginning of current
|
||||
}
|
||||
if n == 0 || (idx == 0 && n < 0) {
|
||||
@@ -224,7 +318,7 @@ func (p *playbackEngine) IsSeeking() bool {
|
||||
}
|
||||
|
||||
func (p *playbackEngine) Stop() error {
|
||||
return p.player.Stop()
|
||||
return p.player.Stop(false)
|
||||
}
|
||||
|
||||
func (p *playbackEngine) Pause() error {
|
||||
@@ -232,7 +326,12 @@ func (p *playbackEngine) Pause() error {
|
||||
}
|
||||
|
||||
func (p *playbackEngine) Continue() error {
|
||||
if p.PlayerStatus().State == player.Stopped {
|
||||
if p.pendingPlayerChange {
|
||||
p.pendingPlayerChange = false
|
||||
return p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeStatus.TimePos)
|
||||
}
|
||||
|
||||
if p.PlaybackStatus().State == player.Stopped {
|
||||
return p.PlayTrackAt(0)
|
||||
}
|
||||
return p.player.Continue()
|
||||
@@ -254,7 +353,7 @@ func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, insertQueueMo
|
||||
|
||||
func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) error {
|
||||
if insertQueueMode == Replace {
|
||||
p.player.Stop()
|
||||
p.player.Stop(false)
|
||||
p.nowPlayingIdx = -1
|
||||
p.playQueue = nil
|
||||
}
|
||||
@@ -280,7 +379,7 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu
|
||||
|
||||
func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) {
|
||||
if insertMode == Replace {
|
||||
p.player.Stop()
|
||||
p.player.Stop(false)
|
||||
p.nowPlayingIdx = -1
|
||||
p.playQueue = nil
|
||||
}
|
||||
@@ -306,7 +405,7 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins
|
||||
// Stop playback and clear the play queue.
|
||||
func (p *playbackEngine) StopAndClearPlayQueue() {
|
||||
changed := len(p.playQueue) > 0
|
||||
p.player.Stop()
|
||||
p.player.Stop(false)
|
||||
p.playQueue = nil
|
||||
p.nowPlayingIdx = -1
|
||||
if changed {
|
||||
@@ -385,6 +484,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) {
|
||||
isPlayingTrackRemoved = true
|
||||
// If we are removing the currently playing track, we need to scrobble it
|
||||
p.checkScrobble()
|
||||
p.alreadyScrobbled = true
|
||||
} else if nowPlaying >= 0 && i == nowPlaying+1 {
|
||||
isNextPlayingTrackremoved = true
|
||||
}
|
||||
@@ -401,7 +501,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) {
|
||||
p.Stop()
|
||||
} else {
|
||||
p.nowPlayingIdx -= 1 // will be incremented in newtrack callback from player
|
||||
p.setTrack(newNowPlaying, false)
|
||||
p.setTrack(newNowPlaying, false, 0)
|
||||
}
|
||||
// setNextTrack and onSongChange callbacks will be handled
|
||||
// when we receive new track event from player
|
||||
@@ -456,21 +556,31 @@ func (p *playbackEngine) SetReplayGainMode(mode player.ReplayGainMode) {
|
||||
}
|
||||
|
||||
func (p *playbackEngine) handleOnTrackChange() {
|
||||
p.checkScrobble() // scrobble the previous song if needed
|
||||
if p.player.GetStatus().State == player.Playing {
|
||||
// scrobble the previous song if needed
|
||||
if !p.alreadyScrobbled {
|
||||
p.checkScrobble()
|
||||
}
|
||||
|
||||
if p.PlaybackStatus().State == player.Playing {
|
||||
p.playTimeStopwatch.Start()
|
||||
}
|
||||
if !p.noIncrementNextTrackChange && (p.wasStopped || p.loopMode != LoopOne) {
|
||||
if p.pendingTrackChangeNum < 0 && (p.wasStopped || p.loopMode != LoopOne) {
|
||||
p.nowPlayingIdx++
|
||||
if p.loopMode == LoopAll && p.nowPlayingIdx == len(p.playQueue) {
|
||||
p.nowPlayingIdx = 0 // wrapped around
|
||||
}
|
||||
} else if p.pendingTrackChangeNum >= 0 {
|
||||
p.nowPlayingIdx = p.pendingTrackChangeNum
|
||||
p.pendingTrackChangeNum = -1
|
||||
}
|
||||
p.noIncrementNextTrackChange = false
|
||||
nowPlaying := p.playQueue[p.nowPlayingIdx]
|
||||
_, isRadio := nowPlaying.(*mediaprovider.RadioStation)
|
||||
p.isRadio = isRadio
|
||||
|
||||
// reset flags
|
||||
p.wasStopped = false
|
||||
p.alreadyScrobbled = false
|
||||
|
||||
p.curTrackDuration = float64(nowPlaying.Metadata().Duration)
|
||||
p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled
|
||||
p.invokeOnSongChangeCallbacks()
|
||||
@@ -480,11 +590,14 @@ func (p *playbackEngine) handleOnTrackChange() {
|
||||
|
||||
func (p *playbackEngine) handleOnStopped() {
|
||||
p.playTimeStopwatch.Stop()
|
||||
p.checkScrobble()
|
||||
if !p.alreadyScrobbled {
|
||||
p.checkScrobble()
|
||||
}
|
||||
p.stopPollTimePos()
|
||||
p.doUpdateTimePos(false)
|
||||
p.invokeOnSongChangeCallbacks()
|
||||
p.invokeNoArgCallbacks(p.onStopped)
|
||||
p.alreadyScrobbled = false
|
||||
p.wasStopped = true
|
||||
p.nowPlayingIdx = -1
|
||||
}
|
||||
@@ -530,12 +643,14 @@ func (p *playbackEngine) setNextTrackAfterQueueUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playbackEngine) setTrack(idx int, next bool) error {
|
||||
func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error {
|
||||
if urlP, ok := p.player.(player.URLPlayer); ok {
|
||||
url := ""
|
||||
var meta mediaprovider.MediaItemMetadata
|
||||
if idx >= 0 {
|
||||
var err error
|
||||
item := p.playQueue[idx]
|
||||
meta = item.Metadata()
|
||||
if tr, ok := item.(*mediaprovider.Track); ok {
|
||||
url, err = p.sm.Server.GetStreamURL(tr.ID, p.transcodeCfg.ForceRawFile)
|
||||
} else {
|
||||
@@ -546,9 +661,9 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
|
||||
}
|
||||
}
|
||||
if next {
|
||||
return urlP.SetNextFile(url)
|
||||
return urlP.SetNextFile(url, meta)
|
||||
}
|
||||
return urlP.PlayFile(url)
|
||||
return urlP.PlayFile(url, meta, startTime)
|
||||
} else if trP, ok := p.player.(player.TrackPlayer); ok {
|
||||
var track *mediaprovider.Track
|
||||
if idx >= 0 {
|
||||
@@ -560,13 +675,13 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
|
||||
if next {
|
||||
return trP.SetNextTrack(track)
|
||||
}
|
||||
return trP.PlayTrack(track)
|
||||
return trP.PlayTrack(track, startTime)
|
||||
}
|
||||
panic("Unsupported player type")
|
||||
}
|
||||
|
||||
func (p *playbackEngine) setNextTrack(idx int) error {
|
||||
return p.setTrack(idx, true)
|
||||
return p.setTrack(idx, true, 0)
|
||||
}
|
||||
|
||||
// call BEFORE updating p.nowPlayingIdx
|
||||
@@ -683,7 +798,7 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) {
|
||||
if p.callbacksDisabled {
|
||||
return
|
||||
}
|
||||
s := p.player.GetStatus()
|
||||
s := p.PlaybackStatus()
|
||||
if s.TimePos > p.latestTrackPosition {
|
||||
p.latestTrackPosition = s.TimePos
|
||||
}
|
||||
|
||||
+99
-13
@@ -7,12 +7,16 @@ import (
|
||||
"math/rand"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/backend/player"
|
||||
"github.com/dweymouth/supersonic/backend/player/dlna"
|
||||
"github.com/dweymouth/supersonic/backend/player/mpv"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/supersonic-app/go-upnpcast/device"
|
||||
"github.com/supersonic-app/go-upnpcast/services"
|
||||
)
|
||||
|
||||
// A high-level MediaProvider-aware playback engine, serves as an
|
||||
@@ -22,11 +26,23 @@ type PlaybackManager struct {
|
||||
cmdQueue *playbackCommandQueue
|
||||
cfg *AppConfig
|
||||
|
||||
localPlayer player.BasePlayer
|
||||
remotePlayersLock sync.Mutex
|
||||
remotePlayers []RemotePlaybackDevice
|
||||
currentRemotePlayer *RemotePlaybackDevice
|
||||
|
||||
autoplay bool
|
||||
|
||||
lastPlayTime float64
|
||||
}
|
||||
|
||||
type RemotePlaybackDevice struct {
|
||||
Name string
|
||||
URL string
|
||||
Protocol string
|
||||
new func() (player.BasePlayer, error)
|
||||
}
|
||||
|
||||
func NewPlaybackManager(
|
||||
ctx context.Context,
|
||||
s *ServerManager,
|
||||
@@ -39,10 +55,11 @@ func NewPlaybackManager(
|
||||
e := NewPlaybackEngine(ctx, s, p, playbackCfg, scrobbleCfg, transcodeCfg)
|
||||
q := NewCommandQueue()
|
||||
pm := &PlaybackManager{
|
||||
engine: e,
|
||||
cmdQueue: q,
|
||||
cfg: appCfg,
|
||||
autoplay: playbackCfg.Autoplay,
|
||||
engine: e,
|
||||
cmdQueue: q,
|
||||
cfg: appCfg,
|
||||
autoplay: playbackCfg.Autoplay,
|
||||
localPlayer: p,
|
||||
}
|
||||
pm.addOnTrackChangeHook()
|
||||
go pm.runCmdQueue(ctx)
|
||||
@@ -68,7 +85,7 @@ func (p *PlaybackManager) addOnTrackChangeHook() {
|
||||
return
|
||||
}
|
||||
// workaround for https://github.com/dweymouth/supersonic/issues/483 (see above comment)
|
||||
if p.NowPlayingIndex() != len(p.engine.playQueue) && p.PlayerStatus().State == player.Playing {
|
||||
if p.NowPlayingIndex() != len(p.engine.playQueue) && p.PlaybackStatus().State == player.Playing {
|
||||
p.lastPlayTime = 0
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
@@ -81,6 +98,68 @@ func (p *PlaybackManager) addOnTrackChangeHook() {
|
||||
})
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, fastScan bool) {
|
||||
if fastScan {
|
||||
p.scanRemotePlayers(ctx, 1 /*waitSec*/)
|
||||
// continue to slow scan to detect players that take longer to respond
|
||||
}
|
||||
p.scanRemotePlayers(ctx, 10 /*waitSec*/)
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) scanRemotePlayers(ctx context.Context, waitSec int) {
|
||||
devices, _ := device.SearchMediaRenderers(ctx, waitSec, services.AVTransport, services.RenderingControl)
|
||||
|
||||
var discovered []RemotePlaybackDevice
|
||||
for _, d := range devices {
|
||||
p := RemotePlaybackDevice{
|
||||
Name: d.FriendlyName,
|
||||
URL: d.URL,
|
||||
Protocol: "DLNA",
|
||||
new: func() (player.BasePlayer, error) {
|
||||
return dlna.NewDLNAPlayer(d)
|
||||
},
|
||||
}
|
||||
discovered = append(discovered, p)
|
||||
}
|
||||
|
||||
p.remotePlayersLock.Lock()
|
||||
p.remotePlayers = discovered
|
||||
p.remotePlayersLock.Unlock()
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) RemotePlayers() []RemotePlaybackDevice {
|
||||
p.remotePlayersLock.Lock()
|
||||
players := p.remotePlayers
|
||||
p.remotePlayersLock.Unlock()
|
||||
return players
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) CurrentRemotePlayer() *RemotePlaybackDevice {
|
||||
return p.currentRemotePlayer
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) SetRemotePlayer(rp *RemotePlaybackDevice) error {
|
||||
p.cmdQueue.Clear()
|
||||
if rp == nil {
|
||||
if err := p.engine.SetPlayer(p.localPlayer); err != nil {
|
||||
return err
|
||||
}
|
||||
p.currentRemotePlayer = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
player, err := rp.new()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.engine.SetPlayer(player); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.currentRemotePlayer = rp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) CurrentPlayer() player.BasePlayer {
|
||||
return p.engine.CurrentPlayer()
|
||||
}
|
||||
@@ -397,8 +476,8 @@ func (p *PlaybackManager) IsAutoplay() bool {
|
||||
return p.autoplay
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) PlayerStatus() player.Status {
|
||||
return p.engine.PlayerStatus()
|
||||
func (p *PlaybackManager) PlaybackStatus() PlaybackStatus {
|
||||
return p.engine.PlaybackStatus()
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) SetVolume(vol int) {
|
||||
@@ -431,7 +510,7 @@ func (p *PlaybackManager) SeekSeconds(sec float64) {
|
||||
|
||||
// Seek by given relative position in the current track by seconds.
|
||||
func (p *PlaybackManager) SeekBySeconds(sec float64) {
|
||||
status := p.engine.PlayerStatus()
|
||||
status := p.engine.PlaybackStatus()
|
||||
target := status.TimePos + sec
|
||||
if target < 0 {
|
||||
target = 0
|
||||
@@ -456,6 +535,10 @@ func (p *PlaybackManager) Stop() {
|
||||
p.cmdQueue.Stop()
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) Shutdown() {
|
||||
p.cmdQueue.StopAndWait()
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) Pause() {
|
||||
p.cmdQueue.Pause()
|
||||
}
|
||||
@@ -465,7 +548,7 @@ func (p *PlaybackManager) Continue() {
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) PlayPause() {
|
||||
switch p.engine.PlayerStatus().State {
|
||||
switch p.engine.PlaybackStatus().State {
|
||||
case player.Playing:
|
||||
p.Pause()
|
||||
case player.Paused:
|
||||
@@ -514,7 +597,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
|
||||
if len(tr.ArtistIDs) > 0 {
|
||||
similar, err := s.GetSimilarTracks(tr.ArtistIDs[0], p.cfg.EnqueueBatchSize)
|
||||
if err != nil {
|
||||
log.Println("autoplay error: failed to get similar tracks: %v", err)
|
||||
log.Printf("autoplay error: failed to get similar tracks: %v", err)
|
||||
}
|
||||
tracks = filterRecentlyPlayed(similar)
|
||||
}
|
||||
@@ -527,7 +610,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
|
||||
}
|
||||
byGenre, err := s.GetRandomTracks(g, p.cfg.EnqueueBatchSize)
|
||||
if err != nil {
|
||||
log.Println("autoplay error: failed to get tracks by genre: %v", err)
|
||||
log.Printf("autoplay error: failed to get tracks by genre: %v", err)
|
||||
}
|
||||
tracks = filterRecentlyPlayed(byGenre)
|
||||
if len(tracks) > 0 {
|
||||
@@ -542,7 +625,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
|
||||
// fallback to random tracks
|
||||
random, err := s.GetRandomTracks("", p.cfg.EnqueueBatchSize)
|
||||
if err != nil {
|
||||
log.Println("autoplay error: failed to get random tracks: %v", err)
|
||||
log.Printf("autoplay error: failed to get random tracks: %v", err)
|
||||
}
|
||||
tracks = filterRecentlyPlayed(random)
|
||||
}
|
||||
@@ -556,7 +639,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
|
||||
func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
|
||||
logIfErr := func(action string, err error) {
|
||||
if err != nil {
|
||||
log.Println("Playback error (%s): %v", action, err)
|
||||
log.Printf("Playback error (%s): %v", action, err)
|
||||
}
|
||||
}
|
||||
for {
|
||||
@@ -606,6 +689,9 @@ func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
|
||||
mpv.ForceRestartPlayback()
|
||||
}
|
||||
}
|
||||
if c.OnDone != nil {
|
||||
c.OnDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func (j *JukeboxPlayer) Pause() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *JukeboxPlayer) Stop() error {
|
||||
func (j *JukeboxPlayer) Stop(_ bool) error {
|
||||
if j.state == stopped {
|
||||
return nil
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func (j *JukeboxPlayer) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track) error {
|
||||
func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track, _ float64) error {
|
||||
if err := j.provider.JukeboxSet(track.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -140,6 +140,8 @@ func (j *JukeboxPlayer) GetStatus() player.Status {
|
||||
}
|
||||
}
|
||||
|
||||
func (j *JukeboxPlayer) Destroy() {}
|
||||
|
||||
func (j *JukeboxPlayer) startAndUpdateTime() error {
|
||||
beforeStart := time.Now()
|
||||
if err := j.provider.JukeboxStart(); err != nil {
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/backend/player"
|
||||
"github.com/supersonic-app/go-mpv"
|
||||
)
|
||||
@@ -67,6 +69,9 @@ type Player struct {
|
||||
equalizer Equalizer
|
||||
peaksEnabled bool
|
||||
|
||||
fileLoadedLock sync.Mutex
|
||||
fileLoadedSig *sync.Cond
|
||||
|
||||
bgCancel context.CancelFunc
|
||||
}
|
||||
|
||||
@@ -79,10 +84,12 @@ func New() *Player {
|
||||
// Same as New, but sets the application name that mpv
|
||||
// reports to the system audio API.
|
||||
func NewWithClientName(c string) *Player {
|
||||
return &Player{
|
||||
p := &Player{
|
||||
vol: -1, // use 100 in Init
|
||||
clientName: c,
|
||||
}
|
||||
p.fileLoadedSig = sync.NewCond(&p.fileLoadedLock)
|
||||
return p
|
||||
}
|
||||
|
||||
// Initializes the Player and makes it ready for playback.
|
||||
@@ -133,23 +140,32 @@ func (p *Player) Init(maxCacheMB int) error {
|
||||
}
|
||||
|
||||
// Plays the specified file, clearing the previous play queue, if any.
|
||||
func (p *Player) PlayFile(url string) error {
|
||||
func (p *Player) PlayFile(url string, _ mediaprovider.MediaItemMetadata, startTime float64) error {
|
||||
if !p.initialized {
|
||||
return ErrUnitialized
|
||||
}
|
||||
err := p.mpv.Command([]string{"loadfile", url, "replace"})
|
||||
if err == nil {
|
||||
p.lenPlaylist = 1
|
||||
if p.status.State == player.Paused {
|
||||
return p.Continue()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.lenPlaylist = 1
|
||||
if p.status.State == player.Paused {
|
||||
err = p.Continue()
|
||||
} else {
|
||||
p.setState(player.Playing)
|
||||
}
|
||||
if startTime > 0 {
|
||||
p.fileLoadedLock.Lock()
|
||||
p.fileLoadedSig.Wait()
|
||||
p.fileLoadedLock.Unlock()
|
||||
p.SeekSeconds(startTime)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Stops playback and clears the play queue.
|
||||
func (p *Player) Stop() error {
|
||||
func (p *Player) Stop(_ bool) error {
|
||||
if !p.initialized {
|
||||
return ErrUnitialized
|
||||
}
|
||||
@@ -169,7 +185,7 @@ func (p *Player) Stop() error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *Player) SetNextFile(url string) error {
|
||||
func (p *Player) SetNextFile(url string, _ mediaprovider.MediaItemMetadata) error {
|
||||
if p.lenPlaylist > p.curPlaylistPos+1 {
|
||||
if err := p.mpv.Command([]string{"playlist-remove", strconv.Itoa(int(p.curPlaylistPos) + 1)}); err != nil {
|
||||
return err
|
||||
@@ -480,10 +496,9 @@ func (p *Player) eventHandler(ctx context.Context) {
|
||||
}
|
||||
switch e.Event_Id {
|
||||
case mpv.EVENT_PLAYBACK_RESTART:
|
||||
if p.seeking {
|
||||
p.seeking = false
|
||||
}
|
||||
fallthrough
|
||||
case mpv.EVENT_SEEK:
|
||||
p.seeking = false
|
||||
p.InvokeOnSeek()
|
||||
case mpv.EVENT_FILE_LOADED:
|
||||
p.curPlaylistPos, _ = p.getInt64Property("playlist-pos")
|
||||
@@ -493,6 +508,7 @@ func (p *Player) eventHandler(ctx context.Context) {
|
||||
p.InvokeOnSeek()
|
||||
}
|
||||
p.InvokeOnTrackChange()
|
||||
p.fileLoadedSig.Signal()
|
||||
case mpv.EVENT_IDLE:
|
||||
p.status.Duration = 0
|
||||
p.status.TimePos = 0
|
||||
|
||||
+32
-28
@@ -1,23 +1,25 @@
|
||||
package player
|
||||
|
||||
import "github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
import (
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
)
|
||||
|
||||
type URLPlayer interface {
|
||||
BasePlayer
|
||||
PlayFile(url string) error
|
||||
SetNextFile(url string) error
|
||||
PlayFile(url string, metadata mediaprovider.MediaItemMetadata, startTime float64) error
|
||||
SetNextFile(url string, metadata mediaprovider.MediaItemMetadata) error
|
||||
}
|
||||
|
||||
type TrackPlayer interface {
|
||||
BasePlayer
|
||||
PlayTrack(track *mediaprovider.Track) error
|
||||
PlayTrack(track *mediaprovider.Track, startTime float64) error
|
||||
SetNextTrack(track *mediaprovider.Track) error
|
||||
}
|
||||
|
||||
type BasePlayer interface {
|
||||
Continue() error
|
||||
Pause() error
|
||||
Stop() error
|
||||
Stop(force bool) error
|
||||
|
||||
SeekSeconds(secs float64) error
|
||||
IsSeeking() bool
|
||||
@@ -27,6 +29,8 @@ type BasePlayer interface {
|
||||
|
||||
GetStatus() Status
|
||||
|
||||
Destroy()
|
||||
|
||||
// Event API
|
||||
OnPaused(func())
|
||||
OnStopped(func())
|
||||
@@ -84,66 +88,66 @@ func (r ReplayGainMode) String() string {
|
||||
}
|
||||
|
||||
type BasePlayerCallbackImpl struct {
|
||||
onPaused []func()
|
||||
onStopped []func()
|
||||
onPlaying []func()
|
||||
onSeek []func()
|
||||
onTrackChange []func()
|
||||
onPaused func()
|
||||
onStopped func()
|
||||
onPlaying func()
|
||||
onSeek func()
|
||||
onTrackChange func()
|
||||
}
|
||||
|
||||
// Registers a callback which is invoked when the player transitions to the Paused state.
|
||||
// Sets a callback which is invoked when the player transitions to the Paused state.
|
||||
func (p *BasePlayerCallbackImpl) OnPaused(cb func()) {
|
||||
p.onPaused = append(p.onPaused, cb)
|
||||
p.onPaused = cb
|
||||
}
|
||||
|
||||
// Registers a callback which is invoked when the player transitions to the Stopped state.
|
||||
// Sets a callback which is invoked when the player transitions to the Stopped state.
|
||||
func (p *BasePlayerCallbackImpl) OnStopped(cb func()) {
|
||||
p.onStopped = append(p.onStopped, cb)
|
||||
p.onStopped = cb
|
||||
}
|
||||
|
||||
// Registers a callback which is invoked when the player transitions to the Playing state.
|
||||
// Sets a callback which is invoked when the player transitions to the Playing state.
|
||||
func (p *BasePlayerCallbackImpl) OnPlaying(cb func()) {
|
||||
p.onPlaying = append(p.onPlaying, cb)
|
||||
p.onPlaying = cb
|
||||
}
|
||||
|
||||
// Registers a callback which is invoked whenever a seek event occurs.
|
||||
func (p *BasePlayerCallbackImpl) OnSeek(cb func()) {
|
||||
p.onSeek = append(p.onSeek, cb)
|
||||
p.onSeek = cb
|
||||
}
|
||||
|
||||
// Registers a callback which is invoked when the currently playing track changes,
|
||||
// or when playback begins at any time from the Stopped state.
|
||||
// Callback is invoked with the index of the currently playing track (zero-based).
|
||||
func (p *BasePlayerCallbackImpl) OnTrackChange(cb func()) {
|
||||
p.onTrackChange = append(p.onTrackChange, cb)
|
||||
p.onTrackChange = cb
|
||||
}
|
||||
|
||||
func (p *BasePlayerCallbackImpl) InvokeOnPaused() {
|
||||
for _, cb := range p.onPaused {
|
||||
cb()
|
||||
if p.onPaused != nil {
|
||||
p.onPaused()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BasePlayerCallbackImpl) InvokeOnPlaying() {
|
||||
for _, cb := range p.onPlaying {
|
||||
cb()
|
||||
if p.onPlaying != nil {
|
||||
p.onPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BasePlayerCallbackImpl) InvokeOnStopped() {
|
||||
for _, cb := range p.onStopped {
|
||||
cb()
|
||||
if p.onStopped != nil {
|
||||
p.onStopped()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BasePlayerCallbackImpl) InvokeOnSeek() {
|
||||
for _, cb := range p.onSeek {
|
||||
cb()
|
||||
if p.onSeek != nil {
|
||||
p.onSeek()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BasePlayerCallbackImpl) InvokeOnTrackChange() {
|
||||
for _, cb := range p.onTrackChange {
|
||||
cb()
|
||||
if p.onTrackChange != nil {
|
||||
p.onTrackChange()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ type serializedSavedPlayQueue struct {
|
||||
// If the provided CanSavePlayQueue server is non-nil, it will also save to the server.
|
||||
func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server mediaprovider.CanSavePlayQueue) error {
|
||||
queue := pm.GetPlayQueue()
|
||||
stats := pm.PlayerStatus()
|
||||
stats := pm.PlaybackStatus()
|
||||
trackIdx := pm.NowPlayingIndex()
|
||||
|
||||
trackIDs := make([]string, 0, len(queue))
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
)
|
||||
|
||||
@@ -21,3 +23,30 @@ func CopyFile(srcPath, dstPath string) error {
|
||||
_, err = io.Copy(fout, fin)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetLocalIP() (string, error) {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
ipnet, ok := addr.(*net.IPNet)
|
||||
if ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
|
||||
return ipnet.IP.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no suitable interface found")
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
module github.com/dweymouth/supersonic
|
||||
|
||||
go 1.21
|
||||
go 1.21.9
|
||||
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.6.0-alpha1
|
||||
fyne.io/fyne/v2 v2.6.0-beta1
|
||||
github.com/20after4/configdir v0.1.1
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/cenkalti/dominantcolor v1.0.3
|
||||
@@ -14,12 +14,14 @@ require (
|
||||
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645
|
||||
github.com/godbus/dbus/v5 v5.1.0
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7
|
||||
github.com/pelletier/go-toml/v2 v2.0.8
|
||||
github.com/quarckster/go-mpris-server v1.0.3
|
||||
github.com/supersonic-app/go-mpv v0.1.0
|
||||
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d
|
||||
github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5
|
||||
github.com/zalando/go-keyring v0.2.6
|
||||
golang.org/x/net v0.25.0
|
||||
golang.org/x/net v0.35.0
|
||||
golang.org/x/sys v0.30.0
|
||||
golang.org/x/text v0.22.0
|
||||
)
|
||||
@@ -33,18 +35,24 @@ require (
|
||||
github.com/fredbi/uri v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/fyne-io/gl-js v0.1.0 // indirect
|
||||
github.com/fyne-io/glfw-js v0.1.0 // indirect
|
||||
github.com/fyne-io/image v0.1.0 // indirect
|
||||
github.com/fyne-io/glfw-js v0.2.0 // indirect
|
||||
github.com/fyne-io/image v0.1.1 // indirect
|
||||
github.com/fyne-io/oksvg v0.1.0 // indirect
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
|
||||
github.com/go-text/render v0.2.0 // indirect
|
||||
github.com/go-text/typesetting v0.2.1 // indirect
|
||||
github.com/h2non/filetype v1.1.3 // indirect
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||
github.com/koron/go-ssdp v0.0.5 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rymdport/portal v0.3.0 // indirect
|
||||
github.com/rymdport/portal v0.4.1 // indirect
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
@@ -53,4 +61,4 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace fyne.io/fyne/v2 v2.6.0-alpha1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250222005659-e73d8f178006
|
||||
replace fyne.io/fyne/v2 v2.6.0-beta1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250406173843-f5cb002423a5
|
||||
|
||||
@@ -23,10 +23,12 @@ github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64 h1:RUIrnGY03
|
||||
github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64/go.mod h1:3YrjFDHMlhCsSZ/OvmJCxWm9QHSgOVWZBxnraZz9Z7c=
|
||||
github.com/dweymouth/fyne-tooltip v0.3.0 h1:NKCyTkh9NtvnTsiHtTOtaJzRDOFYP8AckQ2tyhOh6JY=
|
||||
github.com/dweymouth/fyne-tooltip v0.3.0/go.mod h1:m04ShLW/Tp6LXrNieTumApvNgo7YSB+wi+jZTN+kDBU=
|
||||
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250222005659-e73d8f178006 h1:vsLOmm3C/RLFmWhfv+8MNcjApC9S3SdXZLcIGydiVnI=
|
||||
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250222005659-e73d8f178006/go.mod h1:Bzv2yK+ncZ8LJbHKjyJJpEAFlbs6oulHgKm04ObOqA8=
|
||||
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250406173843-f5cb002423a5 h1:/gxlRSmndtF8QVxV3oFjlozm2clkMlLBlfiKFtehH+M=
|
||||
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250406173843-f5cb002423a5/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU=
|
||||
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645 h1:KzqSaQwG3HsTZQlEtkp0BeUy9vmYZ0rq0B15qIPSiBs=
|
||||
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
|
||||
@@ -35,10 +37,12 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM=
|
||||
github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
||||
github.com/fyne-io/glfw-js v0.1.0 h1:RGGMmVjcsG17Oifl3X2UJ5vH3PgS4B1UY3ASeN5BXbI=
|
||||
github.com/fyne-io/glfw-js v0.1.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
|
||||
github.com/fyne-io/image v0.1.0 h1:Vm2TQJ2PWGHCf3jYi1/XroaNNMu+GfI/O2QpSbZd4XQ=
|
||||
github.com/fyne-io/image v0.1.0/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||
github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM=
|
||||
github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
|
||||
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||
github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
|
||||
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
|
||||
@@ -57,12 +61,30 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
|
||||
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
||||
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||
github.com/koron/go-ssdp v0.0.5 h1:E1iSMxIs4WqxTbIBLtmNBeOOC+1sCIXQeqTWVnpmwhk=
|
||||
github.com/koron/go-ssdp v0.0.5/go.mod h1:Qm59B7hpKpDqfyRNWRNr00jGwLdXjDyZh6y7rH6VS0w=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||
@@ -77,8 +99,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quarckster/go-mpris-server v1.0.3 h1:ef6d3DpxlORtdEBHnhQ/j3gS0Z3+YUfXeJhC9L9DZvA=
|
||||
github.com/quarckster/go-mpris-server v1.0.3/go.mod h1:2b4IdrpnEoEfU+6fQKjYhAgdvsiz4JxmTpDAUrMJVO4=
|
||||
github.com/rymdport/portal v0.3.0 h1:QRHcwKwx3kY5JTQcsVhmhC3TGqGQb9LFghVNUy8AdB8=
|
||||
github.com/rymdport/portal v0.3.0/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||
github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
|
||||
github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||
@@ -97,6 +119,8 @@ github.com/supersonic-app/go-mpv v0.1.0 h1:U+cCnLQxmpqx5mY6nMlC0J4uIdCCXUbAjpjS0
|
||||
github.com/supersonic-app/go-mpv v0.1.0/go.mod h1:1bQz6kBQumJopXEbkiqoLxIXLy7F7yWFBvknvpAtIC0=
|
||||
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4=
|
||||
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4=
|
||||
github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5 h1:aoUJKPFD/ZrNZjK6fl2Xhazwsttx42esKxhSSzEE3Bo=
|
||||
github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5/go.mod h1:ibt19zDV5/vvF14jHJpTv3AOorq1EbmrMAubxnuvR5Y=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
@@ -113,8 +137,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@
|
||||
fyne bundle -package res -prefix Res appicon-256.png > bundled.go
|
||||
fyne bundle -append -prefix Res icons/coreui/playlist-add-next.svg >> bundled.go
|
||||
fyne bundle -append -prefix Res icons/freepik/playbutton.png >> bundled.go
|
||||
fyne bundle -append -prefix Res icons/publicdomain/cast.svg >> bundled.go
|
||||
fyne bundle -append -prefix Res icons/publicdomain/disc.svg >> bundled.go
|
||||
fyne bundle -append -prefix Res icons/publicdomain/headphones.svg >> bundled.go
|
||||
fyne bundle -append -prefix Res icons/publicdomain/heart-filled.svg >> bundled.go
|
||||
|
||||
@@ -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 |
@@ -33,6 +33,8 @@
|
||||
<li>Sort tracklist views by column and configure visible tracklist columns</li>
|
||||
<li>Set/unset favorite and browse by favorite albums, artists, and songs</li>
|
||||
<li>Shuffle and repeat playback modes (partial; shuffle album, playlist, artist radio, random songs)</li>
|
||||
<li>Internet radio station support (Subsonic)</li>
|
||||
<li>Cast to uPnP/DLNA devices</li>
|
||||
</ul>
|
||||
</description>
|
||||
|
||||
@@ -61,44 +63,18 @@
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release date="2025-02-22" version="0.14.0">
|
||||
<release date="2025-04-07" version="0.15.1">
|
||||
<description>
|
||||
<p>
|
||||
Version 0.14.0 of Supersonic
|
||||
Version 0.15.1 of Supersonic
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Added
|
||||
</p>
|
||||
<ul>
|
||||
<li>New translations: Dutch</li>
|
||||
<li>Media Key / SMTC support for Windows</li>
|
||||
<li>Add an Autoplay mode to continually add related songs when nearing the end of queue</li>
|
||||
<li>Shuffle button added to favorite songs view</li>
|
||||
<li>Added toast notifications for certain UI actions</li>
|
||||
<li>Add album shuffle modes to Albums and Genre pages</li>
|
||||
<li>Caching added for LrcLib lyrics, custom LrcLib URL supported in config file</li>
|
||||
<li>Use notify-send for notifications on Linux if available</li>
|
||||
<li>Add setting to disable automatic scaling adjustment for detected DPI</li>
|
||||
<li>New Advanced settings tab exposing more config file settings to the UI</li>
|
||||
<li>Favorite/menu icon buttons added to grid cards when hovered</li>
|
||||
<li>Show full dates from OpenSubsonic servers when available</li>
|
||||
<li>Log to a file instead of stdout on Windows</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
Fixed
|
||||
</p>
|
||||
<ul>
|
||||
<li>Persist repeat setting across restarts</li>
|
||||
<li>Removing track from playlist when sorted removed wrong track</li>
|
||||
<li>Fullscreen artist images not loading in full resolution</li>
|
||||
<li>Fix volume slider scrolling too quickly with some mice</li>
|
||||
<li>Loading dots not stopping if canceling a search before completion</li>
|
||||
<li>Add list/grid debouncing to improve scrolling smoothness when scrolling very quickly</li>
|
||||
<li>Image caching broken on some servers due to track IDs not being legal filenames</li>
|
||||
<li>Migrate to Fyne 2.6 for improved stability and performance</li>
|
||||
<li>Sometimes crashing on exit</li>
|
||||
<li>Fix crashing on playback on Windows</li>
|
||||
<li>Fix crashing when clicking on album filters button</li>
|
||||
</ul>
|
||||
|
||||
</description>
|
||||
|
||||
+4
-25
@@ -3,7 +3,7 @@ package res
|
||||
const (
|
||||
AppName = "supersonic"
|
||||
DisplayName = "Supersonic"
|
||||
AppVersion = "0.14.0"
|
||||
AppVersion = "0.15.1"
|
||||
AppVersionTag = "v" + AppVersion
|
||||
ConfigFile = "config.toml"
|
||||
GithubURL = "https://github.com/dweymouth/supersonic"
|
||||
@@ -13,31 +13,10 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
WhatsAdded = `
|
||||
## Added
|
||||
* New translations: Dutch
|
||||
* Media Key / SMTC support for Windows
|
||||
* Add an Autoplay mode to continually add related songs when nearing the end of queue
|
||||
* Shuffle button added to favorite songs view
|
||||
* Added toast notifications for certain UI actions
|
||||
* Add album shuffle modes to Albums and Genre pages
|
||||
* Caching added for LrcLib lyrics, custom LrcLib URL supported in config file
|
||||
* Use notify-send for notifications on Linux if available
|
||||
* Add setting to disable automatic scaling adjustment for detected DPI
|
||||
* New Advanced settings tab exposing more config file settings to the UI
|
||||
* Favorite/menu icon buttons added to grid cards when hovered
|
||||
* Show full dates from OpenSubsonic servers when available
|
||||
* Log to a file instead of stdout on Windows`
|
||||
WhatsAdded = ``
|
||||
|
||||
WhatsFixed = `
|
||||
## Fixed
|
||||
* Persist repeat setting across restarts
|
||||
* Removing track from playlist when sorted removed wrong track
|
||||
* Fullscreen artist images not loading in full resolution
|
||||
* Fix volume slider scrolling too quickly with some mice
|
||||
* Loading dots not stopping if canceling a search before completion
|
||||
* Add list/grid debouncing to improve scrolling smoothness when scrolling very quickly
|
||||
* Image caching broken on some servers due to track IDs not being legal filenames
|
||||
* Migrate to Fyne 2.6 for improved stability and performance
|
||||
* Sometimes crashing on exit`
|
||||
* Fix crashing on playback on Windows
|
||||
* Fix crashing when clicking on album filters button`
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"BPM": "BPM",
|
||||
"Broadcast": "Broadcast",
|
||||
"Cancel": "Cancel",
|
||||
"Cast to device": "Cast to device",
|
||||
"Check for Updates": "Check for Updates",
|
||||
"Close": "Close",
|
||||
"Close to system tray": "Close to system tray",
|
||||
@@ -168,6 +169,7 @@
|
||||
"Save play queue on exit": "Save play queue on exit",
|
||||
"Saved at": "Saved at",
|
||||
"Scrobble when": "Scrobble when",
|
||||
"Search": "Search",
|
||||
"Search Everywhere": "Search Everywhere",
|
||||
"Search page": "Search page",
|
||||
"Search playlists or new playlist name": "Search playlists or new playlist name",
|
||||
@@ -206,6 +208,7 @@
|
||||
"Testing connection": "Testing connection",
|
||||
"The request timed out": "The request timed out",
|
||||
"Theme": "Theme",
|
||||
"This computer": "This computer",
|
||||
"Time": "Time",
|
||||
"Title": "Title",
|
||||
"Title (A-Z)": "Title (A-Z)",
|
||||
@@ -237,7 +240,6 @@
|
||||
"You are running the latest version of": "You are running the latest version of",
|
||||
"Quit": "Quit",
|
||||
"Rescan Library": "Rescan Library",
|
||||
"Search": "Search",
|
||||
"Owner": "Owner",
|
||||
"To server": "To server",
|
||||
"Fav.": "Fav.",
|
||||
|
||||
@@ -3,6 +3,7 @@ package ui
|
||||
import (
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/backend/player/mpv"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
"github.com/dweymouth/supersonic/ui/layouts"
|
||||
"github.com/dweymouth/supersonic/ui/util"
|
||||
@@ -111,6 +112,10 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
|
||||
pm.OnVolumeChange(func(vol int) {
|
||||
fyne.Do(func() { bp.AuxControls.VolumeControl.SetVolume(vol) })
|
||||
})
|
||||
pm.OnPlayerChange(func() {
|
||||
_, local := pm.CurrentPlayer().(*mpv.Player)
|
||||
fyne.Do(func() { bp.AuxControls.SetIsRemotePlayer(!local) })
|
||||
})
|
||||
bp.AuxControls.VolumeControl.OnSetVolume = func(v int) {
|
||||
pm.SetVolume(v)
|
||||
}
|
||||
@@ -121,6 +126,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
|
||||
pm.SetAutoplay(autoplay)
|
||||
}
|
||||
bp.AuxControls.OnShowPlayQueue(contr.ShowPopUpPlayQueue)
|
||||
bp.AuxControls.OnShowCastMenu(contr.ShowCastMenu)
|
||||
|
||||
bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage)
|
||||
|
||||
|
||||
@@ -471,7 +471,7 @@ func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums, im *ba
|
||||
a.artistImageID = artist.CoverArtID
|
||||
go func() {
|
||||
if cover, err := im.GetCoverThumbnail(artist.CoverArtID); err == nil {
|
||||
a.artistImage.SetImage(cover, true)
|
||||
fyne.Do(func() { a.artistImage.SetImage(cover, true) })
|
||||
} else {
|
||||
log.Printf("error fetching cover: %v", err)
|
||||
}
|
||||
|
||||
@@ -166,6 +166,18 @@ func NewNowPlayingPage(
|
||||
a.relatedList.OnPlaySelectionNext = func(items []mediaprovider.MediaItem) {
|
||||
a.pm.LoadItems(items, backend.InsertNext, false)
|
||||
}
|
||||
a.relatedList.OnPlaySongRadio = func(track *mediaprovider.Track) {
|
||||
go func() {
|
||||
if err := a.pm.PlaySimilarSongs(track.ID); err != nil {
|
||||
fyne.Do(func() {
|
||||
a.contr.ToastProvider.ShowErrorToast(lang.L("Unable to play song radio"))
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
a.relatedList.OnShowTrackInfo = func(track *mediaprovider.Track) {
|
||||
a.contr.ShowTrackInfoDialog(track)
|
||||
}
|
||||
a.lyricsViewer = widgets.NewLyricsViewer()
|
||||
a.statusLabel = widget.NewLabel(lang.L("Stopped"))
|
||||
|
||||
@@ -431,7 +443,7 @@ func (a *NowPlayingPage) Reload() {
|
||||
}
|
||||
switch a.tabs.SelectedIndex() {
|
||||
case 1: /*lyrics*/
|
||||
a.lastPlayPos = a.pm.CurrentPlayer().GetStatus().TimePos
|
||||
a.lastPlayPos = a.pm.PlaybackStatus().TimePos
|
||||
a.updateLyrics()
|
||||
case 2: /*related*/
|
||||
a.updateRelatedList()
|
||||
@@ -515,7 +527,7 @@ func (a *NowPlayingPage) saveSelectedTab(tabNum int) {
|
||||
|
||||
func (a *NowPlayingPage) formatStatusLine() {
|
||||
curPlayer := a.pm.CurrentPlayer()
|
||||
playerStats := curPlayer.GetStatus()
|
||||
playerStats := a.pm.PlaybackStatus()
|
||||
lastStatus := a.statusLabel.Text
|
||||
stopped := lang.L("Stopped")
|
||||
state := stopped
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/sanitize"
|
||||
ttwidget "github.com/dweymouth/fyne-tooltip/widget"
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
"github.com/dweymouth/supersonic/ui/widgets"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
@@ -240,6 +243,43 @@ func (a *PlaylistPage) onRemoveSelectedFromPlaylist() {
|
||||
a.Reload()
|
||||
}
|
||||
|
||||
func (a *PlaylistPage) onSearched(query string) {
|
||||
if query == "" {
|
||||
// switch back to full playlist view
|
||||
|
||||
ids := a.tracklist.SelectedTrackIDs()
|
||||
a.tracklist.Options.Reorderable = true
|
||||
a.tracklist.SetSorting(a.trackSort) // restore old sort order
|
||||
a.tracklist.SetTracks(a.tracks)
|
||||
// if a track was selected in the searched view,
|
||||
// scroll to it when switching back to full playlist
|
||||
if len(ids) > 0 {
|
||||
a.tracklist.SelectAndScrollToTrack(ids[0])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// search tracks within the playlist
|
||||
searched := sharedutil.FilterSlice(a.tracks, func(t *mediaprovider.Track) bool {
|
||||
sani := func(s string) string {
|
||||
return strings.ToLower(sanitize.Accents(s))
|
||||
}
|
||||
qLower := strings.ToLower(query)
|
||||
return strings.Contains(sani(t.Title), qLower) ||
|
||||
strings.Contains(sani(strings.Join(t.ArtistNames, "")), qLower) ||
|
||||
strings.Contains(sani(t.Album), qLower) ||
|
||||
strings.Contains(sani(strings.Join(t.Genres, "")), qLower) ||
|
||||
strings.Contains(sani(strings.Join(t.ComposerNames, "")), qLower) ||
|
||||
strings.Contains(fmt.Sprintf("%d", t.Year), query) ||
|
||||
strings.Contains(sani(t.Comment), qLower)
|
||||
})
|
||||
|
||||
a.trackSort = a.tracklist.Sorting() // save old sort order
|
||||
a.tracklist.Options.Reorderable = false
|
||||
a.tracklist.SetSorting(widgets.TracklistSort{})
|
||||
a.tracklist.SetTracks(searched)
|
||||
}
|
||||
|
||||
type PlaylistPageHeader struct {
|
||||
widget.BaseWidget
|
||||
|
||||
@@ -275,6 +315,7 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
|
||||
a.ownerLabel = util.NewTruncatingLabel()
|
||||
a.createdAtLabel = widget.NewLabel("")
|
||||
a.trackTimeLabel = widget.NewLabel("")
|
||||
|
||||
a.editButton = widget.NewButtonWithIcon(lang.L("Edit"), theme.DocumentCreateIcon(), func() {
|
||||
if a.playlistInfo != nil {
|
||||
a.page.contr.DoEditPlaylistWorkflow(&a.playlistInfo.Playlist)
|
||||
@@ -289,6 +330,58 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
|
||||
a.page.pm.LoadTracks(a.page.tracks, backend.Replace, true)
|
||||
a.page.pm.PlayFromBeginning()
|
||||
})
|
||||
|
||||
// set up search button / entry animation pair
|
||||
// needs reference to containing HBox container (initialized later)
|
||||
var buttonRow *fyne.Container
|
||||
var searchBtn *ttwidget.Button
|
||||
var searchEntry *widgets.SearchEntry
|
||||
searchBtn = ttwidget.NewButtonWithIcon("", theme.SearchIcon(), func() {
|
||||
minW := searchBtn.MinSize().Width
|
||||
if searchEntry == nil {
|
||||
searchEntry = widgets.NewSearchEntry()
|
||||
searchEntry.PlaceHolder = ""
|
||||
searchEntry.Scroll = container.ScrollNone
|
||||
searchEntry.OnSearched = func(s string) {
|
||||
// we can't assign a.page.onSearched directly,
|
||||
// since widget reuse means a.page could change
|
||||
a.page.onSearched(s)
|
||||
}
|
||||
searchEntry.OnFocusLost = func() {
|
||||
if searchEntry.Text == "" {
|
||||
// dismissal animation
|
||||
searchEntry.Scroll = container.ScrollNone
|
||||
searchEntry.SetPlaceHolder("")
|
||||
fyne.NewAnimation(canvas.DurationShort, func(f float32) {
|
||||
f = 1 - f
|
||||
w := (200-minW)*f + minW
|
||||
searchEntry.SetMinWidth(w)
|
||||
if f == 0 {
|
||||
buttonRow.Objects[3] = searchBtn
|
||||
}
|
||||
// re-layout container (without unneeded full refresh)
|
||||
buttonRow.Layout.Layout(buttonRow.Objects, buttonRow.Layout.MinSize(buttonRow.Objects))
|
||||
}).Start()
|
||||
}
|
||||
}
|
||||
}
|
||||
buttonRow.Objects[3] = searchEntry
|
||||
searchEntry.SetMinWidth(minW)
|
||||
fyne.CurrentApp().Driver().CanvasForObject(a).Focus(searchEntry)
|
||||
fyne.NewAnimation(canvas.DurationShort, func(f float32) {
|
||||
w := (200-minW)*f + minW
|
||||
searchEntry.SetMinWidth(w)
|
||||
// re-layout container (without unneeded full refresh)
|
||||
buttonRow.Layout.Layout(buttonRow.Objects, buttonRow.Layout.MinSize(buttonRow.Objects))
|
||||
if f == 1 {
|
||||
searchEntry.Scroll = container.ScrollHorizontalOnly
|
||||
searchEntry.Refresh() // needed to initialize widget's scroller
|
||||
searchEntry.SetPlaceHolder(lang.L("Search"))
|
||||
}
|
||||
}).Start()
|
||||
})
|
||||
searchBtn.SetToolTip(lang.L("Search"))
|
||||
|
||||
var pop *widget.PopUpMenu
|
||||
menuBtn := widget.NewButtonWithIcon("", theme.MoreHorizontalIcon(), nil)
|
||||
menuBtn.OnTapped = func() {
|
||||
@@ -317,13 +410,15 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
|
||||
pop.ShowAtPosition(fyne.NewPos(pos.X, pos.Y+menuBtn.Size().Height))
|
||||
}
|
||||
|
||||
buttonRow = container.NewHBox(a.editButton, playButton, shuffleBtn, searchBtn, menuBtn)
|
||||
|
||||
a.container = util.AddHeaderBackground(
|
||||
container.NewBorder(nil, nil, a.image, nil,
|
||||
container.NewVBox(a.titleLabel, container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-10),
|
||||
a.descriptionLabel,
|
||||
a.ownerLabel,
|
||||
a.trackTimeLabel),
|
||||
container.NewHBox(a.editButton, playButton, shuffleBtn, menuBtn),
|
||||
buttonRow,
|
||||
)))
|
||||
return a
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/widgets"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
@@ -179,4 +181,14 @@ func (c *Controller) ConnectPlayQueuelistActions(list *widgets.PlayQueueList) {
|
||||
}
|
||||
list.OnSetRating = c.SetTrackRatings
|
||||
list.OnSetFavorite = c.SetTrackFavorites
|
||||
list.OnPlaySongRadio = func(track *mediaprovider.Track) {
|
||||
go func() {
|
||||
if err := c.App.PlaybackManager.PlaySimilarSongs(track.ID); err != nil {
|
||||
fyne.Do(func() {
|
||||
c.ToastProvider.ShowErrorToast(lang.L("Unable to play song radio"))
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
list.OnShowTrackInfo = c.ShowTrackInfoDialog
|
||||
}
|
||||
|
||||
+46
-408
@@ -2,7 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
@@ -11,7 +10,6 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -30,7 +28,6 @@ import (
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
@@ -154,6 +151,50 @@ func (m *Controller) HaveModal() bool {
|
||||
return m.haveModal
|
||||
}
|
||||
|
||||
func (m *Controller) ShowCastMenu(onPendingPlayerChange func()) {
|
||||
rp := m.App.PlaybackManager.CurrentRemotePlayer()
|
||||
devices := m.App.PlaybackManager.RemotePlayers()
|
||||
local := fyne.NewMenuItem(lang.L("This computer"), func() {
|
||||
if rp == nil {
|
||||
return // no-op. already not casting
|
||||
}
|
||||
onPendingPlayerChange()
|
||||
go func() {
|
||||
if err := m.App.PlaybackManager.SetRemotePlayer(nil); err != nil {
|
||||
fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to disconnect from remote player") })
|
||||
}
|
||||
}()
|
||||
})
|
||||
local.Icon = theme.ComputerIcon()
|
||||
local.Checked = rp == nil
|
||||
|
||||
menu := fyne.NewMenu("", local)
|
||||
for _, d := range devices {
|
||||
_d := d
|
||||
isCurrent := rp != nil && _d.URL == rp.URL
|
||||
item := fyne.NewMenuItem(d.Name, func() {
|
||||
if isCurrent {
|
||||
return // no-op.
|
||||
}
|
||||
onPendingPlayerChange()
|
||||
go func() {
|
||||
if err := m.App.PlaybackManager.SetRemotePlayer(&_d); err != nil {
|
||||
fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to connect to " + _d.Name) })
|
||||
}
|
||||
}()
|
||||
})
|
||||
item.Icon = myTheme.CastIcon
|
||||
item.Checked = isCurrent
|
||||
menu.Items = append(menu.Items, item)
|
||||
}
|
||||
pop := widget.NewPopUpMenu(menu, m.MainWindow.Canvas())
|
||||
canvasSize := m.MainWindow.Canvas().Size()
|
||||
pop.ShowAtPosition(fyne.NewPos(
|
||||
canvasSize.Width-pop.MinSize().Width-10,
|
||||
canvasSize.Height-pop.MinSize().Height-100,
|
||||
))
|
||||
}
|
||||
|
||||
func (m *Controller) ShowPopUpPlayQueue() {
|
||||
m.popUpQueueMutex.Lock()
|
||||
if m.popUpQueue == nil {
|
||||
@@ -260,327 +301,6 @@ func (m *Controller) GetArtistTracks(artistID string) []*mediaprovider.Track {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Controller) PromptForFirstServer() {
|
||||
d := dialogs.NewAddEditServerDialog(lang.L("Connect to Server"), false, nil, m.MainWindow.Canvas().Focus)
|
||||
pop := widget.NewModalPopUp(d, m.MainWindow.Canvas())
|
||||
d.OnSubmit = func() {
|
||||
d.DisableSubmit()
|
||||
go func() {
|
||||
if m.testConnectionAndUpdateDialogText(d) {
|
||||
// connection is good
|
||||
pop.Hide()
|
||||
m.doModalClosed()
|
||||
conn := backend.ServerConnection{
|
||||
ServerType: d.ServerType,
|
||||
Hostname: d.Host,
|
||||
AltHostname: d.AltHost,
|
||||
Username: d.Username,
|
||||
LegacyAuth: d.LegacyAuth,
|
||||
}
|
||||
server := m.App.ServerManager.AddServer(d.Nickname, conn)
|
||||
if err := m.trySetPasswordAndConnectToServer(server, d.Password); err != nil {
|
||||
log.Printf("error connecting to server: %s", err.Error())
|
||||
}
|
||||
}
|
||||
d.EnableSubmit()
|
||||
}()
|
||||
}
|
||||
m.haveModal = true
|
||||
pop.Show()
|
||||
}
|
||||
|
||||
// Show dialog to select playlist.
|
||||
// Depending on the results of that dialog, potentially create a new playlist
|
||||
// Add tracks to the user-specified playlist
|
||||
func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
|
||||
sp := dialogs.NewSelectPlaylistDialog(m.App.ServerManager.Server, m.App.ImageManager,
|
||||
m.App.ServerManager.LoggedInUser, m.App.Config.Application.AddToPlaylistSkipDuplicates)
|
||||
pop := widget.NewModalPopUp(sp.SearchDialog, m.MainWindow.Canvas())
|
||||
sp.SetOnDismiss(func() {
|
||||
pop.Hide()
|
||||
m.doModalClosed()
|
||||
})
|
||||
sp.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) {
|
||||
notifySuccess := func(n int) {
|
||||
fyne.Do(func() {
|
||||
msg := lang.LocalizePluralKey("playlist.addedtracks",
|
||||
"Added tracks to playlist", n, map[string]string{"trackCount": strconv.Itoa(n)})
|
||||
m.ToastProvider.ShowSuccessToast(msg)
|
||||
})
|
||||
}
|
||||
notifyError := func() {
|
||||
fyne.Do(func() {
|
||||
m.ToastProvider.ShowErrorToast(
|
||||
lang.L("An error occurred adding tracks to the playlist"),
|
||||
)
|
||||
})
|
||||
}
|
||||
pop.Hide()
|
||||
m.App.Config.Application.AddToPlaylistSkipDuplicates = sp.SkipDuplicates
|
||||
if id == "" /* creating new playlist */ {
|
||||
go func() {
|
||||
err := m.App.ServerManager.Server.CreatePlaylist(sp.SearchDialog.SearchQuery(), trackIDs)
|
||||
if err == nil {
|
||||
notifySuccess(len(trackIDs))
|
||||
} else {
|
||||
log.Printf("error adding tracks to playlist: %s", err.Error())
|
||||
notifyError()
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
m.App.Config.Application.DefaultPlaylistID = id
|
||||
if sp.SkipDuplicates {
|
||||
go func() {
|
||||
currentTrackIDs := make(map[string]struct{})
|
||||
if selectedPlaylist, err := m.App.ServerManager.Server.GetPlaylist(id); err != nil {
|
||||
log.Printf("error getting playlist: %s", err.Error())
|
||||
notifyError()
|
||||
} else {
|
||||
for _, track := range selectedPlaylist.Tracks {
|
||||
currentTrackIDs[track.ID] = struct{}{}
|
||||
}
|
||||
filterTrackIDs := sharedutil.FilterSlice(trackIDs, func(trackID string) bool {
|
||||
_, ok := currentTrackIDs[trackID]
|
||||
return !ok
|
||||
})
|
||||
err := m.App.ServerManager.Server.AddPlaylistTracks(id, filterTrackIDs)
|
||||
if err == nil {
|
||||
notifySuccess(len(filterTrackIDs))
|
||||
} else {
|
||||
log.Printf("error adding tracks to playlist: %s", err.Error())
|
||||
notifyError()
|
||||
}
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
go func() {
|
||||
err := m.App.ServerManager.Server.AddPlaylistTracks(id, trackIDs)
|
||||
if err == nil {
|
||||
notifySuccess(len(trackIDs))
|
||||
} else {
|
||||
log.Printf("error adding tracks to playlist: %s", err.Error())
|
||||
notifyError()
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
m.ClosePopUpOnEscape(pop)
|
||||
m.haveModal = true
|
||||
min := sp.MinSize()
|
||||
height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, m.MainWindow.Canvas().Size().Height*0.7))
|
||||
sp.SearchDialog.Show()
|
||||
pop.Resize(fyne.NewSize(min.Width, height))
|
||||
pop.Show()
|
||||
m.MainWindow.Canvas().Focus(sp.GetSearchEntry())
|
||||
}
|
||||
|
||||
func (m *Controller) DoEditPlaylistWorkflow(playlist *mediaprovider.Playlist) {
|
||||
canMakePublic := m.App.ServerManager.Server.CanMakePublicPlaylist()
|
||||
dlg := dialogs.NewEditPlaylistDialog(playlist, canMakePublic)
|
||||
pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas())
|
||||
m.ClosePopUpOnEscape(pop)
|
||||
dlg.OnCanceled = func() {
|
||||
pop.Hide()
|
||||
m.doModalClosed()
|
||||
}
|
||||
dlg.OnDeletePlaylist = func() {
|
||||
pop.Hide()
|
||||
dialog.ShowCustomConfirm(lang.L("Confirm Delete Playlist"), lang.L("OK"), lang.L("Cancel"), layout.NewSpacer(), /*custom content*/
|
||||
func(ok bool) {
|
||||
if !ok {
|
||||
pop.Show()
|
||||
} else {
|
||||
m.doModalClosed()
|
||||
go func() {
|
||||
if err := m.App.ServerManager.Server.DeletePlaylist(playlist.ID); err != nil {
|
||||
log.Printf("error deleting playlist: %s", err.Error())
|
||||
} else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID {
|
||||
// navigate to playlists page if user is still on the page of the deleted playlist
|
||||
fyne.Do(func() { m.NavigateTo(PlaylistsRoute()) })
|
||||
}
|
||||
}()
|
||||
}
|
||||
}, m.MainWindow)
|
||||
}
|
||||
dlg.OnUpdateMetadata = func() {
|
||||
pop.Hide()
|
||||
m.doModalClosed()
|
||||
go func() {
|
||||
err := m.App.ServerManager.Server.EditPlaylist(playlist.ID, dlg.Name, dlg.Description, dlg.IsPublic)
|
||||
if err != nil {
|
||||
log.Printf("error updating playlist: %s", err.Error())
|
||||
} else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID {
|
||||
// if user is on playlist page, reload to get the updates
|
||||
fyne.Do(m.ReloadFunc)
|
||||
}
|
||||
}()
|
||||
}
|
||||
m.haveModal = true
|
||||
pop.Show()
|
||||
}
|
||||
|
||||
// DoConnectToServerWorkflow does the workflow for connecting to the last active server on startup
|
||||
func (c *Controller) DoConnectToServerWorkflow(server *backend.ServerConfig) {
|
||||
pass, err := c.App.ServerManager.GetServerPassword(server.ID)
|
||||
if err != nil {
|
||||
log.Printf("error getting password from keyring: %v", err)
|
||||
c.PromptForLoginAndConnect()
|
||||
return
|
||||
}
|
||||
|
||||
// try connecting to last used server - set up cancelable modal dialog
|
||||
canceled := false
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
dlg := dialog.NewCustom(lang.L("Connecting"), lang.L("Cancel"),
|
||||
widget.NewLabel(fmt.Sprintf(lang.L("Connecting to")+" %s", server.Nickname)), c.MainWindow)
|
||||
dlg.SetOnClosed(func() {
|
||||
canceled = true
|
||||
cancel()
|
||||
})
|
||||
c.haveModal = true
|
||||
dlg.Show()
|
||||
|
||||
// try to connect
|
||||
go func() {
|
||||
defer cancel() // make sure to free up ctx resources if user does not cancel
|
||||
|
||||
if err := c.tryConnectToServer(ctx, server, pass); err != nil {
|
||||
fyne.Do(func() {
|
||||
dlg.Hide()
|
||||
c.haveModal = false
|
||||
if canceled {
|
||||
c.PromptForLoginAndConnect()
|
||||
} else {
|
||||
// connection failure
|
||||
dlg := dialog.NewError(err, c.MainWindow)
|
||||
dlg.SetOnClosed(func() {
|
||||
c.PromptForLoginAndConnect()
|
||||
})
|
||||
c.haveModal = true
|
||||
dlg.Show()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
fyne.Do(func() {
|
||||
dlg.Hide()
|
||||
c.haveModal = false
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Controller) PromptForLoginAndConnect() {
|
||||
d := dialogs.NewLoginDialog(m.App.Config.Servers, m.App.ServerManager.GetServerPassword)
|
||||
pop := widget.NewModalPopUp(d, m.MainWindow.Canvas())
|
||||
d.OnSubmit = func(server *backend.ServerConfig, password string) {
|
||||
d.DisableSubmit()
|
||||
d.SetInfoText(lang.L("Testing connection") + "...")
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := m.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password)
|
||||
fyne.Do(func() {
|
||||
if err == backend.ErrUnreachable {
|
||||
d.SetErrorText(lang.L("Server unreachable"))
|
||||
} else if err != nil {
|
||||
d.SetErrorText(lang.L("Authentication failed"))
|
||||
} else {
|
||||
pop.Hide()
|
||||
m.trySetPasswordAndConnectToServer(server, password)
|
||||
m.doModalClosed()
|
||||
}
|
||||
d.EnableSubmit()
|
||||
})
|
||||
}()
|
||||
}
|
||||
d.OnEditServer = func(server *backend.ServerConfig) {
|
||||
pop.Hide()
|
||||
editD := dialogs.NewAddEditServerDialog(lang.L("Edit server"), true, server, m.MainWindow.Canvas().Focus)
|
||||
editPop := widget.NewModalPopUp(editD, m.MainWindow.Canvas())
|
||||
editD.OnSubmit = func() {
|
||||
d.DisableSubmit()
|
||||
go func() {
|
||||
success := m.testConnectionAndUpdateDialogText(editD)
|
||||
fyne.Do(func() {
|
||||
if success {
|
||||
// connection is good
|
||||
editPop.Hide()
|
||||
server.Hostname = editD.Host
|
||||
server.AltHostname = editD.AltHost
|
||||
server.Nickname = editD.Nickname
|
||||
server.Username = editD.Username
|
||||
server.LegacyAuth = editD.LegacyAuth
|
||||
m.trySetPasswordAndConnectToServer(server, editD.Password)
|
||||
m.doModalClosed()
|
||||
}
|
||||
d.EnableSubmit()
|
||||
})
|
||||
}()
|
||||
}
|
||||
editD.OnCancel = func() {
|
||||
editPop.Hide()
|
||||
pop.Show()
|
||||
}
|
||||
editPop.Show()
|
||||
}
|
||||
d.OnNewServer = func() {
|
||||
pop.Hide()
|
||||
newD := dialogs.NewAddEditServerDialog(lang.L("Add Server"), true, nil, m.MainWindow.Canvas().Focus)
|
||||
newPop := widget.NewModalPopUp(newD, m.MainWindow.Canvas())
|
||||
newD.OnSubmit = func() {
|
||||
d.DisableSubmit()
|
||||
go func() {
|
||||
success := m.testConnectionAndUpdateDialogText(newD)
|
||||
fyne.Do(func() {
|
||||
if success {
|
||||
// connection is good
|
||||
newPop.Hide()
|
||||
conn := backend.ServerConnection{
|
||||
ServerType: newD.ServerType,
|
||||
Hostname: newD.Host,
|
||||
AltHostname: newD.AltHost,
|
||||
Username: newD.Username,
|
||||
LegacyAuth: newD.LegacyAuth,
|
||||
}
|
||||
server := m.App.ServerManager.AddServer(newD.Nickname, conn)
|
||||
m.trySetPasswordAndConnectToServer(server, newD.Password)
|
||||
m.doModalClosed()
|
||||
}
|
||||
d.EnableSubmit()
|
||||
})
|
||||
}()
|
||||
}
|
||||
newD.OnCancel = func() {
|
||||
newPop.Hide()
|
||||
pop.Show()
|
||||
}
|
||||
newPop.Show()
|
||||
}
|
||||
d.OnDeleteServer = func(server *backend.ServerConfig) {
|
||||
pop.Hide()
|
||||
dialog.ShowConfirm(lang.L("Confirm Delete Server"),
|
||||
fmt.Sprintf(lang.L("Are you sure you want to delete the server")+" %q?", server.Nickname),
|
||||
func(ok bool) {
|
||||
if ok {
|
||||
m.App.ServerManager.DeleteServer(server.ID)
|
||||
m.App.DeleteServerCacheDir(server.ID)
|
||||
d.SetServers(m.App.Config.Servers)
|
||||
}
|
||||
if len(m.App.Config.Servers) == 0 {
|
||||
m.PromptForFirstServer()
|
||||
} else {
|
||||
pop.Show()
|
||||
}
|
||||
}, m.MainWindow)
|
||||
|
||||
}
|
||||
m.haveModal = true
|
||||
pop.Show()
|
||||
}
|
||||
|
||||
func (c *Controller) ShowAboutDialog() {
|
||||
dlg := dialogs.NewAboutDialog(c.AppVersion)
|
||||
pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas())
|
||||
@@ -594,6 +314,7 @@ func (c *Controller) ShowAboutDialog() {
|
||||
}
|
||||
|
||||
func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map[string]string) {
|
||||
|
||||
devs, err := c.App.LocalPlayer.ListAudioDevices()
|
||||
if err != nil {
|
||||
log.Printf("error listing audio devices: %v", err)
|
||||
@@ -606,6 +327,7 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
|
||||
_, canSavePlayQueue := c.App.ServerManager.Server.(mediaprovider.CanSavePlayQueue)
|
||||
isLocalPlayer := isEqualizerPlayer
|
||||
bands := c.App.LocalPlayer.Equalizer().BandFrequencies()
|
||||
|
||||
dlg := dialogs.NewSettingsDialog(c.App.Config,
|
||||
devs, themeFiles, bands,
|
||||
c.App.ServerManager.Server.ClientDecidesScrobble(),
|
||||
@@ -641,91 +363,7 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
|
||||
c.ClosePopUpOnEscape(pop)
|
||||
c.haveModal = true
|
||||
pop.Show()
|
||||
}
|
||||
|
||||
func (c *Controller) ShowQuickSearch() {
|
||||
qs := dialogs.NewQuickSearch(c.App.ServerManager.Server, c.App.ImageManager)
|
||||
pop := widget.NewModalPopUp(qs.SearchDialog, c.MainWindow.Canvas())
|
||||
qs.SetOnDismiss(func() {
|
||||
pop.Hide()
|
||||
c.doModalClosed()
|
||||
})
|
||||
qs.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) {
|
||||
pop.Hide()
|
||||
c.doModalClosed()
|
||||
switch contentType {
|
||||
case mediaprovider.ContentTypeAlbum:
|
||||
c.NavigateTo(AlbumRoute(id))
|
||||
case mediaprovider.ContentTypeArtist:
|
||||
c.NavigateTo(ArtistRoute(id))
|
||||
case mediaprovider.ContentTypeTrack:
|
||||
go c.App.PlaybackManager.PlayTrack(id)
|
||||
case mediaprovider.ContentTypePlaylist:
|
||||
c.NavigateTo(PlaylistRoute(id))
|
||||
case mediaprovider.ContentTypeGenre:
|
||||
c.NavigateTo(GenreRoute(id))
|
||||
case mediaprovider.ContentTypeRadioStation:
|
||||
if rp, ok := c.App.ServerManager.Server.(mediaprovider.RadioProvider); ok {
|
||||
if radio, err := rp.GetRadioStation(id); err == nil {
|
||||
go c.App.PlaybackManager.PlayRadioStation(radio)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
c.ClosePopUpOnEscape(pop)
|
||||
c.haveModal = true
|
||||
min := qs.MinSize()
|
||||
height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, c.MainWindow.Canvas().Size().Height*0.7))
|
||||
qs.SearchDialog.Show()
|
||||
pop.Resize(fyne.NewSize(min.Width, height))
|
||||
pop.Show()
|
||||
c.MainWindow.Canvas().Focus(qs.GetSearchEntry())
|
||||
}
|
||||
|
||||
func (c *Controller) trySetPasswordAndConnectToServer(server *backend.ServerConfig, password string) error {
|
||||
if err := c.App.ServerManager.SetServerPassword(server, password); err != nil {
|
||||
log.Printf("error setting keyring credentials: %v", err)
|
||||
// Don't return an error; fall back to just using the password in-memory
|
||||
// User will need to log in with the password on subsequent runs.
|
||||
}
|
||||
return c.tryConnectToServer(context.Background(), server, password)
|
||||
}
|
||||
|
||||
// try to connect to the given server, with the configured timeout added to the context
|
||||
func (c *Controller) tryConnectToServer(ctx context.Context, server *backend.ServerConfig, password string) error {
|
||||
timeout := time.Duration(c.App.Config.Application.RequestTimeoutSeconds) * time.Second
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
if err := c.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.App.ServerManager.ConnectToServer(server, password); err != nil {
|
||||
log.Printf("error connecting to server: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) testConnectionAndUpdateDialogText(dlg *dialogs.AddEditServerDialog) bool {
|
||||
dlg.SetInfoText(lang.L("Testing connection") + "...")
|
||||
conn := backend.ServerConnection{
|
||||
ServerType: dlg.ServerType,
|
||||
Hostname: dlg.Host,
|
||||
AltHostname: dlg.AltHost,
|
||||
Username: dlg.Username,
|
||||
LegacyAuth: dlg.LegacyAuth,
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
err := c.App.ServerManager.TestConnectionAndAuth(ctx, conn, dlg.Password)
|
||||
if err == backend.ErrUnreachable {
|
||||
dlg.SetErrorText(lang.L("Could not reach server") + fmt.Sprintf(" (%s?)", lang.L("wrong URL")))
|
||||
return false
|
||||
} else if err != nil {
|
||||
dlg.SetErrorText(lang.L("Authentication failed") + fmt.Sprintf(" (%s)", lang.L("wrong username/password")))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Controller) doModalClosed() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"github.com/dweymouth/supersonic/backend/player"
|
||||
"github.com/dweymouth/supersonic/backend/player/mpv"
|
||||
"github.com/dweymouth/supersonic/ui/shortcuts"
|
||||
"github.com/dweymouth/supersonic/ui/util"
|
||||
"github.com/dweymouth/supersonic/ui/visualizations"
|
||||
@@ -21,11 +22,13 @@ type visualizationData struct {
|
||||
}
|
||||
|
||||
func (c *Controller) initVisualizations() {
|
||||
c.App.LocalPlayer.OnStopped(c.stopVisualizationAnim)
|
||||
c.App.LocalPlayer.OnPaused(c.stopVisualizationAnim)
|
||||
c.App.LocalPlayer.OnPlaying(func() {
|
||||
if c.peakMeter != nil {
|
||||
c.startVisualizationAnim()
|
||||
c.App.PlaybackManager.OnStopped(c.stopVisualizationAnim)
|
||||
c.App.PlaybackManager.OnPaused(c.stopVisualizationAnim)
|
||||
c.App.PlaybackManager.OnPlaying(func() {
|
||||
if _, ok := c.App.PlaybackManager.CurrentPlayer().(*mpv.Player); ok {
|
||||
if c.peakMeter != nil {
|
||||
c.startVisualizationAnim()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -71,13 +74,13 @@ func (c *Controller) stopVisualizationAnim() {
|
||||
if c.visualizationAnim != nil {
|
||||
c.visualizationAnim.Stop()
|
||||
c.visualizationAnim = nil
|
||||
c.App.LocalPlayer.SetPeaksEnabled(false)
|
||||
// c.App.LocalPlayer.SetPeaksEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) startVisualizationAnim() {
|
||||
if c.visualizationAnim == nil {
|
||||
c.App.LocalPlayer.SetPeaksEnabled(true)
|
||||
//c.App.LocalPlayer.SetPeaksEnabled(true)
|
||||
c.visualizationAnim = fyne.NewAnimation(
|
||||
time.Duration(math.MaxInt64), /*until stopped*/
|
||||
c.tickVisualizations)
|
||||
@@ -86,8 +89,8 @@ func (c *Controller) startVisualizationAnim() {
|
||||
}
|
||||
|
||||
func (c *Controller) tickVisualizations(_ float32) {
|
||||
lP, rP, lRMS, rRMS := c.App.LocalPlayer.GetPeaks()
|
||||
//lP, rP, lRMS, rRMS := c.App.LocalPlayer.GetPeaks()
|
||||
if c.visualizationData.peakMeter != nil {
|
||||
c.visualizationData.peakMeter.UpdatePeaks(lP, rP, lRMS, rRMS)
|
||||
//c.visualizationData.peakMeter.UpdatePeaks(lP, rP, lRMS, rRMS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,31 +5,115 @@ import (
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
myTheme "github.com/dweymouth/supersonic/ui/theme"
|
||||
"github.com/dweymouth/supersonic/ui/util"
|
||||
)
|
||||
|
||||
type QuickSearch struct {
|
||||
SearchDialog *SearchDialog
|
||||
results []*mediaprovider.SearchResult
|
||||
mp mediaprovider.MediaProvider
|
||||
|
||||
OnPlay func(t mediaprovider.ContentType, id string, item any, shuffle bool)
|
||||
OnAddToQueue func(t mediaprovider.ContentType, id string, item any, next bool)
|
||||
OnAddToPlaylist func(t mediaprovider.ContentType, id string, item any)
|
||||
OnSetFavorite func(trackID string, fav bool)
|
||||
OnSetRating func(trackID string, rating int)
|
||||
OnDownload func(track *mediaprovider.Track)
|
||||
OnShare func(trackID string)
|
||||
OnPlaySongRadio func(track *mediaprovider.Track)
|
||||
OnShowTrackInfo func(track *mediaprovider.Track)
|
||||
}
|
||||
|
||||
func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *QuickSearch {
|
||||
q := &QuickSearch{mp: mp}
|
||||
q.SearchDialog = NewSearchDialog(im, lang.L("Search Everywhere"), lang.L("Close"), q.onSearched)
|
||||
q.SearchDialog.OnShowContextMenu = q.showMenu
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *QuickSearch) onSearched(query string) []*mediaprovider.SearchResult {
|
||||
var results []*mediaprovider.SearchResult
|
||||
if query != "" {
|
||||
if res, err := q.mp.SearchAll(query, 20); err != nil {
|
||||
q.results = nil
|
||||
log.Printf("Error searching: %s", err.Error())
|
||||
} else {
|
||||
results = res
|
||||
q.results = res
|
||||
}
|
||||
}
|
||||
return results
|
||||
return q.results
|
||||
}
|
||||
|
||||
func (q *QuickSearch) showMenu(idx int, pos fyne.Position) {
|
||||
cType := q.results[idx].Type
|
||||
id := q.results[idx].ID
|
||||
item := q.results[idx].Item
|
||||
|
||||
canvas := fyne.CurrentApp().Driver().CanvasForObject(q.SearchDialog)
|
||||
|
||||
switch cType {
|
||||
case mediaprovider.ContentTypeTrack:
|
||||
menu := util.NewTrackContextMenu(false, nil)
|
||||
menu.OnPlay = func(shuffle bool) {
|
||||
q.OnPlay(cType, id, item, shuffle)
|
||||
}
|
||||
menu.OnAddToQueue = func(next bool) {
|
||||
q.OnAddToQueue(cType, id, item, next)
|
||||
}
|
||||
menu.OnAddToPlaylist = func() {
|
||||
q.OnAddToPlaylist(cType, id, item)
|
||||
}
|
||||
menu.OnDownload = func() {
|
||||
q.OnDownload(item.(*mediaprovider.Track))
|
||||
}
|
||||
menu.OnFavorite = func(fav bool) {
|
||||
q.OnSetFavorite(id, fav)
|
||||
}
|
||||
menu.OnSetRating = func(rating int) {
|
||||
q.OnSetRating(id, rating)
|
||||
}
|
||||
menu.OnPlaySongRadio = func() {
|
||||
q.OnPlaySongRadio(item.(*mediaprovider.Track))
|
||||
}
|
||||
menu.OnShowInfo = func() {
|
||||
q.OnShowTrackInfo(item.(*mediaprovider.Track))
|
||||
}
|
||||
menu.OnShare = func() {
|
||||
q.OnShare(id)
|
||||
}
|
||||
menu.ShowAtPosition(pos, canvas)
|
||||
default:
|
||||
play := fyne.NewMenuItem(lang.L("Play"), func() {
|
||||
q.OnPlay(cType, id, item, false)
|
||||
})
|
||||
play.Icon = theme.MediaPlayIcon()
|
||||
shuffle := fyne.NewMenuItem(lang.L("Shuffle"), func() {
|
||||
q.OnPlay(cType, id, item, true)
|
||||
})
|
||||
shuffle.Icon = myTheme.ShuffleIcon
|
||||
playNext := fyne.NewMenuItem(lang.L("Play next"), func() {
|
||||
q.OnAddToQueue(cType, id, item, true)
|
||||
})
|
||||
playNext.Icon = myTheme.PlayNextIcon
|
||||
add := fyne.NewMenuItem(lang.L("Add to queue"), func() {
|
||||
q.OnAddToQueue(cType, id, item, false)
|
||||
})
|
||||
add.Icon = theme.ContentAddIcon()
|
||||
menu := fyne.NewMenu("", play, shuffle, playNext, add)
|
||||
|
||||
if cType != mediaprovider.ContentTypeRadioStation {
|
||||
playlist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() {
|
||||
q.OnAddToPlaylist(cType, id, item)
|
||||
})
|
||||
playlist.Icon = myTheme.PlaylistIcon
|
||||
menu.Items = append(menu.Items, playlist)
|
||||
}
|
||||
|
||||
widget.ShowPopUpMenuAtPosition(menu, canvas, pos)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QuickSearch) SetOnDismiss(onDismiss func()) {
|
||||
|
||||
@@ -30,9 +30,10 @@ type SearchDialog struct {
|
||||
// of the dismiss buttons
|
||||
ActionItem fyne.CanvasObject
|
||||
|
||||
OnDismiss func()
|
||||
OnNavigateTo func(mediaprovider.ContentType, string)
|
||||
OnSearched func(string) []*mediaprovider.SearchResult
|
||||
OnDismiss func()
|
||||
OnNavigateTo func(mediaprovider.ContentType, string)
|
||||
OnShowContextMenu func(itemIdx int, pos fyne.Position)
|
||||
OnSearched func(string) []*mediaprovider.SearchResult
|
||||
|
||||
imgSource util.ImageFetcher
|
||||
resultsMutex sync.RWMutex
|
||||
@@ -326,6 +327,12 @@ func (q *searchResult) Tapped(_ *fyne.PointEvent) {
|
||||
q.parent.onSelected(q.index)
|
||||
}
|
||||
|
||||
func (q *searchResult) TappedSecondary(e *fyne.PointEvent) {
|
||||
if q.parent.OnShowContextMenu != nil {
|
||||
q.parent.OnShowContextMenu(q.index, e.AbsolutePosition)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *searchResult) CreateRenderer() fyne.WidgetRenderer {
|
||||
if q.content == nil {
|
||||
q.content = container.NewBorder(nil, nil, container.NewCenter(q.image), nil,
|
||||
|
||||
@@ -39,6 +39,7 @@ var (
|
||||
AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg)
|
||||
ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg)
|
||||
AutoplayIcon fyne.Resource = theme.NewThemedResource(res.ResInfinitySvg)
|
||||
CastIcon fyne.Resource = theme.NewThemedResource(res.ResCastSvg)
|
||||
RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg)
|
||||
FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg)
|
||||
NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -52,12 +52,13 @@ func SelectedIndexes(items []*TrackListModel) []int {
|
||||
return selected
|
||||
}
|
||||
|
||||
func SelectItem(items []*TrackListModel, idx int) {
|
||||
func SelectItem(items []*TrackListModel, idx int) bool {
|
||||
if items[idx].Selected {
|
||||
return
|
||||
return false
|
||||
}
|
||||
UnselectAllItems(items)
|
||||
items[idx].Selected = true
|
||||
return true
|
||||
}
|
||||
|
||||
func SelectAllItems(items []*TrackListModel) {
|
||||
|
||||
@@ -199,7 +199,10 @@ func NewAlbumFilterPopup(filter *AlbumFilterButton) *AlbumFilterPopup {
|
||||
)
|
||||
|
||||
go func() {
|
||||
a.genreFilter.SetGenreList(<-a.filterBtn.genreListChan)
|
||||
genres := <-a.filterBtn.genreListChan
|
||||
fyne.Do(func() {
|
||||
a.genreFilter.SetGenreList(genres)
|
||||
})
|
||||
}()
|
||||
|
||||
return a
|
||||
|
||||
@@ -2,6 +2,7 @@ package widgets
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
@@ -25,6 +26,7 @@ type AuxControls struct {
|
||||
VolumeControl *VolumeControl
|
||||
autoplay *IconButton
|
||||
loop *IconButton
|
||||
cast *IconButton
|
||||
showQueue *IconButton
|
||||
|
||||
container *fyne.Container
|
||||
@@ -35,10 +37,17 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
|
||||
VolumeControl: NewVolumeControl(initialVolume),
|
||||
autoplay: NewIconButton(myTheme.AutoplayIcon, nil),
|
||||
loop: NewIconButton(myTheme.RepeatIcon, nil),
|
||||
cast: NewIconButton(myTheme.CastIcon, nil),
|
||||
showQueue: NewIconButton(myTheme.PlayQueueIcon, nil),
|
||||
}
|
||||
|
||||
a.loop.IconSize = IconButtonSizeSmaller
|
||||
a.loop.SetToolTip(lang.L("Repeat"))
|
||||
a.SetLoopMode(initialLoopMode)
|
||||
|
||||
a.cast.IconSize = IconButtonSizeSmaller
|
||||
a.cast.SetToolTip(lang.L("Cast to device"))
|
||||
|
||||
a.autoplay.Highlighted = initialAutoplay
|
||||
//a.autoplay.IconSize = IconButtonSizeSmaller
|
||||
a.autoplay.SetToolTip(lang.L("Autoplay"))
|
||||
@@ -48,9 +57,10 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
|
||||
a.OnChangeAutoplay(a.autoplay.Highlighted)
|
||||
}
|
||||
}
|
||||
a.SetLoopMode(initialLoopMode)
|
||||
|
||||
a.showQueue.IconSize = IconButtonSizeSmaller
|
||||
a.showQueue.SetToolTip(lang.L("Show play queue"))
|
||||
|
||||
a.container = container.NewHBox(
|
||||
layout.NewSpacer(),
|
||||
container.NewVBox(
|
||||
@@ -58,7 +68,7 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
|
||||
a.VolumeControl,
|
||||
container.New(
|
||||
layout.NewCustomPaddedHBoxLayout(theme.Padding()*1.5),
|
||||
layout.NewSpacer(), a.autoplay, a.loop, a.showQueue, util.NewHSpace(5)),
|
||||
layout.NewSpacer(), a.autoplay, a.loop, a.cast, a.showQueue, util.NewHSpace(5)),
|
||||
layout.NewSpacer(),
|
||||
),
|
||||
)
|
||||
@@ -88,6 +98,16 @@ func (a *AuxControls) SetLoopMode(mode backend.LoopMode) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuxControls) DisableCastButton() {
|
||||
a.cast.Disable()
|
||||
}
|
||||
|
||||
func (a *AuxControls) SetIsRemotePlayer(isRemote bool) {
|
||||
a.cast.Enable()
|
||||
a.cast.Highlighted = isRemote
|
||||
a.cast.Refresh()
|
||||
}
|
||||
|
||||
func (a *AuxControls) SetAutoplay(autoplay bool) {
|
||||
if autoplay == a.autoplay.Highlighted {
|
||||
return
|
||||
@@ -100,6 +120,12 @@ func (a *AuxControls) OnShowPlayQueue(f func()) {
|
||||
a.showQueue.OnTapped = f
|
||||
}
|
||||
|
||||
func (a *AuxControls) OnShowCastMenu(f func(func())) {
|
||||
a.cast.OnTapped = func() {
|
||||
f(a.DisableCastButton)
|
||||
}
|
||||
}
|
||||
|
||||
type volumeSlider struct {
|
||||
widget.Slider
|
||||
|
||||
@@ -145,6 +171,10 @@ type VolumeControl struct {
|
||||
muted bool
|
||||
lastVol int
|
||||
|
||||
setVolDebouncer func()
|
||||
delaySetVolume bool
|
||||
pendingVolume int
|
||||
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
@@ -160,6 +190,15 @@ func NewVolumeControl(initialVol int) *VolumeControl {
|
||||
v.slider.Orientation = widget.Horizontal
|
||||
v.slider.Value = float64(v.lastVol)
|
||||
v.slider.OnChanged = v.onChanged
|
||||
|
||||
// for players that are slow to respond to volume changes
|
||||
// (e.g. DLNA), delay responding to the SetVolume call
|
||||
// to avoid hiccuping from callback "echoes"
|
||||
v.setVolDebouncer = util.NewDebouncer(100*time.Millisecond, func() {
|
||||
v.delaySetVolume = false
|
||||
v.SetVolume(v.pendingVolume)
|
||||
})
|
||||
|
||||
v.container = container.NewHBox(container.NewCenter(v.icon), v.slider)
|
||||
return v
|
||||
}
|
||||
@@ -167,6 +206,11 @@ func NewVolumeControl(initialVol int) *VolumeControl {
|
||||
// Sets the volume that is displayed in the slider.
|
||||
// Does not invoke OnSetVolume callback.
|
||||
func (v *VolumeControl) SetVolume(vol int) {
|
||||
if v.delaySetVolume {
|
||||
v.pendingVolume = vol
|
||||
return
|
||||
}
|
||||
|
||||
if (vol == v.lastVol && !v.muted) || (v.muted && vol == 0) {
|
||||
return
|
||||
}
|
||||
@@ -177,6 +221,8 @@ func (v *VolumeControl) SetVolume(vol int) {
|
||||
|
||||
func (v *VolumeControl) onChanged(volume float64) {
|
||||
vol := int(volume)
|
||||
v.delaySetVolume = true
|
||||
v.setVolDebouncer()
|
||||
v.lastVol = vol
|
||||
v.muted = false
|
||||
v.updateIconForVolume(vol)
|
||||
|
||||
+43
-106
@@ -42,6 +42,7 @@ type PlayQueueList struct {
|
||||
OnPlayItemAt func(idx int)
|
||||
OnPlaySelection func(items []mediaprovider.MediaItem, shuffle bool)
|
||||
OnPlaySelectionNext func(items []mediaprovider.MediaItem)
|
||||
OnPlaySongRadio func(track *mediaprovider.Track)
|
||||
OnAddToQueue func(items []mediaprovider.MediaItem)
|
||||
OnAddToPlaylist func(trackIDs []string)
|
||||
OnSetFavorite func(trackIDs []string, fav bool)
|
||||
@@ -54,11 +55,8 @@ type PlayQueueList struct {
|
||||
OnReorderItems func(idxs []int, reorderTo int)
|
||||
|
||||
useNonQueueMenu bool
|
||||
menu *widget.PopUpMenu // ctx menu for when only tracks are selected
|
||||
radiosMenu *widget.PopUpMenu // ctx menu for when selection contains radios
|
||||
ratingSubmenu *fyne.MenuItem
|
||||
infoMenuItem *fyne.MenuItem
|
||||
shareMenuItem *fyne.MenuItem
|
||||
menu *util.TrackContextMenu // ctx menu for when only tracks are selected
|
||||
radiosMenu *widget.PopUpMenu // ctx menu for when selection contains radios
|
||||
|
||||
nowPlayingID string
|
||||
|
||||
@@ -256,82 +254,24 @@ func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
|
||||
}
|
||||
}
|
||||
|
||||
var menu *widget.PopUpMenu
|
||||
if allTracks {
|
||||
p.ensureTracksMenu()
|
||||
p.ratingSubmenu.Disabled = p.DisableRating
|
||||
p.infoMenuItem.Disabled = len(selected) != 1
|
||||
p.shareMenuItem.Disabled = p.DisableSharing || len(selected) != 1
|
||||
menu = p.menu
|
||||
p.menu.SetRatingDisabled(p.DisableRating)
|
||||
p.menu.SetInfoDisabled(len(selected) != 1)
|
||||
p.menu.SetShareDisabled(p.DisableSharing || len(selected) != 1)
|
||||
p.menu.ShowAtPosition(e.AbsolutePosition, fyne.CurrentApp().Driver().CanvasForObject(p))
|
||||
} else {
|
||||
p.ensureRadiosMenu()
|
||||
menu = p.radiosMenu
|
||||
p.radiosMenu.ShowAtPosition(e.AbsolutePosition)
|
||||
}
|
||||
menu.ShowAtPosition(e.AbsolutePosition)
|
||||
|
||||
}
|
||||
|
||||
func (p *PlayQueueList) ensureTracksMenu() {
|
||||
if p.menu != nil {
|
||||
return
|
||||
}
|
||||
var menuItems []*fyne.MenuItem
|
||||
if p.useNonQueueMenu {
|
||||
menuItems = append(menuItems, p.createQueueActionMenuItems()...)
|
||||
menuItems = append(menuItems, fyne.NewMenuItemSeparator())
|
||||
}
|
||||
|
||||
playlist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() {
|
||||
if p.OnAddToPlaylist != nil {
|
||||
p.OnAddToPlaylist(p.selectedItemIDs())
|
||||
}
|
||||
})
|
||||
playlist.Icon = myTheme.PlaylistIcon
|
||||
download := fyne.NewMenuItem(lang.L("Download")+"...", func() {
|
||||
if p.OnDownload != nil {
|
||||
p.OnDownload(p.selectedTracks(), "Selected tracks")
|
||||
}
|
||||
})
|
||||
download.Icon = theme.DownloadIcon()
|
||||
p.infoMenuItem = fyne.NewMenuItem(lang.L("Show info")+"...", func() {
|
||||
if p.OnShowTrackInfo != nil {
|
||||
p.OnShowTrackInfo(p.selectedTracks()[0])
|
||||
}
|
||||
})
|
||||
p.infoMenuItem.Icon = theme.InfoIcon()
|
||||
p.shareMenuItem = fyne.NewMenuItem(lang.L("Share")+"...", func() {
|
||||
if p.OnShare != nil {
|
||||
p.OnShare(p.selectedTracks())
|
||||
}
|
||||
})
|
||||
p.shareMenuItem.Icon = myTheme.ShareIcon
|
||||
favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() {
|
||||
if p.OnSetFavorite != nil {
|
||||
p.OnSetFavorite(p.selectedItemIDs(), true)
|
||||
}
|
||||
})
|
||||
favorite.Icon = myTheme.FavoriteIcon
|
||||
unfavorite := fyne.NewMenuItem(lang.L("Unset favorite"), func() {
|
||||
if p.OnSetFavorite != nil {
|
||||
p.OnSetFavorite(p.selectedItemIDs(), false)
|
||||
}
|
||||
})
|
||||
unfavorite.Icon = myTheme.NotFavoriteIcon
|
||||
p.ratingSubmenu = util.NewRatingSubmenu(func(rating int) {
|
||||
if p.OnSetRating != nil {
|
||||
p.OnSetRating(p.selectedItemIDs(), rating)
|
||||
}
|
||||
})
|
||||
menuItems = append(menuItems,
|
||||
playlist,
|
||||
download,
|
||||
p.infoMenuItem,
|
||||
p.shareMenuItem,
|
||||
fyne.NewMenuItemSeparator(),
|
||||
favorite,
|
||||
unfavorite,
|
||||
p.ratingSubmenu,
|
||||
)
|
||||
|
||||
var auxItems []*fyne.MenuItem
|
||||
if !p.useNonQueueMenu {
|
||||
remove := fyne.NewMenuItem(lang.L("Remove from queue"), func() {
|
||||
if p.OnRemoveFromQueue != nil {
|
||||
@@ -339,15 +279,40 @@ func (p *PlayQueueList) ensureTracksMenu() {
|
||||
}
|
||||
})
|
||||
remove.Icon = theme.ContentRemoveIcon()
|
||||
menuItems = append(menuItems,
|
||||
fyne.NewMenuItemSeparator(),
|
||||
remove)
|
||||
auxItems = append(auxItems, remove)
|
||||
}
|
||||
p.menu = util.NewTrackContextMenu(!p.useNonQueueMenu, auxItems)
|
||||
p.menu.OnPlay = func(shuffle bool) {
|
||||
p.OnPlaySelection(p.selectedItems(), shuffle)
|
||||
}
|
||||
p.menu.OnAddToQueue = func(next bool) {
|
||||
if next {
|
||||
p.OnPlaySelectionNext(p.selectedItems())
|
||||
} else {
|
||||
p.OnAddToQueue(p.selectedItems())
|
||||
}
|
||||
}
|
||||
p.menu.OnPlaySongRadio = func() {
|
||||
p.OnPlaySongRadio(p.selectedTracks()[0])
|
||||
}
|
||||
p.menu.OnDownload = func() {
|
||||
p.OnDownload(p.selectedTracks(), "Selected tracks")
|
||||
}
|
||||
p.menu.OnAddToPlaylist = func() {
|
||||
p.OnAddToPlaylist(p.selectedItemIDs())
|
||||
}
|
||||
p.menu.OnShowInfo = func() {
|
||||
p.OnShowTrackInfo(p.selectedTracks()[0])
|
||||
}
|
||||
p.menu.OnShare = func() {
|
||||
p.OnShare(p.selectedTracks())
|
||||
}
|
||||
p.menu.OnSetRating = func(rating int) {
|
||||
p.OnSetRating(p.selectedItemIDs(), rating)
|
||||
}
|
||||
p.menu.OnFavorite = func(fav bool) {
|
||||
p.OnSetFavorite(p.selectedItemIDs(), fav)
|
||||
}
|
||||
|
||||
p.menu = widget.NewPopUpMenu(
|
||||
fyne.NewMenu("", menuItems...),
|
||||
fyne.CurrentApp().Driver().CanvasForObject(p),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *PlayQueueList) ensureRadiosMenu() {
|
||||
@@ -366,34 +331,6 @@ func (p *PlayQueueList) ensureRadiosMenu() {
|
||||
)
|
||||
}
|
||||
|
||||
func (p *PlayQueueList) createQueueActionMenuItems() []*fyne.MenuItem {
|
||||
play := fyne.NewMenuItem(lang.L("Play"), func() {
|
||||
if p.OnPlaySelection != nil {
|
||||
p.OnPlaySelection(p.selectedItems(), false)
|
||||
}
|
||||
})
|
||||
play.Icon = theme.MediaPlayIcon()
|
||||
shuffle := fyne.NewMenuItem(lang.L("Shuffle"), func() {
|
||||
if p.OnPlaySelection != nil {
|
||||
p.OnPlaySelection(p.selectedItems(), true)
|
||||
}
|
||||
})
|
||||
shuffle.Icon = myTheme.ShuffleIcon
|
||||
playNext := fyne.NewMenuItem(lang.L("Play next"), func() {
|
||||
if p.OnPlaySelection != nil {
|
||||
p.OnPlaySelectionNext(p.selectedItems())
|
||||
}
|
||||
})
|
||||
playNext.Icon = myTheme.PlayNextIcon
|
||||
add := fyne.NewMenuItem(lang.L("Add to queue"), func() {
|
||||
if p.OnPlaySelection != nil {
|
||||
p.OnAddToQueue(p.selectedItems())
|
||||
}
|
||||
})
|
||||
add.Icon = theme.ContentAddIcon()
|
||||
return []*fyne.MenuItem{play, shuffle, playNext, add}
|
||||
}
|
||||
|
||||
func (t *PlayQueueList) selectedItems() []mediaprovider.MediaItem {
|
||||
t.tracksMutex.RLock()
|
||||
defer t.tracksMutex.RUnlock()
|
||||
|
||||
@@ -17,7 +17,9 @@ import (
|
||||
type SearchEntry struct {
|
||||
widget.Entry
|
||||
|
||||
OnSearched func(string)
|
||||
minWidth float32
|
||||
OnSearched func(string)
|
||||
OnFocusLost func()
|
||||
}
|
||||
|
||||
func NewSearchEntry() *SearchEntry {
|
||||
@@ -53,13 +55,28 @@ func (s *SearchEntry) TypedKey(e *fyne.KeyEvent) {
|
||||
s.Entry.TypedKey(e)
|
||||
}
|
||||
|
||||
func (s *SearchEntry) FocusLost() {
|
||||
s.Entry.FocusLost()
|
||||
if s.OnFocusLost != nil {
|
||||
s.OnFocusLost()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SearchEntry) Refresh() {
|
||||
s.updateActionButton()
|
||||
s.Entry.Refresh()
|
||||
}
|
||||
|
||||
func (s *SearchEntry) SetMinWidth(w float32) {
|
||||
s.minWidth = w
|
||||
}
|
||||
|
||||
func (s *SearchEntry) MinSize() fyne.Size {
|
||||
return fyne.NewSize(200, s.Entry.MinSize().Height)
|
||||
w := s.minWidth
|
||||
if w == 0 {
|
||||
w = 200
|
||||
}
|
||||
return fyne.NewSize(w, s.Entry.MinSize().Height)
|
||||
}
|
||||
|
||||
func (s *SearchEntry) updateActionButton() bool {
|
||||
|
||||
+44
-92
@@ -6,13 +6,10 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"fyne.io/fyne/v2/lang"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/layouts"
|
||||
myTheme "github.com/dweymouth/supersonic/ui/theme"
|
||||
"github.com/dweymouth/supersonic/ui/util"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
@@ -93,17 +90,13 @@ type Tracklist struct {
|
||||
tracks []*util.TrackListModel
|
||||
tracksOrigOrder []*util.TrackListModel
|
||||
|
||||
nowPlayingID string
|
||||
colLayout *layouts.ColumnsLayout
|
||||
hdr *ListHeader
|
||||
list *FocusList
|
||||
ctxMenu *fyne.Menu
|
||||
ratingSubmenu *fyne.MenuItem
|
||||
shareMenuItem *fyne.MenuItem
|
||||
songRadioMenuItem *fyne.MenuItem
|
||||
infoMenuItem *fyne.MenuItem
|
||||
loadingDots *LoadingDots
|
||||
container *fyne.Container
|
||||
nowPlayingID string
|
||||
colLayout *layouts.ColumnsLayout
|
||||
hdr *ListHeader
|
||||
list *FocusList
|
||||
ctxMenu *util.TrackContextMenu
|
||||
loadingDots *LoadingDots
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
func NewTracklist(tracks []*mediaprovider.Track, im *backend.ImageManager, useCompactRows bool) *Tracklist {
|
||||
@@ -342,7 +335,7 @@ func (t *Tracklist) GetTracks() []*mediaprovider.Track {
|
||||
|
||||
// Append more tracks to the tracklist.
|
||||
func (t *Tracklist) AppendTracks(trs []*mediaprovider.Track) {
|
||||
t.tracksOrigOrder = append(t.tracks, util.ToTrackListModels(trs)...)
|
||||
t.tracksOrigOrder = append(t.tracksOrigOrder, util.ToTrackListModels(trs)...)
|
||||
t.doSortTracks()
|
||||
t.list.Refresh()
|
||||
}
|
||||
@@ -491,8 +484,8 @@ func (t *Tracklist) selectAddOrRemove(idx int) {
|
||||
t.tracks[idx].Selected = !t.tracks[idx].Selected
|
||||
}
|
||||
|
||||
func (t *Tracklist) selectTrack(idx int) {
|
||||
util.SelectItem(t.tracks, idx)
|
||||
func (t *Tracklist) selectTrack(idx int) bool {
|
||||
return util.SelectItem(t.tracks, idx)
|
||||
}
|
||||
|
||||
func (t *Tracklist) selectRange(idx int) {
|
||||
@@ -500,88 +493,47 @@ func (t *Tracklist) selectRange(idx int) {
|
||||
}
|
||||
|
||||
func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
|
||||
t.selectTrack(trackIdx)
|
||||
t.list.Refresh()
|
||||
if t.selectTrack(trackIdx) {
|
||||
t.list.Refresh()
|
||||
}
|
||||
if t.ctxMenu == nil {
|
||||
t.ctxMenu = fyne.NewMenu("")
|
||||
if !t.Options.DisablePlaybackMenu {
|
||||
play := fyne.NewMenuItem(lang.L("Play"), func() {
|
||||
if t.OnPlaySelection != nil {
|
||||
t.OnPlaySelection(t.selectedTracks(), false)
|
||||
}
|
||||
})
|
||||
play.Icon = theme.MediaPlayIcon()
|
||||
shuffle := fyne.NewMenuItem(lang.L("Shuffle"), func() {
|
||||
if t.OnPlaySelection != nil {
|
||||
t.OnPlaySelection(t.selectedTracks(), true)
|
||||
}
|
||||
})
|
||||
shuffle.Icon = myTheme.ShuffleIcon
|
||||
playNext := fyne.NewMenuItem(lang.L("Play next"), func() {
|
||||
if t.OnPlaySelection != nil {
|
||||
t.OnPlaySelectionNext(t.selectedTracks())
|
||||
}
|
||||
})
|
||||
playNext.Icon = myTheme.PlayNextIcon
|
||||
add := fyne.NewMenuItem(lang.L("Add to queue"), func() {
|
||||
if t.OnPlaySelection != nil {
|
||||
t.OnAddToQueue(t.selectedTracks())
|
||||
}
|
||||
})
|
||||
add.Icon = theme.ContentAddIcon()
|
||||
t.songRadioMenuItem = fyne.NewMenuItem(lang.L("Play song radio"), func() {
|
||||
t.onPlaySongRadio(t.selectedTracks())
|
||||
})
|
||||
t.songRadioMenuItem.Icon = myTheme.RadioIcon
|
||||
t.ctxMenu.Items = append(t.ctxMenu.Items,
|
||||
play, shuffle, playNext, add, t.songRadioMenuItem)
|
||||
t.ctxMenu = util.NewTrackContextMenu(t.Options.DisablePlaybackMenu, t.Options.AuxiliaryMenuItems)
|
||||
t.ctxMenu.OnPlay = func(shuffle bool) {
|
||||
t.OnPlaySelection(t.selectedTracks(), shuffle)
|
||||
}
|
||||
playlist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() {
|
||||
if t.OnAddToPlaylist != nil {
|
||||
t.OnAddToPlaylist(t.SelectedTrackIDs())
|
||||
t.ctxMenu.OnAddToQueue = func(next bool) {
|
||||
if next {
|
||||
t.OnPlaySelectionNext(t.selectedTracks())
|
||||
} else {
|
||||
t.OnAddToQueue(t.selectedTracks())
|
||||
}
|
||||
})
|
||||
playlist.Icon = myTheme.PlaylistIcon
|
||||
download := fyne.NewMenuItem(lang.L("Download")+"...", func() {
|
||||
}
|
||||
t.ctxMenu.OnPlaySongRadio = func() {
|
||||
t.onPlaySongRadio(t.selectedTracks())
|
||||
}
|
||||
t.ctxMenu.OnAddToPlaylist = func() {
|
||||
t.OnAddToPlaylist(t.SelectedTrackIDs())
|
||||
}
|
||||
t.ctxMenu.OnDownload = func() {
|
||||
t.onDownload(t.selectedTracks(), "Selected tracks")
|
||||
})
|
||||
download.Icon = theme.DownloadIcon()
|
||||
t.infoMenuItem = fyne.NewMenuItem(lang.L("Show info")+"...", func() {
|
||||
if t.OnShowTrackInfo != nil {
|
||||
t.OnShowTrackInfo(t.selectedTracks()[0])
|
||||
}
|
||||
})
|
||||
t.infoMenuItem.Icon = theme.InfoIcon()
|
||||
favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() {
|
||||
t.onSetFavorites(t.selectedTracks(), true, true)
|
||||
})
|
||||
favorite.Icon = myTheme.FavoriteIcon
|
||||
unfavorite := fyne.NewMenuItem(lang.L("Unset favorite"), func() {
|
||||
t.onSetFavorites(t.selectedTracks(), false, true)
|
||||
})
|
||||
unfavorite.Icon = myTheme.NotFavoriteIcon
|
||||
t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator(),
|
||||
playlist, download, t.infoMenuItem)
|
||||
t.shareMenuItem = fyne.NewMenuItem(lang.L("Share")+"...", func() {
|
||||
}
|
||||
t.ctxMenu.OnShowInfo = func() {
|
||||
t.OnShowTrackInfo(t.selectedTracks()[0])
|
||||
}
|
||||
t.ctxMenu.OnFavorite = func(fav bool) {
|
||||
t.onSetFavorites(t.selectedTracks(), fav, true /*needRefresh*/)
|
||||
}
|
||||
t.ctxMenu.OnShare = func() {
|
||||
t.onShare(t.selectedTracks())
|
||||
})
|
||||
t.shareMenuItem.Icon = myTheme.ShareIcon
|
||||
t.ctxMenu.Items = append(t.ctxMenu.Items, t.shareMenuItem)
|
||||
t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator())
|
||||
t.ctxMenu.Items = append(t.ctxMenu.Items, favorite, unfavorite)
|
||||
t.ratingSubmenu = util.NewRatingSubmenu(func(rating int) {
|
||||
t.onSetRatings(t.selectedTracks(), rating, true)
|
||||
})
|
||||
t.ctxMenu.Items = append(t.ctxMenu.Items, t.ratingSubmenu)
|
||||
if len(t.Options.AuxiliaryMenuItems) > 0 {
|
||||
t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator())
|
||||
t.ctxMenu.Items = append(t.ctxMenu.Items, t.Options.AuxiliaryMenuItems...)
|
||||
}
|
||||
t.ctxMenu.OnSetRating = func(rating int) {
|
||||
t.onSetRatings(t.selectedTracks(), rating, true /*needRefresh*/)
|
||||
}
|
||||
}
|
||||
t.ratingSubmenu.Disabled = t.Options.DisableRating
|
||||
t.shareMenuItem.Disabled = t.Options.DisableSharing || len(t.selectedTracks()) != 1
|
||||
t.infoMenuItem.Disabled = len(t.selectedTracks()) != 1
|
||||
widget.ShowPopUpMenuAtPosition(t.ctxMenu, fyne.CurrentApp().Driver().CanvasForObject(t), e.AbsolutePosition)
|
||||
t.ctxMenu.SetRatingDisabled(t.Options.DisableRating)
|
||||
t.ctxMenu.SetShareDisabled(t.Options.DisableSharing || len(t.selectedTracks()) != 1)
|
||||
t.ctxMenu.SetInfoDisabled(len(t.selectedTracks()) != 1)
|
||||
t.ctxMenu.ShowAtPosition(e.AbsolutePosition, fyne.CurrentApp().Driver().CanvasForObject(t))
|
||||
}
|
||||
|
||||
func (t *Tracklist) onSetFavorite(trackID string, fav bool) {
|
||||
|
||||
@@ -72,7 +72,7 @@ func (t *TracklistLoader) loadMoreTracks(num int) {
|
||||
if t.disposed.Load() {
|
||||
return
|
||||
}
|
||||
fyne.Do(func() {
|
||||
fyne.DoAndWait(func() {
|
||||
t.tracklist.SetLoading(false)
|
||||
t.tracklist.AppendTracks(t.trackBuffer)
|
||||
t.len += len(t.trackBuffer)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
|
||||
|
||||
#define MyAppName "Supersonic"
|
||||
#define MyAppVersion "0.14.0"
|
||||
#define MyAppVersion "0.15.1"
|
||||
#define MyAppPublisher "Drew Weymouth"
|
||||
#define MyAppURL "https://github.com/dweymouth/supersonic"
|
||||
#define MyAppExeName "Supersonic.exe"
|
||||
|
||||
Reference in New Issue
Block a user