diff --git a/.github/workflows/build-appimage-compat.yml b/.github/workflows/build-appimage-compat.yml index 3c8577c..982962f 100644 --- a/.github/workflows/build-appimage-compat.yml +++ b/.github/workflows/build-appimage-compat.yml @@ -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: | diff --git a/.github/workflows/build-appimage.yml b/.github/workflows/build-appimage.yml index 1bc96c2..6715d6a 100644 --- a/.github/workflows/build-appimage.yml +++ b/.github/workflows/build-appimage.yml @@ -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: | diff --git a/.github/workflows/build-macos-arm64.yml b/.github/workflows/build-macos-arm64.yml index 8a017a7..e1669ad 100644 --- a/.github/workflows/build-macos-arm64.yml +++ b/.github/workflows/build-macos-arm64.yml @@ -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 diff --git a/.github/workflows/build-macos-x64.yml b/.github/workflows/build-macos-x64.yml index 5d97b6d..51f958f 100644 --- a/.github/workflows/build-macos-x64.yml +++ b/.github/workflows/build-macos-x64.yml @@ -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 && diff --git a/.github/workflows/build-ubuntu-22.04.yml b/.github/workflows/build-ubuntu-22.04.yml index 2e4d48b..33b8a6a 100644 --- a/.github/workflows/build-ubuntu-22.04.yml +++ b/.github/workflows/build-ubuntu-22.04.yml @@ -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 diff --git a/.github/workflows/build-ubuntu-24.04.yml b/.github/workflows/build-ubuntu-24.04.yml index b2402e3..53c0e83 100644 --- a/.github/workflows/build-ubuntu-24.04.yml +++ b/.github/workflows/build-ubuntu-24.04.yml @@ -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 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 1d8e7dd..7775e3d 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d25b8a0..12de2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/FyneApp.toml b/FyneApp.toml index 0add8c8..cc5c35c 100644 --- a/FyneApp.toml +++ b/FyneApp.toml @@ -1,7 +1,7 @@ [Details] Icon = "res/appicon-512.png" Name = "Supersonic" - Version = "0.14.0" + Version = "0.15.1" [LinuxAndBSD] Categories = ["Audio", "AudioVideo"] diff --git a/Makefile b/Makefile index f91cfdf..8ac1bc2 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index becd07a..4427104 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/app.go b/backend/app.go index d93309f..9e0183c 100644 --- a/backend/app.go +++ b/backend/app.go @@ -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() } diff --git a/backend/mediaprovider/jellyfin/searchall.go b/backend/mediaprovider/jellyfin/searchall.go index 09908d2..1494818 100644 --- a/backend/mediaprovider/jellyfin/searchall.go +++ b/backend/mediaprovider/jellyfin/searchall.go @@ -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), }) } diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 4c65c80..bd7b4d7 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -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 } diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go index ffb099f..e7f7d78 100644 --- a/backend/mediaprovider/subsonic/searchall.go +++ b/backend/mediaprovider/subsonic/searchall.go @@ -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, }) } diff --git a/backend/mpmedia_mac.go b/backend/mpmedia_mac.go index dff1fcc..e7616ee 100644 --- a/backend/mpmedia_mac.go +++ b/backend/mpmedia_mac.go @@ -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 diff --git a/backend/mpris.go b/backend/mpris.go index 9d90da4..f2921a4 100644 --- a/backend/mpris.go +++ b/backend/mpris.go @@ -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) { diff --git a/backend/playbackcommands.go b/backend/playbackcommands.go index cb06459..2632fef 100644 --- a/backend/playbackcommands.go +++ b/backend/playbackcommands.go @@ -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}) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 17295c3..09e7192 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -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 } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 95debbb..153760f 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -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() + } } } } diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go new file mode 100644 index 0000000..0dcb38f --- /dev/null +++ b/backend/player/dlna/dlnaplayer.go @@ -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) + } +} diff --git a/backend/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go index 3518beb..0dd63b8 100644 --- a/backend/player/jukebox/jukeboxplayer.go +++ b/backend/player/jukebox/jukeboxplayer.go @@ -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 { diff --git a/backend/player/mpv/player.go b/backend/player/mpv/player.go index e97535b..3c5965a 100644 --- a/backend/player/mpv/player.go +++ b/backend/player/mpv/player.go @@ -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 diff --git a/backend/player/player.go b/backend/player/player.go index 778c941..877fcea 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -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() } } diff --git a/backend/savedplayqueue.go b/backend/savedplayqueue.go index fd6e025..5efc0d8 100644 --- a/backend/savedplayqueue.go +++ b/backend/savedplayqueue.go @@ -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)) diff --git a/backend/util/util.go b/backend/util/util.go index 9111db8..bf573c8 100644 --- a/backend/util/util.go +++ b/backend/util/util.go @@ -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") +} diff --git a/go.mod b/go.mod index b4f1784..dcbf747 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 8dde906..c26a6e2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/res/bundled.go b/res/bundled.go index dfb3f95..590f9a0 100644 --- a/res/bundled.go +++ b/res/bundled.go @@ -20,6 +20,11 @@ var ResPlaybuttonPng = &fyne.StaticResource{ StaticContent: []byte( "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x80\x00\x00\x00\x80\b\x03\x00\x00\x00\xf4\xe0\x91\xf9\x00\x00\x00\x04gAMA\x00\x00\xb1\x8f\v\xfca\x05\x00\x00\x00 cHRM\x00\x00z&\x00\x00\x80\x84\x00\x00\xfa\x00\x00\x00\x80\xe8\x00\x00u0\x00\x00\xea`\x00\x00:\x98\x00\x00\x17p\x9c\xbaQ<\x00\x00\x01\xcePLTE\x00\x00\x00\"\xd7)!\xd3(#\xe2+!\xd6( \xce'\"\xd9)!\xd5(\"\xd6)\x1b\xae!!\xd2(\"\xd8)\x1b\xb0!\"\xd8)!\xd5(!\xd5(\"\xd7)!\xd6(!\xd6(!\xd6(!\xd6(\"\xd7)!\xd6(\"\xdb)!\xd6(!\xd6(!\xd6(!\xd6(!\xd6(\"\xd7)\"\xd6)\"\xd6)\"\xd7)!\xd6(!\xd6(!\xd6(!\xd6(!\xd6(\"\xd7)!\xd6(!\xd5(!\xd6(\"\xd7)!\xd6(#\xde*!\xd5(!\xd6(!\xd6(!\xd6( \xcd'!\xd3(!\xd6(!\xd6(!\xd2(!\xd5(!\xd6(!\xd6(!\xd6(!\xd6(\"\xd7)!\xd6(!\xd6(\"\xd7)!\xd6(!\xd5(!\xd6(\"\xd6)\"\xd7)\"\xd6)!\xd6(!\xd6(\"\xd6)!\xd6(!\xd5(!\xd6(\"\xd6)!\xd6(!\xd6(!\xd6(\"\xd6)!\xd5(!\xd6(\"\xd6)\"\xd7)\"\xd7)!\xd6(\"\xd7)!\xd6(!\xd5(\"\xd6)!\xd6(!\xd5(\"\xd7)!\xd6(!\xd6(!\xd6(!\xd6(!\xd5(!\xd6(!\xd4(!\xd6(!\xd5(!\xd6(!\xd6(!\xd5(\"\xd7)\"\xd7)!\xd6(!\xd6(\"\xd7)\"\xd7)\"\xd6)!\xd6(!\xd6(\"\xd7)\"\xd6) \xd6'(\xd8/)\xd80\x1f\xd6&>\xdcD\x97\xec\x9b\xc3\xf4\xc5\xc6\xf4Ǐ\xea\x93?\xdcE\xc5\xf4\xc7\xff\xff\xff\xfe\xfe\xfe\xdc\xf8\xddz\xe6~-\xd94\x97\xec\x9a\xfa\xfe\xfa\xba\xf2\xbcQ\xdfW\xec\xfb\xec\x90\xeb\x944\xda:9\xdb?\xe0\xf9\xe1\xfc\xfe\xfc\xcf\xf6\xd0f\xe3k$\xd7+<\xdbB\xe4\xfa\xe5\xf2\xfc\xf2\xa7\xef\xa9D\xddI\xbf\xf3\xc1&\xd7-\x98원\xea\x93(2|%\x00\x00\x00qtRNS\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x04\x141S\x7f\x93\xbe\xcf\xdd\x01\x1cEq\xa6\xcb\xe7\xf8\xfe\v3f\xa9\xd9\xfe\x05'e\xb4\xee\xfd\x01\x11L\xa0\xe4\x01\x16\xc7\xf6\x15o\xd0\xfb\x14\xca\nM\xbf\xfc\x01)\xa1\xf5\ro(\xa4\xfb\x04Q\xd7\x0f\x80\xf0\"\xfc*\xbdCC\xdd\xd6\"\x0e\x03\xfd)\rp\tN\x15\xc9p\xfc\x17\xce\x12Ɵ\n3\xed\xa8\xd8\x03\xa60\x92\x9c\x8e\xa7 \x00\x00\x00\x01bKGD\x7fH\xbfq\xe5\x00\x00\x00\atIME\a\xe7\x01\x13\x01%2\x99\xbf\xbb\v\x00\x00\x04[IDATx\xda\xe5\x9bgC\x13A\x10\x86wÑP\x83\xb4H\rx\x82tA\x9a\"\x1d%H\x93Б\xa2\"Ho\xb7\x89\x82\xf5,\xd8{\x03\xfd\xb7\xe6\x12\x90\x00Iؽ\xdd\xcb|\xf0\xfd\xbe\xf7>\x99\x9d\xdcm\x99A\x88Q8ޚp*1)9%զ\x90=)\xb6Ԕ\xe4\xa4\xc4S\t\xd6x\xcc\xfa<6\xf3\xd3ִ\xf4\x8c̬l{\x0e9\xa6\x1c{vVfFz\x9a\xf5\xb4A\x10\x18瞑\xcf\xe6\xd9\xf3I\b\xe5\xdb\xf3\xce\xcagr\xb1x\x06|\xae\xa0\xb0\xa8\xb8\x84P\xa8\xa4\xb8\xa8\xb0\xe0\x9cP\x04\\Zv\xbe\xbc\xa2\xc4Ec\xaf\xc9URQ~\xbe\xacT\x14\x83\xe9B\xa5\\UMk\xbe\xaf\xea*\xb9\xf2\x82I\x84}DM\xed\xc5K\xac\xf6\x9a.]\xac\xad\x89\xe0E\xc0R\x9d|Y\x97\xbd\x17\xe1\xb2\\'qM\x04\xaeohl\xd2k\xaf\xa9\xa9\xb1\xa1^?\x01nnim\xa3μ\xc0r\xb5\xb5\xb64\xebD\xc0W\xae\x1e\xbc\xec\xf4K\xb1]\xbd\xa2\x87\x00G\xb6;:\xf8\xed5u8\xda#\x99\x11\xf0\xb5\xce.\xce\xe8\xfb\xcdCW\xe75F\x02\xdc\xdd\xd3+\xcc\xdfC\xd0\xdb\xd3\xcdB\x80\xa5\xeb\x8e>q\xf6\x9a\xfa\x1c\xd7\xe9\xff\x90\xd8\xec\xec\x17k\xaf\xa9\xdfi\xa6$\xc0x\xa0K@\xf6\x1f\x95\xd25@\xf7\x91\xc4xpH\xbc\xbd\xa6\xa1A\x1a\x02\xe3\xfc\xe9\b\x8c\xf4\xa7!0\xd6\xffd\x02lq\x0e\x1b\xe9OȰ\xd3\x12\x8a\x00\x8f\xf4\x1b\x90\xff\xfeR\xfaGB\x00H\xa3\x0ec\xed59F\xa5\xa0\xfec=7\x8c\a\xb8\xd13\x16\x84\x00\xe3\xce^\xe3\xfd\t\xe9\xed\f\x92\x88\xd2\xf8\xb0\xc0\xefOp\xb9\x86\xc7\x03\x86@\x9ap\x84\xc5\xdfC\xe0\x98\b@\x80\xa3&\x05\xad?NV\xc7d\xd4\xf1I\xb08m\xe1\xf2'\xc4\xe6\xb4\x1c\x9b\x80\xa9V\x83\xdf\x00\xfeRZ\xa7\x8eLB\xb4\xf9f[\xf8\xfc\ti\xbbi\x8e>\x1c\x80[\x8da\xca@\x9f\\\x8d\xb7\x0e\x85 &V\xe6\xda\x7f\xb0\xabI\x8e\x8d\xf1\x030U\xde\x0e\xaf?!\xb7+\xfd\xb6\x8dX\x9a\xbe\x13n\x80;\xd3~\x8bT\xd3\xccl\xb8\xfd\t\x99\x9d1\x1d\x04@\x0e{\x00ݢ\x8d\x81A\x00\xea6\xed\xc2\xdd(\x80\x9d\xdd\xff\x1d\x00z\n\x80\x93\x90\xe9o\b\xfe\"\x02\x7f\x15\x83\x7f\x8c\xc0?\xc7\xe0\v\x12\xf0%\x19\xf8\xa2\x14|Y\x0e\xbe1\x01ߚ\x81oN\xc1\xb7\xe7\xe0\a\x14\xe0G4\xe0\x87T\xe0\xc7t\xf0\a\x95\xe0G\xb5\xe0\x87\xd5\xcc\xc7\xf5\xbfwv\xfe\b=\xaeg\xbe\xb0\xd8\xdd\x15{a\x01\x7fe\x03~i\x05~m\a\x7fq\t~u\v\x7fy\r~}\x0f_\xc0\x00^\xc2\x01_\xc4\x02_\xc6\x03^\xc8\x04_\xca\x05_\xcc\x06^\xce\a_\xd0\b_\xd2\t_\xd4\n^\xd6\v_\xd8\f_\xda\r_\xdc\x0e_\xde\x0f\xdf\xe0\x00\xdf\xe2\x81\xc0\x9b\\\x10|\x9b\x0f|\xa3\x13\x02o\xf5B\xf0\xcdn\b\xbc\xdd\x0f\xc17<\"\xf0\x96O/\x02lӫw\"`\xdb~}\f\xa0\x8d\xcf>\x04\xd8\xd6o\x1f\x03h\xf3\xfb>\x84\xe0\xf6\xff\xbfN\xd2\xc9+\xf1{\x02!\x00\x00\x00%tEXtdate:create\x002023-01-19T01:37:03+00:00/\x9b0\x17\x00\x00\x00%tEXtdate:modify\x002023-01-19T01:36:39+00:00\x9b\xfb\xbb8\x00\x00\x00\x00IEND\xaeB`\x82"), } +var ResCastSvg = &fyne.StaticResource{ + StaticName: "cast.svg", + StaticContent: []byte( + "\n"), +} var ResDiscSvg = &fyne.StaticResource{ StaticName: "disc.svg", StaticContent: []byte( diff --git a/res/bundled_gen.sh b/res/bundled_gen.sh index a963c50..67c52fa 100755 --- a/res/bundled_gen.sh +++ b/res/bundled_gen.sh @@ -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 diff --git a/res/icons/publicdomain/cast.svg b/res/icons/publicdomain/cast.svg new file mode 100644 index 0000000..0a32d9c --- /dev/null +++ b/res/icons/publicdomain/cast.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/res/io.github.dweymouth.supersonic.appdata.xml b/res/io.github.dweymouth.supersonic.appdata.xml index c419982..06b3510 100644 --- a/res/io.github.dweymouth.supersonic.appdata.xml +++ b/res/io.github.dweymouth.supersonic.appdata.xml @@ -33,6 +33,8 @@
- Version 0.14.0 of Supersonic + Version 0.15.1 of Supersonic
-- Added -
-Fixed