From df5d84716901603cce14c81073ee9c3db8241f7b Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 24 Oct 2026 17:08:51 -0700 Subject: [PATCH] Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web Co-Authored-By: Claude Sonnet 5.5 --- .dockerignore | 4 +- README.md | 24 +- pyproject.toml => desktop/pyproject.toml | 0 .../scripts}/install-desktop.sh | 0 {stemify => desktop/stemify}/__init__.py | 0 {stemify => desktop/stemify}/__main__.py | 0 {stemify => desktop/stemify}/app.py | 0 .../stemify}/assets/fonts/Inter-Bold.ttf | Bin .../stemify}/assets/fonts/Inter-LICENSE.txt | 0 .../stemify}/assets/fonts/Inter-Medium.ttf | Bin .../stemify}/assets/fonts/Inter-Regular.ttf | Bin .../stemify}/assets/fonts/Inter-SemiBold.ttf | Bin .../stemify}/assets/stemify.svg | 0 {stemify => desktop/stemify}/audio_engine.py | 0 {stemify => desktop/stemify}/catalog.py | 0 {stemify => desktop/stemify}/controller.py | 0 {stemify => desktop/stemify}/gpu_monitor.py | 0 {stemify => desktop/stemify}/jobs_model.py | 0 {stemify => desktop/stemify}/library_core.py | 0 {stemify => desktop/stemify}/player/README.md | 0 .../stemify}/player/__init__.py | 0 .../stemify}/player/__main__.py | 0 {stemify => desktop/stemify}/player/app.py | 0 .../player/assets/fonts/Carlito-Bold.ttf | Bin .../player/assets/fonts/Carlito-OFL.txt | 0 .../player/assets/fonts/Carlito-Regular.ttf | Bin .../player/assets/fonts/Selawik-Bold.ttf | Bin .../player/assets/fonts/Selawik-LICENSE.txt | 0 .../player/assets/fonts/Selawik-Regular.ttf | Bin .../player/assets/fonts/Selawik-Semibold.ttf | Bin .../stemify}/player/assets/stemify-player.svg | 0 {stemify => desktop/stemify}/player/config.py | 0 {stemify => desktop/stemify}/player/engine.py | 0 {stemify => desktop/stemify}/player/icons.py | 0 .../stemify}/player/library.py | 0 {stemify => desktop/stemify}/player/mpris.py | 0 {stemify => desktop/stemify}/player/player.py | 0 {stemify => desktop/stemify}/player/plex.py | 0 .../player/qml/StemPlayer/Backstage.qml | 0 .../player/qml/StemPlayer/CaptionButton.qml | 0 .../player/qml/StemPlayer/FormulaBar.qml | 0 .../stemify}/player/qml/StemPlayer/Ico.qml | 0 .../stemify}/player/qml/StemPlayer/Main.qml | 0 .../player/qml/StemPlayer/MessageBar.qml | 0 .../player/qml/StemPlayer/OfficeButton.qml | 0 .../player/qml/StemPlayer/RButton.qml | 0 .../stemify}/player/qml/StemPlayer/RGroup.qml | 0 .../player/qml/StemPlayer/ResizeEdge.qml | 0 .../stemify}/player/qml/StemPlayer/Ribbon.qml | 0 .../stemify}/player/qml/StemPlayer/Sheet.qml | 0 .../player/qml/StemPlayer/SheetTabs.qml | 0 .../player/qml/StemPlayer/StatusBar.qml | 0 .../player/qml/StemPlayer/StemChart.qml | 0 .../stemify}/player/qml/StemPlayer/T.qml | 0 .../player/qml/StemPlayer/TaskPane.qml | 0 .../stemify}/player/qml/StemPlayer/Tip.qml | 0 .../player/qml/StemPlayer/TitleBar.qml | 0 .../stemify}/player/qml/StemPlayer/X.qml | 0 .../stemify}/player/qml/StemPlayer/XCheck.qml | 0 .../stemify}/player/qml/StemPlayer/XField.qml | 0 .../stemify}/player/qml/StemPlayer/XMenu.qml | 0 .../player/qml/StemPlayer/XScrollBar.qml | 0 .../stemify}/player/qml/StemPlayer/qmldir | 0 {stemify => desktop/stemify}/player/sheets.py | 0 {stemify => desktop/stemify}/player/stems.py | 0 {stemify => desktop/stemify}/providers.py | 0 .../stemify}/qml/Stemify/AeroButton.qml | 0 .../stemify}/qml/Stemify/AeroComboBox.qml | 0 .../stemify}/qml/Stemify/AeroSlider.qml | 0 .../stemify}/qml/Stemify/AeroSwitch.qml | 0 .../stemify}/qml/Stemify/CaptionButton.qml | 0 .../stemify}/qml/Stemify/Card.qml | 0 .../stemify}/qml/Stemify/DashedFrame.qml | 0 .../stemify}/qml/Stemify/DropZone.qml | 0 .../stemify}/qml/Stemify/Elevation.qml | 0 .../stemify}/qml/Stemify/FrameOverlay.qml | 0 .../stemify}/qml/Stemify/GlassPanel.qml | 0 .../stemify}/qml/Stemify/Glow.qml | 0 .../stemify}/qml/Stemify/Icon.qml | 0 .../stemify}/qml/Stemify/IconButton.qml | 0 .../stemify}/qml/Stemify/LevelMeter.qml | 0 .../stemify}/qml/Stemify/Main.qml | 0 .../stemify}/qml/Stemify/QueueItem.qml | 0 .../stemify}/qml/Stemify/ResizeEdge.qml | 0 .../stemify}/qml/Stemify/SettingsDialog.qml | 0 .../stemify}/qml/Stemify/Sidebar.qml | 0 .../stemify}/qml/Stemify/StatusChip.qml | 0 .../stemify}/qml/Stemify/StemLane.qml | 0 .../stemify}/qml/Stemify/StripedProgress.qml | 0 .../stemify}/qml/Stemify/Theme.qml | 0 .../stemify}/qml/Stemify/Tip.qml | 0 .../stemify}/qml/Stemify/TitleBar.qml | 0 .../stemify}/qml/Stemify/Toast.qml | 0 .../stemify}/qml/Stemify/TrackView.qml | 0 .../stemify}/qml/Stemify/Transport.qml | 0 .../stemify}/qml/Stemify/Txt.qml | 0 .../stemify}/qml/Stemify/qmldir | 0 {stemify => desktop/stemify}/settings.py | 0 .../stemify}/worker/__init__.py | 0 .../stemify}/worker/__main__.py | 0 .../stemify}/worker/audio_io.py | 0 .../stemify}/worker/backends.py | 0 {stemify => desktop/stemify}/worker/cli.py | 0 {stemify => desktop/stemify}/worker_client.py | 0 uv.lock => desktop/uv.lock | 0 specs.txt => docs/specs.txt | 0 ios/Shared/MixIntents.swift | 68 +++ ios/Shared/WidgetShared.swift | 58 ++ ios/Stemify/App/AppGraph.swift | 12 +- ios/Stemify/App/StemifyApp.swift | 5 +- ios/Stemify/App/WidgetBridge.swift | 90 +++ ios/Stemify/Cache/Downloads.swift | 87 +++ ios/Stemify/CarPlay/CarLibrary.swift | 11 +- ios/Stemify/CarPlay/CarPlayScene.swift | 2 +- ios/Stemify/CarPlay/HomeReach.swift | 13 +- ios/Stemify/Core/Settings.swift | 55 +- ios/Stemify/Playback/MixPresets.swift | 121 +++- ios/Stemify/Playback/NowPlaying.swift | 34 +- ios/Stemify/Playback/PlaybackManager.swift | 80 ++- ios/Stemify/Playback/StemPlaylists.swift | 2 +- ios/Stemify/Playback/WebRadio.swift | 80 +++ ios/Stemify/Plex/JellyfinClient.swift | 448 ++++++++++++++ ios/Stemify/Plex/MediaServer.swift | 116 ++++ ios/Stemify/Plex/PlexClient.swift | 375 +++++------- ios/Stemify/Plex/PlexServer.swift | 347 +++++++++++ .../AvatarChris.imageset/AvatarChris.png | Bin 0 -> 8052 bytes .../AvatarChris.imageset/Contents.json | 1 + .../AvatarKevin.imageset/AvatarKevin.png | Bin 0 -> 3263 bytes .../AvatarKevin.imageset/Contents.json | 1 + ios/Stemify/Stems/StemCoverage.swift | 8 +- ios/Stemify/UI/Components/Chrome.swift | 10 +- ios/Stemify/UI/Components/MediaMenu.swift | 84 +-- ios/Stemify/UI/Components/Rows.swift | 13 +- ios/Stemify/UI/Nav.swift | 4 +- ios/Stemify/UI/RootView.swift | 9 +- ios/Stemify/UI/Screens/LibraryScreens.swift | 299 +++++---- .../UI/Screens/NowPlayingOptions.swift | 89 +++ ios/Stemify/UI/Screens/NowPlayingScreen.swift | 145 ++++- ios/Stemify/UI/Screens/PlaylistScreens.swift | 2 +- ios/Stemify/UI/Screens/SettingsScreens.swift | 577 +++++++++++++----- ios/Stemify/UI/Screens/StemEditor.swift | 163 +++++ ios/Stemify/UI/Screens/WebRadioScreen.swift | 116 ++++ ios/TODO.md | 14 + ios/Widgets/MixerViews.swift | 342 +++++++++++ ios/Widgets/StemifyWidgets.swift | 100 +++ ios/project.yml | 30 +- ios/scripts/build-ipa.sh | 68 ++- ios/scripts/build-remote.sh | 11 + server/stemd.Dockerfile | 6 +- server/stemd.py | 4 +- web/app.js | 302 +++++++-- web/index.html | 4 +- web/player.js | 184 +++++- web/plex.js | 4 + web/store.js | 14 +- web/style.css | 40 ++ web/util.js | 1 + 157 files changed, 3935 insertions(+), 742 deletions(-) rename pyproject.toml => desktop/pyproject.toml (100%) rename {scripts => desktop/scripts}/install-desktop.sh (100%) rename {stemify => desktop/stemify}/__init__.py (100%) rename {stemify => desktop/stemify}/__main__.py (100%) rename {stemify => desktop/stemify}/app.py (100%) rename {stemify => desktop/stemify}/assets/fonts/Inter-Bold.ttf (100%) rename {stemify => desktop/stemify}/assets/fonts/Inter-LICENSE.txt (100%) rename {stemify => desktop/stemify}/assets/fonts/Inter-Medium.ttf (100%) rename {stemify => desktop/stemify}/assets/fonts/Inter-Regular.ttf (100%) rename {stemify => desktop/stemify}/assets/fonts/Inter-SemiBold.ttf (100%) rename {stemify => desktop/stemify}/assets/stemify.svg (100%) rename {stemify => desktop/stemify}/audio_engine.py (100%) rename {stemify => desktop/stemify}/catalog.py (100%) rename {stemify => desktop/stemify}/controller.py (100%) rename {stemify => desktop/stemify}/gpu_monitor.py (100%) rename {stemify => desktop/stemify}/jobs_model.py (100%) rename {stemify => desktop/stemify}/library_core.py (100%) rename {stemify => desktop/stemify}/player/README.md (100%) rename {stemify => desktop/stemify}/player/__init__.py (100%) rename {stemify => desktop/stemify}/player/__main__.py (100%) rename {stemify => desktop/stemify}/player/app.py (100%) rename {stemify => desktop/stemify}/player/assets/fonts/Carlito-Bold.ttf (100%) rename {stemify => desktop/stemify}/player/assets/fonts/Carlito-OFL.txt (100%) rename {stemify => desktop/stemify}/player/assets/fonts/Carlito-Regular.ttf (100%) rename {stemify => desktop/stemify}/player/assets/fonts/Selawik-Bold.ttf (100%) rename {stemify => desktop/stemify}/player/assets/fonts/Selawik-LICENSE.txt (100%) rename {stemify => desktop/stemify}/player/assets/fonts/Selawik-Regular.ttf (100%) rename {stemify => desktop/stemify}/player/assets/fonts/Selawik-Semibold.ttf (100%) rename {stemify => desktop/stemify}/player/assets/stemify-player.svg (100%) rename {stemify => desktop/stemify}/player/config.py (100%) rename {stemify => desktop/stemify}/player/engine.py (100%) rename {stemify => desktop/stemify}/player/icons.py (100%) rename {stemify => desktop/stemify}/player/library.py (100%) rename {stemify => desktop/stemify}/player/mpris.py (100%) rename {stemify => desktop/stemify}/player/player.py (100%) rename {stemify => desktop/stemify}/player/plex.py (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/Backstage.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/CaptionButton.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/FormulaBar.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/Ico.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/Main.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/MessageBar.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/OfficeButton.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/RButton.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/RGroup.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/ResizeEdge.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/Ribbon.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/Sheet.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/SheetTabs.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/StatusBar.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/StemChart.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/T.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/TaskPane.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/Tip.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/TitleBar.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/X.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/XCheck.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/XField.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/XMenu.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/XScrollBar.qml (100%) rename {stemify => desktop/stemify}/player/qml/StemPlayer/qmldir (100%) rename {stemify => desktop/stemify}/player/sheets.py (100%) rename {stemify => desktop/stemify}/player/stems.py (100%) rename {stemify => desktop/stemify}/providers.py (100%) rename {stemify => desktop/stemify}/qml/Stemify/AeroButton.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/AeroComboBox.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/AeroSlider.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/AeroSwitch.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/CaptionButton.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/Card.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/DashedFrame.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/DropZone.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/Elevation.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/FrameOverlay.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/GlassPanel.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/Glow.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/Icon.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/IconButton.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/LevelMeter.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/Main.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/QueueItem.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/ResizeEdge.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/SettingsDialog.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/Sidebar.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/StatusChip.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/StemLane.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/StripedProgress.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/Theme.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/Tip.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/TitleBar.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/Toast.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/TrackView.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/Transport.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/Txt.qml (100%) rename {stemify => desktop/stemify}/qml/Stemify/qmldir (100%) rename {stemify => desktop/stemify}/settings.py (100%) rename {stemify => desktop/stemify}/worker/__init__.py (100%) rename {stemify => desktop/stemify}/worker/__main__.py (100%) rename {stemify => desktop/stemify}/worker/audio_io.py (100%) rename {stemify => desktop/stemify}/worker/backends.py (100%) rename {stemify => desktop/stemify}/worker/cli.py (100%) rename {stemify => desktop/stemify}/worker_client.py (100%) rename uv.lock => desktop/uv.lock (100%) rename specs.txt => docs/specs.txt (100%) create mode 100644 ios/Shared/MixIntents.swift create mode 100644 ios/Shared/WidgetShared.swift create mode 100644 ios/Stemify/App/WidgetBridge.swift create mode 100644 ios/Stemify/Playback/WebRadio.swift create mode 100644 ios/Stemify/Plex/JellyfinClient.swift create mode 100644 ios/Stemify/Plex/MediaServer.swift create mode 100644 ios/Stemify/Plex/PlexServer.swift create mode 100644 ios/Stemify/Resources/Assets.xcassets/AvatarChris.imageset/AvatarChris.png create mode 100644 ios/Stemify/Resources/Assets.xcassets/AvatarChris.imageset/Contents.json create mode 100644 ios/Stemify/Resources/Assets.xcassets/AvatarKevin.imageset/AvatarKevin.png create mode 100644 ios/Stemify/Resources/Assets.xcassets/AvatarKevin.imageset/Contents.json create mode 100644 ios/Stemify/UI/Screens/NowPlayingOptions.swift create mode 100644 ios/Stemify/UI/Screens/StemEditor.swift create mode 100644 ios/Stemify/UI/Screens/WebRadioScreen.swift create mode 100644 ios/TODO.md create mode 100644 ios/Widgets/MixerViews.swift create mode 100644 ios/Widgets/StemifyWidgets.swift create mode 100755 ios/scripts/build-remote.sh diff --git a/.dockerignore b/.dockerignore index bd35756..6552552 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,5 @@ * !server/stemd.py !server/stemd-requirements.txt -!stemify/*.py -!stemify/worker/*.py +!desktop/stemify/*.py +!desktop/stemify/worker/*.py diff --git a/README.md b/README.md index 5997d34..7ccb0f5 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ mkdir -p /srv/stemify/stemserve/cache \ chown -R 1000:1000 /srv/stemify/stemserve /srv/stemify/stemd ``` -**stemd** builds from the repo root because it uses the `stemify` package. Copy the repo (at least `stemify/`, `server/` and `.dockerignore`) to `/srv/stemify/stemd/src`, then: +**stemd** builds from the repo root because it uses the `stemify` package. Copy the repo (at least `desktop/stemify/`, `server/` and `.dockerignore`) to `/srv/stemify/stemd/src`, then: ```sh docker build -f /srv/stemify/stemd/src/server/stemd.Dockerfile -t stemd /srv/stemify/stemd/src @@ -241,9 +241,10 @@ It's plain HTML and JavaScript in `web/` with no build step. To run it somewhere ## Desktop -Linux, Python 3.12 and [uv](https://docs.astral.sh/uv/). PyTorch defaults to ROCm (AMD); point the torch index in `pyproject.toml` elsewhere for NVIDIA or CPU. +Linux, Python 3.12 and [uv](https://docs.astral.sh/uv/). PyTorch defaults to ROCm (AMD); point the torch index in `desktop/pyproject.toml` elsewhere for NVIDIA or CPU. ```sh +cd desktop uv sync uv run stemify # the drag-and-drop separator uv run stemify-player # the player @@ -252,7 +253,7 @@ uv run stemify-player # the player **Stemify** is the separator. Drop files on the window and each one is separated with your last-used model and device. You get a lane per stem with a waveform, solo, mute, gain and export (everything, one stem, or the current mix, as WAV, FLAC or MP3). Separation runs in its own process, results are cached, and it works on AMD, NVIDIA and CPU. -**Stemify Player** is your Plex library and its stems, dressed up as Microsoft Excel 2016. Sheets are your library, the zoom slider is master volume, Cell Styles are mix presets, and Undo/Redo skip songs. It reports plays to Plex, supports media keys and MPRIS, and remembers your queue and mix. More in [`stemify/player/README.md`](stemify/player/README.md). +**Stemify Player** is your Plex library and its stems, dressed up as Microsoft Excel 2016. Sheets are your library, the zoom slider is master volume, Cell Styles are mix presets, and Undo/Redo skip songs. It reports plays to Plex, supports media keys and MPRIS, and remembers your queue and mix. More in [`desktop/stemify/player/README.md`](desktop/stemify/player/README.md). ## Stem Now @@ -319,12 +320,13 @@ ios/ iOS and CarPlay app (SwiftUI, xcodegen project) android/ Android and Android Auto app (Kotlin, Compose, Media3) server/ stemd.py, stemserve.py, Dockerfiles, compose file web/ web player (served by stemserve) -stemify/ desktop separator (PySide6 + QML) and shared separation code - worker/ Demucs and RoFormer backends, run in a separate process - player/ Stemify Player - library_core.py folder layout rules shared with stemd -scripts/ install-desktop.sh -specs.txt original design doc for the desktop separator +desktop/ desktop apps (uv project) + stemify/ separator (PySide6 + QML) and shared separation code + worker/ Demucs and RoFormer backends, run in a separate process + player/ Stemify Player + library_core.py folder layout rules shared with stemd + scripts/ install-desktop.sh +docs/specs.txt original design doc for the desktop separator ``` ## Building and testing @@ -333,7 +335,8 @@ specs.txt original design doc for the desktop separator ```sh cd ios -scripts/build-ipa.sh # unsigned build/stemify-.ipa +scripts/build-ipa.sh # unsigned build/public/stemify-.ipa +scripts/build-remote.sh # from DevOps: build on White-M1 and pull IPAs back to build/{public,prov} xcodegen generate # or open Stemify.xcodeproj in Xcode ``` @@ -352,6 +355,7 @@ The cache and index tests use a stemserve on `localhost:8765` and skip themselve **Desktop** can be checked without a window: ```sh +cd desktop QT_QPA_PLATFORM=offscreen uv run stemify-player --no-mpris --screenshot /tmp/p.png@5 --quit-after 6 ``` diff --git a/pyproject.toml b/desktop/pyproject.toml similarity index 100% rename from pyproject.toml rename to desktop/pyproject.toml diff --git a/scripts/install-desktop.sh b/desktop/scripts/install-desktop.sh similarity index 100% rename from scripts/install-desktop.sh rename to desktop/scripts/install-desktop.sh diff --git a/stemify/__init__.py b/desktop/stemify/__init__.py similarity index 100% rename from stemify/__init__.py rename to desktop/stemify/__init__.py diff --git a/stemify/__main__.py b/desktop/stemify/__main__.py similarity index 100% rename from stemify/__main__.py rename to desktop/stemify/__main__.py diff --git a/stemify/app.py b/desktop/stemify/app.py similarity index 100% rename from stemify/app.py rename to desktop/stemify/app.py diff --git a/stemify/assets/fonts/Inter-Bold.ttf b/desktop/stemify/assets/fonts/Inter-Bold.ttf similarity index 100% rename from stemify/assets/fonts/Inter-Bold.ttf rename to desktop/stemify/assets/fonts/Inter-Bold.ttf diff --git a/stemify/assets/fonts/Inter-LICENSE.txt b/desktop/stemify/assets/fonts/Inter-LICENSE.txt similarity index 100% rename from stemify/assets/fonts/Inter-LICENSE.txt rename to desktop/stemify/assets/fonts/Inter-LICENSE.txt diff --git a/stemify/assets/fonts/Inter-Medium.ttf b/desktop/stemify/assets/fonts/Inter-Medium.ttf similarity index 100% rename from stemify/assets/fonts/Inter-Medium.ttf rename to desktop/stemify/assets/fonts/Inter-Medium.ttf diff --git a/stemify/assets/fonts/Inter-Regular.ttf b/desktop/stemify/assets/fonts/Inter-Regular.ttf similarity index 100% rename from stemify/assets/fonts/Inter-Regular.ttf rename to desktop/stemify/assets/fonts/Inter-Regular.ttf diff --git a/stemify/assets/fonts/Inter-SemiBold.ttf b/desktop/stemify/assets/fonts/Inter-SemiBold.ttf similarity index 100% rename from stemify/assets/fonts/Inter-SemiBold.ttf rename to desktop/stemify/assets/fonts/Inter-SemiBold.ttf diff --git a/stemify/assets/stemify.svg b/desktop/stemify/assets/stemify.svg similarity index 100% rename from stemify/assets/stemify.svg rename to desktop/stemify/assets/stemify.svg diff --git a/stemify/audio_engine.py b/desktop/stemify/audio_engine.py similarity index 100% rename from stemify/audio_engine.py rename to desktop/stemify/audio_engine.py diff --git a/stemify/catalog.py b/desktop/stemify/catalog.py similarity index 100% rename from stemify/catalog.py rename to desktop/stemify/catalog.py diff --git a/stemify/controller.py b/desktop/stemify/controller.py similarity index 100% rename from stemify/controller.py rename to desktop/stemify/controller.py diff --git a/stemify/gpu_monitor.py b/desktop/stemify/gpu_monitor.py similarity index 100% rename from stemify/gpu_monitor.py rename to desktop/stemify/gpu_monitor.py diff --git a/stemify/jobs_model.py b/desktop/stemify/jobs_model.py similarity index 100% rename from stemify/jobs_model.py rename to desktop/stemify/jobs_model.py diff --git a/stemify/library_core.py b/desktop/stemify/library_core.py similarity index 100% rename from stemify/library_core.py rename to desktop/stemify/library_core.py diff --git a/stemify/player/README.md b/desktop/stemify/player/README.md similarity index 100% rename from stemify/player/README.md rename to desktop/stemify/player/README.md diff --git a/stemify/player/__init__.py b/desktop/stemify/player/__init__.py similarity index 100% rename from stemify/player/__init__.py rename to desktop/stemify/player/__init__.py diff --git a/stemify/player/__main__.py b/desktop/stemify/player/__main__.py similarity index 100% rename from stemify/player/__main__.py rename to desktop/stemify/player/__main__.py diff --git a/stemify/player/app.py b/desktop/stemify/player/app.py similarity index 100% rename from stemify/player/app.py rename to desktop/stemify/player/app.py diff --git a/stemify/player/assets/fonts/Carlito-Bold.ttf b/desktop/stemify/player/assets/fonts/Carlito-Bold.ttf similarity index 100% rename from stemify/player/assets/fonts/Carlito-Bold.ttf rename to desktop/stemify/player/assets/fonts/Carlito-Bold.ttf diff --git a/stemify/player/assets/fonts/Carlito-OFL.txt b/desktop/stemify/player/assets/fonts/Carlito-OFL.txt similarity index 100% rename from stemify/player/assets/fonts/Carlito-OFL.txt rename to desktop/stemify/player/assets/fonts/Carlito-OFL.txt diff --git a/stemify/player/assets/fonts/Carlito-Regular.ttf b/desktop/stemify/player/assets/fonts/Carlito-Regular.ttf similarity index 100% rename from stemify/player/assets/fonts/Carlito-Regular.ttf rename to desktop/stemify/player/assets/fonts/Carlito-Regular.ttf diff --git a/stemify/player/assets/fonts/Selawik-Bold.ttf b/desktop/stemify/player/assets/fonts/Selawik-Bold.ttf similarity index 100% rename from stemify/player/assets/fonts/Selawik-Bold.ttf rename to desktop/stemify/player/assets/fonts/Selawik-Bold.ttf diff --git a/stemify/player/assets/fonts/Selawik-LICENSE.txt b/desktop/stemify/player/assets/fonts/Selawik-LICENSE.txt similarity index 100% rename from stemify/player/assets/fonts/Selawik-LICENSE.txt rename to desktop/stemify/player/assets/fonts/Selawik-LICENSE.txt diff --git a/stemify/player/assets/fonts/Selawik-Regular.ttf b/desktop/stemify/player/assets/fonts/Selawik-Regular.ttf similarity index 100% rename from stemify/player/assets/fonts/Selawik-Regular.ttf rename to desktop/stemify/player/assets/fonts/Selawik-Regular.ttf diff --git a/stemify/player/assets/fonts/Selawik-Semibold.ttf b/desktop/stemify/player/assets/fonts/Selawik-Semibold.ttf similarity index 100% rename from stemify/player/assets/fonts/Selawik-Semibold.ttf rename to desktop/stemify/player/assets/fonts/Selawik-Semibold.ttf diff --git a/stemify/player/assets/stemify-player.svg b/desktop/stemify/player/assets/stemify-player.svg similarity index 100% rename from stemify/player/assets/stemify-player.svg rename to desktop/stemify/player/assets/stemify-player.svg diff --git a/stemify/player/config.py b/desktop/stemify/player/config.py similarity index 100% rename from stemify/player/config.py rename to desktop/stemify/player/config.py diff --git a/stemify/player/engine.py b/desktop/stemify/player/engine.py similarity index 100% rename from stemify/player/engine.py rename to desktop/stemify/player/engine.py diff --git a/stemify/player/icons.py b/desktop/stemify/player/icons.py similarity index 100% rename from stemify/player/icons.py rename to desktop/stemify/player/icons.py diff --git a/stemify/player/library.py b/desktop/stemify/player/library.py similarity index 100% rename from stemify/player/library.py rename to desktop/stemify/player/library.py diff --git a/stemify/player/mpris.py b/desktop/stemify/player/mpris.py similarity index 100% rename from stemify/player/mpris.py rename to desktop/stemify/player/mpris.py diff --git a/stemify/player/player.py b/desktop/stemify/player/player.py similarity index 100% rename from stemify/player/player.py rename to desktop/stemify/player/player.py diff --git a/stemify/player/plex.py b/desktop/stemify/player/plex.py similarity index 100% rename from stemify/player/plex.py rename to desktop/stemify/player/plex.py diff --git a/stemify/player/qml/StemPlayer/Backstage.qml b/desktop/stemify/player/qml/StemPlayer/Backstage.qml similarity index 100% rename from stemify/player/qml/StemPlayer/Backstage.qml rename to desktop/stemify/player/qml/StemPlayer/Backstage.qml diff --git a/stemify/player/qml/StemPlayer/CaptionButton.qml b/desktop/stemify/player/qml/StemPlayer/CaptionButton.qml similarity index 100% rename from stemify/player/qml/StemPlayer/CaptionButton.qml rename to desktop/stemify/player/qml/StemPlayer/CaptionButton.qml diff --git a/stemify/player/qml/StemPlayer/FormulaBar.qml b/desktop/stemify/player/qml/StemPlayer/FormulaBar.qml similarity index 100% rename from stemify/player/qml/StemPlayer/FormulaBar.qml rename to desktop/stemify/player/qml/StemPlayer/FormulaBar.qml diff --git a/stemify/player/qml/StemPlayer/Ico.qml b/desktop/stemify/player/qml/StemPlayer/Ico.qml similarity index 100% rename from stemify/player/qml/StemPlayer/Ico.qml rename to desktop/stemify/player/qml/StemPlayer/Ico.qml diff --git a/stemify/player/qml/StemPlayer/Main.qml b/desktop/stemify/player/qml/StemPlayer/Main.qml similarity index 100% rename from stemify/player/qml/StemPlayer/Main.qml rename to desktop/stemify/player/qml/StemPlayer/Main.qml diff --git a/stemify/player/qml/StemPlayer/MessageBar.qml b/desktop/stemify/player/qml/StemPlayer/MessageBar.qml similarity index 100% rename from stemify/player/qml/StemPlayer/MessageBar.qml rename to desktop/stemify/player/qml/StemPlayer/MessageBar.qml diff --git a/stemify/player/qml/StemPlayer/OfficeButton.qml b/desktop/stemify/player/qml/StemPlayer/OfficeButton.qml similarity index 100% rename from stemify/player/qml/StemPlayer/OfficeButton.qml rename to desktop/stemify/player/qml/StemPlayer/OfficeButton.qml diff --git a/stemify/player/qml/StemPlayer/RButton.qml b/desktop/stemify/player/qml/StemPlayer/RButton.qml similarity index 100% rename from stemify/player/qml/StemPlayer/RButton.qml rename to desktop/stemify/player/qml/StemPlayer/RButton.qml diff --git a/stemify/player/qml/StemPlayer/RGroup.qml b/desktop/stemify/player/qml/StemPlayer/RGroup.qml similarity index 100% rename from stemify/player/qml/StemPlayer/RGroup.qml rename to desktop/stemify/player/qml/StemPlayer/RGroup.qml diff --git a/stemify/player/qml/StemPlayer/ResizeEdge.qml b/desktop/stemify/player/qml/StemPlayer/ResizeEdge.qml similarity index 100% rename from stemify/player/qml/StemPlayer/ResizeEdge.qml rename to desktop/stemify/player/qml/StemPlayer/ResizeEdge.qml diff --git a/stemify/player/qml/StemPlayer/Ribbon.qml b/desktop/stemify/player/qml/StemPlayer/Ribbon.qml similarity index 100% rename from stemify/player/qml/StemPlayer/Ribbon.qml rename to desktop/stemify/player/qml/StemPlayer/Ribbon.qml diff --git a/stemify/player/qml/StemPlayer/Sheet.qml b/desktop/stemify/player/qml/StemPlayer/Sheet.qml similarity index 100% rename from stemify/player/qml/StemPlayer/Sheet.qml rename to desktop/stemify/player/qml/StemPlayer/Sheet.qml diff --git a/stemify/player/qml/StemPlayer/SheetTabs.qml b/desktop/stemify/player/qml/StemPlayer/SheetTabs.qml similarity index 100% rename from stemify/player/qml/StemPlayer/SheetTabs.qml rename to desktop/stemify/player/qml/StemPlayer/SheetTabs.qml diff --git a/stemify/player/qml/StemPlayer/StatusBar.qml b/desktop/stemify/player/qml/StemPlayer/StatusBar.qml similarity index 100% rename from stemify/player/qml/StemPlayer/StatusBar.qml rename to desktop/stemify/player/qml/StemPlayer/StatusBar.qml diff --git a/stemify/player/qml/StemPlayer/StemChart.qml b/desktop/stemify/player/qml/StemPlayer/StemChart.qml similarity index 100% rename from stemify/player/qml/StemPlayer/StemChart.qml rename to desktop/stemify/player/qml/StemPlayer/StemChart.qml diff --git a/stemify/player/qml/StemPlayer/T.qml b/desktop/stemify/player/qml/StemPlayer/T.qml similarity index 100% rename from stemify/player/qml/StemPlayer/T.qml rename to desktop/stemify/player/qml/StemPlayer/T.qml diff --git a/stemify/player/qml/StemPlayer/TaskPane.qml b/desktop/stemify/player/qml/StemPlayer/TaskPane.qml similarity index 100% rename from stemify/player/qml/StemPlayer/TaskPane.qml rename to desktop/stemify/player/qml/StemPlayer/TaskPane.qml diff --git a/stemify/player/qml/StemPlayer/Tip.qml b/desktop/stemify/player/qml/StemPlayer/Tip.qml similarity index 100% rename from stemify/player/qml/StemPlayer/Tip.qml rename to desktop/stemify/player/qml/StemPlayer/Tip.qml diff --git a/stemify/player/qml/StemPlayer/TitleBar.qml b/desktop/stemify/player/qml/StemPlayer/TitleBar.qml similarity index 100% rename from stemify/player/qml/StemPlayer/TitleBar.qml rename to desktop/stemify/player/qml/StemPlayer/TitleBar.qml diff --git a/stemify/player/qml/StemPlayer/X.qml b/desktop/stemify/player/qml/StemPlayer/X.qml similarity index 100% rename from stemify/player/qml/StemPlayer/X.qml rename to desktop/stemify/player/qml/StemPlayer/X.qml diff --git a/stemify/player/qml/StemPlayer/XCheck.qml b/desktop/stemify/player/qml/StemPlayer/XCheck.qml similarity index 100% rename from stemify/player/qml/StemPlayer/XCheck.qml rename to desktop/stemify/player/qml/StemPlayer/XCheck.qml diff --git a/stemify/player/qml/StemPlayer/XField.qml b/desktop/stemify/player/qml/StemPlayer/XField.qml similarity index 100% rename from stemify/player/qml/StemPlayer/XField.qml rename to desktop/stemify/player/qml/StemPlayer/XField.qml diff --git a/stemify/player/qml/StemPlayer/XMenu.qml b/desktop/stemify/player/qml/StemPlayer/XMenu.qml similarity index 100% rename from stemify/player/qml/StemPlayer/XMenu.qml rename to desktop/stemify/player/qml/StemPlayer/XMenu.qml diff --git a/stemify/player/qml/StemPlayer/XScrollBar.qml b/desktop/stemify/player/qml/StemPlayer/XScrollBar.qml similarity index 100% rename from stemify/player/qml/StemPlayer/XScrollBar.qml rename to desktop/stemify/player/qml/StemPlayer/XScrollBar.qml diff --git a/stemify/player/qml/StemPlayer/qmldir b/desktop/stemify/player/qml/StemPlayer/qmldir similarity index 100% rename from stemify/player/qml/StemPlayer/qmldir rename to desktop/stemify/player/qml/StemPlayer/qmldir diff --git a/stemify/player/sheets.py b/desktop/stemify/player/sheets.py similarity index 100% rename from stemify/player/sheets.py rename to desktop/stemify/player/sheets.py diff --git a/stemify/player/stems.py b/desktop/stemify/player/stems.py similarity index 100% rename from stemify/player/stems.py rename to desktop/stemify/player/stems.py diff --git a/stemify/providers.py b/desktop/stemify/providers.py similarity index 100% rename from stemify/providers.py rename to desktop/stemify/providers.py diff --git a/stemify/qml/Stemify/AeroButton.qml b/desktop/stemify/qml/Stemify/AeroButton.qml similarity index 100% rename from stemify/qml/Stemify/AeroButton.qml rename to desktop/stemify/qml/Stemify/AeroButton.qml diff --git a/stemify/qml/Stemify/AeroComboBox.qml b/desktop/stemify/qml/Stemify/AeroComboBox.qml similarity index 100% rename from stemify/qml/Stemify/AeroComboBox.qml rename to desktop/stemify/qml/Stemify/AeroComboBox.qml diff --git a/stemify/qml/Stemify/AeroSlider.qml b/desktop/stemify/qml/Stemify/AeroSlider.qml similarity index 100% rename from stemify/qml/Stemify/AeroSlider.qml rename to desktop/stemify/qml/Stemify/AeroSlider.qml diff --git a/stemify/qml/Stemify/AeroSwitch.qml b/desktop/stemify/qml/Stemify/AeroSwitch.qml similarity index 100% rename from stemify/qml/Stemify/AeroSwitch.qml rename to desktop/stemify/qml/Stemify/AeroSwitch.qml diff --git a/stemify/qml/Stemify/CaptionButton.qml b/desktop/stemify/qml/Stemify/CaptionButton.qml similarity index 100% rename from stemify/qml/Stemify/CaptionButton.qml rename to desktop/stemify/qml/Stemify/CaptionButton.qml diff --git a/stemify/qml/Stemify/Card.qml b/desktop/stemify/qml/Stemify/Card.qml similarity index 100% rename from stemify/qml/Stemify/Card.qml rename to desktop/stemify/qml/Stemify/Card.qml diff --git a/stemify/qml/Stemify/DashedFrame.qml b/desktop/stemify/qml/Stemify/DashedFrame.qml similarity index 100% rename from stemify/qml/Stemify/DashedFrame.qml rename to desktop/stemify/qml/Stemify/DashedFrame.qml diff --git a/stemify/qml/Stemify/DropZone.qml b/desktop/stemify/qml/Stemify/DropZone.qml similarity index 100% rename from stemify/qml/Stemify/DropZone.qml rename to desktop/stemify/qml/Stemify/DropZone.qml diff --git a/stemify/qml/Stemify/Elevation.qml b/desktop/stemify/qml/Stemify/Elevation.qml similarity index 100% rename from stemify/qml/Stemify/Elevation.qml rename to desktop/stemify/qml/Stemify/Elevation.qml diff --git a/stemify/qml/Stemify/FrameOverlay.qml b/desktop/stemify/qml/Stemify/FrameOverlay.qml similarity index 100% rename from stemify/qml/Stemify/FrameOverlay.qml rename to desktop/stemify/qml/Stemify/FrameOverlay.qml diff --git a/stemify/qml/Stemify/GlassPanel.qml b/desktop/stemify/qml/Stemify/GlassPanel.qml similarity index 100% rename from stemify/qml/Stemify/GlassPanel.qml rename to desktop/stemify/qml/Stemify/GlassPanel.qml diff --git a/stemify/qml/Stemify/Glow.qml b/desktop/stemify/qml/Stemify/Glow.qml similarity index 100% rename from stemify/qml/Stemify/Glow.qml rename to desktop/stemify/qml/Stemify/Glow.qml diff --git a/stemify/qml/Stemify/Icon.qml b/desktop/stemify/qml/Stemify/Icon.qml similarity index 100% rename from stemify/qml/Stemify/Icon.qml rename to desktop/stemify/qml/Stemify/Icon.qml diff --git a/stemify/qml/Stemify/IconButton.qml b/desktop/stemify/qml/Stemify/IconButton.qml similarity index 100% rename from stemify/qml/Stemify/IconButton.qml rename to desktop/stemify/qml/Stemify/IconButton.qml diff --git a/stemify/qml/Stemify/LevelMeter.qml b/desktop/stemify/qml/Stemify/LevelMeter.qml similarity index 100% rename from stemify/qml/Stemify/LevelMeter.qml rename to desktop/stemify/qml/Stemify/LevelMeter.qml diff --git a/stemify/qml/Stemify/Main.qml b/desktop/stemify/qml/Stemify/Main.qml similarity index 100% rename from stemify/qml/Stemify/Main.qml rename to desktop/stemify/qml/Stemify/Main.qml diff --git a/stemify/qml/Stemify/QueueItem.qml b/desktop/stemify/qml/Stemify/QueueItem.qml similarity index 100% rename from stemify/qml/Stemify/QueueItem.qml rename to desktop/stemify/qml/Stemify/QueueItem.qml diff --git a/stemify/qml/Stemify/ResizeEdge.qml b/desktop/stemify/qml/Stemify/ResizeEdge.qml similarity index 100% rename from stemify/qml/Stemify/ResizeEdge.qml rename to desktop/stemify/qml/Stemify/ResizeEdge.qml diff --git a/stemify/qml/Stemify/SettingsDialog.qml b/desktop/stemify/qml/Stemify/SettingsDialog.qml similarity index 100% rename from stemify/qml/Stemify/SettingsDialog.qml rename to desktop/stemify/qml/Stemify/SettingsDialog.qml diff --git a/stemify/qml/Stemify/Sidebar.qml b/desktop/stemify/qml/Stemify/Sidebar.qml similarity index 100% rename from stemify/qml/Stemify/Sidebar.qml rename to desktop/stemify/qml/Stemify/Sidebar.qml diff --git a/stemify/qml/Stemify/StatusChip.qml b/desktop/stemify/qml/Stemify/StatusChip.qml similarity index 100% rename from stemify/qml/Stemify/StatusChip.qml rename to desktop/stemify/qml/Stemify/StatusChip.qml diff --git a/stemify/qml/Stemify/StemLane.qml b/desktop/stemify/qml/Stemify/StemLane.qml similarity index 100% rename from stemify/qml/Stemify/StemLane.qml rename to desktop/stemify/qml/Stemify/StemLane.qml diff --git a/stemify/qml/Stemify/StripedProgress.qml b/desktop/stemify/qml/Stemify/StripedProgress.qml similarity index 100% rename from stemify/qml/Stemify/StripedProgress.qml rename to desktop/stemify/qml/Stemify/StripedProgress.qml diff --git a/stemify/qml/Stemify/Theme.qml b/desktop/stemify/qml/Stemify/Theme.qml similarity index 100% rename from stemify/qml/Stemify/Theme.qml rename to desktop/stemify/qml/Stemify/Theme.qml diff --git a/stemify/qml/Stemify/Tip.qml b/desktop/stemify/qml/Stemify/Tip.qml similarity index 100% rename from stemify/qml/Stemify/Tip.qml rename to desktop/stemify/qml/Stemify/Tip.qml diff --git a/stemify/qml/Stemify/TitleBar.qml b/desktop/stemify/qml/Stemify/TitleBar.qml similarity index 100% rename from stemify/qml/Stemify/TitleBar.qml rename to desktop/stemify/qml/Stemify/TitleBar.qml diff --git a/stemify/qml/Stemify/Toast.qml b/desktop/stemify/qml/Stemify/Toast.qml similarity index 100% rename from stemify/qml/Stemify/Toast.qml rename to desktop/stemify/qml/Stemify/Toast.qml diff --git a/stemify/qml/Stemify/TrackView.qml b/desktop/stemify/qml/Stemify/TrackView.qml similarity index 100% rename from stemify/qml/Stemify/TrackView.qml rename to desktop/stemify/qml/Stemify/TrackView.qml diff --git a/stemify/qml/Stemify/Transport.qml b/desktop/stemify/qml/Stemify/Transport.qml similarity index 100% rename from stemify/qml/Stemify/Transport.qml rename to desktop/stemify/qml/Stemify/Transport.qml diff --git a/stemify/qml/Stemify/Txt.qml b/desktop/stemify/qml/Stemify/Txt.qml similarity index 100% rename from stemify/qml/Stemify/Txt.qml rename to desktop/stemify/qml/Stemify/Txt.qml diff --git a/stemify/qml/Stemify/qmldir b/desktop/stemify/qml/Stemify/qmldir similarity index 100% rename from stemify/qml/Stemify/qmldir rename to desktop/stemify/qml/Stemify/qmldir diff --git a/stemify/settings.py b/desktop/stemify/settings.py similarity index 100% rename from stemify/settings.py rename to desktop/stemify/settings.py diff --git a/stemify/worker/__init__.py b/desktop/stemify/worker/__init__.py similarity index 100% rename from stemify/worker/__init__.py rename to desktop/stemify/worker/__init__.py diff --git a/stemify/worker/__main__.py b/desktop/stemify/worker/__main__.py similarity index 100% rename from stemify/worker/__main__.py rename to desktop/stemify/worker/__main__.py diff --git a/stemify/worker/audio_io.py b/desktop/stemify/worker/audio_io.py similarity index 100% rename from stemify/worker/audio_io.py rename to desktop/stemify/worker/audio_io.py diff --git a/stemify/worker/backends.py b/desktop/stemify/worker/backends.py similarity index 100% rename from stemify/worker/backends.py rename to desktop/stemify/worker/backends.py diff --git a/stemify/worker/cli.py b/desktop/stemify/worker/cli.py similarity index 100% rename from stemify/worker/cli.py rename to desktop/stemify/worker/cli.py diff --git a/stemify/worker_client.py b/desktop/stemify/worker_client.py similarity index 100% rename from stemify/worker_client.py rename to desktop/stemify/worker_client.py diff --git a/uv.lock b/desktop/uv.lock similarity index 100% rename from uv.lock rename to desktop/uv.lock diff --git a/specs.txt b/docs/specs.txt similarity index 100% rename from specs.txt rename to docs/specs.txt diff --git a/ios/Shared/MixIntents.swift b/ios/Shared/MixIntents.swift new file mode 100644 index 0000000..178550e --- /dev/null +++ b/ios/Shared/MixIntents.swift @@ -0,0 +1,68 @@ +import AppIntents + +/// What a widget or control button asks the player to do. [MixRemote] carries it out: the app's version drives +/// the player, the widget extension's does nothing, since audio intents run in the app's process. +enum MixAction: Sendable { + case cycle + case nudge(String, Int) + case mute(String) + case playPause +} + +struct CycleMixIntent: AudioPlaybackIntent { + static let title: LocalizedStringResource = "Change Mix" + static let description = IntentDescription("Moves to the next stem mix, in the order set in the Stem Editor.") + + init() {} + + func perform() async throws -> some IntentResult { + await MixRemote.run(.cycle) + return .result() + } +} + +struct NudgeStemIntent: AudioPlaybackIntent { + static let title: LocalizedStringResource = "Turn a Stem Up or Down" + static let isDiscoverable = false + + @Parameter(title: "Stem") var stem: String + @Parameter(title: "Steps") var steps: Int + + init() {} + init(stem: String, steps: Int) { + self.stem = stem + self.steps = steps + } + + func perform() async throws -> some IntentResult { + await MixRemote.run(.nudge(stem, steps)) + return .result() + } +} + +struct MuteStemIntent: AudioPlaybackIntent { + static let title: LocalizedStringResource = "Mute or Unmute a Stem" + static let isDiscoverable = false + + @Parameter(title: "Stem") var stem: String + + init() {} + init(stem: String) { self.stem = stem } + + func perform() async throws -> some IntentResult { + await MixRemote.run(.mute(stem)) + return .result() + } +} + +struct PlayPauseIntent: AudioPlaybackIntent { + static let title: LocalizedStringResource = "Play or Pause" + static let isDiscoverable = false + + init() {} + + func perform() async throws -> some IntentResult { + await MixRemote.run(.playPause) + return .result() + } +} diff --git a/ios/Shared/WidgetShared.swift b/ios/Shared/WidgetShared.swift new file mode 100644 index 0000000..24a7e19 --- /dev/null +++ b/ios/Shared/WidgetShared.swift @@ -0,0 +1,58 @@ +import Foundation + +/// One stem as the widgets draw it. [level] is the fader position (0.8 is unity), [percent] the gain the mixer shows. +struct WidgetStem: Codable, Hashable, Identifiable { + var name: String + var label: String + var color: UInt32 + var level: Double + var percent: Int + var muted = false + var solo = false + var id: String { name } +} + +/// What's playing and how it's mixed, written by the app for the widgets and the lock screen control. +struct WidgetSnapshot: Codable, Equatable { + var title = "" + var artist = "" + /// The current mix ("Karaoke", "Custom Mix"…), empty unless stems are playing. + var mix = "" + /// What the Mix button moves to. + var next = "" + var playing = false + /// Empty unless stems are playing. + var stems: [WidgetStem] = [] + /// The art key the saved artwork belongs to, nil while it isn't saved yet. + var art: String? + + var hasTrack: Bool { !title.isEmpty } + var hasStems: Bool { !stems.isEmpty } + /// Muted, or left out by another stem's solo. + func silent(_ s: WidgetStem) -> Bool { s.muted || stems.contains(where: \.solo) && !s.solo } +} + +enum WidgetStore { + static let group = "group.org.cdnutter.stemify" + static let mixerKind = "org.cdnutter.stemify.mixer" + static let mixKind = "org.cdnutter.stemify.mix" + static let controlKind = "org.cdnutter.stemify.mix-control" + private static let key = "widgetSnapshot" + + /// Without the App Group (some sideloading tools drop it) this is a private store and the widgets show Nothing Playing. + private static var defaults: UserDefaults? { UserDefaults(suiteName: group) } + + static var artURL: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group)?.appendingPathComponent("art.jpg") + } + + static func load() -> WidgetSnapshot { + guard let d = defaults?.data(forKey: key), let s = try? JSONDecoder().decode(WidgetSnapshot.self, from: d) else { return WidgetSnapshot() } + return s + } + + static func save(_ s: WidgetSnapshot) { + guard let d = try? JSONEncoder().encode(s) else { return } + defaults?.set(d, forKey: key) + } +} diff --git a/ios/Stemify/App/AppGraph.swift b/ios/Stemify/App/AppGraph.swift index 702ef5c..96c40ad 100644 --- a/ios/Stemify/App/AppGraph.swift +++ b/ios/Stemify/App/AppGraph.swift @@ -24,6 +24,7 @@ final class AppGraph { let downloads: Downloads let coverage: StemCoverage let nowPlaying: NowPlaying + let radio: WebRadio let car: CarLibrary private init() { @@ -64,7 +65,16 @@ final class AppGraph { playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support) downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support) coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support) - nowPlaying = NowPlaying(pm: playback, plex: plex) + radio = WebRadio(pm: playback) + nowPlaying = NowPlaying(pm: playback, plex: plex, radio: radio, settings: settings) + radio.nowPlaying = nowPlaying car = CarLibrary(plex: carPlex, reach: reach, downloads: downloads, stems: stems, pm: playback) + // Mix names and the rotation show on the lock screen and in the widgets, so redraw those when the Stem Editor or Appearance change them. + let np = nowPlaying, pm = playback + settings.onChange("acapella", "presets", "presetOrder", "hiddenPresets") { + np.refresh() + WidgetBridge.shared.publish(pm, settings) + } + WidgetBridge.shared.start(self) } } diff --git a/ios/Stemify/App/StemifyApp.swift b/ios/Stemify/App/StemifyApp.swift index 65ec8f0..90cbd6e 100644 --- a/ios/Stemify/App/StemifyApp.swift +++ b/ios/Stemify/App/StemifyApp.swift @@ -36,7 +36,10 @@ struct StemifyApp: App { RootView() } .onChange(of: phase) { _, p in - if p == .active { AppGraph.shared.eq.refreshRoute() } + guard p == .active else { return } + AppGraph.shared.eq.refreshRoute() + // Keep downloaded playlists in step with the server, like Plexamp. + Task { await AppGraph.shared.downloads.refresh() } } } } diff --git a/ios/Stemify/App/WidgetBridge.swift b/ios/Stemify/App/WidgetBridge.swift new file mode 100644 index 0000000..b2fac1a --- /dev/null +++ b/ios/Stemify/App/WidgetBridge.swift @@ -0,0 +1,90 @@ +import UIKit +import WidgetKit + +/// Carries out widget and control taps. Audio intents run here in the app's process, starting the app in the background if it isn't running. +@MainActor +enum MixRemote { + /// One knob step on the widget, in fader position. Ten steps from silent to full, with unity on a step. + static let step: Float = 0.1 + + static func run(_ a: MixAction) { + let g = AppGraph.shared, pm = g.playback + switch a { + case .cycle: + if pm.source == .Stems { g.nowPlaying.cycleMix() } + case let .nudge(name, steps): + guard pm.source == .Stems, pm.stemNames.contains(name) else { break } + let cur = pm.mix[name] ?? StemSetting() + // Turning a muted knob brings it back first. + if cur.muted { pm.toggleMute(name) } + pm.setStemLevel(name, ((cur.level / step).rounded() + Float(steps)) * step) + case let .mute(name): + if pm.source == .Stems && pm.stemNames.contains(name) { pm.toggleMute(name) } + case .playPause: + if g.radio.active { g.radio.toggle() } else { pm.togglePlay() } + } + WidgetBridge.shared.publish(pm, g.settings, force: true) + } +} + +/// Keeps the widgets' copy of what's playing up to date: the snapshot in the App Group, plus a small copy of the art. +final class WidgetBridge: @unchecked Sendable { + static let shared = WidgetBridge() + + private var last: WidgetSnapshot? + private var artKey: String? + private var savedArt: String? + + func start(_ g: AppGraph) { + let pm = g.playback, s = g.settings, plex = g.plex + pm.addListener { [weak self] in + self?.loadArt(pm, plex) + self?.publish(pm, s) + } + loadArt(pm, plex) + publish(pm, s) + } + + /// Writes the snapshot and asks WidgetKit to redraw, but only when something the widgets show has changed. + func publish(_ pm: PlaybackManager, _ s: Settings, force: Bool = false) { + let snap = make(pm, s) + guard force || snap != last else { return } + last = snap + WidgetStore.save(snap) + WidgetCenter.shared.reloadAllTimelines() + if #available(iOS 18.0, *) { ControlCenter.shared.reloadAllControls() } + } + + private func make(_ pm: PlaybackManager, _ s: Settings) -> WidgetSnapshot { + guard let t = pm.current?.track else { return WidgetSnapshot() } + var w = WidgetSnapshot(title: t.title, artist: t.artist, playing: pm.isPlaying, art: savedArt == t.thumb ? savedArt : nil) + guard pm.source == .Stems else { return w } + let names = pm.stemNames, mix = pm.mix + w.mix = MixPresets.current(s, names, mix)?.label ?? "Custom Mix" + w.next = MixPresets.next(s, names, mix).label + w.stems = Stems.sort(names).map { n in + let m = mix[n] ?? StemSetting() + return WidgetStem(name: n, label: Stems.label(n), color: Stems.color(n), level: Double(m.level), + percent: Int((stemTaper(m.level) * 100).rounded()), muted: m.muted, solo: m.solo) + } + return w + } + + /// Saves a 300 px copy of the current art for the widgets, once per album. + private func loadArt(_ pm: PlaybackManager, _ plex: PlexClient) { + let key = pm.current?.track.thumb + guard key != artKey else { return } + artKey = key + guard let key, let url = plex.artUrl(key, px: 300), let file = WidgetStore.artURL else { return } + Task { @MainActor [weak self] in + guard let img = await ArtLoader.shared.image(url), let self, self.artKey == key else { return } + let side: CGFloat = 300 + let small = UIGraphicsImageRenderer(size: CGSize(width: side, height: side)).image { _ in + img.draw(in: CGRect(x: 0, y: 0, width: side, height: side)) + } + guard let data = small.jpegData(compressionQuality: 0.85), (try? data.write(to: file, options: .atomic)) != nil else { return } + self.savedArt = key + self.publish(pm, AppGraph.shared.settings, force: true) + } + } +} diff --git a/ios/Stemify/Cache/Downloads.swift b/ios/Stemify/Cache/Downloads.swift index a1fc474..4bca5aa 100644 --- a/ios/Stemify/Cache/Downloads.swift +++ b/ios/Stemify/Cache/Downloads.swift @@ -25,6 +25,21 @@ struct DownloadRecord: Codable, Equatable { } } +/// What gets downloaded, Plexamp-style: a whole playlist, album or artist, kept in step with the server. +/// Its songs are ordinary [DownloadRecord]s, shared when two groups hold the same song. +struct DownloadGroup: Codable, Identifiable, Equatable { + enum Kind: String, Codable { case playlist, stemPlaylist, album, artist } + var kind: Kind + var key: String + var title: String + var subtitle = "" + var thumb: String? + var trackKeys: [String] = [] + var added = Int64(Date().timeIntervalSince1970 * 1000) + var id: String { Self.id(kind, key) } + static func id(_ kind: Kind, _ key: String) -> String { "\(kind.rawValue):\(key)" } +} + /// Offline copies: the track's stems (or the original file) fully fetched into /// [MediaCache] and pinned, plus the metadata needed to browse them with no server. @Observable @@ -35,6 +50,7 @@ final class Downloads: @unchecked Sendable { @ObservationIgnored private let cache: MediaCache @ObservationIgnored private let net: Connectivity @ObservationIgnored private let file: URL + @ObservationIgnored private let groupFile: URL @ObservationIgnored private let lock = NSCondition() @ObservationIgnored private var state: [String: DownloadRecord] = [:] @ObservationIgnored private var prog: [String: Float] = [:] @@ -47,6 +63,8 @@ final class Downloads: @unchecked Sendable { static let waveBuckets = 256 private(set) var records: [String: DownloadRecord] = [:] + /// Group id -> group. Only touched on the main thread. + private(set) var groups: [String: DownloadGroup] = [:] /// ratingKey -> 0..1 for queued and running downloads. private(set) var progress: [String: Float] = [:] @@ -57,10 +75,24 @@ final class Downloads: @unchecked Sendable { self.cache = cache self.net = net file = dir.appendingPathComponent("downloads.json") + groupFile = dir.appendingPathComponent("download-groups.json") if let data = try? Data(contentsOf: file), let list = try? JSONDecoder().decode([Lenient].self, from: data) { for r in list.compactMap(\.value) { state[r.track.ratingKey] = r } } records = state + if let data = try? Data(contentsOf: groupFile), let list = try? JSONDecoder().decode([DownloadGroup].self, from: data) { + groups = Dictionary(list.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) + } else { + // Downloads from before groups: gather them back into their albums. + for (k, rs) in Dictionary(grouping: state.values.filter { $0.track.albumKey != nil }, by: { $0.track.albumKey! }) { + let t = rs[0].track + let gr = DownloadGroup(kind: .album, key: k, title: t.album, subtitle: t.artist, thumb: t.thumb, + trackKeys: rs.sorted { (($0.track.disc ?? 0), ($0.track.index ?? 0)) < (($1.track.disc ?? 0), ($1.track.index ?? 0)) }.map(\.track.ratingKey), + added: rs.map(\.added).min() ?? 0) + groups[gr.id] = gr + } + saveGroups() + } for _ in 0..<2 { let t = Thread { [weak self] in self?.work() } t.qualityOfService = .utility @@ -72,6 +104,61 @@ final class Downloads: @unchecked Sendable { func isDownloaded(_ ratingKey: String) -> Bool { records[ratingKey]?.done == true } + func group(_ kind: DownloadGroup.Kind, _ key: String) -> DownloadGroup? { groups[DownloadGroup.id(kind, key)] } + + /// Songs no group holds (single downloads from older versions). + var looseRecords: [DownloadRecord] { + let held = Set(groups.values.flatMap(\.trackKeys)) + return records.values.filter { !held.contains($0.track.ratingKey) } + } + + /// Downloads [gr] with these songs, or brings an existing group up to date: new songs are fetched, + /// songs that left it are removed unless another group still holds them. + func download(_ gr: DownloadGroup, _ tracks: [Track]) { + var gr = gr + if let old = groups[gr.id] { gr.added = old.added } + let before = Set(groups[gr.id]?.trackKeys ?? []) + gr.trackKeys = tracks.map(\.ratingKey) + groups[gr.id] = gr + saveGroups() + download(tracks) + prune(before.subtracting(gr.trackKeys)) + } + + func removeGroup(_ id: String) { + guard let gr = groups.removeValue(forKey: id) else { return } + saveGroups() + prune(Set(gr.trackKeys)) + } + + /// Removes the songs among [keys] that no group holds any more. + private func prune(_ keys: Set) { + let held = Set(groups.values.flatMap(\.trackKeys)) + let gone = keys.subtracting(held) + if !gone.isEmpty { remove(gone.compactMap { records[$0]?.track }) } + } + + /// Brings every group in line with the server: playlists gain and lose songs, artists gain albums. + @MainActor func refresh() async { + for gr in groups.values { + let tracks: [Track]? + switch gr.kind { + case .playlist: tracks = try? await plex.playlistTracks(gr.key) + case .album: tracks = try? await plex.albumTracks(gr.key) + case .artist: tracks = try? await plex.artistTracks(gr.key) + case .stemPlaylist: tracks = AppGraph.shared.stemPlaylists.lists.first { $0.id == gr.key }?.tracks + } + if let tracks, !tracks.isEmpty, let cur = groups[gr.id], cur.trackKeys != tracks.map(\.ratingKey) { download(cur, tracks) } + } + } + + private func saveGroups() { + let list = groups.values.sorted { $0.added < $1.added } + writeQueue.async { [groupFile] in + if let data = try? JSONEncoder().encode(list) { try? data.write(to: groupFile, options: .atomic) } + } + } + func download(_ tracks: [Track]) { lock.lock() let add = tracks.filter { t in diff --git a/ios/Stemify/CarPlay/CarLibrary.swift b/ios/Stemify/CarPlay/CarLibrary.swift index d2387e7..3e0ef9b 100644 --- a/ios/Stemify/CarPlay/CarLibrary.swift +++ b/ios/Stemify/CarPlay/CarLibrary.swift @@ -76,7 +76,7 @@ final class CarLibrary: @unchecked Sendable { default: guard let (kind, key) = CarIds.split(id) else { return nil } switch kind { - case "playlist": return await songs(id, try tracksOf(id), shuffleRow: true, showArtist: true) + case "playlist", "stemplaylist": return await songs(id, try tracksOf(id), shuffleRow: true, showArtist: true) case "album": return await songs(id, try tracksOf(id), shuffleRow: false, showArtist: false) case "artist": return try await artistPage(key, away) case "albums": return try await albumsFor(away).filter { CarTree.letterOf($0.title) == key }.map { albumItem($0) } @@ -95,7 +95,13 @@ final class CarLibrary: @unchecked Sendable { } else { shown = all } - return shown.map { playlistItem($0) } + let stem = await MainActor.run { AppGraph.shared.stemPlaylists.lists.filter { !$0.items.isEmpty } } + let stemItems = stem.map { l in + CarItem(id: "stemplaylist:\(l.id)", title: l.title, subtitle: CarTree.countLabel(l.items.count, "song", "songs"), + art: artOr(l.thumb, l.title), playable: false, group: "Stem Playlists") + } + let plexItems = shown.map { playlistItem($0, group: stemItems.isEmpty ? nil : "Plex Playlists") } + return stemItems + plexItems } private func albumsTab(_ away: Bool) async throws -> [CarItem] { @@ -168,6 +174,7 @@ final class CarLibrary: @unchecked Sendable { guard let (kind, key) = CarIds.split(container) else { return [] } switch kind { case "playlist": all = try await plex.playlistTracks(key) + case "stemplaylist": all = await MainActor.run { AppGraph.shared.stemPlaylists.list(key)?.tracks ?? [] } case "album": all = try await plex.albumTracks(key) case "artist": all = away ? downloadedTracks().filter { $0.artistKey == key } : try await plex.artistTracks(key) case "search": all = try await searchTracks(key, away) diff --git a/ios/Stemify/CarPlay/CarPlayScene.swift b/ios/Stemify/CarPlay/CarPlayScene.swift index c9f378b..9a29a0a 100644 --- a/ios/Stemify/CarPlay/CarPlayScene.swift +++ b/ios/Stemify/CarPlay/CarPlayScene.swift @@ -172,7 +172,7 @@ final class CarPlayScene: UIResponder, CPTemplateApplicationSceneDelegate, CPNow guard let self else { return } let label = self.g.nowPlaying.nextMix.label self.g.nowPlaying.cycleMix() - self.alert("Mix: \(label)") + self.alert(label) }) } let shuffle = CPNowPlayingShuffleButton { [weak self] _ in self?.g.playback.toggleShuffle() } diff --git a/ios/Stemify/CarPlay/HomeReach.swift b/ios/Stemify/CarPlay/HomeReach.swift index 9c38121..87f8de4 100644 --- a/ios/Stemify/CarPlay/HomeReach.swift +++ b/ios/Stemify/CarPlay/HomeReach.swift @@ -49,12 +49,13 @@ final class HomeReach: @unchecked Sendable { let now = DispatchTime.now().uptimeNanoseconds let (at, last) = stamp.with { (checkedAt, _last) } if at != 0 && now - at < Self.ttl { return last } - let plex = settings.plexUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/identity" - let stems = settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/info" - async let a = answers(plex) - async let b = answers(stems) - let (ra, rb) = await (a, b) - let ok = ra || rb + let urls = settings.servers.map { $0.base + ($0.kind == .plex ? "/identity" : "/System/Info/Public") } + + [settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/info"] + let ok = await withTaskGroup(of: Bool.self) { g in + for u in urls { g.addTask { await self.answers(u) } } + for await r in g where r { g.cancelAll(); return true } + return false + } stamp.with { _last = ok; checkedAt = DispatchTime.now().uptimeNanoseconds } await MainActor.run { if home != ok { diff --git a/ios/Stemify/Core/Settings.swift b/ios/Stemify/Core/Settings.swift index 5d56f21..f85549e 100644 --- a/ios/Stemify/Core/Settings.swift +++ b/ios/Stemify/Core/Settings.swift @@ -97,13 +97,19 @@ final class Settings: @unchecked Sendable { clientId = UUID().uuidString.lowercased() defaults.set(clientId, forKey: "clientId") } + // Single-Plex settings from before multi-server become the first server, with bare keys. + if store["servers"] == nil, let t = store["plexToken"] as? String, !t.isEmpty { + let sections = (store["musicSection"] as? String ?? "").split(separator: ",").map(String.init) + servers = [MediaServer(id: "", kind: .plex, name: "Plex", url: plexUrl, token: t, user: plexUser, + machineId: plexMachineId, sections: sections, sectionTitles: musicSectionTitle)] + } } private static let keys = [ - "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle", + "servers", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle", "stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate", "stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout", - "karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", + "karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", "noStems", "precacheCount", "hiddenNowPlaying", "presets", "presetOrder", "hiddenPresets", "lockedPreset", "webStations", "lockScreenMix", "acapella", ] private func read(_ kp: KeyPath, _ key: String, _ def: T) -> T { @@ -171,6 +177,34 @@ final class Settings: @unchecked Sendable { var plexMachineId: String { get { read(\.plexMachineId, "plexMachineId", "") } set { write(\.plexMachineId, "plexMachineId", newValue, "") } } var musicSection: String { get { read(\.musicSection, "musicSection", "") } set { write(\.musicSection, "musicSection", newValue, "") } } var musicSectionTitle: String { get { read(\.musicSectionTitle, "musicSectionTitle", "") } set { write(\.musicSectionTitle, "musicSectionTitle", newValue, "") } } + /// Music servers as JSON; use [servers]. + /// Web radio stations as JSON ([WebStation]). + var stationsJson: String { get { read(\.stationsJson, "webStations", "") } set { write(\.stationsJson, "webStations", newValue, "") } } + var stations: [WebStation] { + get { (try? JSONDecoder().decode([WebStation].self, from: Data(stationsJson.utf8))) ?? [] } + set { stationsJson = (try? JSONEncoder().encode(newValue)).map { String(decoding: $0, as: UTF8.self) } ?? "" } + } + var serversJson: String { get { read(\.serversJson, "servers", "") } set { write(\.serversJson, "servers", newValue, "") } } + var servers: [MediaServer] { + get { (try? JSONDecoder().decode([MediaServer].self, from: Data(serversJson.utf8))) ?? [] } + set { serversJson = (try? JSONEncoder().encode(newValue)).map { String(decoding: $0, as: UTF8.self) } ?? "" } + } + /// Changes whenever the libraries being read change; screens reload on it. + var libraryKey: String { servers.filter(\.ready).map { "\($0.id)@\($0.url)/\($0.sections.joined(separator: ","))" }.joined(separator: ";") } + var libraryReady: Bool { servers.contains(where: \.ready) } + + /// Adds or replaces a server by id. + func save(_ server: MediaServer) { + var list = servers + if let i = list.firstIndex(where: { $0.id == server.id }) { list[i] = server } else { list.append(server) } + servers = list + } + + func removeServer(_ id: String) { servers = servers.filter { $0.id != id } } + + /// The id a new server gets: empty (bare keys) when no server holds it yet. + func nextServerId() -> String { servers.contains { $0.id.isEmpty } ? MediaServer.newId() : "" } + var stemUrl: String { get { read(\.stemUrl, "stemUrl", "http://192.168.86.2:8765") } set { write(\.stemUrl, "stemUrl", newValue, "http://192.168.86.2:8765") } } var stemToken: String { get { read(\.stemToken, "stemToken", "") } set { write(\.stemToken, "stemToken", newValue, "") } } /// Plex path prefixes that matched the stems index, newline-separated. @@ -179,6 +213,9 @@ final class Settings: @unchecked Sendable { var tabLayout: String { get { read(\.tabLayout, "tabLayout", Tab.encode(Tab.defaultLayout)) } set { write(\.tabLayout, "tabLayout", newValue, Tab.encode(Tab.defaultLayout)) } } var theme: ThemeMode { get { readEnum(\.theme, "theme", .Black) } set { writeEnum(\.theme, "theme", newValue, .Black) } } var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } } + /// Spells the A Cappella mix "Acapella". MixPresets reads the key directly. + var acapella: Bool { get { read(\.acapella, "acapella", false) } set { write(\.acapella, "acapella", newValue, false) } } + var lockScreenMix: Bool { get { read(\.lockScreenMix, "lockScreenMix", false) } set { write(\.lockScreenMix, "lockScreenMix", newValue, false) } } var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } } var highRefreshRate: Bool { get { read(\.highRefreshRate, "highRefreshRate", true) } set { write(\.highRefreshRate, "highRefreshRate", newValue, true) } } @@ -191,7 +228,19 @@ final class Settings: @unchecked Sendable { /// How far down Soft karaoke puts the vocals, in dB. var karaokeSoftDb: Int { get { read(\.karaokeSoftDb, "karaokeSoftDb", -12) } set { write(\.karaokeSoftDb, "karaokeSoftDb", newValue, -12) } } /// Library lists show only what has stems. - var stemsOnly: Bool { get { read(\.stemsOnly, "stemsOnly", false) } set { write(\.stemsOnly, "stemsOnly", newValue, false) } } + var stemsOnly: Bool { get { read(\.stemsOnly, "stemsOnly", false) } set { write(\.stemsOnly, "stemsOnly", newValue, false); if newValue { noStems = false } } } + var noStems: Bool { get { read(\.noStems, "noStems", false) } set { write(\.noStems, "noStems", newValue, false); if newValue { stemsOnly = false } } } + /// How many songs after the current one to cache ahead (0 to 50). + var precacheCount: Int { get { read(\.precacheCount, "precacheCount", 5) } set { write(\.precacheCount, "precacheCount", min(50, max(0, newValue)), 5) } } + /// Now-playing buttons the user hid, comma-separated. + var hiddenNowPlaying: String { get { read(\.hiddenNowPlaying, "hiddenNowPlaying", "") } set { write(\.hiddenNowPlaying, "hiddenNowPlaying", newValue, "") } } + /// App-wide saved stem mixes as JSON ([name: mix]). + var presets: String { get { read(\.presets, "presets", "") } set { write(\.presets, "presets", newValue, "") } } + /// Mixer bar order and hidden built-ins, newline-separated preset ids ("b:Karaoke", "c:My Mix"). + var presetOrder: String { get { read(\.presetOrder, "presetOrder", "") } set { write(\.presetOrder, "presetOrder", newValue, "") } } + var hiddenPresets: String { get { read(\.hiddenPresets, "hiddenPresets", "") } set { write(\.hiddenPresets, "hiddenPresets", newValue, "") } } + /// The id of the preset every song starts with, or empty. + var lockedPreset: String { get { read(\.lockedPreset, "lockedPreset", "") } set { write(\.lockedPreset, "lockedPreset", newValue, "") } } var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } } var playbackSpeed: Double { get { access(keyPath: \.playbackSpeed); lock.lock(); defer { lock.unlock() }; return (store["playbackSpeed"] as? NSNumber)?.doubleValue ?? 1 } diff --git a/ios/Stemify/Playback/MixPresets.swift b/ios/Stemify/Playback/MixPresets.swift index f06020c..309a244 100644 --- a/ios/Stemify/Playback/MixPresets.swift +++ b/ios/Stemify/Playback/MixPresets.swift @@ -2,28 +2,74 @@ import Foundation /// A one-tap stem mix. [build] returns nil when the track lacks the stems it needs. struct MixPreset: Identifiable, Equatable { - let label: String + /// The stored name, which ids are built from. Show [label] instead. + let name: String + /// One the user saved, as opposed to a built-in. + var custom = false let build: ([String]) -> [String: StemSetting]? - var id: String { label } - static func == (a: MixPreset, b: MixPreset) -> Bool { a.label == b.label } + var id: String { (custom ? "c:" : "b:") + name } + /// The name as shown, with A Cappella spelled the way Appearance says. + var label: String { !custom && name == "A Cappella" && MixPresets.acapella ? "Acapella" : name } + static func == (a: MixPreset, b: MixPreset) -> Bool { a.id == b.id } } enum MixPresets { - static let full = MixPreset(label: "Full Mix") { _ in [:] } - static let karaoke = MixPreset(label: "Karaoke") { n in n.contains("vocals") ? ["vocals": StemSetting(muted: true)] : nil } + static let full = MixPreset(name: "Full Mix") { _ in [:] } + static let karaoke = MixPreset(name: "Karaoke") { n in n.contains("vocals") ? ["vocals": StemSetting(muted: true)] : nil } static let all: [MixPreset] = [ full, karaoke, - MixPreset(label: "A Cappella") { n in n.contains("vocals") ? ["vocals": StemSetting(solo: true)] : nil }, - MixPreset(label: "No Drums") { n in n.contains("drums") ? ["drums": StemSetting(muted: true)] : nil }, - MixPreset(label: "Rhythm") { n in + MixPreset(name: "A Cappella") { n in n.contains("vocals") ? ["vocals": StemSetting(solo: true)] : nil }, + MixPreset(name: "No Drums") { n in n.contains("drums") ? ["drums": StemSetting(muted: true)] : nil }, + MixPreset(name: "Rhythm") { n in n.contains("drums") && n.contains("bass") ? ["drums": StemSetting(solo: true), "bass": StemSetting(solo: true)] : nil }, - MixPreset(label: "No Guitar") { n in n.contains("guitar") ? ["guitar": StemSetting(muted: true)] : nil }, - MixPreset(label: "No Bass") { n in n.contains("bass") ? ["bass": StemSetting(muted: true)] : nil }, + MixPreset(name: "No Guitar") { n in n.contains("guitar") ? ["guitar": StemSetting(muted: true)] : nil }, + MixPreset(name: "No Bass") { n in n.contains("bass") ? ["bass": StemSetting(muted: true)] : nil }, ] + static func custom(_ c: CustomPreset) -> MixPreset { MixPreset(name: c.name, custom: true) { _ in c.mix } } + + /// Every preset, the user's and the built-ins, in the order set in the Stem Editor (new ones go to the end). + static func ordered(_ s: Settings) -> [MixPreset] { + let pool = CustomPreset.load(s).map(custom) + all + let order = s.presetOrder.split(separator: "\n").map(String.init) + let rank = Dictionary(order.enumerated().map { ($1, $0) }, uniquingKeysWith: { a, _ in a }) + return pool.enumerated().sorted { a, b in + (rank[a.element.id] ?? order.count + a.offset, a.offset) < (rank[b.element.id] ?? order.count + b.offset, b.offset) + }.map(\.element) + } + + /// Set by Appearance. Read straight from UserDefaults so labels work off the main thread. + static var acapella: Bool { UserDefaults.standard.bool(forKey: "acapella") } + + /// Built-ins deleted in the Stem Editor. Custom ids from older builds, which could hide those too, don't count. + static func hidden(_ s: Settings) -> Set { + Set(s.hiddenPresets.split(separator: "\n").map(String.init).filter { $0.hasPrefix("b:") }) + } + + /// The chips in the mixer bar, and the rotation for every Mix button outside the app. + static func bar(_ s: Settings) -> [MixPreset] { + let h = hidden(s) + return ordered(s).filter { !h.contains($0.id) } + } + + static func restoreDefaults(_ s: Settings) { s.hiddenPresets = "" } + + static func setOrder(_ s: Settings, _ list: [MixPreset]) { s.presetOrder = list.map(\.id).joined(separator: "\n") } + + static func setHidden(_ s: Settings, _ id: String, _ on: Bool) { + var h = hidden(s) + if on { h.insert(id) } else { h.remove(id) } + s.hiddenPresets = h.sorted().joined(separator: "\n") + } + + /// The preset every song starts with, whatever Keep Stem Mix says. Nil when unlocked. + static func locked(_ s: Settings) -> MixPreset? { + s.lockedPreset.isEmpty ? nil : ordered(s).first { $0.id == s.lockedPreset } + } + /// Drops stems left at their defaults, so mixes compare by what they actually change. static func normalized(_ m: [String: StemSetting]) -> [String: StemSetting] { m.filter { $0.value != StemSetting() } } @@ -33,10 +79,19 @@ enum MixPresets { return all.first { p in p.build(names).map { normalized($0) == m } ?? false } } - /// The preset after the current one (a custom mix goes back to Full), skipping ones these stems can't do. - static func next(_ names: [String], _ mix: [String: StemSetting]) -> MixPreset { - let usable = all.filter { $0.build(names) != nil } - guard let cur = current(names, mix), let i = usable.firstIndex(of: cur) else { return full } + /// Like [current], but also matches the user's own presets, so a saved mix is named wherever it shows. + static func current(_ s: Settings, _ names: [String], _ mix: [String: StemSetting]) -> MixPreset? { + let m = normalized(mix) + return ordered(s).first { p in p.build(names).map { normalized($0) == m } ?? false } + } + + /// The mix after the current one in the Stem Editor's list, skipping ones these stems can't do. + /// A hand-made mix, or one deleted from the editor, goes to the start of the list. + static func next(_ s: Settings, _ names: [String], _ mix: [String: StemSetting]) -> MixPreset { + let usable = bar(s).filter { $0.build(names) != nil } + guard !usable.isEmpty else { return full } + let m = normalized(mix) + guard let i = usable.firstIndex(where: { p in p.build(names).map { normalized($0) == m } ?? false }) else { return usable[0] } return usable[(i + 1) % usable.count] } @@ -48,3 +103,41 @@ enum MixPresets { return v.muted || anySolo && !v.solo || v.level <= 0 } } + +/// A named, app-wide mix the user saved from the mixer. Stored as JSON in [Settings.presets]. +struct CustomPreset: Codable, Identifiable, Equatable { + var name: String + var mix: [String: StemSetting] + var id: String { name } + + @MainActor static func load() -> [CustomPreset] { load(AppGraph.shared.settings) } + + static func load(_ s: Settings) -> [CustomPreset] { + guard let d = s.presets.data(using: .utf8) else { return [] } + return (try? JSONDecoder().decode([CustomPreset].self, from: d)) ?? [] + } + + @MainActor static func save(_ list: [CustomPreset]) { + guard let d = try? JSONEncoder().encode(list), let s = String(data: d, encoding: .utf8) else { return } + AppGraph.shared.settings.presets = s + } + + /// Adds or replaces the preset with this name. + @MainActor static func add(_ name: String, _ mix: [String: StemSetting]) { + var l = load().filter { $0.name != name } + l.append(CustomPreset(name: name, mix: MixPresets.normalized(mix))) + save(l) + } + + @MainActor static func remove(_ name: String) { + let s = AppGraph.shared.settings + if s.lockedPreset == "c:" + name { s.lockedPreset = "" } + save(load().filter { $0.name != name }) + } + + @MainActor static func removeAll() { + let s = AppGraph.shared.settings + if s.lockedPreset.hasPrefix("c:") { s.lockedPreset = "" } + save([]) + } +} diff --git a/ios/Stemify/Playback/NowPlaying.swift b/ios/Stemify/Playback/NowPlaying.swift index a9e823c..e889420 100644 --- a/ios/Stemify/Playback/NowPlaying.swift +++ b/ios/Stemify/Playback/NowPlaying.swift @@ -8,13 +8,17 @@ import UIKit final class NowPlaying: @unchecked Sendable { private let pm: PlaybackManager private let plex: PlexClient + private let radio: WebRadio + private let settings: Settings private let center = MPNowPlayingInfoCenter.default() private var artKey: String? private var artwork: MPMediaItemArtwork? - init(pm: PlaybackManager, plex: PlexClient) { + init(pm: PlaybackManager, plex: PlexClient, radio: WebRadio, settings: Settings) { self.pm = pm self.plex = plex + self.radio = radio + self.settings = settings pm.addListener { [weak self] in self?.publish() } wireCommands() publish() @@ -25,13 +29,14 @@ final class NowPlaying: @unchecked Sendable { /// The mix shown next to the artist: nil unless stems are playing or the mix is Full. var mixLabel: String? { guard pm.source == .Stems else { return nil } - guard let p = MixPresets.current(pm.stemNames, pm.mix) else { return "Custom mix" } + guard let p = MixPresets.current(settings, pm.stemNames, pm.mix) else { return "Custom mix" } return p == MixPresets.full ? nil : p.label } var vocalsAvailable: Bool { pm.source == .Stems && pm.stemNames.contains("vocals") } var vocalsSilent: Bool { MixPresets.vocalsSilent(pm.stemNames, pm.mix) } - var nextMix: MixPreset { MixPresets.next(pm.stemNames, pm.mix) } + /// Follows the Stem Editor: deleted mixes are skipped and the user's own are in the rotation. + var nextMix: MixPreset { MixPresets.next(settings, pm.stemNames, pm.mix) } /// One tap: hear the vocals again, or take them out. func toggleVocals() { @@ -50,7 +55,11 @@ final class NowPlaying: @unchecked Sendable { // ---- info -------------------------------------------------------------------------- + /// Puts the queue back on the lock screen (after the web radio stops). + func refresh() { publish() } + private func publish() { + if radio.active { return } guard let t = pm.current?.track else { center.nowPlayingInfo = nil center.playbackState = .stopped @@ -92,16 +101,21 @@ final class NowPlaying: @unchecked Sendable { private func wireCommands() { let c = MPRemoteCommandCenter.shared() - func on(_ cmd: MPRemoteCommand, _ f: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) { + func on(_ cmd: MPRemoteCommand, _ f: @escaping @MainActor (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) { cmd.isEnabled = true cmd.addTarget { e in MainActor.assumeIsolated { f(e) } } } - on(c.playCommand) { [pm] _ in pm.play(); return .success } - on(c.pauseCommand) { [pm] _ in pm.pause(); return .success } - on(c.stopCommand) { [pm] _ in pm.pause(); return .success } - on(c.togglePlayPauseCommand) { [pm] _ in pm.togglePlay(); return .success } - on(c.nextTrackCommand) { [pm] _ in pm.next(); return .success } - on(c.previousTrackCommand) { [pm] _ in pm.previous(); return .success } + // While a web radio station is loaded, play/pause drive it and skipping does nothing. + on(c.playCommand) { [pm, radio] _ in if radio.active { radio.resume() } else { pm.play() }; return .success } + on(c.pauseCommand) { [pm, radio] _ in if radio.active { radio.pause() } else { pm.pause() }; return .success } + on(c.stopCommand) { [pm, radio] _ in if radio.active { radio.stop() } else { pm.pause() }; return .success } + on(c.togglePlayPauseCommand) { [pm, radio] _ in if radio.active { radio.toggle() } else { pm.togglePlay() }; return .success } + on(c.nextTrackCommand) { [pm, radio] _ in if !radio.active { pm.next() }; return .success } + on(c.previousTrackCommand) { [weak self, pm, radio] _ in + guard !radio.active else { return .success } + if self?.settings.lockScreenMix == true && pm.source == .Stems { self?.cycleMix() } else { pm.previous() } + return .success + } on(c.changePlaybackPositionCommand) { [pm] e in guard let e = e as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } pm.seekTo(Int64(e.positionTime * 1000)) diff --git a/ios/Stemify/Playback/PlaybackManager.swift b/ios/Stemify/Playback/PlaybackManager.swift index c8f5a1f..a0c574c 100644 --- a/ios/Stemify/Playback/PlaybackManager.swift +++ b/ios/Stemify/Playback/PlaybackManager.swift @@ -100,7 +100,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { "stem|\(entry.key)|\(file)|\(size)|\(q.rawValue)" } static func plexKey(_ track: Track) -> String { - "plex|\(track.partId.map(String.init) ?? "null")|\(track.partSize.map(String.init) ?? "null")" + "plex|\(track.ratingKey.firstIndex(of: "~").map { String(track.ratingKey[...$0]) } ?? "")\(track.partId.map(String.init) ?? "null")|\(track.partSize.map(String.init) ?? "null")" } @ObservationIgnored private let settings: Settings @@ -131,6 +131,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { private(set) var songMixes: [String: [String: StemSetting]] = [:] /// Karaoke mode: vocals on, soft or out (per settings) on every song, lyrics on Now Playing. private(set) var karaoke = false + /// The last mix set by hand; the next song starts from it (never a song's saved mix). + @ObservationIgnored private var carryMix: [String: StemSetting] = [:] @ObservationIgnored private var preKaraokeMix: [String: StemSetting]? @ObservationIgnored private var listeners: [UUID: () -> Void] = [:] @@ -142,9 +144,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { @ObservationIgnored private var loadTask: Task? @ObservationIgnored private var nextTask: Task? @ObservationIgnored private var currentToken: Int64 = -1 - @ObservationIgnored private var nextToken: Int64 = -1 - @ObservationIgnored private var nextIndex = -1 - @ObservationIgnored private var nextEntry: StemEntry? + /// Sessions handed to the engine for a gapless switch, by token: which queue item and entry they are. + /// Kept until a switch or load, since the engine may already have switched when the queue changes. + @ObservationIgnored private var queued: [Int64: (uid: Int64, entry: StemEntry?)] = [:] + /// The queue ran out and playback stopped at its last song. + @ObservationIgnored private var finished = false @ObservationIgnored private var currentEntry: StemEntry? @ObservationIgnored private var pendingPositionMs: Int64 = 0 @ObservationIgnored private var failures = 0 @@ -218,13 +222,18 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { } /// `mixes` lines up with `tracks`: each song plays with its own mix (stem playlists). + /// Shuffles whole albums, keeping each album's tracks in order (Plexamp-style), when the setting is on. + private func shuffled(_ items: [QueueItem]) -> [QueueItem] { + items.shuffled() + } + func playTracks(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil, start: Int = 0, shuffle doShuffle: Bool = false) { if tracks.isEmpty { return } let items = items(tracks, mixes) if doShuffle { unshuffled = items let first = start > 0 && start < items.count ? items[start] : items.randomElement()! - queue = [first] + items.filter { $0.uid != first.uid }.shuffled() + queue = [first] + shuffled(items.filter { $0.uid != first.uid }) shuffle = true load(0, 0, true) } else { @@ -245,8 +254,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { q.insert(contentsOf: items, at: min(max(at, 0), q.count)) queue = q unshuffled = unshuffled.map { $0 + items } - if index < 0 { - load(0, 0, true) + if index < 0 || finished { + load(finished ? index + 1 : 0, 0, true) } else { requeueNext() changed() @@ -303,7 +312,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { let cur = q.indices.contains(idx) ? q[idx] : nil if !shuffle { unshuffled = q - queue = Array(q.prefix(idx + 1)) + q.dropFirst(idx + 1).shuffled() + queue = Array(q.prefix(idx + 1)) + shuffled(Array(q.dropFirst(idx + 1))) shuffle = true } else { let orig = unshuffled ?? q @@ -376,6 +385,14 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { else { pause(); seekTo(0) } } + /// Skips past the rest of the current album (holding Next). + func nextAlbum() { + guard let album = current?.track.albumKey, index >= 0 else { return next() } + if let i = queue.indices.dropFirst(index + 1).first(where: { queue[$0].track.albumKey != album }) { load(i, 0, playWhenReady) } + else if repeatMode != .Off { load(0, 0, playWhenReady) } + else { pause(); seekTo(0) } + } + func previous() { if positionMs() > 3000 || index <= 0 { seekTo(0) @@ -401,7 +418,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { engine.stop() reportTimeline("stopped") currentToken = -1 - nextToken = -1 + queued = [:] queue = [] index = -1 current = nil @@ -428,14 +445,17 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { /// Karaoke and friends: set every stem at once. func applyMix(_ m: [String: StemSetting]) { mix = m + carryMix = m pushMix() } /// The mix a song starts with: karaoke if that's on, else its stem playlist mix, else the song's saved mix, /// else cleared (or kept, per settings). private func startMix(_ item: QueueItem) -> [String: StemSetting] { + if karaoke && !settings.keepStemMix { karaoke = false; preKaraokeMix = nil } if karaoke { return karaokeMix } - return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? mix : [:]) + if let l = MixPresets.locked(settings), let m = l.build(StemPlaylists.allStems) { return m } + return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? carryMix : [:]) } /// How much of the singer is left in karaoke mode. Soft is a guide under your own voice, as quiet as settings say. @@ -491,6 +511,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { var s = mix[name] ?? StemSetting() f(&s) mix[name] = s + carryMix = mix pushMix() } @@ -533,7 +554,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { let item = queue[i] loadTask?.cancel() nextTask?.cancel() - nextToken = -1 + queued = [:] + finished = false engine.queueNext(nil) index = i current = item @@ -649,7 +671,6 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { /// Builds the next track's session now so the switch can be gapless. private func prepareNext() { nextTask?.cancel() - nextToken = -1 engine.queueNext(nil) guard let i = upcomingIndex() else { return } let item = queue[i] @@ -666,10 +687,27 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { s.release() return } - self.nextToken = s.token - self.nextIndex = i - self.nextEntry = entry + self.queued[s.token] = (item.uid, entry) self.engine.queueNext(s) + await self.precacheAhead(from: i) + } + } + + /// Warms the cache for the songs after the gapless one, up to the Cache Ahead setting. + private func precacheAhead(from i: Int) async { + let n = settings.precacheCount - 1 + guard n > 0 else { return } + for k in 1...n { + let j = i + k + guard !Task.isCancelled, j < queue.count else { return } + let t = queue[j].track + if playableOffline(t) { continue } + guard let (s, _) = try? await openDetached(t, 0) else { continue } + // Keep the session open until its prefetch has filled the cache. + for _ in 0..<300 where !Task.isCancelled && !playableOffline(t) { + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + s.release() } } @@ -709,13 +747,14 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { } func onTransition(_ token: Int64) { - if token == currentToken || token != nextToken { return } + guard token != currentToken, let q = queued[token] else { return } + queued = [:] scrobble() currentToken = token - nextToken = -1 - index = nextIndex - current = queue.indices.contains(nextIndex) ? queue[nextIndex] : nil - currentEntry = nextEntry + let i = queue.firstIndex { $0.uid == q.uid } ?? min(index + 1, queue.count - 1) + index = i + current = queue.indices.contains(i) ? queue[i] : nil + currentEntry = q.entry pendingPositionMs = 0 scrobbled = -1 guard let item = current else { return } @@ -743,6 +782,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { } playWhenReady = false pendingPositionMs = 0 + finished = true reportTimeline("stopped") changed() save() diff --git a/ios/Stemify/Playback/StemPlaylists.swift b/ios/Stemify/Playback/StemPlaylists.swift index 70387be..dad58ca 100644 --- a/ios/Stemify/Playback/StemPlaylists.swift +++ b/ios/Stemify/Playback/StemPlaylists.swift @@ -30,7 +30,7 @@ final class StemPlaylists { @ObservationIgnored private let file: URL /// Every stem a song can have, for naming saved mixes without loading the song. - static let allStems = ["vocals", "drums", "bass", "guitar", "piano", "other"] + nonisolated static let allStems = ["vocals", "drums", "bass", "guitar", "piano", "other"] init(dir: URL) { file = dir.appendingPathComponent("stem-playlists.json") diff --git a/ios/Stemify/Playback/WebRadio.swift b/ios/Stemify/Playback/WebRadio.swift new file mode 100644 index 0000000..d4e8da7 --- /dev/null +++ b/ios/Stemify/Playback/WebRadio.swift @@ -0,0 +1,80 @@ +import AVFoundation +import Foundation +import MediaPlayer + +/// An internet radio stream the user saved in Browse > Web Radio. +struct WebStation: Codable, Hashable, Identifiable { + var id = UUID().uuidString + var name: String + var url: String +} + +/// Plays a web radio stream through AVPlayer, outside the stem engine. Starting a station pauses +/// the queue, and playing the queue again stops the station. Main-thread only, like PlaybackManager. +@Observable +final class WebRadio: @unchecked Sendable { + private let pm: PlaybackManager + @ObservationIgnored private let player = AVPlayer() + @ObservationIgnored weak var nowPlaying: NowPlaying? + + private(set) var station: WebStation? + private(set) var playing = false + /// A station is loaded (playing or paused); the lock screen belongs to the radio. + var active: Bool { station != nil } + + init(pm: PlaybackManager) { + self.pm = pm + player.automaticallyWaitsToMinimizeStalling = true + pm.addListener { [weak self] in + guard let self, self.active, pm.playWhenReady else { return } + self.stop() + } + } + + func play(_ s: WebStation) { + guard let url = URL(string: s.url) else { return } + station = s + if pm.playWhenReady { pm.pause() } + let a = AVAudioSession.sharedInstance() + try? a.setCategory(.playback, mode: .default, policy: .longFormAudio) + try? a.setActive(true) + player.replaceCurrentItem(with: AVPlayerItem(url: url)) + player.play() + playing = true + publish() + } + + func resume() { + guard active else { return } + // Live streams go stale while paused; rejoin at the live edge. + if let s = station { play(s) } + } + + func pause() { + player.pause() + playing = false + publish() + } + + func toggle() { if playing { pause() } else { resume() } } + + func stop() { + player.pause() + player.replaceCurrentItem(with: nil) + station = nil + playing = false + nowPlaying?.refresh() + } + + private func publish() { + guard let s = station else { return } + let c = MPNowPlayingInfoCenter.default() + c.nowPlayingInfo = [ + MPMediaItemPropertyTitle: s.name, + MPMediaItemPropertyArtist: "Web Radio", + MPNowPlayingInfoPropertyIsLiveStream: true, + MPNowPlayingInfoPropertyPlaybackRate: playing ? 1.0 : 0.0, + ] + c.playbackState = playing ? .playing : .paused + } +} diff --git a/ios/Stemify/Plex/JellyfinClient.swift b/ios/Stemify/Plex/JellyfinClient.swift new file mode 100644 index 0000000..08dd9dd --- /dev/null +++ b/ios/Stemify/Plex/JellyfinClient.swift @@ -0,0 +1,448 @@ +import Foundation +import UIKit + +/// A Jellyfin item, read leniently like PlexItem. +struct JItem: Decodable { + var id = "" + var name = "" + var type = "" + var sortName: String? + var album: String? + var albumId: String? + var albumArtist: String? + var albumArtistId: String? + var artistId: String? + var artist: String? + var index: Int? + var disc: Int? + var ticks: Int64? + var year: Int? + var premiere: String? + var path: String? + var size: Int64? + var container: String? + var primaryTag: String? + var albumPrimaryTag: String? + var childCount: Int? + var genre: String? + var mediaType: String? + var playlistItemId: String? + var collectionType: String? + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: AnyKey.self) + id = c.str("Id") ?? "" + name = c.str("Name") ?? "" + type = c.str("Type") ?? "" + sortName = c.str("SortName") + album = c.str("Album") + albumId = c.str("AlbumId") + albumArtist = c.str("AlbumArtist") + let albumArtists: [JRef] = c.list("AlbumArtists") + let artists: [JRef] = c.list("ArtistItems") + albumArtistId = albumArtists.first?.id + artistId = artists.first?.id ?? albumArtistId + artist = artists.first?.name + index = c.int("IndexNumber") + disc = c.int("ParentIndexNumber") + ticks = c.int64("RunTimeTicks") + year = c.int("ProductionYear") + premiere = c.str("PremiereDate") + path = c.str("Path") + let sources: [JSource] = c.list("MediaSources") + size = sources.first?.size + container = sources.first?.container ?? c.str("Container") + if path == nil { path = sources.first?.path } + primaryTag = (try? c.decodeIfPresent([String: String].self, forKey: AnyKey("ImageTags")))?["Primary"] + albumPrimaryTag = c.str("AlbumPrimaryImageTag") + childCount = c.int("ChildCount") ?? c.int("SongCount") + genre = (try? c.decodeIfPresent([String].self, forKey: AnyKey("Genres")))?.first + mediaType = c.str("MediaType") + playlistItemId = c.str("PlaylistItemId") + collectionType = c.str("CollectionType") + } + + var durationMs: Int64 { (ticks ?? 0) / 10_000 } + var thumb: String? { primaryTag != nil ? id : albumPrimaryTag != nil ? albumId : nil } + + func toTrack(_ position: Int? = nil) -> Track { + Track(ratingKey: id, title: name, artist: artist ?? albumArtist ?? "Unknown Artist", album: album ?? "", + albumKey: albumId, artistKey: albumArtistId ?? artistId, index: index, disc: disc, durationMs: durationMs, + thumb: thumb, partKey: id, partId: stableId(id), partFile: path, + partSize: size, codec: container, year: year, playlistItemId: position.map(Int64.init)) + } + + func toAlbum() -> Album { + Album(ratingKey: id, title: name, artist: albumArtist ?? "Unknown Artist", artistKey: albumArtistId, year: year, + thumb: thumb, trackCount: childCount, genre: genre, released: premiere.map { String($0.prefix(10)) }) + } + + func toArtist() -> Artist { Artist(ratingKey: id, title: name, sortTitle: sortName ?? name, thumb: thumb) } + + func toPlaylist() -> Playlist { + Playlist(ratingKey: id, title: name, thumb: thumb, trackCount: childCount ?? 0, durationMs: durationMs) + } +} + +/// FNV-1a, so the cache key for a Jellyfin file is the same on every launch. +private func stableId(_ s: String) -> Int64 { + var h: UInt64 = 0xcbf29ce484222325 + for b in s.utf8 { h = (h ^ UInt64(b)) &* 0x100000001b3 } + return Int64(bitPattern: h & 0x7FFF_FFFF_FFFF_FFFF) +} + +private struct JRef: Decodable { + var id = "" + var name = "" + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: AnyKey.self) + id = c.str("Id") ?? "" + name = c.str("Name") ?? "" + } +} + +private struct JSource: Decodable { + var size: Int64? + var container: String? + var path: String? + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: AnyKey.self) + size = c.int64("Size") + container = c.str("Container") + path = c.str("Path") + } +} + +private struct JList: Decodable { + var items: [JItem] = [] + var total = 0 + init(from decoder: Decoder) throws { + if let arr = try? [Lenient](from: decoder) { + items = arr.compactMap(\.value) + total = items.count + return + } + let c = try decoder.container(keyedBy: AnyKey.self) + items = c.list("Items") + total = c.int("TotalRecordCount") ?? items.count + } +} + +private struct JFolder: Decodable { + var id = "" + var locations: [String] = [] + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: AnyKey.self) + id = c.str("ItemId") ?? "" + locations = (try? c.decodeIfPresent([String].self, forKey: AnyKey("Locations"))) ?? [] + } +} + +struct JellyfinLogin { + var token: String + var userId: String + var user: String + var serverId: String +} + +/// One Jellyfin server. Tracks keep the file Path so stem matching works the same as on Plex. +final class JellyfinClient: MediaSource, @unchecked Sendable { + private static let page = 1000 + private static let fields = "Path,MediaSources,SortName,Genres,PremiereDate,ChildCount,ProductionYear" + + private let http: Http + private let settings: Settings + let server: MediaServer + + init(http: Http, settings: Settings, server: MediaServer) { + self.http = http + self.settings = settings + self.server = server + } + + private var base: String { server.base } + private var uid: String { server.userId } + + private func request(_ url: URL, method: String = "GET", token: String? = nil) -> URLRequest { + var r = URLRequest(url: url) + r.httpMethod = method + r.setValue("application/json", forHTTPHeaderField: "Accept") + let t = token ?? server.token + var auth = "MediaBrowser Client=\"\(PlexClient.product)\", Device=\"iPhone\", DeviceId=\"\(settings.clientId)\", Version=\"1.1\"" + if !t.isEmpty { auth += ", Token=\"\(t)\"" } + r.setValue(auth, forHTTPHeaderField: "Authorization") + return r + } + + private func url(_ path: String, _ params: [(String, Any?)] = []) throws -> URL { + guard let u = URL(string: base + path) else { throw PlainError("Bad Jellyfin address") } + return u.with(params) + } + + private func get(_ path: String, _ params: [(String, Any?)] = []) async throws -> JList { + guard server.signedIn else { throw NotSignedIn() } + return try JSONDecoder().decode(JList.self, from: try await http.data(request(try url(path, params)))) + } + + @discardableResult + private func send(_ method: String, _ path: String, _ params: [(String, Any?)] = [], body: [String: Any]? = nil) async throws -> Data { + guard server.signedIn else { throw NotSignedIn() } + var req = request(try url(path, params), method: method) + if let body { + req.httpBody = try JSONSerialization.data(withJSONObject: body) + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + } else if method != "DELETE" { req.httpBody = Data() } + return try await http.data(req) + } + + /// Plex sort keys ("addedAt:desc", "artist.titleSort,year") to Jellyfin's SortBy and SortOrder. + private func sortParams(_ sort: String, tracks: Bool = false) -> [(String, Any?)] { + let desc = sort.contains(":desc") + let first = sort.split(separator: ",").first.map { String($0).replacingOccurrences(of: ":desc", with: "") } ?? "" + let by: String = switch first { + case "addedAt", "updatedAt": "DateCreated" + case "lastViewedAt": "DatePlayed" + case "viewCount": "PlayCount" + case "userRating": "CommunityRating" + case "year": "ProductionYear,SortName" + case "originallyAvailableAt": "PremiereDate,SortName" + case "duration": "Runtime" + case "random": "Random" + case "artist.titleSort": tracks ? "AlbumArtist,Album,ParentIndexNumber,IndexNumber" : "AlbumArtist,ProductionYear" + case "album.titleSort": "Album,ParentIndexNumber,IndexNumber" + default: "SortName" + } + return [("SortBy", by), ("SortOrder", desc ? "Descending" : "Ascending")] + } + + private func items(_ type: String, _ params: [(String, Any?)], parent: String? = nil, start: Int? = nil, limit: Int? = nil) async throws -> JList { + try await get("/Items", [("userId", uid), ("IncludeItemTypes", type), ("Recursive", "true"), ("Fields", Self.fields), + ("ParentId", parent), ("StartIndex", start), ("Limit", limit)] + params) + } + + /// Every chosen library's items, paged. + private func pooled(_ type: String, _ params: [(String, Any?)]) async throws -> [JItem] { + var out: [JItem] = [] + for sec in server.sections { + var start = 0 + while true { + let page = try await items(type, params, parent: sec, start: start, limit: Self.page) + out += page.items + start += page.items.count + if page.items.isEmpty || start >= page.total { break } + } + } + return out + } + + private func first(_ type: String, _ params: [(String, Any?)], count: Int) async throws -> [JItem] { + var out: [JItem] = [] + for sec in server.sections { out += try await items(type, params, parent: sec, limit: count).items } + return out + } + + // ---- sign-in -------------------------------------------------------------------- + + func login(user: String, password: String) async throws -> JellyfinLogin { + var req = request(try url("/Users/AuthenticateByName"), method: "POST", token: "") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: ["Username": user, "Pw": password]) + let data = try await http.data(req) + guard let o = try JSONSerialization.jsonObject(with: data) as? [String: Any], let t = o["AccessToken"] as? String, + let u = o["User"] as? [String: Any], let id = u["Id"] as? String + else { throw PlainError("Jellyfin didn't accept that sign-in") } + return JellyfinLogin(token: t, userId: id, user: u["Name"] as? String ?? user, serverId: o["ServerId"] as? String ?? "") + } + + /// An API key from the dashboard has no user, so take the first user (admins only see that list). + func loginWithKey(_ key: String) async throws -> JellyfinLogin { + let data = try await http.data(request(try url("/Users"), token: key)) + guard let list = try JSONSerialization.jsonObject(with: data) as? [[String: Any]], let u = list.first, let id = u["Id"] as? String + else { throw PlainError("That API key didn't work on this server") } + return JellyfinLogin(token: key, userId: id, user: u["Name"] as? String ?? "Jellyfin user", serverId: u["ServerId"] as? String ?? "") + } + + func musicSections() async throws -> [MusicSection] { + let all: [JItem] + do { all = try await get("/UserViews", [("userId", uid)]).items } catch { all = try await get("/Users/\(uid)/Views").items } + let views = all.filter { $0.collectionType == "music" } + // Folder paths need an admin; without them the picker just shows names. + var paths: [String: [String]] = [:] + if let data = try? await http.data(request(try url("/Library/VirtualFolders"))), + let folders = try? JSONDecoder().decode([Lenient].self, from: data) { + for f in folders.compactMap(\.value) { paths[f.id] = f.locations } + } + return views.map { MusicSection(key: $0.id, title: $0.name, locations: paths[$0.id] ?? []) } + } + + // ---- library ---------------------------------------------------------------------- + + func artists(sort: String) async throws -> [Artist] { + var out: [Artist] = [] + for sec in server.sections { + out += try await get("/Artists/AlbumArtists", [("userId", uid), ("ParentId", sec), ("Fields", "SortName")] + sortParams(sort)) + .items.map { $0.toArtist() } + } + return out + } + + func albums(sort: String) async throws -> [Album] { + try await pooled("MusicAlbum", sortParams(sort)).map { $0.toAlbum() } + } + + func recentlyAdded(count: Int) async throws -> [Album] { + try await first("MusicAlbum", sortParams("addedAt:desc"), count: count).map { $0.toAlbum() } + } + + /// Albums of the songs played last, newest first. + func recentlyPlayed(count: Int) async throws -> [Album] { + let played = try await first("Audio", sortParams("lastViewedAt:desc") + [("Filters", "IsPlayed")], count: count * 4) + var seen = Set() + let ids = played.compactMap(\.albumId).filter { seen.insert($0).inserted }.prefix(count) + guard !ids.isEmpty else { return [] } + let list = try await get("/Items", [("userId", uid), ("Ids", ids.joined(separator: ",")), ("Fields", Self.fields)]).items + return ids.compactMap { id in list.first { $0.id == id }?.toAlbum() } + } + + func recentlyAddedTracks(count: Int) async throws -> [Track] { + try await first("Audio", sortParams("addedAt:desc"), count: count).map { $0.toTrack() } + } + + func randomAlbums(_ count: Int) async throws -> [Album] { + Array(try await first("MusicAlbum", sortParams("random"), count: count).map { $0.toAlbum() }.shuffled().prefix(count)) + } + + func tracksPage(start: Int, size: Int, sort: String) async throws -> ([Track], Int) { + var out: [Track] = [] + var offset = 0, total = 0 + for sec in server.sections { + let count = try await items("Audio", [], parent: sec, limit: 0).total + let from = max(start + out.count - offset, 0) + if out.count < size, from < count { + out += try await items("Audio", sortParams(sort, tracks: true), parent: sec, start: from, limit: size - out.count).items.map { $0.toTrack() } + } + offset += count; total += count + } + return (out, total) + } + + func randomTracks(_ count: Int) async throws -> [Track] { + let each = count / max(server.sections.count, 1) + 1 + return Array(try await first("Audio", sortParams("random"), count: each).map { $0.toTrack() }.shuffled().prefix(count)) + } + + func album(_ key: String) async throws -> Album? { + try await get("/Items", [("userId", uid), ("Ids", key), ("Fields", Self.fields)]).items.first?.toAlbum() + } + + func albumTracks(_ key: String) async throws -> [Track] { + try await items("Audio", [("SortBy", "ParentIndexNumber,IndexNumber,SortName")], parent: key).items.map { $0.toTrack() } + } + + func artistAlbums(_ key: String) async throws -> [Album] { + try await items("MusicAlbum", [("AlbumArtistIds", key), ("SortBy", "ProductionYear,SortName")]).items.map { $0.toAlbum() } + } + + func artistTracks(_ key: String) async throws -> [Track] { + try await items("Audio", [("ArtistIds", key), ("SortBy", "Album,ParentIndexNumber,IndexNumber")]).items.map { $0.toTrack() } + } + + func searchTracks(_ title: String) async throws -> [Track] { + try await items("Audio", [("searchTerm", title), ("Limit", 50)]).items.map { $0.toTrack() } + } + + func loudness(_ trackKey: String, samples: Int) async throws -> [Float]? { nil } + + func search(_ query: String) async throws -> SearchResults { + async let ar = get("/Artists", [("userId", uid), ("searchTerm", query), ("Limit", 12)]) + async let al = items("MusicAlbum", [("searchTerm", query)], limit: 12) + async let tr = items("Audio", [("searchTerm", query)], limit: 12) + async let pl = items("Playlist", [("searchTerm", query)], limit: 12) + return SearchResults(artists: try await ar.items.map { $0.toArtist() }, albums: try await al.items.map { $0.toAlbum() }, + tracks: try await tr.items.map { $0.toTrack() }, + playlists: try await pl.items.filter { $0.mediaType != "Video" }.map { $0.toPlaylist() }) + } + + // ---- playlists ---------------------------------------------------------------------- + + func playlists(sort: String) async throws -> [Playlist] { + try await items("Playlist", sortParams(sort)).items.filter { $0.mediaType != "Video" }.map { $0.toPlaylist() } + } + + private func entries(_ key: String) async throws -> [JItem] { + try await get("/Playlists/\(key)/Items", [("userId", uid), ("Fields", Self.fields)]).items + } + + /// Jellyfin's entry ids are strings, so a track's playlistItemId is its position. + func playlistTracks(_ key: String) async throws -> [Track] { + try await entries(key).enumerated().map { $1.toTrack($0) } + } + + func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist? { + let data = try await send("POST", "/Playlists", body: ["Name": title, "Ids": trackKeys, "UserId": uid, "MediaType": "Audio"]) + guard let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let id = o["Id"] as? String else { return nil } + return Playlist(ratingKey: id, title: title, trackCount: trackKeys.count) + } + + func addToPlaylist(_ key: String, trackKeys: [String]) async throws { + try await send("POST", "/Playlists/\(key)/Items", [("ids", trackKeys.joined(separator: ",")), ("userId", uid)]) + } + + private func entryId(_ key: String, _ position: Int64) async throws -> String { + let list = try await entries(key) + guard position >= 0, position < list.count, let id = list[Int(position)].playlistItemId else { throw PlainError("That song moved; refresh the playlist") } + return id + } + + func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws { + let id = try await entryId(key, playlistItemId) + try await send("DELETE", "/Playlists/\(key)/Items", [("entryIds", id)]) + } + + func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws { + let id = try await entryId(key, playlistItemId) + // The new index counts the list without the moved song. + let to = afterItemId.map { $0 < playlistItemId ? $0 + 1 : $0 } ?? 0 + try await send("POST", "/Playlists/\(key)/Items/\(id)/Move/\(to)") + } + + func renamePlaylist(_ key: String, title: String) async throws { + try await send("POST", "/Playlists/\(key)", body: ["Name": title]) + } + + func deletePlaylist(_ key: String) async throws { + try await send("DELETE", "/Items/\(key)") + } + + // ---- playback reporting ---------------------------------------------------------------- + + func scrobble(_ trackKey: String) async { + if (try? await send("POST", "/UserPlayedItems/\(trackKey)", [("userId", uid)])) == nil { + _ = try? await send("POST", "/Users/\(uid)/PlayedItems/\(trackKey)") + } + } + + func timeline(_ track: Track, state: String, positionMs: Int64) async { + let path = state == "stopped" ? "/Sessions/Playing/Stopped" : "/Sessions/Playing/Progress" + _ = try? await send("POST", path, body: ["ItemId": track.ratingKey, "PositionTicks": positionMs * 10_000, + "IsPaused": state == "paused", "CanSeek": true]) + } + + // ---- URLs --------------------------------------------------------------------------- + + func artUrl(_ thumb: String?, px: Int) -> URL? { + guard let thumb, !thumb.isEmpty, server.signedIn, let u = URL(string: base + "/Items/\(thumb)/Images/Primary") else { return nil } + return u.with([("fillWidth", px), ("fillHeight", px), ("quality", 90), ("api_key", server.token)]) + } + + /// The original file, untouched, like Plex's part URL. + func partUrl(_ partKey: String) -> URL? { + URL(string: base + "/Audio/\(partKey)/stream?static=true&api_key=" + urlEncode(server.token)) + } + + /// True if [url] answers as a Jellyfin server. + func probe() async -> Bool { + guard let u = URL(string: base + "/System/Info/Public") else { return false } + return await http.ok(request(u, token: "")) + } +} diff --git a/ios/Stemify/Plex/MediaServer.swift b/ios/Stemify/Plex/MediaServer.swift new file mode 100644 index 0000000..a7f66cc --- /dev/null +++ b/ios/Stemify/Plex/MediaServer.swift @@ -0,0 +1,116 @@ +import Foundation + +/// One music server the app reads from. The first one ever added has an empty [id], so its +/// keys stay bare and everything saved before multi-server support (downloads, mixes, stem +/// playlists) still matches. Every other server's keys carry "~" in front. +struct MediaServer: Codable, Hashable, Identifiable { + enum Kind: String, Codable { case plex, jellyfin } + + var id = "" + var kind = Kind.plex + var name = "" + var url = "" + var token = "" + var user = "" + /// Jellyfin user id; Plex leaves it empty. + var userId = "" + var machineId = "" + var sections: [String] = [] + var sectionTitles = "" + + var signedIn: Bool { !token.isEmpty } + var ready: Bool { signedIn && !sections.isEmpty } + var base: String { url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } + var label: String { name.isEmpty ? (kind == .plex ? "Plex" : "Jellyfin") : name } + var kindLabel: String { kind == .plex ? "Plex" : "Jellyfin" } + + static func newId() -> String { "s" + String(UUID().uuidString.prefix(6)).lowercased() } +} + +/// What a single server can do. [PlexClient] pools these and tags their keys. +protocol MediaSource: AnyObject, Sendable { + var server: MediaServer { get } + func artists(sort: String) async throws -> [Artist] + func albums(sort: String) async throws -> [Album] + func recentlyAdded(count: Int) async throws -> [Album] + func recentlyPlayed(count: Int) async throws -> [Album] + func recentlyAddedTracks(count: Int) async throws -> [Track] + func randomAlbums(_ count: Int) async throws -> [Album] + func tracksPage(start: Int, size: Int, sort: String) async throws -> ([Track], Int) + func randomTracks(_ count: Int) async throws -> [Track] + func album(_ key: String) async throws -> Album? + func albumTracks(_ key: String) async throws -> [Track] + func artistAlbums(_ key: String) async throws -> [Album] + func artistTracks(_ key: String) async throws -> [Track] + func searchTracks(_ title: String) async throws -> [Track] + func loudness(_ trackKey: String, samples: Int) async throws -> [Float]? + func search(_ query: String) async throws -> SearchResults + func playlists(sort: String) async throws -> [Playlist] + func playlistTracks(_ key: String) async throws -> [Track] + func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist? + func addToPlaylist(_ key: String, trackKeys: [String]) async throws + func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws + func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws + func renamePlaylist(_ key: String, title: String) async throws + func deletePlaylist(_ key: String) async throws + func scrobble(_ trackKey: String) async + func timeline(_ track: Track, state: String, positionMs: Int64) async + func artUrl(_ thumb: String?, px: Int) -> URL? + func partUrl(_ partKey: String) -> URL? + /// Music libraries on the server, for the picker in Settings. + func musicSections() async throws -> [MusicSection] +} + +// ---- key tagging ----------------------------------------------------------------- + +extension String { + func tagged(_ id: String) -> String { id.isEmpty ? self : id + "~" + self } +} + +extension Optional where Wrapped == String { + func tagged(_ id: String) -> String? { map { $0.tagged(id) } } +} + +extension Track { + func tagged(_ id: String) -> Track { + guard !id.isEmpty else { return self } + var t = self + t.ratingKey = ratingKey.tagged(id); t.albumKey = albumKey.tagged(id); t.artistKey = artistKey.tagged(id) + t.thumb = thumb.tagged(id); t.partKey = partKey.tagged(id) + return t + } +} + +extension Album { + func tagged(_ id: String) -> Album { + guard !id.isEmpty else { return self } + var a = self + a.ratingKey = ratingKey.tagged(id); a.artistKey = artistKey.tagged(id); a.thumb = thumb.tagged(id) + return a + } +} + +extension Artist { + func tagged(_ id: String) -> Artist { + guard !id.isEmpty else { return self } + var a = self + a.ratingKey = ratingKey.tagged(id); a.thumb = thumb.tagged(id) + return a + } +} + +extension Playlist { + func tagged(_ id: String) -> Playlist { + guard !id.isEmpty else { return self } + var p = self + p.ratingKey = ratingKey.tagged(id); p.thumb = thumb.tagged(id) + return p + } +} + +extension SearchResults { + func tagged(_ id: String) -> SearchResults { + SearchResults(artists: artists.map { $0.tagged(id) }, albums: albums.map { $0.tagged(id) }, + tracks: tracks.map { $0.tagged(id) }, playlists: playlists.map { $0.tagged(id) }) + } +} diff --git a/ios/Stemify/Plex/PlexClient.swift b/ios/Stemify/Plex/PlexClient.swift index b4ddccb..db096ea 100644 --- a/ios/Stemify/Plex/PlexClient.swift +++ b/ios/Stemify/Plex/PlexClient.swift @@ -1,8 +1,7 @@ import Foundation -import UIKit struct NotSignedIn: LocalizedError { - var errorDescription: String? { "Not signed in to Plex" } + var errorDescription: String? { "Not signed in to a music server" } } struct PlainError: LocalizedError { @@ -11,318 +10,264 @@ struct PlainError: LocalizedError { var errorDescription: String? { message } } -/// Plex Media Server and plex.tv calls. Library reads go through [http], whose cache -/// answers from disk when the server can't be reached (see AppGraph). +/// Every music server in Settings, pooled into one library. Listings run on each server and are +/// joined in Settings order; anything keyed (an album, a track's file, a playlist) goes back to +/// the server its key is tagged with (see MediaServer). final class PlexClient: @unchecked Sendable { static let product = "Stemify" - private static let page = 1000 private let http: Http private let settings: Settings - private let model: String - private let osVersion: String + private let lock = NSLock() + private var builtFrom = "\u{0}" + private var built: [MediaSource] = [] init(http: Http, settings: Settings) { self.http = http self.settings = settings - var sys = utsname() - uname(&sys) - let machine = withUnsafeBytes(of: &sys.machine) { String(decoding: $0.prefix { $0 != 0 }, as: UTF8.self) } - model = machine.isEmpty ? "iPhone" : machine - osVersion = ProcessInfo.processInfo.operatingSystemVersionString - .replacingOccurrences(of: "Version ", with: "").components(separatedBy: " ").first ?? "" } - var signedIn: Bool { !settings.plexToken.isEmpty } - private var base: String { settings.plexUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } - private var token: String { settings.plexToken } - /// The chosen music libraries; listings pool them, in the order they were picked. - private var sections: [String] { settings.musicSection.split(separator: ",").map(String.init) } + func make(_ s: MediaServer) -> MediaSource { + s.kind == .plex ? PlexServer(http: http, settings: settings, server: s) : JellyfinClient(http: http, settings: settings, server: s) + } - private func request(_ url: URL, withToken: Bool = true, method: String = "GET") -> URLRequest { - var r = URLRequest(url: url) - r.httpMethod = method - r.setValue("application/json", forHTTPHeaderField: "Accept") - r.setValue(Self.product, forHTTPHeaderField: "X-Plex-Product") - r.setValue("1.0", forHTTPHeaderField: "X-Plex-Version") - r.setValue(settings.clientId, forHTTPHeaderField: "X-Plex-Client-Identifier") - r.setValue("iOS", forHTTPHeaderField: "X-Plex-Platform") - r.setValue(osVersion, forHTTPHeaderField: "X-Plex-Platform-Version") - r.setValue(model, forHTTPHeaderField: "X-Plex-Device") - r.setValue("\(Self.product) (\(model))", forHTTPHeaderField: "X-Plex-Device-Name") - if withToken, !token.isEmpty { r.setValue(token, forHTTPHeaderField: "X-Plex-Token") } + /// Every signed-in server, rebuilt only when the saved list changes. + var all: [MediaSource] { + let json = settings.serversJson + lock.lock() + defer { lock.unlock() } + if json != builtFrom { + built = settings.servers.filter(\.signedIn).map(make) + builtFrom = json + } + return built + } + + /// Servers with at least one music library picked. + private var sources: [MediaSource] { all.filter { $0.server.ready } } + + var signedIn: Bool { !all.isEmpty } + + /// The server a tagged key belongs to, and the key as that server knows it. + private func route(_ key: String) -> (MediaSource, String)? { + let list = all + if let i = key.firstIndex(of: "~"), let s = list.first(where: { !$0.server.id.isEmpty && $0.server.id == key[.. (MediaSource, String) { + guard let r = route(key) else { throw NotSignedIn() } return r } - private func url(_ path: String, _ params: [(String, Any?)] = []) throws -> URL { - guard let u = URL(string: base + path) else { throw PlainError("Bad Plex address") } - return u.with(params) - } - - private func decode(_ data: Data) throws -> MediaContainer { - try JSONDecoder().decode(PlexResponse.self, from: data).container - } - - private func get(_ path: String, _ params: [(String, Any?)] = [], start: Int? = nil, size: Int? = nil) async throws -> MediaContainer { - guard signedIn else { throw NotSignedIn() } - var req = request(try url(path, params)) - if let start { req.setValue(String(start), forHTTPHeaderField: "X-Plex-Container-Start") } - if let size { req.setValue(String(size), forHTTPHeaderField: "X-Plex-Container-Size") } - let body = try await http.data(req) - if body.allSatisfy({ $0 == 0x20 || $0 == 0x0A || $0 == 0x0D || $0 == 0x09 }) { return MediaContainer() } - return try decode(body) - } - - @discardableResult - private func send(_ method: String, _ path: String, _ params: [(String, Any?)] = []) async throws -> MediaContainer { - guard signedIn else { throw NotSignedIn() } - var req = request(try url(path, params), method: method) - if method != "DELETE" { req.httpBody = Data() } - let body = try await http.data(req) - let text = String(decoding: body, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) - guard text.hasPrefix("{") else { return MediaContainer() } - return (try? decode(body)) ?? MediaContainer() - } - - /// Pages through a section listing; Plex caps a single response on big libraries. - private func all(_ path: String, _ params: [(String, Any?)] = []) async throws -> [PlexItem] { - var out: [PlexItem] = [] - var start = 0 - while true { - let page = try await get(path, params, start: start, size: Self.page) - out += page.metadata - start += page.metadata.count - let total = page.totalSize ?? page.size - if page.metadata.isEmpty || start >= total { break } + /// Runs [f] on every server at once and joins the results in Settings order. + /// One server being down doesn't hide the others; it only fails when they all do. + private func pool(_ f: @escaping @Sendable (MediaSource) async throws -> [T]) async throws -> [T] { + let list = sources + guard !list.isEmpty else { throw NotSignedIn() } + if list.count == 1 { return try await f(list[0]) } + var results = [[T]?](repeating: nil, count: list.count) + var failure: Error? + await withTaskGroup(of: (Int, Result<[T], Error>).self) { g in + for (i, s) in list.enumerated() { + g.addTask { do { return (i, .success(try await f(s))) } catch { return (i, .failure(error)) } } + } + for await (i, r) in g { + switch r { + case .success(let v): results[i] = v + case .failure(let e): failure = e + } + } } - return out + if results.allSatisfy({ $0 == nil }), let failure { throw failure } + return results.compactMap { $0 }.flatMap { $0 } } - /// Every section's listing, one after another. - private func pooled(_ params: [(String, Any?)]) async throws -> [PlexItem] { - var out: [PlexItem] = [] - for sec in sections { out += try await all("/library/sections/\(sec)/all", params) } - return out + private var many: Bool { sources.count > 1 } + + /// Name sorts come back sorted per server; put the joined list back in order. + private func byName(_ list: [T], _ sort: String, _ name: (T) -> String) -> [T] { + guard many, sort.hasPrefix("titleSort") else { return list } + let desc = sort.hasPrefix("titleSort:desc") + return list.sorted { (name($0).localizedStandardCompare(name($1)) == .orderedAscending) != desc } } - /// A page of a listing that runs on through each section in turn. - private func pooledPage(_ params: [(String, Any?)], start: Int, size: Int) async throws -> ([PlexItem], Int) { - if sections.count == 1 { - let c = try await get("/library/sections/\(sections[0])/all", params, start: start, size: size) - return (c.metadata, c.totalSize ?? c.size) + // ---- library ------------------------------------------------------------------ + + func artists(sort: String = "titleSort") async throws -> [Artist] { + byName(try await pool { s in try await s.artists(sort: sort).map { $0.tagged(s.server.id) } }, sort, \.sortTitle) + } + + func albums(sort: String = "titleSort") async throws -> [Album] { + byName(try await pool { s in try await s.albums(sort: sort).map { $0.tagged(s.server.id) } }, sort, \.title) + } + + func recentlyAdded(count: Int = 20) async throws -> [Album] { + try await pool { s in try await s.recentlyAdded(count: count).map { $0.tagged(s.server.id) } } + } + + func recentlyPlayed(count: Int = 20) async throws -> [Album] { + try await pool { s in try await s.recentlyPlayed(count: count).map { $0.tagged(s.server.id) } } + } + + func recentlyAddedTracks(count: Int = 20) async throws -> [Track] { + try await pool { s in try await s.recentlyAddedTracks(count: count).map { $0.tagged(s.server.id) } } + } + + func randomAlbums(_ count: Int) async throws -> [Album] { + Array(try await pool { s in try await s.randomAlbums(count).map { $0.tagged(s.server.id) } }.shuffled().prefix(count)) + } + + /// One page of every track, running on through each server in turn, and the total count. + func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) { + let list = sources + guard !list.isEmpty else { throw NotSignedIn() } + if list.count == 1 { + let (page, total) = try await list[0].tracksPage(start: start, size: size, sort: sort) + return (page.map { $0.tagged(list[0].server.id) }, total) } - var out: [PlexItem] = [] + var out: [Track] = [] var offset = 0, total = 0 - for sec in sections { - let n = try await get("/library/sections/\(sec)/all", params, start: 0, size: 0) - let count = n.totalSize ?? n.size + for s in list { + let count = try await s.tracksPage(start: 0, size: 0, sort: sort).1 let from = max(start + out.count - offset, 0) if out.count < size, from < count { - out += try await get("/library/sections/\(sec)/all", params, start: from, size: size - out.count).metadata + out += try await s.tracksPage(start: from, size: size - out.count, sort: sort).0.map { $0.tagged(s.server.id) } } offset += count; total += count } return (out, total) } - // ---- server ------------------------------------------------------------------- - - func machineId() async throws -> String { - let body = try await http.data(request(try url("/identity"), withToken: false)) - guard let id = try decode(body).machineIdentifier else { throw PlainError("Not a Plex server") } - return id - } - - func musicSections() async throws -> [MusicSection] { - try await get("/library/sections").directories - .filter { $0.type == "artist" } - .map { MusicSection(key: $0.key, title: $0.title, locations: $0.locations.map(\.path)) } - } - - // ---- library ------------------------------------------------------------------ - - func artists(sort: String = "titleSort") async throws -> [Artist] { - try await pooled([("type", 8), ("sort", sort)]).map { $0.toArtist() } - } - - func albums(sort: String = "titleSort") async throws -> [Album] { - try await pooled([("type", 9), ("sort", sort)]).map { $0.toAlbum() } - } - - func recentlyAdded(count: Int = 20) async throws -> [Album] { - var out: [Album] = [] - for sec in sections { - out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "addedAt:desc")], start: 0, size: count) - .metadata.map { $0.toAlbum() } - } - return out - } - - /// One page of every track. Returns the page and the library's track count. - func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) { - let (page, total) = try await pooledPage([("type", 10), ("sort", sort)], start: start, size: size) - return (page.map { $0.toTrack() }, total) - } - - /// Random tracks from the whole library, for shuffling it without loading every page. func randomTracks(_ count: Int) async throws -> [Track] { - var out: [Track] = [] - for sec in sections { - out += try await get("/library/sections/\(sec)/all", [("type", 10), ("sort", "random")], start: 0, size: count / max(sections.count, 1) + 1) - .metadata.map { $0.toTrack() } - } - return Array(out.shuffled().prefix(count)) + let each = count / max(sources.count, 1) + 1 + return Array(try await pool { s in try await s.randomTracks(each).map { $0.tagged(s.server.id) } }.shuffled().prefix(count)) } func album(_ key: String) async throws -> Album? { - try await get("/library/metadata/\(key)").metadata.first?.toAlbum() + let (s, k) = try routed(key) + return try await s.album(k)?.tagged(s.server.id) } func albumTracks(_ key: String) async throws -> [Track] { - try await get("/library/metadata/\(key)/children").metadata.map { $0.toTrack() } + let (s, k) = try routed(key) + return try await s.albumTracks(k).map { $0.tagged(s.server.id) } } - // Not /library/metadata//children: that leaves out compilations, singles and live - // albums (an artist with only "The Best of Kansas" came back empty). func artistAlbums(_ key: String) async throws -> [Album] { - try await pooled([("type", 9), ("artist.id", key)]).map { $0.toAlbum() } + let (s, k) = try routed(key) + return try await s.artistAlbums(k).map { $0.tagged(s.server.id) } } func artistTracks(_ key: String) async throws -> [Track] { - try await get("/library/metadata/\(key)/allLeaves").metadata.map { $0.toTrack() } + let (s, k) = try routed(key) + return try await s.artistTracks(k).map { $0.tagged(s.server.id) } } - func track(_ key: String) async throws -> PlexItem? { - try await get("/library/metadata/\(key)").metadata.first - } - - /// Title search inside the music section; used to give stem-only entries Plex metadata. + /// Title search on every server; used to give stem-only entries library metadata. func searchTracks(_ title: String) async throws -> [Track] { - var out: [Track] = [] - for sec in sections { out += try await get("/library/sections/\(sec)/search", [("type", 10), ("query", title)]).metadata.map { $0.toTrack() } } - return out + try await pool { s in try await s.searchTracks(title).map { $0.tagged(s.server.id) } } } - /// Plex's own loudness analysis (what Plexamp draws its waveform from), in dB. + /// Plex's own loudness analysis (what Plexamp draws its waveform from), in dB. Jellyfin has none. func loudness(_ trackKey: String, samples: Int) async throws -> [Float]? { - guard let streamId = try await track(trackKey)?.media.first?.parts.first?.streams.first(where: { $0.streamType == 2 })?.id - else { return nil } - let levels: [PlexLevel] - do { - levels = try await get("/library/streams/\(streamId)/levels", [("subsample", samples)]).levels - } catch is HttpError { - return nil // not analysed yet - } - return levels.isEmpty ? nil : levels.map(\.v) + let (s, k) = try routed(trackKey) + return try await s.loudness(k, samples: samples) } func search(_ query: String) async throws -> SearchResults { - let hubs = try await get("/hubs/search", [("query", query), ("limit", 12), ("sectionId", sections.count == 1 ? sections[0] : nil)]).hubs - func hub(_ type: String) -> [PlexItem] { hubs.first { $0.type == type }?.metadata ?? [] } - return SearchResults( - artists: hub("artist").map { $0.toArtist() }, - albums: hub("album").map { $0.toAlbum() }, - tracks: hub("track").map { $0.toTrack() }, - playlists: hub("playlist").filter { $0.playlistType == "audio" }.map { $0.toPlaylist() } - ) + let parts = try await pool { s in [try await s.search(query).tagged(s.server.id)] } + return SearchResults(artists: parts.flatMap(\.artists), albums: parts.flatMap(\.albums), + tracks: parts.flatMap(\.tracks), playlists: parts.flatMap(\.playlists)) } // ---- playlists ---------------------------------------------------------------- func playlists(sort: String = "titleSort") async throws -> [Playlist] { - try await get("/playlists", [("playlistType", "audio"), ("sort", sort)]).metadata.map { $0.toPlaylist() } + byName(try await pool { s in try await s.playlists(sort: sort).map { $0.tagged(s.server.id) } }, sort, \.title) } func playlistTracks(_ key: String) async throws -> [Track] { - try await all("/playlists/\(key)/items").map { $0.toTrack() } + let (s, k) = try routed(key) + return try await s.playlistTracks(k).map { $0.tagged(s.server.id) } } - private func libraryUri(_ keys: [String]) async throws -> String { - var machine = settings.plexMachineId - if machine.isEmpty { - machine = try await machineId() - settings.plexMachineId = machine - } - return "server://\(machine)/com.plexapp.plugins.library/library/metadata/\(keys.joined(separator: ","))" + /// The keys that live on [s], untagged. A playlist can't hold another server's songs. + private func keys(_ trackKeys: [String], on s: MediaSource) -> [String] { + trackKeys.compactMap { k in route(k).flatMap { $0.0 === s ? $0.1 : nil } } } + /// Made on the server of the first song; songs from other servers are left out. func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist? { - let uri = try await libraryUri(trackKeys) - return try await send("POST", "/playlists", [("type", "audio"), ("title", title), ("smart", 0), ("uri", uri)]) - .metadata.first?.toPlaylist() + guard let first = trackKeys.first else { return nil } + let (s, _) = try routed(first) + return try await s.createPlaylist(title, trackKeys: keys(trackKeys, on: s))?.tagged(s.server.id) } func addToPlaylist(_ key: String, trackKeys: [String]) async throws { - let uri = try await libraryUri(trackKeys) - try await send("PUT", "/playlists/\(key)/items", [("uri", uri)]) + let (s, k) = try routed(key) + let own = keys(trackKeys, on: s) + guard !own.isEmpty else { throw PlainError("Those songs are on a different server than this playlist") } + try await s.addToPlaylist(k, trackKeys: own) } func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws { - try await send("DELETE", "/playlists/\(key)/items/\(playlistItemId)") + let (s, k) = try routed(key) + try await s.removeFromPlaylist(k, playlistItemId: playlistItemId) } /// Moves an item to just after [afterItemId], or to the top when it's nil. func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws { - try await send("PUT", "/playlists/\(key)/items/\(playlistItemId)/move", [("after", afterItemId)]) + let (s, k) = try routed(key) + try await s.movePlaylistItem(k, playlistItemId: playlistItemId, afterItemId: afterItemId) } func renamePlaylist(_ key: String, title: String) async throws { - try await send("PUT", "/playlists/\(key)", [("title", title)]) + let (s, k) = try routed(key) + try await s.renamePlaylist(k, title: title) } func deletePlaylist(_ key: String) async throws { - try await send("DELETE", "/playlists/\(key)") + let (s, k) = try routed(key) + try await s.deletePlaylist(k) } // ---- playback reporting --------------------------------------------------------- func scrobble(_ trackKey: String) async { - _ = try? await get("/:/scrobble", [("key", trackKey), ("identifier", "com.plexapp.plugins.library")]) + guard let r = route(trackKey) else { return } + let (s, k) = r + await s.scrobble(k) } func timeline(_ track: Track, state: String, positionMs: Int64) async { - _ = try? await get("/:/timeline", [ - ("ratingKey", track.ratingKey), ("key", "/library/metadata/\(track.ratingKey)"), - ("state", state), ("time", positionMs), ("duration", track.durationMs), ("hasMDE", 1), - ]) + guard let r = route(track.ratingKey) else { return } + let (s, k) = r + var t = track + t.ratingKey = k + await s.timeline(t, state: state, positionMs: positionMs) } // ---- URLs ----------------------------------------------------------------------- func artUrl(_ thumb: String?, px: Int) -> URL? { - guard let thumb, !thumb.isEmpty, signedIn, let u = URL(string: base + "/photo/:/transcode") else { return nil } - return u.with([("width", px), ("height", px), ("minSize", 1), ("upscale", 1), ("url", thumb), ("X-Plex-Token", token)]) + guard let thumb, !thumb.isEmpty, let r = route(thumb) else { return nil } + let (s, k) = r + return s.artUrl(k, px: px) } func partUrl(_ partKey: String) -> URL? { - URL(string: base + partKey + "?X-Plex-Token=" + urlEncode(token)) + guard let r = route(partKey) else { return nil } + let (s, k) = r + return s.partUrl(k) } - // ---- plex.tv sign-in ------------------------------------------------------------ + // ---- sign-in helpers -------------------------------------------------------------- - func createPin() async throws -> PlexPin { - var req = request(URL(string: "https://plex.tv/api/v2/pins?strong=false")!, withToken: false, method: "POST") - req.httpBody = Data() - return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req)) - } + /// A Plex client for plex.tv calls and for a server that isn't saved yet. + func plexTv(_ url: String = "") -> PlexServer { PlexServer(http: http, settings: settings, server: MediaServer(url: url)) } - func checkPin(_ id: Int64) async throws -> PlexPin { - let req = request(URL(string: "https://plex.tv/api/v2/pins/\(id)")!, withToken: false) - return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req)) - } - - func user(token: String) async throws -> PlexUser { - var req = request(URL(string: "https://plex.tv/api/v2/user")!, withToken: false) - req.setValue(token, forHTTPHeaderField: "X-Plex-Token") - return try JSONDecoder().decode(PlexUser.self, from: try await http.data(req)) - } - - /// True if [url] answers as a Plex server with the given token. - func probe(_ url: String, token: String) async -> Bool { - let trimmed = url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - guard let u = URL(string: trimmed + "/library/sections") else { return false } - var req = request(u, withToken: false) - req.setValue(token, forHTTPHeaderField: "X-Plex-Token") - return await http.ok(req) - } + func jellyfin(_ s: MediaServer) -> JellyfinClient { JellyfinClient(http: http, settings: settings, server: s) } } diff --git a/ios/Stemify/Plex/PlexServer.swift b/ios/Stemify/Plex/PlexServer.swift new file mode 100644 index 0000000..fae9aec --- /dev/null +++ b/ios/Stemify/Plex/PlexServer.swift @@ -0,0 +1,347 @@ +import Foundation +import UIKit + +/// One Plex Media Server, plus the plex.tv calls. Library reads go through [http], whose cache +/// answers from disk when the server can't be reached (see AppGraph). +final class PlexServer: MediaSource, @unchecked Sendable { + static let product = "Stemify" + private static let page = 1000 + + private let http: Http + private let settings: Settings + let server: MediaServer + private var machine: String + private let model: String + private let osVersion: String + + init(http: Http, settings: Settings, server: MediaServer) { + self.http = http + self.settings = settings + self.server = server + machine = server.machineId + var sys = utsname() + uname(&sys) + let machine = withUnsafeBytes(of: &sys.machine) { String(decoding: $0.prefix { $0 != 0 }, as: UTF8.self) } + model = machine.isEmpty ? "iPhone" : machine + osVersion = ProcessInfo.processInfo.operatingSystemVersionString + .replacingOccurrences(of: "Version ", with: "").components(separatedBy: " ").first ?? "" + } + + var signedIn: Bool { server.signedIn } + private var base: String { server.base } + private var token: String { server.token } + /// The chosen music libraries; listings pool them, in the order they were picked. + private var sections: [String] { server.sections } + + private func request(_ url: URL, withToken: Bool = true, method: String = "GET") -> URLRequest { + var r = URLRequest(url: url) + r.httpMethod = method + r.setValue("application/json", forHTTPHeaderField: "Accept") + r.setValue(Self.product, forHTTPHeaderField: "X-Plex-Product") + r.setValue("1.0", forHTTPHeaderField: "X-Plex-Version") + r.setValue(settings.clientId, forHTTPHeaderField: "X-Plex-Client-Identifier") + r.setValue("iOS", forHTTPHeaderField: "X-Plex-Platform") + r.setValue(osVersion, forHTTPHeaderField: "X-Plex-Platform-Version") + r.setValue(model, forHTTPHeaderField: "X-Plex-Device") + r.setValue("\(Self.product) (\(model))", forHTTPHeaderField: "X-Plex-Device-Name") + if withToken, !token.isEmpty { r.setValue(token, forHTTPHeaderField: "X-Plex-Token") } + return r + } + + private func url(_ path: String, _ params: [(String, Any?)] = []) throws -> URL { + guard let u = URL(string: base + path) else { throw PlainError("Bad Plex address") } + return u.with(params) + } + + private func decode(_ data: Data) throws -> MediaContainer { + try JSONDecoder().decode(PlexResponse.self, from: data).container + } + + private func get(_ path: String, _ params: [(String, Any?)] = [], start: Int? = nil, size: Int? = nil) async throws -> MediaContainer { + guard signedIn else { throw NotSignedIn() } + var req = request(try url(path, params)) + if let start { req.setValue(String(start), forHTTPHeaderField: "X-Plex-Container-Start") } + if let size { req.setValue(String(size), forHTTPHeaderField: "X-Plex-Container-Size") } + let body = try await http.data(req) + if body.allSatisfy({ $0 == 0x20 || $0 == 0x0A || $0 == 0x0D || $0 == 0x09 }) { return MediaContainer() } + return try decode(body) + } + + @discardableResult + private func send(_ method: String, _ path: String, _ params: [(String, Any?)] = []) async throws -> MediaContainer { + guard signedIn else { throw NotSignedIn() } + var req = request(try url(path, params), method: method) + if method != "DELETE" { req.httpBody = Data() } + let body = try await http.data(req) + let text = String(decoding: body, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + guard text.hasPrefix("{") else { return MediaContainer() } + return (try? decode(body)) ?? MediaContainer() + } + + /// Pages through a section listing; Plex caps a single response on big libraries. + private func all(_ path: String, _ params: [(String, Any?)] = []) async throws -> [PlexItem] { + var out: [PlexItem] = [] + var start = 0 + while true { + let page = try await get(path, params, start: start, size: Self.page) + out += page.metadata + start += page.metadata.count + let total = page.totalSize ?? page.size + if page.metadata.isEmpty || start >= total { break } + } + return out + } + + /// Every section's listing, one after another. + private func pooled(_ params: [(String, Any?)]) async throws -> [PlexItem] { + var out: [PlexItem] = [] + for sec in sections { out += try await all("/library/sections/\(sec)/all", params) } + return out + } + + /// A page of a listing that runs on through each section in turn. + private func pooledPage(_ params: [(String, Any?)], start: Int, size: Int) async throws -> ([PlexItem], Int) { + if sections.count == 1 { + let c = try await get("/library/sections/\(sections[0])/all", params, start: start, size: size) + return (c.metadata, c.totalSize ?? c.size) + } + var out: [PlexItem] = [] + var offset = 0, total = 0 + for sec in sections { + let n = try await get("/library/sections/\(sec)/all", params, start: 0, size: 0) + let count = n.totalSize ?? n.size + let from = max(start + out.count - offset, 0) + if out.count < size, from < count { + out += try await get("/library/sections/\(sec)/all", params, start: from, size: size - out.count).metadata + } + offset += count; total += count + } + return (out, total) + } + + // ---- server ------------------------------------------------------------------- + + func machineId() async throws -> String { + let body = try await http.data(request(try url("/identity"), withToken: false)) + guard let id = try decode(body).machineIdentifier else { throw PlainError("Not a Plex server") } + return id + } + + func musicSections() async throws -> [MusicSection] { + try await get("/library/sections").directories + .filter { $0.type == "artist" } + .map { MusicSection(key: $0.key, title: $0.title, locations: $0.locations.map(\.path)) } + } + + // ---- library ------------------------------------------------------------------ + + func artists(sort: String = "titleSort") async throws -> [Artist] { + try await pooled([("type", 8), ("sort", sort)]).map { $0.toArtist() } + } + + func albums(sort: String = "titleSort") async throws -> [Album] { + try await pooled([("type", 9), ("sort", sort)]).map { $0.toAlbum() } + } + + func recentlyAdded(count: Int = 20) async throws -> [Album] { + var out: [Album] = [] + for sec in sections { + out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "addedAt:desc")], start: 0, size: count) + .metadata.map { $0.toAlbum() } + } + return out + } + + /// Albums played most recently first. + func recentlyPlayed(count: Int = 20) async throws -> [Album] { + var out: [Album] = [] + for sec in sections { + out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "lastViewedAt:desc"), ("viewCount>>", 0)], start: 0, size: count) + .metadata.map { $0.toAlbum() } + } + return out + } + + /// Songs added most recently first. + func recentlyAddedTracks(count: Int = 20) async throws -> [Track] { + var out: [Track] = [] + for sec in sections { + out += try await get("/library/sections/\(sec)/all", [("type", 10), ("sort", "addedAt:desc")], start: 0, size: count) + .metadata.map { $0.toTrack() } + } + return out + } + + func randomAlbums(_ count: Int) async throws -> [Album] { + var out: [Album] = [] + for sec in sections { + out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "random")], start: 0, size: count) + .metadata.map { $0.toAlbum() } + } + return Array(out.shuffled().prefix(count)) + } + + /// One page of every track. Returns the page and the library's track count. + func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) { + let (page, total) = try await pooledPage([("type", 10), ("sort", sort)], start: start, size: size) + return (page.map { $0.toTrack() }, total) + } + + /// Random tracks from the whole library, for shuffling it without loading every page. + func randomTracks(_ count: Int) async throws -> [Track] { + var out: [Track] = [] + for sec in sections { + out += try await get("/library/sections/\(sec)/all", [("type", 10), ("sort", "random")], start: 0, size: count / max(sections.count, 1) + 1) + .metadata.map { $0.toTrack() } + } + return Array(out.shuffled().prefix(count)) + } + + func album(_ key: String) async throws -> Album? { + try await get("/library/metadata/\(key)").metadata.first?.toAlbum() + } + + func albumTracks(_ key: String) async throws -> [Track] { + try await get("/library/metadata/\(key)/children").metadata.map { $0.toTrack() } + } + + // Not /library/metadata//children: that leaves out compilations, singles and live + // albums (an artist with only "The Best of Kansas" came back empty). + func artistAlbums(_ key: String) async throws -> [Album] { + try await pooled([("type", 9), ("artist.id", key)]).map { $0.toAlbum() } + } + + func artistTracks(_ key: String) async throws -> [Track] { + try await get("/library/metadata/\(key)/allLeaves").metadata.map { $0.toTrack() } + } + + func track(_ key: String) async throws -> PlexItem? { + try await get("/library/metadata/\(key)").metadata.first + } + + /// Title search inside the music section; used to give stem-only entries Plex metadata. + func searchTracks(_ title: String) async throws -> [Track] { + var out: [Track] = [] + for sec in sections { out += try await get("/library/sections/\(sec)/search", [("type", 10), ("query", title)]).metadata.map { $0.toTrack() } } + return out + } + + /// Plex's own loudness analysis (what Plexamp draws its waveform from), in dB. + func loudness(_ trackKey: String, samples: Int) async throws -> [Float]? { + guard let streamId = try await track(trackKey)?.media.first?.parts.first?.streams.first(where: { $0.streamType == 2 })?.id + else { return nil } + let levels: [PlexLevel] + do { + levels = try await get("/library/streams/\(streamId)/levels", [("subsample", samples)]).levels + } catch is HttpError { + return nil // not analysed yet + } + return levels.isEmpty ? nil : levels.map(\.v) + } + + func search(_ query: String) async throws -> SearchResults { + let hubs = try await get("/hubs/search", [("query", query), ("limit", 12), ("sectionId", sections.count == 1 ? sections[0] : nil)]).hubs + func hub(_ type: String) -> [PlexItem] { hubs.first { $0.type == type }?.metadata ?? [] } + return SearchResults( + artists: hub("artist").map { $0.toArtist() }, + albums: hub("album").map { $0.toAlbum() }, + tracks: hub("track").map { $0.toTrack() }, + playlists: hub("playlist").filter { $0.playlistType == "audio" }.map { $0.toPlaylist() } + ) + } + + // ---- playlists ---------------------------------------------------------------- + + func playlists(sort: String = "titleSort") async throws -> [Playlist] { + try await get("/playlists", [("playlistType", "audio"), ("sort", sort)]).metadata.map { $0.toPlaylist() } + } + + func playlistTracks(_ key: String) async throws -> [Track] { + try await all("/playlists/\(key)/items").map { $0.toTrack() } + } + + private func libraryUri(_ keys: [String]) async throws -> String { + if machine.isEmpty { machine = try await machineId() } + return "server://\(machine)/com.plexapp.plugins.library/library/metadata/\(keys.joined(separator: ","))" + } + + func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist? { + let uri = try await libraryUri(trackKeys) + return try await send("POST", "/playlists", [("type", "audio"), ("title", title), ("smart", 0), ("uri", uri)]) + .metadata.first?.toPlaylist() + } + + func addToPlaylist(_ key: String, trackKeys: [String]) async throws { + let uri = try await libraryUri(trackKeys) + try await send("PUT", "/playlists/\(key)/items", [("uri", uri)]) + } + + func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws { + try await send("DELETE", "/playlists/\(key)/items/\(playlistItemId)") + } + + /// Moves an item to just after [afterItemId], or to the top when it's nil. + func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws { + try await send("PUT", "/playlists/\(key)/items/\(playlistItemId)/move", [("after", afterItemId)]) + } + + func renamePlaylist(_ key: String, title: String) async throws { + try await send("PUT", "/playlists/\(key)", [("title", title)]) + } + + func deletePlaylist(_ key: String) async throws { + try await send("DELETE", "/playlists/\(key)") + } + + // ---- playback reporting --------------------------------------------------------- + + func scrobble(_ trackKey: String) async { + _ = try? await get("/:/scrobble", [("key", trackKey), ("identifier", "com.plexapp.plugins.library")]) + } + + func timeline(_ track: Track, state: String, positionMs: Int64) async { + _ = try? await get("/:/timeline", [ + ("ratingKey", track.ratingKey), ("key", "/library/metadata/\(track.ratingKey)"), + ("state", state), ("time", positionMs), ("duration", track.durationMs), ("hasMDE", 1), + ]) + } + + // ---- URLs ----------------------------------------------------------------------- + + func artUrl(_ thumb: String?, px: Int) -> URL? { + guard let thumb, !thumb.isEmpty, signedIn, let u = URL(string: base + "/photo/:/transcode") else { return nil } + return u.with([("width", px), ("height", px), ("minSize", 1), ("upscale", 1), ("url", thumb), ("X-Plex-Token", token)]) + } + + func partUrl(_ partKey: String) -> URL? { + URL(string: base + partKey + "?X-Plex-Token=" + urlEncode(token)) + } + + // ---- plex.tv sign-in ------------------------------------------------------------ + + func createPin() async throws -> PlexPin { + var req = request(URL(string: "https://plex.tv/api/v2/pins?strong=false")!, withToken: false, method: "POST") + req.httpBody = Data() + return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req)) + } + + func checkPin(_ id: Int64) async throws -> PlexPin { + let req = request(URL(string: "https://plex.tv/api/v2/pins/\(id)")!, withToken: false) + return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req)) + } + + func user(token: String) async throws -> PlexUser { + var req = request(URL(string: "https://plex.tv/api/v2/user")!, withToken: false) + req.setValue(token, forHTTPHeaderField: "X-Plex-Token") + return try JSONDecoder().decode(PlexUser.self, from: try await http.data(req)) + } + + /// True if [url] answers as a Plex server with the given token. + func probe(_ url: String, token: String) async -> Bool { + let trimmed = url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let u = URL(string: trimmed + "/library/sections") else { return false } + var req = request(u, withToken: false) + req.setValue(token, forHTTPHeaderField: "X-Plex-Token") + return await http.ok(req) + } +} diff --git a/ios/Stemify/Resources/Assets.xcassets/AvatarChris.imageset/AvatarChris.png b/ios/Stemify/Resources/Assets.xcassets/AvatarChris.imageset/AvatarChris.png new file mode 100644 index 0000000000000000000000000000000000000000..17555878fc6f6cfcc4342a6cfc3cd89a45aa68a9 GIT binary patch literal 8052 zcmd^k^;c9~_%9$agTyd^qzo|wNT(o(!qAO?fKpNdBHdB~14u}Br*sKO*U$}8BHcA~ z_ji2ny??;{_1?42S?jEQp8fRm+2`3iSWQKi5T6M~l!;q3RFnJG+xAy( z?zV1U?V^NNn>JF@^c&fmJq4spTklyY=&>095&$^J4}|#t;Z+2R+;@<0*-gTb)X~|B zF))xsc93;-bWOfJ*xW6j_V{u>vF)T)TvJ%s_qnXmgfEHh;8Tf~#`=7Awy~ngYCZC? zva<5=^R*IZjW1Wdg3dDjxVS+G0wN-O0^-MeH%=LCZJ5U)oMLW=nI%oxm;I9i384z* zhOO==@l9B0FCRhx4qSY*lgp{+gL zi)Jsv370-f6k@Ty_i&t>pEq+1-=A-MZK*KFZMM#L7)s^MyNYN{Oo}tTt*>_L%f?Y* zQOg;-yuQ|Z>GMTqPJpV8)Pcn*T{J5tg^R(vyV26twX-9CMo;_8-gO*1?Cs6@t)UhA9yIQi9EcwzGB|3M--24aLzvA&|_l(`He1<5CMs2lkdzPyhr*o zRX900@rX8Thi2@r@)>9DYnC$GIuV!E6GHQIbEa0>K@qebVz8Q;nzy*)*5< zaba|7G&lajqr66|O7q<2ebjvt+ry1S`Gu(0aiYNii~rs_L?ksenvG>=qPl^O&OzCS z0wN^l+VccrYfGisYg&}bW4W{6d^34Xpk=riX?Qp^@hpZ+I%=pJ7ZU9*_*{Z)Sj+n_%DB$x>}JX_E-orEt!TbF7)g6& z{WH~#cHMqLB_fBP^m`H&yR(Qkm1aRh1GA+|f7bzbU#2&yN%jS*KPqN#a$+$Q@m``cK?rhfko-dr$Q>tnwr>6P~^@mb!!u9^HImHXxL>Xi=F!`e`Axc%sOOZ%R0F@cE2+zq`4cm? z_&p)_e2W$GR-lDVz)K0NWHNzM_}7p4PX+5%+fKo3UvfrFVe*@c)T)d9JForMtp`c= zR~=V_2Xh|7&3LfbAVj-_#En(a#>QYErZHJ5>u~aY=xnWR2L=<$#p7(jD>9W)ls%^R zm`O#}UgY}E_Ez0i@2c!4e}62auapDYPbfUqwuw%N%HEIA)i5Po-+p2Zx3})B|9cE5$WFkKzUb-4GuA8`g~aRewSf9of2s7TDaF>2p&+Qb zrq>Nz9+#WDzSSuN{HhSqCl5>!vi>TSP9tQa;^B^4b>Wn8MJ=rM7)AxP--vbMro-Vk+Hy#C62H<=yDVwlOS5QD?!Qo!*Q<_%`*zywu6W8MM zxB+m?|4dPUOMr^lT&W2}C^M0%^(W=7&Jtk3+yxe?KTAXpx->#1!4aXpQUPYt?66bk zPtD$Bv8G##5Xj(?e^SruM0r)kyjtG9n;h%e*~hQ-Fy*lbfXtrWDGJh;_UsB2buE}CIy5rWaa0Z@3nj?jUbLQ#2eh`=3Jkpm;VaF zZydClZ|EXSD7sKZOO8b4cNLf`9@PBq=uGRQwl-^|Hoq-Xdo z9t|#gZ-9pV!rN2gKUet*yq;ovg_NY!Sg-|Bg^hHzFSAny`iCiDeU_0HW8>ph4W?qy zKJ-dn^Km{9!^g+=0F&DG;46j?BP3tgQ41^OObsprrao=(d)@RXn2|kHG1pD&<9Pjs z(4sdnIA4v`Kwt|G@A9!<=JYNj6H-~mB;(cXxQ*nEt>WWY)+`h(*WT}UhfZl4%qnjh z7t(E0>*KYPa%1kKy;pc;v(a(#iJSX%s|1q_fofR9+3+QlQ zhj+;W^Ek6w7c*lFZ}s!k+c7wY4eKaBg&?3BWcXcgKrv0lp#mOVs!TpTAR6c)rd@A z3wn=7U0loA0k-1uY5l52S)JRg|6q0}F}%fM)JMye46I+0mL?%7`{v3Lh4=%^n*~xe z@m?;m&FT^TD~oId5iq|=;Dt!Y*aC0LkX8dwo7v9H@2nqD0MlfP3s8I_nLRuevbVSQ zM2?j~fz=WC6#Oyw0p)J!;##E3ZkIv5uVK=ls*D95P7-^pu#@`2s!cxr8D${McpwBnHlXA?{#&yf`42T?y;xGu29nf% zey||dFZ@n)lZn@|G7cR98un08u1rv>ejOTq< zg4zlghwDwRcQ8C>Odv%S2!~K+s#xv9<}FB}|6Fah2#kpV>rkU()pA#(hPLQGVZ!!E zVOA>N5$U7)0=e|i5Mz-dHCC=v(V?Y3^LX0YuCC%sc5;7!m8AVGq(6Q@s{{@kyMiTy zve1|gPg2~9!Tz(oU9xe6?O4(G()Di8Z8+KIZ6GC!R2VYDzjY6o)|{z zU}H1mZ^?|w7mC?S&$tXgkpoNb*u8k=1Czu&&TGv_ZfW8+>1aeG2ipXB~9i-nY7mBQ~T36U<>`b?B?d?ni_Iic;b4nqMkmL z&G(WWE#FQnxA!@1FZSs7O(CGsA=wZ09E7DG1- z;*6!BS=gt!=U%CPz+MAt_|PWA(8K z3JQrc&KaK?>XDM(1BtZZcjXB6KAuR_*w`2*c$pE>t)S z=*CHc@ta8Ylb|X?l5tkJg}D#_>UY`^c;?Q<#nfg5pu*1zQo;pGTuX5L8XE4$2x2g0 zMNCa;(Uau*tSzi=47=0N21W*hk`oiZN`bzsnf|y^z7iEC$Qoww93CTq`dJ_f3%93+ zQlK5FMp%*sx`3lW>hyQJyMxKpgM)+W(<-R9$ zyZK7VIUKF5S9~kj6%^!=h$+0+_@U}Su1NR$nY$c*5QC&RBb1k)W^`^2MZu$g;eZF( zeAW|jLnBVIGRDf8St_3y_3bCvblE@HCOhL#ChIU6j~bTR#=HqZht+?UTI^tG|lr z&dgelGOpC=F&t`oaiG_DKG1T9_u1Ooh)5gRqK#BcDF13o<14n8Bcqx!Xcw!?ZO0N=TPK zzq$?~!%Rx=y?2bnib8L2+~HX3Z!fd$B&GAAZcx$V<)OdtBe4%VKo0Z>#w@@ZsaCoC z?D@LOzaj~&*dgn4sb|cxo9K;N0`Yy5YSu=|GBNUPY$!(`jCF&N@i!L|(MBkpmqL=4 zb728pRU^bME-u5m6{B{uDO5@TVr^PXd0fMKS|}Wszv`81SGXo!Xn3&4Cw$3XH^9L{ zv+Q{(x=vTvv#u)(?M{ZXjnQ(&5RushJLBf!;17vaCx&8k?F@QQ4#p4^1o$ASG5(> z#t3-vakK)|r0HclLy2+Wkcj{=m|ep7-^T-UoC|+wccX34S1BFoV)GfcaLw*6maiQk zJ__W@JZ!Kjl8EVYh@OVV$`0V6^UhNHo4Z$g_w5-D8Vd*l4sRetzEE^z2_EZX5E&6i z$xUodU(d*}sZJ^pFNS&<4 zY&U^E*vQf{Dd`t3ZT3@Q$SdqV8S}EDqMZuqH~%09QQ>wmO)t+k>C@jEby+8t*0ZNE z&L{D+^Za3d4e24|1J^NJRC7P26pn^+>f(dP{2o(qKl|y}R@s>?=eoHnXehPlvi_bn9-ht@A z$7+^2>8BlCS5uWMKMJytSP5CHG9U3ZDKxS&RzJ= zk*lJaRzyT-Nbnz!xss7Pn)4D69FB|(;Rac>`;C4}PNrF^X1`ZVDV_c0xT!q%RZ{Ma z=JH1(u|_IBQMcpV;3bfNrl!uB+3-lv$>IfNQjLeY?Ah3~ zj08jEZ0j0Ybbh%;1T_*c)0v1$Fd%ENw01qU#QHk|n}#6~Cs&xrH=;Vp^yT zd(qC_yHm&`1ef?%Q1F6MDs9_K zu89AgIJ8emLCMZBO)5lneWq&Dghj>1!h#+ro%GYHBi#AtPsaqVccz~|&&9D{{6RO$ zS2siP1M63>Z>2;C!*_FoJ?VPfJMGk~Ntt7w#VH(RRtM3={@z-)7|^!1-fMk6etII# zh;&U8b2Kif5qu`V&CKkxaQD{jFZsP8E-|zIOV}clOhg4?!m3@$NW2# zG*tS^>osC-(iu&qW#O-!F4a(D*VkWuk8yqch#Gaa(&8ZAwl$JyAd=2g_iE<;G_ug! z-7WJQGb@|{9nQ9|_pUZ(NtH9XnI^fviyn>fj`7OID!{faNO1it8JzZhai-`fF>i|` z7uDKI#+UZ@H7n!OPrOBtNh`ZJuAc~>E`BSlt25n9ASu*tVUmWJ5ADQ>-dCZwqQl72 zzQMBqsjaLRT6E98s@0NsyNTT$^vUNZ{`irbY0JjOHsa{M=)m=o0~}eVy~;Z;#`+<> zJR%_=KziB+CdS17dDo><2aXnJkAQ%Fl>}*X8$uHV=*`Sl5E|wxEX>WGS0PSfiDNl> z@c|q}1gQfbE9Iu+P%|`z8W|ddh%|9D!nEnNY5;rsVBh=h{NVTN?3R|(0ok&-yT6ni zGgc0SJd$rTJw$bXJM${!@qHdR@60t-$6x^clDISyOLaPT!V2v|t(@Hm;CD4PUhGpm zdF6Jup}g!Lgg(B=mW?puqq_1It*oZCurlGd6Zeso#X~dm@MwI)#nfo!QHM&MbEZK; zqaFV7yvKxxWHX0BQjnsHg{mHTR}&Mx>0}g&&~@twY)WeC_qw_>vom~@^Hs`AvZQpC z9)@d}4nTl!g&5k|FCr+1*P-v;6G3r)cf|(79^aENfA20n|d3t;wI~@2pEzZrgN#9!@aLkxP3M6XHEr z2Pb-*<0ygW&*@$~fz&@_J~$QswbdIYpTV#y#&7(Kej{73SK@7Hrl zp&)C$de3Li1O#0V79vFLqX$;m$?%67FT@K50J_~16BDzJ*Mmu~syw~Iz6)-ACaBbL z;AauyXBXw>ZdKx77Q{h_7q|m-BN8@GaZddG9&L{8FwMMI)pAk!BE$R|c6N1QC>La* z>qMRPrAbW8^P}6Wr%(Lwvx>w)ULP8?uPIbi8v@9}9ea+bfJzv?K$aZYK!i6ktyjy}i{rV){&LYH)CdMD=20GkzVR4LZR#>O8GS-kExSmF;BTVn=xP5EYur^~HAnWi;L(__Qw zQDks}AG$vv-nwa*)@aqS{vW!}G2kT#9bhHc7}lxqEe^!ap!I)0aHowl#KZ8<5^OWk TjEzU1renxUt4Ni>KluF*+tjiK literal 0 HcmV?d00001 diff --git a/ios/Stemify/Resources/Assets.xcassets/AvatarChris.imageset/Contents.json b/ios/Stemify/Resources/Assets.xcassets/AvatarChris.imageset/Contents.json new file mode 100644 index 0000000..2fd57ba --- /dev/null +++ b/ios/Stemify/Resources/Assets.xcassets/AvatarChris.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"AvatarChris.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} diff --git a/ios/Stemify/Resources/Assets.xcassets/AvatarKevin.imageset/AvatarKevin.png b/ios/Stemify/Resources/Assets.xcassets/AvatarKevin.imageset/AvatarKevin.png new file mode 100644 index 0000000000000000000000000000000000000000..9ed9f660d659afaea81743bd3cf3f3a5554a5e74 GIT binary patch literal 3263 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i985rwk9xwoGT9&Rv!NX;S>%y)$Mm&7aF$wxL<}O#-|1je~r{mAO0o##Nd9 z@2%cj7H^ZkJ%{tNvOU+oi9J&{8igG@6(OmeoVupR_hbZ3L*O^h?)JM@62Mq;PME#zfT0A`TG|BMY3CNTz>T6i4oEMK+n@h=!1Ls>E;r=rJ(7?-%3jRzizvJGW%Oz^1sTd(3GILJ8IYznz2XUZAxx8 zV_KPQDfjGO5 cZNAOO5cN+(;Gex=9;jIIboFyt=akR{0GnKds{jB1 literal 0 HcmV?d00001 diff --git a/ios/Stemify/Resources/Assets.xcassets/AvatarKevin.imageset/Contents.json b/ios/Stemify/Resources/Assets.xcassets/AvatarKevin.imageset/Contents.json new file mode 100644 index 0000000..f9eda0a --- /dev/null +++ b/ios/Stemify/Resources/Assets.xcassets/AvatarKevin.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"AvatarKevin.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} diff --git a/ios/Stemify/Stems/StemCoverage.swift b/ios/Stemify/Stems/StemCoverage.swift index 4a3e5b9..1523edb 100644 --- a/ios/Stemify/Stems/StemCoverage.swift +++ b/ios/Stemify/Stems/StemCoverage.swift @@ -38,14 +38,14 @@ final class StemCoverage: @unchecked Sendable { self.settings = settings file = dir.appendingPathComponent("library_tracks.json") listing = (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode(Listing.self, from: $0) } - settings.onChange("musicSection") { [weak self] in self?.sectionChanged() } + settings.onChange("servers") { [weak self] in self?.sectionChanged() } stems.onIndexChange { [weak self] in self?.recompute() } sectionChanged() recompute() } private func sectionChanged() { - let section = settings.musicSection + let section = settings.libraryKey guard !section.isEmpty else { return } if let l = listing, l.section == section, Date().timeIntervalSince1970 - l.at <= Self.staleSeconds { return } Task { await refresh() } @@ -54,7 +54,7 @@ final class StemCoverage: @unchecked Sendable { /// Lists every track in the library again (every few hours). func refresh() async { await lock.run { - let section = settings.musicSection + let section = settings.libraryKey guard !section.isEmpty, plex.signedIn else { return } var all: [Slim] = [] do { @@ -76,7 +76,7 @@ final class StemCoverage: @unchecked Sendable { } private func recompute() { - guard let l = listing, l.section == settings.musicSection, stems.index.size > 0 else { return } + guard let l = listing, l.section == settings.libraryKey, stems.index.size > 0 else { return } let tracks = l.tracks Task.detached { [self] in var al: [String: (Int, Int)] = [:] diff --git a/ios/Stemify/UI/Components/Chrome.swift b/ios/Stemify/UI/Components/Chrome.swift index cf0f08f..e03ebb3 100644 --- a/ios/Stemify/UI/Components/Chrome.swift +++ b/ios/Stemify/UI/Components/Chrome.swift @@ -60,14 +60,14 @@ struct Artwork: View { } } -/// Settings.app-style coloured rounded-square icon. +/// A settings icon: the glyph in its colour on a faint wash of the same colour. struct SettingsIcon: View { let symbol: String let bg: UInt32 var body: some View { - Image(systemName: symbol).font(.system(size: 16, weight: .medium)).foregroundStyle(.white) + Image(systemName: symbol).font(.system(size: 15, weight: .semibold)).foregroundStyle(Color(hex: bg)) .frame(width: 30, height: 30) - .background(Color(hex: bg), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .background(Color(hex: bg).opacity(0.16), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } } @@ -124,8 +124,8 @@ struct ErrorRow: View { @Environment(Ui.self) private var ui var body: some View { if !AppGraph.shared.plex.signedIn { - EmptyState(icon: "server.rack", title: "Not Connected", message: "Sign in to your Plex server to see your music here.", action: "Connect") { - ui.push(.PlexAccount) + EmptyState(icon: "server.rack", title: "Not Connected", message: "Add your Plex or Jellyfin server to see your music here.", action: "Connect") { + ui.push(.Servers) } } else { EmptyState(icon: "exclamationmark.triangle", title: "Couldn't Load", message: message, action: "Try Again", onAction: retry) diff --git a/ios/Stemify/UI/Components/MediaMenu.swift b/ios/Stemify/UI/Components/MediaMenu.swift index dea974d..8c4e248 100644 --- a/ios/Stemify/UI/Components/MediaMenu.swift +++ b/ios/Stemify/UI/Components/MediaMenu.swift @@ -1,7 +1,7 @@ import SwiftUI /// The long-press / ••• menu for anything playable: songs, albums, artists and playlists all -/// offer the front or end of the queue, download, and add-to-playlist (playlists included). +/// offer Play Next, Add to Queue, download (whole albums, artists and playlists, like Plexamp), and add-to-playlist. /// Each call returns the actions; follow-up choices go through [Overlays]. @MainActor struct MediaMenu { @@ -11,9 +11,7 @@ struct MediaMenu { func track(_ t: Track, extra: [SheetAction] = [], allowGoTo: Bool = true) -> [SheetAction] { var a = queueActions { [t] } - if g.downloads.records[t.ratingKey] == nil { - a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download([t]); overlays.hud("Downloading", "arrow.down.circle") }) - } else { + if g.downloads.looseRecords.contains(where: { $0.track.ratingKey == t.ratingKey }) { a.append(SheetAction("Remove Download", icon: "trash", destructive: true) { g.downloads.remove([t]) }) } a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { addToPlaylist([t]) }) @@ -30,46 +28,44 @@ struct MediaMenu { } func album(_ al: Album, extra: [SheetAction] = [], allowGoTo: Bool = true) -> [SheetAction] { - let have = g.downloads.records.values.filter { $0.done && $0.track.albumKey == al.ratingKey }.map(\.track) let cov = g.coverage.albums[al.ratingKey] - let total = cov?.total ?? al.trackCount ?? Int.max var more: [SheetAction] = [] if allowGoTo, let k = al.artistKey { more.append(SheetAction("Go to Artist", icon: "music.mic") { ui.showInLibrary(.ArtistPage(Artist(ratingKey: k, title: al.artist, sortTitle: al.artist))) }) } let plex = g.plex - return collection("\(al.title) — \(al.artist)", have, have.count >= total, { try await plex.albumTracks(al.ratingKey) }, + let gr = DownloadGroup(kind: .album, key: al.ratingKey, title: al.title, subtitle: al.artist, thumb: al.thumb) + return collection("\(al.title) — \(al.artist)", gr, { try await plex.albumTracks(al.ratingKey) }, playlistKey: nil, extra: more + extra, stemmed: stemmed(cov)) } func artist(_ ar: Artist) -> [SheetAction] { - let have = g.downloads.records.values.filter { $0.done && $0.track.artistKey == ar.ratingKey }.map(\.track) let cov = g.coverage.artists[ar.ratingKey] let plex = g.plex - return collection(ar.title, have, have.count >= (cov?.total ?? Int.max), { try await plex.artistTracks(ar.ratingKey) }, + let gr = DownloadGroup(kind: .artist, key: ar.ratingKey, title: ar.title, subtitle: "Artist", thumb: ar.thumb) + return collection(ar.title, gr, { try await plex.artistTracks(ar.ratingKey) }, playlistKey: nil, extra: [], confirmDownload: true, stemmed: stemmed(cov)) } func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] { let plex = g.plex let copy = SheetAction("Copy to Stem Playlist…", icon: "doc.on.doc") { copyToStems(pl) } - return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: [copy] + extra, + let gr = DownloadGroup(kind: .playlist, key: pl.ratingKey, title: pl.title, subtitle: "Playlist", thumb: pl.thumb) + return collection(pl.title, gr, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: [copy] + extra, confirmDownload: pl.trackCount > 50) } func stemPlaylist(_ pl: StemPlaylist, extra: [SheetAction] = [], onPage: Bool = false) -> [SheetAction] { var a = [ - SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") { + SheetAction("Play Next", icon: "text.line.first.and.arrowtriangle.forward") { g.playback.playNext(pl.tracks, mixes: pl.mixes); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward") }, - SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") { + SheetAction("Add to Queue", icon: "text.line.last.and.arrowtriangle.forward") { g.playback.addToQueue(pl.tracks, mixes: pl.mixes); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward") }, ] - let missing = pl.tracks.filter { !g.downloads.isDownloaded($0.ratingKey) } - if !missing.isEmpty { - a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download(missing); overlays.hud("Downloading", "arrow.down.circle") }) - } + a.append(downloadAction(pl.title, DownloadGroup(kind: .stemPlaylist, key: pl.id, title: pl.title, subtitle: "Stem Playlist", thumb: pl.thumb), + { pl.tracks }, confirm: false)) if pl.items.isEmpty { a = [] } let lists = g.stemPlaylists a.append(SheetAction("Delete Stem Playlist", icon: "trash", destructive: true) { @@ -82,10 +78,10 @@ struct MediaMenu { func stemItem(_ item: StemPlaylistItem, in listId: String) -> [SheetAction] { let lists = g.stemPlaylists return [ - SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") { + SheetAction("Play Next", icon: "text.line.first.and.arrowtriangle.forward") { g.playback.playNext([item.track], mixes: [item.mix]); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward") }, - SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") { + SheetAction("Add to Queue", icon: "text.line.last.and.arrowtriangle.forward") { g.playback.addToQueue([item.track], mixes: [item.mix]); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward") }, SheetAction("Change Mix…", icon: "slider.horizontal.3") { @@ -97,9 +93,11 @@ struct MediaMenu { /// For songs already in hand (downloads with no Plex album behind them). func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] { - let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) } let stemmed = g.stems.index.size > 0 && tracks.allSatisfy { $0.partFile == nil || g.stems.find($0) != nil } - return collection(title, have, have.count == tracks.count, { tracks }, playlistKey: nil, extra: extra, stemmed: stemmed) + let remove = SheetAction("Remove Download", icon: "trash", destructive: true) { + overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(tracks) } + } + return collection(title, nil, { tracks }, playlistKey: nil, extra: [remove] + extra, stemmed: stemmed) } /// True when every song is known to have stems, so there's nothing for Stem Now to do. @@ -108,43 +106,46 @@ struct MediaMenu { return cov.withStems >= cov.total } - private func collection(_ title: String, _ downloaded: [Track], _ allDownloaded: Bool, _ load: @escaping () async throws -> [Track], + private func collection(_ title: String, _ group: DownloadGroup?, _ load: @escaping () async throws -> [Track], playlistKey: String?, extra: [SheetAction], confirmDownload: Bool = false, stemmed: Bool = false) -> [SheetAction] { var a = queueActions(load) - if !allDownloaded { - a.append(SheetAction(downloaded.isEmpty ? "Download" : "Download the Rest", icon: "arrow.down.circle") { - withTracks(load) { tracks in - let go = { g.downloads.download(tracks); overlays.hud("Downloading", "arrow.down.circle") } - if confirmDownload && tracks.count > 20 { - overlays.confirm("Download \(songs(tracks.count))?", "“\(title)” will play without your server, stems and all.", "Download", destructive: false, go) - } else { - go() - } - } - }) - } - if !downloaded.isEmpty { - a.append(SheetAction("Remove Download", icon: "trash", destructive: true) { - overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(downloaded) } - }) - } + if let group { a.append(downloadAction(title, group, load, confirm: confirmDownload)) } a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey, title: title) } }) if !stemmed { a.append(stemNow(load)) } return a + extra } - /// Front of the queue (right after this song) and the end of it. + /// Play Next (right after this song) and Add to Queue (the end of it). private func queueActions(_ load: @escaping () async throws -> [Track]) -> [SheetAction] { [ - SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") { + SheetAction("Play Next", icon: "text.line.first.and.arrowtriangle.forward") { withTracks(load) { g.playback.playNext($0); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward") } }, - SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") { + SheetAction("Add to Queue", icon: "text.line.last.and.arrowtriangle.forward") { withTracks(load) { g.playback.addToQueue($0); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward") } }, ] } + /// Download, or Remove Download once [group] is downloaded. A downloaded group keeps itself in step with the server. + func downloadAction(_ title: String, _ group: DownloadGroup, _ load: @escaping () async throws -> [Track], confirm: Bool) -> SheetAction { + if g.downloads.groups[group.id] != nil { + return SheetAction("Remove Download", icon: "trash", destructive: true) { + overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.removeGroup(group.id) } + } + } + return SheetAction("Download", icon: "arrow.down.circle") { + withTracks(load) { tracks in + let go = { g.downloads.download(group, tracks); overlays.hud("Downloading", "arrow.down.circle") } + if confirm && tracks.count > 20 { + overlays.confirm("Download \(songs(tracks.count))?", "“\(title)” will play without your server, stems and all.", "Download", destructive: false, go) + } else { + go() + } + } + } + } + /// Has stemd on the server separate the songs that lack stems ahead of the rest of the library. private func stemNow(_ load: @escaping () async throws -> [Track]) -> SheetAction { SheetAction("Stem Now", icon: "waveform") { @@ -189,6 +190,7 @@ struct MediaMenu { acts.append(SheetAction("This Song’s Mix (\(StemPlaylists.label(m)))", icon: "bookmark") { then(m) }) } } + for c in CustomPreset.load() { acts.append(SheetAction(c.name, icon: "slider.horizontal.3") { then(c.mix) }) } for p in MixPresets.all { guard let m = p.build(StemPlaylists.allStems) else { continue } acts.append(SheetAction(StemPlaylists.label(m)) { then(m) }) diff --git a/ios/Stemify/UI/Components/Rows.swift b/ios/Stemify/UI/Components/Rows.swift index f5369d7..67ba693 100644 --- a/ios/Stemify/UI/Components/Rows.swift +++ b/ios/Stemify/UI/Components/Rows.swift @@ -87,6 +87,7 @@ struct AlbumGrid: View { var showArtist = true var subtitle: ((Album) -> String?)? var menu: ((Album) -> [SheetAction])? + var columns = 2 let open: (Album) -> Void @ViewBuilder var badge: (Album) -> Badge @@ -94,7 +95,9 @@ struct AlbumGrid: View { var body: some View { let cov = AppGraph.shared.coverage.albums - LazyVGrid(columns: [GridItem(.flexible(), spacing: 16, alignment: .top), GridItem(.flexible(), spacing: 16, alignment: .top)], spacing: 18) { + let small = columns >= 4 + let gap: CGFloat = columns > 2 ? 10 : 16 + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: gap, alignment: .top), count: columns), spacing: small ? 12 : 18) { ForEach(albums) { a in VStack(alignment: .leading, spacing: 0) { GeometryReader { geo in @@ -103,12 +106,12 @@ struct AlbumGrid: View { } .aspectRatio(1, contentMode: .fit) HStack(spacing: 6) { - Text(a.title).font(.subheadline.weight(.medium)).foregroundStyle(p.label).lineLimit(1) + Text(a.title).font((small ? Font.caption : .subheadline).weight(.medium)).foregroundStyle(p.label).lineLimit(1) StemFlag(cov: cov[a.ratingKey]) } .padding(.top, 6) let sub = subtitle?(a) ?? (showArtist ? a.artist : a.year.map(String.init) ?? "") - if !sub.isEmpty { Text(sub).font(.subheadline).foregroundStyle(p.label2).lineLimit(1) } + if !sub.isEmpty { Text(sub).font(small ? .caption : .subheadline).foregroundStyle(p.label2).lineLimit(1) } } .contentShape(Rectangle()) .onTapGesture { open(a) } @@ -120,8 +123,8 @@ struct AlbumGrid: View { } extension AlbumGrid where Badge == EmptyView { - init(albums: [Album], showArtist: Bool = true, subtitle: ((Album) -> String?)? = nil, menu: ((Album) -> [SheetAction])? = nil, open: @escaping (Album) -> Void) { - self.init(albums: albums, showArtist: showArtist, subtitle: subtitle, menu: menu, open: open) { _ in EmptyView() } + init(albums: [Album], showArtist: Bool = true, subtitle: ((Album) -> String?)? = nil, menu: ((Album) -> [SheetAction])? = nil, columns: Int = 2, open: @escaping (Album) -> Void) { + self.init(albums: albums, showArtist: showArtist, subtitle: subtitle, menu: menu, columns: columns, open: open) { _ in EmptyView() } } } diff --git a/ios/Stemify/UI/Nav.swift b/ios/Stemify/UI/Nav.swift index 2782289..2981ca5 100644 --- a/ios/Stemify/UI/Nav.swift +++ b/ios/Stemify/UI/Nav.swift @@ -36,9 +36,9 @@ enum Tab: String, CaseIterable, Hashable { } enum Route: Hashable { - case Artists, Albums, Songs, Playlists, Genres, Downloads, TabBar + case Artists, Albums, Songs, Playlists, Genres, Downloads, TabBar, WebRadio case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String) - case PlexAccount, StemServer, Quality, Storage, Appearance + case Servers, Server(String), StemServer, Quality, Storage, Appearance, NowPlayingOptions, StemEditor, About case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String) } diff --git a/ios/Stemify/UI/RootView.swift b/ios/Stemify/UI/RootView.swift index 24d32a9..fb9435c 100644 --- a/ios/Stemify/UI/RootView.swift +++ b/ios/Stemify/UI/RootView.swift @@ -21,7 +21,7 @@ struct RootView: View { } .modifier(GlassMiniPlayerHost()) .onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() } - .sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) { + .fullScreenCover(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) { NowPlayingScreen() .overlayHost(ui.overlays, level: 1) .environment(ui) @@ -62,15 +62,20 @@ struct RootView: View { case .Genres: GenresScreen() case .Downloads: DownloadsScreen() case .TabBar: TabBarScreen() + case .WebRadio: WebRadioScreen() case .GenrePage(let g): GenreScreen(genre: g) case .Eq: EqScreen() case .EqFilters(let id): EqFiltersScreen(profileId: id) case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f) - case .PlexAccount: PlexAccountScreen() + case .Servers: ServersScreen() + case .Server(let id): ServerScreen(id: id) case .StemServer: StemServerScreen() case .Quality: QualityScreen() case .Storage: StorageScreen() case .Appearance: AppearanceScreen() + case .NowPlayingOptions: NowPlayingOptionsScreen() + case .StemEditor: StemEditorScreen() + case .About: AboutScreen() case .ArtistPage(let a): ArtistScreen(artist: a) case .AlbumPage(let a): AlbumScreen(album: a) case .PlaylistPage(let pl): PlaylistScreen(playlist: pl) diff --git a/ios/Stemify/UI/Screens/LibraryScreens.swift b/ios/Stemify/UI/Screens/LibraryScreens.swift index 5c7b3a2..3651281 100644 --- a/ios/Stemify/UI/Screens/LibraryScreens.swift +++ b/ios/Stemify/UI/Screens/LibraryScreens.swift @@ -6,26 +6,34 @@ struct LibraryHome: View { @Environment(Ui.self) private var ui @Environment(\.palette) private var p @State private var albums: LoadState<[Album]> = .loading + @State private var played: [Album] = [] + @State private var added: [Track] = [] @State private var reload = 0 private var g: AppGraph { .shared } var body: some View { let s = g.settings - let ready = !s.plexToken.isEmpty && !s.musicSection.isEmpty + let ready = s.libraryReady let today = Date() Page(title: "Library") { if !ready { - EmptyState(icon: "server.rack", title: "Connect to Plex", - message: "Sign in to your Plex server to browse your music. Stems come from stemserve on the same box.", action: "Connect") { - ui.push(.PlexAccount) + EmptyState(icon: "server.rack", title: "Connect a Server", + message: "Add your Plex or Jellyfin server to browse your music. Stems come from stemserve on the same box.", action: "Connect") { + ui.push(.Servers) } } - let rows: [(String, String, Route)] = [ - ("music.note.list", "Playlists", .Playlists), ("music.mic", "Artists", .Artists), - ("square.stack", "Albums", .Albums), ("music.note", "Songs", .Songs), - ] - ForEach(Array(rows.enumerated()), id: \.offset) { i, r in - LibraryRow(icon: r.0, title: r.1, last: i == rows.count - 1) { ui.push(r.2) } + if ready { + if !played.isEmpty { + SectionTitle(title: "Recent Plays") + RecentShelf(albums: played) + } + if !added.isEmpty { + SectionTitle(title: "Recently Added") + ForEach(Array(added.enumerated()), id: \.element.ratingKey) { i, t in + TrackRow(track: t, menu: ui.menu.track(t)) { g.playback.playTracks(added, start: i) } + .listRow(inset: MARGIN + 60, line: i < added.count - 1) + } + } } if ready { SectionTitle(title: "On This Day") @@ -52,10 +60,48 @@ struct LibraryHome: View { } } } + if ready { + SectionTitle(title: "Stations") + let stations: [(String, String, () async throws -> [Track])] = [ + ("dot.radiowaves.left.and.right", "Library Radio", { try await g.plex.randomTracks(200) }), + ("square.stack", "Random Album Radio", { + var out: [Track] = [] + for a in try await g.plex.randomAlbums(10) { out += try await g.plex.albumTracks(a.ratingKey) } + return out + }), + ] + ForEach(stations, id: \.1) { st in + LibraryRow(icon: st.0, title: st.1, last: st.1 == stations.last?.1) { play(st.2) } + } + } + // Every tab except Settings (and this one). + SectionTitle(title: "Browse") + let rows: [(String, String, Route)] = [ + ("music.note.list", "Playlists", .Playlists), ("music.mic", "Artists", .Artists), + ("square.stack", "Albums", .Albums), ("music.note", "Songs", .Songs), ("guitars.fill", "Genres", .Genres), + ("arrow.down.circle", "Downloads", .Downloads), ("antenna.radiowaves.left.and.right", "Web Radio", .WebRadio), + ] + ForEach(rows, id: \.1) { r in + LibraryRow(icon: r.0, title: r.1, last: r.1 == rows.last?.1) { ui.push(r.2) } + } } - .task(id: "\(s.musicSection)|\(reload)") { + .task(id: "\(s.libraryKey)|\(reload)") { guard ready else { return } + async let rp = try? g.plex.recentlyPlayed(count: 24) + async let ra = try? g.plex.recentlyAddedTracks(count: 10) await LoadState.load($albums) { try await g.plex.albums() } + played = Array((await rp ?? []).prefix(24)) + added = Array((await ra ?? []).prefix(10)) + } + } + + /// Starts a station: its songs play in the order given (albums stay whole). + private func play(_ load: @escaping () async throws -> [Track]) { + Task { + do { + let list = try await load() + if list.isEmpty { ui.overlays.hud("Nothing to play", "music.note") } else { g.playback.playTracks(list) } + } catch { ui.overlays.hud("Couldn't start station", "exclamationmark.triangle") } } } } @@ -87,6 +133,35 @@ private func birthdays(_ albums: [Album], _ now: Date) -> ([Birthday], [Birthday return (onDay, week) } +/// Recent plays as a two-row sideways shelf of small tiles, like Plexamp's, in iOS dress. +private struct RecentShelf: View { + let albums: [Album] + @Environment(Ui.self) private var ui + @Environment(\.palette) private var p + private let size: CGFloat = 112 + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + LazyHGrid(rows: Array(repeating: GridItem(.fixed(size + 40), spacing: 14), count: albums.count > 4 ? 2 : 1), spacing: 12) { + ForEach(albums) { a in + VStack(alignment: .leading, spacing: 0) { + Artwork(thumb: a.thumb, size: size, corner: 8, placeholder: "square.stack") + Text(a.title).font(.footnote.weight(.medium)).foregroundStyle(p.label).lineLimit(1).padding(.top, 5) + Text(a.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1) + } + .frame(width: size, alignment: .topLeading) + .contentShape(Rectangle()) + .onTapGesture { ui.push(.AlbumPage(a)) } + .actionsMenu(ui.menu.album(a)) + } + } + .scrollTargetLayout() + .padding(.horizontal, MARGIN) + } + .scrollTargetBehavior(.viewAligned) + } +} + private struct LibraryRow: View { let icon: String let title: String @@ -236,6 +311,7 @@ struct SortMenu: View { } } Section { Toggle("Stems Only", systemImage: "waveform", isOn: Bindable(AppGraph.shared.settings).stemsOnly) + Toggle("No Stems", systemImage: "waveform.slash", isOn: Bindable(AppGraph.shared.settings).noStems) } } label: { Image(systemName: "arrow.up.arrow.down") @@ -248,13 +324,15 @@ struct SortMenu: View { @MainActor enum StemsOnly { private static var g: AppGraph { .shared } - static var on: Bool { g.settings.stemsOnly && g.stems.index.size > 0 } - static func tracks(_ list: [Track]) -> [Track] { on ? list.filter { g.stems.find($0) != nil } : list } + static var on: Bool { (g.settings.stemsOnly || g.settings.noStems) && g.stems.index.size > 0 } + /// With No Stems the filter keeps what Stems Only would drop. + private static var want: Bool { !g.settings.noStems } + static func tracks(_ list: [Track]) -> [Track] { on ? list.filter { (g.stems.find($0) != nil) == want } : list } static func albums(_ list: [Album]) -> [Album] { - on && g.coverage.ready ? list.filter { (g.coverage.albums[$0.ratingKey]?.withStems ?? 0) > 0 } : list + on && g.coverage.ready ? list.filter { ((g.coverage.albums[$0.ratingKey]?.withStems ?? 0) > 0) == want } : list } static func artists(_ list: [Artist]) -> [Artist] { - on && g.coverage.ready ? list.filter { (g.coverage.artists[$0.ratingKey]?.withStems ?? 0) > 0 } : list + on && g.coverage.ready ? list.filter { ((g.coverage.artists[$0.ratingKey]?.withStems ?? 0) > 0) == want } : list } } @@ -293,7 +371,7 @@ struct ArtistsScreen: View { } .navigationTitle("Artists") .toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } } - .task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.token)|\(reload)") { + .task(id: "\(AppGraph.shared.settings.libraryKey)|\(sort.token)|\(reload)") { await LoadState.load($artists) { try await AppGraph.shared.plex.artists(sort: sort.param) } } } @@ -352,17 +430,24 @@ struct AlbumsScreen: View { private let sort = SortState("albums", ALBUM_SORTS) @State private var albums: LoadState<[Album]> = .loading @State private var reload = 0 + /// Pinch out for fewer, bigger albums; pinch in for more. + @AppStorage("albumColumns") private var columns = 2 var body: some View { Page(title: "Albums") { Loaded(state: albums, retry: { reload += 1 }) { list in - AlbumGrid(albums: StemsOnly.albums(list), menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8) + AlbumGrid(albums: StemsOnly.albums(list), menu: { ui.menu.album($0) }, columns: columns, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8) } } + .simultaneousGesture(MagnifyGesture().onEnded { v in + let n = v.magnification < 0.85 ? min(columns + 1, 5) : v.magnification > 1.15 ? max(columns - 1, 2) : columns + if n != columns { withAnimation(.spring(duration: 0.35)) { columns = n } } + }) + .sensoryFeedback(.selection, trigger: columns) .toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } } - .task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.token)|\(reload)") { + .task(id: "\(AppGraph.shared.settings.libraryKey)|\(sort.token)|\(reload)") { await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: sort.param) } } } @@ -415,7 +500,7 @@ struct SongsScreen: View { } .toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } } .task { if tracks.isEmpty { loadMore() } } - .onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.musicSection)") { + .onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.libraryKey)") { gen += 1; tracks = []; total = -1; offset = 0; error = nil; loading = false loadMore() } @@ -464,7 +549,7 @@ struct AlbumScreen: View { var body: some View { let a = detail ?? album let list = tracks.value ?? [] - let allDownloaded = !list.isEmpty && list.allSatisfy { g.downloads.records[$0.ratingKey]?.done == true } + let dl = g.downloads.group(.album, a.ratingKey) Page(title: a.title, large: false) { VStack(spacing: 0) { Artwork(thumb: a.thumb, size: 264, corner: 10, placeholder: "square.stack") @@ -503,12 +588,13 @@ struct AlbumScreen: View { if !list.isEmpty { ToolbarItemGroup(placement: .topBarTrailing) { Button { - if allDownloaded { - ui.overlays.confirm("Remove Download?", "“\(a.title)” will no longer be available offline.", "Remove") { g.downloads.remove(list) } + if let dl { + ui.overlays.confirm("Remove Download?", "“\(a.title)” will no longer be available offline.", "Remove") { g.downloads.removeGroup(dl.id) } } else { - g.downloads.download(list); ui.overlays.hud("Downloading", "arrow.down.circle") + g.downloads.download(DownloadGroup(kind: .album, key: a.ratingKey, title: a.title, subtitle: a.artist, thumb: a.thumb), list) + ui.overlays.hud("Downloading", "arrow.down.circle") } - } label: { Image(systemName: allDownloaded ? "checkmark.circle" : "arrow.down.circle") } + } label: { Image(systemName: dl != nil ? "checkmark.circle" : "arrow.down.circle") } MoreMenu(items: ui.menu.album(a)) } } @@ -551,137 +637,128 @@ struct ArtistScreen: View { // ---- downloads ------------------------------------------------------------------------------ -private let DOWNLOAD_SORTS = ["Download Order", "Artist", "Release Date"] +private let DOWNLOAD_SORTS = ["Download Order", "Name"] -/// Downloaded songs gathered back into their albums. -private struct DownloadedAlbum { - let album: Album +/// A downloaded playlist, album or artist with its songs' download state. +private struct DownloadedGroup { + let group: DownloadGroup let records: [DownloadRecord] - let released: String? - var added: Int64 { records.map(\.added).max() ?? 0 } var done: Int { records.filter(\.done).count } var failed: Int { records.filter { $0.error != nil }.count } var complete: Bool { done == records.count } - var tracks: [Track] { - records.map(\.track).sorted { ($0.disc ?? 0, $0.index ?? 0, $0.title.lowercased()) < ($1.disc ?? 0, $1.index ?? 0, $1.title.lowercased()) } - } -} - -private func artistSortKey(_ s: String) -> String { - let t = s.trimmingCharacters(in: .whitespaces).lowercased() - return t.hasPrefix("the ") ? String(t.dropFirst(4)) : t + /// In the group's own order (playlist order, album track order). + var tracks: [Track] { records.filter(\.done).map(\.track) } + /// Shown through [AlbumGrid], so the key carries the group id. + var tile: Album { Album(ratingKey: group.id, title: group.title, artist: group.subtitle, thumb: group.thumb) } } +/// Downloads the way Plexamp does them: whole playlists, albums and artists, kept in step with the server. struct DownloadsScreen: View { @Environment(Ui.self) private var ui @Environment(\.palette) private var p - // Release days come from the album list (served from the HTTP cache when the server's away). - @State private var info: [String: Album] = [:] private var g: AppGraph { .shared } var body: some View { - let records = g.downloads.records - let sort = g.settings.downloadSort - let albums = group(records, sort) - let byKey = Dictionary(albums.map { ($0.album.ratingKey, $0) }, uniquingKeysWith: { a, _ in a }) - let allDone = albums.flatMap { $0.tracks.filter { records[$0.ratingKey]?.done == true } } + let d = g.downloads + let records = d.records + let sort = min(g.settings.downloadSort, DOWNLOAD_SORTS.count - 1) + let all = d.groups.values.map { gr in DownloadedGroup(group: gr, records: gr.trackKeys.compactMap { records[$0] }) } + .sorted { sort == 1 ? $0.group.title.lowercased() < $1.group.title.lowercased() : $0.group.added > $1.group.added } + let byId = Dictionary(all.map { ($0.group.id, $0) }, uniquingKeysWith: { a, _ in a }) + let loose = d.looseRecords.map(\.track) + let allDone = records.values.filter(\.done).map(\.track) let pinned = String(format: "%.1f", Double(g.cache.usage.pinnedBytes) / 1e9) Page(title: "Downloads") { - if albums.isEmpty { + if all.isEmpty && loose.isEmpty { EmptyState(icon: "arrow.down.circle", title: "Nothing Downloaded", - message: "Hold any album, artist, playlist or song and choose Download to play it, stems and all, without your server.") + message: "Hold any playlist, album or artist and choose Download to play it, stems and all, without your server.") } else { - Text("\(albums.count) album\(albums.count == 1 ? "" : "s") · \(songs(records.count)) · \(pinned) GB · by \(DOWNLOAD_SORTS[sort].lowercased())") + Text("\(songs(records.count)) · \(pinned) GB · by \(DOWNLOAD_SORTS[sort].lowercased())") .font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12) PlayShuffle(play: { g.playback.playTracks(allDone) }, shuffle: { g.playback.playTracks(allDone, shuffle: true) }) .padding(.bottom, 14) - AlbumGrid(albums: albums.map(\.album), subtitle: { a in - guard let d = byKey[a.ratingKey] else { return a.artist } - if d.failed > 0 { return "\(a.artist) · \(d.failed) failed" } - if !d.complete { return "\(a.artist) · \(d.done) of \(d.records.count)" } - if sort == 2, let r = d.released { return "\(a.artist) · \(r.prefix(4))" } - return a.artist - }, menu: { a in - guard let d = byKey[a.ratingKey] else { return [] } - var extra: [SheetAction] = [] - if d.failed > 0 { extra.append(SheetAction("Retry Failed Songs", icon: "arrow.clockwise") { g.downloads.retryFailed() }) } - if !d.complete { - extra.append(SheetAction("Cancel Download", icon: "xmark.circle", destructive: true) { - g.downloads.remove(d.records.filter { !$0.done }.map(\.track)) - }) + ForEach([("Playlists", [DownloadGroup.Kind.playlist, .stemPlaylist]), ("Albums", [.album]), ("Artists", [.artist])], id: \.0) { title, kinds in + let list = all.filter { kinds.contains($0.group.kind) } + if !list.isEmpty { + SectionTitle(title: title) + grid(list, byId) } - return a.ratingKey.hasPrefix("name:") ? ui.menu.tracks("\(a.title) — \(a.artist)", d.tracks, extra: extra) : ui.menu.album(a, extra: extra) - }, open: { a in - guard let d = byKey[a.ratingKey] else { return } - if a.ratingKey.hasPrefix("name:") { g.playback.playTracks(d.tracks) } else { ui.push(.AlbumPage(a)) } - }) { a in - if let d = byKey[a.ratingKey], !d.complete { - let inFlight = d.records.reduce(Float(0)) { $0 + (g.downloads.progress[$1.track.ratingKey] ?? 0) } - ZStack { - Circle().fill(Color.black.opacity(0.55)) - if d.failed > 0 { - Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundStyle(.white) - } else { - ProgressRing(progress: (Float(d.done) + inFlight) / Float(d.records.count), size: 20, color: .white) - } - } - .frame(width: 30, height: 30) + } + if !loose.isEmpty { + SectionTitle(title: "Other Songs") + ForEach(Array(loose.enumerated()), id: \.element.ratingKey) { i, t in + TrackRow(track: t, menu: ui.menu.track(t)) { g.playback.playTracks(loose, start: i) } } } } } .toolbar { - if !albums.isEmpty { + if !all.isEmpty || !loose.isEmpty { ToolbarItemGroup(placement: .topBarTrailing) { Button("Sort") { ui.overlays.actions("Sort By", DOWNLOAD_SORTS.enumerated().map { i, label in SheetAction(i == sort ? "✓ \(label)" : label) { g.settings.downloadSort = i } }) } - MoreMenu(items: moreItems(albums, pinned)) + MoreMenu(items: moreItems(all, pinned)) } } } - .task(id: g.settings.musicSection) { - guard g.plex.signedIn else { return } - if let list = try? await g.plex.albums() { info = Dictionary(list.map { ($0.ratingKey, $0) }, uniquingKeysWith: { a, _ in a }) } + .task { await g.downloads.refresh() } + } + + private func grid(_ list: [DownloadedGroup], _ byId: [String: DownloadedGroup]) -> some View { + AlbumGrid(albums: list.map(\.tile), subtitle: { a in + guard let d = byId[a.ratingKey] else { return a.artist } + if d.failed > 0 { return "\(a.artist) · \(d.failed) failed" } + if !d.complete { return "\(a.artist) · \(d.done) of \(d.records.count)" } + return a.artist == "Artist" || a.artist.hasSuffix("Playlist") ? "\(a.artist) · \(songs(d.records.count))" : a.artist + }, menu: { a in + guard let d = byId[a.ratingKey] else { return [] } + var items = [ + SheetAction("Play", icon: "play.fill") { g.playback.playTracks(d.tracks) }, + SheetAction("Shuffle", icon: "shuffle") { g.playback.playTracks(d.tracks, shuffle: true) }, + ] + if d.failed > 0 { items.append(SheetAction("Retry Failed Songs", icon: "arrow.clockwise") { g.downloads.retryFailed() }) } + items.append(SheetAction("Remove Download", icon: "trash", destructive: true) { + ui.overlays.confirm("Remove Download?", "“\(d.group.title)” will no longer be available offline.", "Remove") { g.downloads.removeGroup(d.group.id) } + }) + return items + }, open: { a in + guard let gr = byId[a.ratingKey]?.group else { return } + switch gr.kind { + case .album: ui.push(.AlbumPage(Album(ratingKey: gr.key, title: gr.title, artist: gr.subtitle, thumb: gr.thumb))) + case .artist: ui.push(.ArtistPage(Artist(ratingKey: gr.key, title: gr.title, sortTitle: gr.title, thumb: gr.thumb))) + case .playlist: ui.push(.PlaylistPage(Playlist(ratingKey: gr.key, title: gr.title, thumb: gr.thumb, trackCount: gr.trackKeys.count))) + case .stemPlaylist: ui.push(.StemPlaylistPage(gr.key)) + } + }) { a in + if let d = byId[a.ratingKey], !d.complete, !d.records.isEmpty { + let inFlight = d.records.reduce(Float(0)) { $0 + (g.downloads.progress[$1.track.ratingKey] ?? 0) } + ZStack { + Circle().fill(Color.black.opacity(0.55)) + if d.failed > 0 { + Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundStyle(.white) + } else { + ProgressRing(progress: (Float(d.done) + inFlight) / Float(d.records.count), size: 20, color: .white) + } + } + .frame(width: 30, height: 30) + } } } - private func moreItems(_ albums: [DownloadedAlbum], _ pinned: String) -> [SheetAction] { + private func moreItems(_ all: [DownloadedGroup], _ pinned: String) -> [SheetAction] { var a: [SheetAction] = [] - if albums.contains(where: { $0.failed > 0 }) { a.append(SheetAction("Retry Failed", icon: "arrow.clockwise") { g.downloads.retryFailed() }) } + if all.contains(where: { $0.failed > 0 }) { a.append(SheetAction("Retry Failed", icon: "arrow.clockwise") { g.downloads.retryFailed() }) } a.append(SheetAction("Remove All Downloads", icon: "trash", destructive: true) { ui.overlays.confirm("Remove All Downloads?", "Frees \(pinned) GB. Songs stay in your library.", "Remove All") { + for id in g.downloads.groups.keys { g.downloads.removeGroup(id) } g.downloads.remove(g.downloads.records.values.map(\.track)) } }) return a } - - private func group(_ records: [String: DownloadRecord], _ sort: Int) -> [DownloadedAlbum] { - let grouped = Dictionary(grouping: records.values) { r in r.track.albumKey ?? "name:\(r.track.artist)|\(r.track.album)" }.map { key, rs in - let t = rs[0].track - let known = info[key] - let artists = Set(rs.map(\.track.artist)) - let album = known ?? Album(ratingKey: key, title: t.album.isEmpty ? "Unknown Album" : t.album, - artist: artists.count > 1 ? "Various Artists" : t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb) - return DownloadedAlbum(album: album, records: rs, released: known?.released ?? (known?.year ?? t.year).map(String.init)) - } - switch sort { - case 1: - return grouped.sorted { - (artistSortKey($0.album.artist), $0.released ?? "", $0.album.title.lowercased()) < - (artistSortKey($1.album.artist), $1.released ?? "", $1.album.title.lowercased()) - } - case 2: - return grouped.sorted { a, b in - (a.released ?? "") != (b.released ?? "") ? (a.released ?? "") > (b.released ?? "") : a.album.title.lowercased() < b.album.title.lowercased() - } - default: - return grouped.sorted { $0.added > $1.added } - } - } } // ---- genres ----------------------------------------------------------------------------------- @@ -713,7 +790,7 @@ struct GenresScreen: View { } } } - .task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") { + .task(id: "\(AppGraph.shared.settings.libraryKey)|\(reload)") { await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") } } } @@ -731,7 +808,7 @@ struct GenreScreen: View { AlbumGrid(albums: StemsOnly.albums(list.filter { $0.genre == genre }), menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8) } } - .task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") { + .task(id: "\(AppGraph.shared.settings.libraryKey)|\(reload)") { await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") } } } diff --git a/ios/Stemify/UI/Screens/NowPlayingOptions.swift b/ios/Stemify/UI/Screens/NowPlayingOptions.swift new file mode 100644 index 0000000..f1a826e --- /dev/null +++ b/ios/Stemify/UI/Screens/NowPlayingOptions.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// The buttons under the transport on Now Playing that the user can hide. +enum NowPlayingButton: String, CaseIterable, Identifiable { + case Mixer, Speed, Route, AirPlay, Karaoke + var id: String { rawValue } + var label: String { + switch self { + case .Mixer: "Stem Mixer" + case .Speed: "Speed" + case .Route: "Output & EQ" + case .AirPlay: "AirPlay" + case .Karaoke: "Karaoke" + } + } + var icon: String { + switch self { + case .Mixer: "slider.vertical.3" + case .Speed: "speedometer" + case .Route: "hifispeaker" + case .AirPlay: "airplayaudio" + case .Karaoke: "music.mic" + } + } + static func hidden(_ s: String) -> Set { Set(s.split(separator: ",").compactMap { Self(rawValue: String($0)) }) } +} + +struct NowPlayingOptionsScreen: View { + private var g: AppGraph { .shared } + @Environment(\.palette) private var p + + var body: some View { + @Bindable var s = g.settings + List { + Section { + ForEach(NowPlayingButton.allCases) { b in + Toggle(isOn: Binding( + get: { !NowPlayingButton.hidden(s.hiddenNowPlaying).contains(b) }, + set: { on in + var h = NowPlayingButton.hidden(s.hiddenNowPlaying) + if on { h.remove(b) } else { h.insert(b) } + s.hiddenNowPlaying = h.map(\.rawValue).sorted().joined(separator: ",") + })) { + HStack(spacing: 14) { + SettingsIcon(symbol: b.icon, bg: 0x5AC8FA) + Text(b.label).foregroundStyle(p.label) + } + } + .tint(Color(hex: 0x34C759)) + .themedRow() + } + } footer: { Text("Choose which buttons show under the playback controls.").foregroundStyle(p.label2) } + Section { + Toggle(isOn: $s.lockScreenMix) { + HStack(spacing: 14) { + SettingsIcon(symbol: "lock.fill", bg: 0xFF9500) + Text("Back Button Changes Mix").foregroundStyle(p.label) + } + } + .tint(Color(hex: 0x34C759)) + .themedRow() + } header: { Text("Lock Screen").foregroundStyle(p.label2) } footer: { + Text("While stems play, the back button on the lock screen, Control Center and headphones moves to the next mix instead of the previous song, like the Mix button in CarPlay. The mix name shows after the artist.").foregroundStyle(p.label2) + } + } + .listStyle(.insetGrouped) + .themedList() + .navigationTitle("Now Playing") + } +} + +struct StepperCell: View { + let title: String + @Binding var value: Int + let icon: String + let iconBg: UInt32 + @Environment(\.palette) private var p + var body: some View { + Stepper(value: $value, in: 0...50) { + HStack(spacing: 14) { + SettingsIcon(symbol: icon, bg: iconBg) + Text(title).foregroundStyle(p.label) + Spacer(minLength: 8) + Text(value == 0 ? "Off" : "\(value) songs").foregroundStyle(p.label2) + } + } + .themedRow() + } +} diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift index f3e3a82..8825d78 100644 --- a/ios/Stemify/UI/Screens/NowPlayingScreen.swift +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -92,6 +92,8 @@ struct NowPlayingScreen: View { @Environment(\.dismiss) private var dismiss @State private var panel = NpPanel.Art @State private var tint: Color? + /// How far the screen is being dragged down to close it. + @State private var drop: CGFloat = 0 private var g: AppGraph { .shared } var body: some View { @@ -130,11 +132,22 @@ struct NowPlayingScreen: View { .padding(.bottom, 8) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(c.bg.ignoresSafeArea()) + .overlay(alignment: .top) { + // Stands in for the sheet's grabber: tap or drag down to close. + Capsule().fill(c.label3).frame(width: 36, height: 5) + .frame(width: 120, height: 20).contentShape(Rectangle()) + .onTapGesture { dismiss() } + .gesture(screenDrag) + } + .offset(y: drop) + // On the art and the mixer: drag down anywhere to close, swipe up anywhere for the + // queue. The queue and lyrics scroll, so there it's the song header or the grabber. + .simultaneousGesture(screenDrag, including: panel == .Art || panel == .Mixer ? .all : .subviews) .environment(\.palette, c) .preferredColorScheme(c.dark ? .dark : .light) .tint(c.label) .animation(.easeInOut(duration: 0.3), value: tint) - .presentationDragIndicator(.visible) + .presentationBackground(.clear) .onAppear { if g.playback.karaoke { panel = .Lyrics } } .task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") { guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return } @@ -145,10 +158,10 @@ struct NowPlayingScreen: View { @ViewBuilder private func panelView(_ track: Track, header: Bool) -> some View { switch panel { - case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady) + case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady).contentShape(Rectangle()) case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() } - case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track) }; QueuePanel() } - case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track) }; LyricsPanel(track: track) } + case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; QueuePanel(close: { show(.Queue) }) } + case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; LyricsPanel(track: track) } } } @@ -189,21 +202,31 @@ struct NowPlayingScreen: View { } .buttonStyle(PressFade()) Spacer() - TransportButton(symbol: "forward.fill", size: 34, action: pm.next) + TransportButton(symbol: "forward.fill", size: 34, action: pm.next, hold: pm.nextAlbum) Spacer() SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat) } .frame(height: 84) .padding(.top, 14) HStack { - PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) } - SpeedMenu() - Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity) - AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40) - PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() } - PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) } + let hidden = NowPlayingButton.hidden(g.settings.hiddenNowPlaying) + if !hidden.contains(.Mixer) { PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) } } + if !hidden.contains(.Speed) { SpeedMenu() } + if !hidden.contains(.Route) { Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity) } else { Spacer() } + if !hidden.contains(.AirPlay) { AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40) } + if !hidden.contains(.Karaoke) { PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() } } } .frame(height: 48) + // Swipe up on the art (or here) for the queue. + Button { show(.Queue) } label: { + VStack(spacing: 0) { + Image(systemName: panel == .Queue ? "chevron.compact.down" : "chevron.compact.up").font(.system(size: 22, weight: .medium)) + if panel != .Queue { Text("Playing Next").font(.caption2.weight(.medium)) } + } + .foregroundStyle(c.label3).frame(maxWidth: .infinity, minHeight: 30).contentShape(Rectangle()) + } + .buttonStyle(PressFade()) + .gesture(swipe(up: panel != .Queue)) } .padding(.horizontal, 28) } @@ -223,6 +246,30 @@ struct NowPlayingScreen: View { withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to } } + /// Swipe up for the queue, down to go back to the art. + private func swipe(up: Bool) -> some Gesture { + DragGesture(minimumDistance: 20).onEnded { v in + let dy = v.translation.height + guard up ? dy < -50 : dy > 50, abs(dy) > abs(v.translation.width) else { return } + withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = up ? .Queue : .Art } + } + } + + private var screenDrag: some Gesture { + DragGesture(minimumDistance: 20) + .onChanged { v in + let dy = v.translation.height + drop = dy > 0 && dy > abs(v.translation.width) ? dy : 0 + } + .onEnded { v in + let dy = v.translation.height + let vertical = abs(dy) > abs(v.translation.width) + if vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return } + withAnimation(.spring(duration: 0.3, bounce: 0.1)) { drop = 0 } + if vertical && dy < -50 { withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = .Queue } } + } + } + private func routeLabel(_ c: Palette) -> some View { let eq = g.eq let device = eq.device @@ -263,12 +310,32 @@ private struct TransportButton: View { let symbol: String let size: CGFloat let action: () -> Void + var hold: (() -> Void)? @Environment(\.palette) private var p var body: some View { - Button(action: action) { + HoldButton(action: action, hold: hold) { Image(systemName: symbol).font(.system(size: size)).foregroundStyle(p.label).frame(width: 72, height: 72).contentShape(Rectangle()) } - .buttonStyle(PressFade()) + } +} + +/// A PressFade button that runs [hold] on a long press instead of the tap (hold Next to skip the album). +struct HoldButton: View { + let action: () -> Void + var hold: (() -> Void)? + @ViewBuilder let label: () -> Label + @State private var held = false + @State private var fired = 0 + var body: some View { + Button { if held { held = false } else { action() } } label: { label() } + .buttonStyle(PressFade()) + .simultaneousGesture(LongPressGesture(minimumDuration: 0.5) + .onChanged { _ in held = false } + .onEnded { _ in + guard let hold else { return } + held = true; fired += 1; hold() + }) + .sensoryFeedback(.impact(weight: .medium), trigger: fired) } } @@ -612,9 +679,32 @@ private struct MixerPanel: View { if let saved, !isSaved { PresetChip(text: "This Song", selected: false) { pm.applyMix(saved) } } - ForEach(MixPresets.all) { preset in + PresetChip(text: "New Preset", icon: "plus", selected: false) { + ui.overlays.prompt("Save Preset", "Saves this mix for every song.", "Save", placeholder: "Preset name") { name in + let n = name.trimmingCharacters(in: .whitespaces) + guard !n.isEmpty else { return } + CustomPreset.add(n, pm.mix) + ui.overlays.hud("Preset saved", "slider.horizontal.3") + } + } + let s = g.settings + ForEach(MixPresets.bar(s)) { preset in if let target = preset.build(names) { - PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) } + let on = MixPresets.normalized(target) == current + let locked = s.lockedPreset == preset.id + // Tapping the mix that's on goes back to the full song. + PresetChip(text: preset.label, icon: locked ? "lock.fill" : nil, selected: on) { pm.applyMix(on ? [:] : target) } + .contextMenu { + if locked { + Button("Unlock", systemImage: "lock.open") { s.lockedPreset = "" } + } else { + Button("Lock for All Songs", systemImage: "lock") { s.lockedPreset = preset.id; pm.applyMix(target) } + } + Button("Edit Stems", systemImage: "slider.horizontal.3") { ui.showInSettings(.StemEditor) } + if preset.custom { + Button("Delete Preset", systemImage: "trash", role: .destructive) { CustomPreset.remove(preset.label) } + } + } } } } @@ -638,6 +728,8 @@ private struct MixerPanel: View { .padding(.horizontal, 24) } .scrollIndicators(.hidden) + // Holds still when everything fits, so a drag down closes Now Playing instead. + .scrollBounceBehavior(.basedOnSize) } } } @@ -702,7 +794,7 @@ private struct StemFader: View { /// One mixer line: name, fader, level and buttons side by side, so all six stems and the /// master fit without scrolling (the faders grab drags, so the list can't scroll well). -private struct FaderRow: View { +struct FaderRow: View { let dot: Color? let label: String let percent: Int @@ -728,7 +820,7 @@ private struct FaderRow: View { } } -private struct ToggleBox: View { +struct ToggleBox: View { let text: String let on: Bool let onColor: Color @@ -799,6 +891,7 @@ struct FaderSlider: View { // ---- queue ---------------------------------------------------------------------------------- private struct QueuePanel: View { + var close: () -> Void = {} @Environment(\.palette) private var p private var g: AppGraph { .shared } @@ -820,7 +913,9 @@ private struct QueuePanel: View { } } .padding(.horizontal, 24).padding(.vertical, 6) - // Hold a row to drag it; swipe for Play Next or Remove. + .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height > 50 { close() } }) + // Drag the handles to reorder; tap to jump, long press for Play Next or Remove. List { ForEach(Array(upcoming.enumerated()), id: \.element.uid) { i, q in HStack(spacing: 12) { @@ -830,7 +925,6 @@ private struct QueuePanel: View { Text(q.track.artist).font(.subheadline).foregroundStyle(p.label2).lineLimit(1) } Spacer(minLength: 6) - Image(systemName: "line.3.horizontal").foregroundStyle(p.label3) } .frame(height: 60) .contentShape(Rectangle()) @@ -838,12 +932,9 @@ private struct QueuePanel: View { .listRowInsets(EdgeInsets(top: 0, leading: 24, bottom: 0, trailing: 16)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) - .swipeActions(edge: .trailing) { - Button(role: .destructive) { pm.removeAt(first + i) } label: { Label("Remove", systemImage: "trash") } - } - .swipeActions(edge: .leading) { + .contextMenu { Button { pm.move(first + i, first) } label: { Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") } - .tint(.indigo) + Button(role: .destructive) { pm.removeAt(first + i) } label: { Label("Remove", systemImage: "trash") } } } .onMove { from, to in @@ -851,7 +942,7 @@ private struct QueuePanel: View { pm.move(first + f, first + (to > f ? to - 1 : to)) } if upcoming.isEmpty { - Text("Nothing queued. Use Add to Front of Queue or Add to End of Queue on any song.") + Text("Nothing queued. Use Play Next or Add to Queue on any song.") .font(.subheadline).foregroundStyle(p.label2) .listRowBackground(Color.clear).listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) @@ -859,6 +950,7 @@ private struct QueuePanel: View { } .listStyle(.plain) .scrollContentBackground(.hidden) + .environment(\.editMode, .constant(.active)) } } } @@ -977,10 +1069,9 @@ private struct MiniTransport: View { } .buttonStyle(PressFade()) if next { - Button(action: pm.next) { + HoldButton(action: pm.next, hold: pm.nextAlbum) { Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle()) } - .buttonStyle(PressFade()) } } } diff --git a/ios/Stemify/UI/Screens/PlaylistScreens.swift b/ios/Stemify/UI/Screens/PlaylistScreens.swift index 686f21b..14efd24 100644 --- a/ios/Stemify/UI/Screens/PlaylistScreens.swift +++ b/ios/Stemify/UI/Screens/PlaylistScreens.swift @@ -47,7 +47,7 @@ struct PlaylistsScreen: View { Button { kind == .Plex ? newPlaylist() : newStemPlaylist() } label: { Image(systemName: "plus") } } } - .task(id: "\(g.settings.musicSection)|\(sort.token)|\(reload)") { + .task(id: "\(g.settings.libraryKey)|\(sort.token)|\(reload)") { // /playlists may not take sort=random, so Random shuffles here. let random = sort.option.random await LoadState.load($lists) { try await random ? g.plex.playlists().shuffled() : g.plex.playlists(sort: sort.param) } diff --git a/ios/Stemify/UI/Screens/SettingsScreens.swift b/ios/Stemify/UI/Screens/SettingsScreens.swift index b65a8ee..5516c49 100644 --- a/ios/Stemify/UI/Screens/SettingsScreens.swift +++ b/ios/Stemify/UI/Screens/SettingsScreens.swift @@ -102,7 +102,7 @@ private struct FieldCell: View { private struct Header: View { let text: String @Environment(\.palette) private var p - var body: some View { Text(text).foregroundStyle(p.label2) } + var body: some View { Text(text).font(.subheadline.weight(.semibold)).foregroundStyle(p.label2).textCase(nil) } } private struct Footer: View { @@ -117,57 +117,246 @@ private func clean(_ url: String) -> String { return u } +/// Four little faders in the stem colours: the Settings mark. +private struct StemMark: View { + var scale: CGFloat = 1 + var body: some View { + HStack(alignment: .bottom, spacing: 3 * scale) { + ForEach(Array(zip(["vocals", "drums", "bass", "other"], [18.0, 26, 14, 22])), id: \.0) { name, h in + Capsule().fill(Color(hex: Stems.color(name))).frame(width: 5 * scale, height: h * scale) + } + } + } +} + +/// One stop on the way from the servers to the phone. The rail runs through the dot and +/// joins the rows above and below, so the three read as one path. +private struct PathStop: View { + let title: String + let detail: String + let color: UInt32 + let on: Bool + var first = false + var last = false + let route: Route + @Environment(\.palette) private var p + var body: some View { + NavigationLink(value: route) { + HStack(spacing: 14) { + ZStack { + VStack(spacing: 0) { + Rectangle().fill(first ? Color.clear : p.separator).frame(width: 2) + Rectangle().fill(last ? Color.clear : p.separator).frame(width: 2) + } + Circle().fill(on ? Color(hex: color) : p.cell) + .overlay(Circle().strokeBorder(on ? Color.clear : p.label3, lineWidth: 2)) + .frame(width: 13, height: 13) + } + .frame(width: 30) + .frame(maxHeight: .infinity) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.body.weight(.semibold)).foregroundStyle(p.label) + Text(detail).font(.subheadline).foregroundStyle(on ? p.label2 : p.label3).lineLimit(1) + } + } + .frame(minHeight: 64) + } + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .listRowSeparator(.hidden) + .themedRow() + } +} + +private let appVersion: String = { + let info = Bundle.main.infoDictionary + return "\(info?["CFBundleShortVersionString"] as? String ?? "1.1") (\(info?["CFBundleVersion"] as? String ?? "1"))" +}() + // ---- settings ------------------------------------------------------------------------------- struct SettingsScreen: View { + @Environment(\.palette) private var p private var g: AppGraph { .shared } var body: some View { @Bindable var s = g.settings let status = g.stems.status List { - // Everything on one screen: no footers but the last, one line each. + // The path music takes: servers, then stems, then this phone. Section { - NavCell(title: "Plex", value: s.plexToken.isEmpty ? "Not Signed In" : (s.musicSectionTitle.isEmpty ? s.plexUser : s.musicSectionTitle), - icon: "server.rack", iconBg: 0xE5A00D, route: .PlexAccount) - NavCell(title: "Stem Server", value: { - switch status { - case .online(let n, _): "\(n) songs" - case .offline: "Offline" - case .unknown: "…" - } - }(), icon: "waveform", iconBg: 0xAF52DE, route: .StemServer) - } header: { Header(text: "Servers") } + HStack(alignment: .bottom, spacing: 12) { + StemMark() + Text("Stemify").font(.system(size: 30, weight: .heavy, design: .rounded)).foregroundStyle(p.label) + Spacer() + Text(appVersion).font(.footnote.monospacedDigit()).foregroundStyle(p.label3).padding(.bottom, 4) + } + .padding(.top, 14).padding(.bottom, 6) + .listRowSeparator(.hidden) + .themedRow() + PathStop(title: "Music Servers", detail: musicDetail(s), color: Stems.color("vocals"), on: s.libraryReady, first: true, route: .Servers) + PathStop(title: "Stem Server", detail: stemDetail(status), color: Stems.color("drums"), on: status.isOnline, route: .StemServer) + PathStop(title: "This \(UIDevice.current.model)", detail: "\(gb(g.cache.usage.cachedBytes)) cached" + (s.downloadsWifiOnly ? ", downloads on Wi-Fi" : ""), + color: Stems.color("bass"), on: true, last: true, route: .Storage) + } + Section { + NavCell(title: "Stem Editor", value: s.lockedPreset.isEmpty ? nil : "Locked", icon: "slider.horizontal.3", iconBg: 0xAF52DE, route: .StemEditor) + NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality) + ToggleCell(title: "Keep Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix) + SoftVocalsCell() + } header: { Header(text: "Listening") } Section { NavCell(title: "Equalizer", value: !g.eq.state.enabled ? "Off" : g.eq.active?.name ?? "On", icon: "slider.vertical.3", iconBg: 0x34C759, route: .Eq) - NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality) - ToggleCell(title: "Keep Stem Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix) - SoftVocalsCell() - } header: { Header(text: "Sound") } - Section { - NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage) - ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly) - } header: { Header(text: "Storage") } + NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance) + } header: { Header(text: "Sound and look") } Section { + StepperCell(title: "Cache Ahead", value: $s.precacheCount, icon: "arrow.down.to.line", iconBg: 0xFF9500) + ToggleCell(title: "Download on Wi-Fi Only", icon: "wifi", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly) BackupCells() - } header: { Header(text: "Backup") } footer: { - Footer(text: "Stem playlists, their mixes and sound settings. Tokens and server addresses are left out. Save to iCloud Drive from the Files sheet.") + } header: { Header(text: "On this phone") } footer: { + Footer(text: "Export saves your stem playlists, their mixes and your sound settings to a file. Server addresses and sign-ins stay out of it.") + } + Section { + NavCell(title: "About", value: nil, icon: "info.circle.fill", iconBg: 0x8E8E93, route: .About) } Section { DebugCells() - } header: { Header(text: "Debug") } footer: { - Footer(text: "Verbose adds playback and network detail. Tokens are removed from the log.") + } header: { Header(text: "Troubleshooting") } footer: { + Footer(text: "Verbose logging adds playback and network detail. Sign-in tokens are stripped from the log.") } Section { - NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance) - ToggleCell(title: "Only Show Songs With Stems", icon: "waveform", iconBg: 0xAF52DE, isOn: $s.stemsOnly) - } header: { Header(text: "Display") } footer: { - Footer(text: "Stemify \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.1") · stems by stemd on your server · \(UIScreen.main.maximumFramesPerSecond) Hz") + VStack(spacing: 8) { + StemMark(scale: 0.6).opacity(0.8) + Text("Stemify \(appVersion)").font(.footnote.weight(.medium)).foregroundStyle(p.label2) + Text("Made by Chris Nutter and Kevin Viers").font(.footnote).foregroundStyle(p.label3) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .listRowBackground(Color.clear) } } .listStyle(.insetGrouped) + .listSectionSpacing(22) .themedList() .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + } + + private func musicDetail(_ s: Settings) -> String { + let ready = s.servers.filter(\.ready) + if s.servers.isEmpty { return "Add a Plex or Jellyfin server" } + if ready.isEmpty { return "No libraries switched on" } + return ready.map(\.label).joined(separator: ", ") + } + + private func stemDetail(_ st: StemServerStatus) -> String { + switch st { + case .online(let n, _): "\(n.formatted()) songs with stems" + case .offline(_, let cached): cached > 0 ? "Can't reach it, \(cached.formatted()) songs saved" : "Can't reach it" + case .unknown: "Checking…" + } + } +} + +// ---- about ---------------------------------------------------------------------------------- + +private struct Maintainer: Identifiable { + let name: String + let role: String + let avatar: String + let color: UInt32 + let site: (title: String, icon: String, value: String, url: String) + let coffee: String? + var id: String { name } +} + +private let maintainers = [ + Maintainer(name: "Chris Nutter", role: "Stemify WebUI and the iOS app", avatar: "AvatarChris", color: Stems.color("vocals"), + site: ("Website", "globe", "iamchrisama.com", "https://iamchrisama.com"), + coffee: "https://buymeacoffee.com/cdnutter"), + Maintainer(name: "Kevin Viers", role: "stemd and the Android app", avatar: "AvatarKevin", color: Stems.color("drums"), + site: ("GitHub", "chevron.left.forwardslash.chevron.right", "kevinViers", "https://github.com/kevinViers/"), + coffee: nil), +] + +/// A maintainer's picture in a ring of their stem colour, their name and what they look after. +private struct MaintainerCard: View { + let m: Maintainer + @Environment(\.palette) private var p + var body: some View { + HStack(spacing: 16) { + Image(m.avatar).resizable().interpolation(.high).aspectRatio(contentMode: .fill) + .frame(width: 60, height: 60) + .clipShape(Circle()) + .padding(3) + .overlay(Circle().strokeBorder(Color(hex: m.color), lineWidth: 2.5)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text(m.name).font(.title3.weight(.bold)).foregroundStyle(p.label) + Text(m.role).font(.subheadline).foregroundStyle(p.label2) + } + Spacer(minLength: 0) + } + .padding(.vertical, 8) + .accessibilityElement(children: .combine) + .themedRow() + } +} + +struct AboutScreen: View { + @Environment(\.palette) private var p + + var body: some View { + List { + Section { + VStack(spacing: 10) { + StemMark(scale: 1.5) + Text("Stemify").font(.system(size: 34, weight: .heavy, design: .rounded)).foregroundStyle(p.label) + Text("Version \(appVersion)").font(.footnote.monospacedDigit()).foregroundStyle(p.label3) + Text("Your Plex and Jellyfin music, split into stems you can mix.") + .font(.subheadline).foregroundStyle(p.label2) + .multilineTextAlignment(.center) + .padding(.top, 4) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .listRowBackground(Color.clear) + } + ForEach(maintainers) { m in + Section { + MaintainerCard(m: m) + Link(destination: URL(string: m.site.url)!) { + ValueCell(title: m.site.title, value: m.site.value, icon: m.site.icon, iconBg: 0x007AFF) + } + .themedRow() + if let coffee = m.coffee { + Link(destination: URL(string: coffee)!) { + ValueCell(title: "Buy Me a Coffee", value: nil, icon: "cup.and.saucer.fill", iconBg: 0xFF9500) + } + .themedRow() + } else { + HStack(spacing: 14) { + SettingsIcon(symbol: "cup.and.saucer.fill", bg: 0x8E8E93) + Text("Buy Me a Coffee").foregroundStyle(p.label3) + Spacer(minLength: 8) + Text("Coming soon").foregroundStyle(p.label3) + } + .accessibilityElement(children: .combine) + .themedRow() + } + } + } + Section { + } footer: { + Footer(text: "Stemify is free. A coffee goes straight to the person you pick.") + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + } + .listStyle(.insetGrouped) + .listSectionSpacing(22) + .themedList() + .navigationTitle("About") + .navigationBarTitleDisplayMode(.inline) } } @@ -207,103 +396,109 @@ struct BackupCells: View { } } -// ---- plex ----------------------------------------------------------------------------------- +// ---- music servers -------------------------------------------------------------------------- -struct PlexAccountScreen: View { - @Environment(Ui.self) private var ui +/// Said on both server screens: stems are found by file path, so libraries have to line up. +private let matchNote = "For stems to work, every Plex or Jellyfin music library here has to match a stem library one-to-one: the same folders with the same files, as your stem server sees them. Stems are found by each song's file path, so a library with no matching stem folder plays without stems, and songs that are only on one side don't get stems at all." + +struct ServersScreen: View { @Environment(\.palette) private var p - @Environment(\.openURL) private var openURL - @State private var url = AppGraph.shared.settings.plexUrl - @State private var manual = "" - @State private var pinCode: String? - @State private var pinId: Int64 = 0 - @State private var pinError: String? - @State private var sections: [MusicSection]? - @State private var testing = false + @State private var libs: [String: [MusicSection]] = [:] private var g: AppGraph { .shared } var body: some View { - let s = g.settings + let list = g.settings.servers List { Section { - FieldCell(label: "Address", text: $url, placeholder: "http://192.168.86.2:32400") - ActionCell(title: testing ? "Testing…" : "Test Connection") { test() } - } header: { Header(text: "Server") } footer: { Footer(text: "Your server's LAN address (port 32400).") } - if s.plexToken.isEmpty { - Section { - ActionCell(title: pinCode == nil ? "Sign In with plex.tv" : "Get a New Code") { newPin() } - if let pinCode { - VStack(spacing: 0) { - Text(pinCode).font(.system(size: 44, weight: .bold)).tracking(8).foregroundStyle(p.label).textSelection(.enabled) - HStack(spacing: 8) { - ProgressView().controlSize(.small) - Text("Waiting for plex.tv/link…").font(.footnote).foregroundStyle(p.label2) - } - .padding(.top, 10) - Button { openURL(URL(string: "https://plex.tv/link")!) } label: { - Text("Open plex.tv/link").font(.headline).foregroundStyle(.white) - .padding(.horizontal, 18).padding(.vertical, 9).background(p.accent, in: Capsule()) - } - .buttonStyle(.plain) - .padding(.top, 14) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 18) - .themedRow() - } - } header: { Header(text: "Sign In") } footer: { - if let f = pinError ?? (pinCode == nil ? "Enter the code at plex.tv/link while signed in to Plex." : nil) { Footer(text: f) } + if list.isEmpty { Text("No servers yet").foregroundStyle(p.label2).themedRow() } + ForEach(list) { s in + NavCell(title: s.label, value: s.ready ? s.sectionTitles : (s.signedIn ? "No Libraries" : "Not Signed In"), + icon: "server.rack", iconBg: s.kind == .plex ? 0xE5A00D : 0xAA5CC3, route: .Server(s.id)) } + } header: { Header(text: "Music Servers") } footer: { + Footer(text: "Every server's picked libraries are pooled into one library. Playlists stay on the server they were made on.") + } + ForEach(list.filter(\.signedIn)) { s in Section { - FieldCell(label: "Token", text: $manual, placeholder: "Required", secret: true) - ActionCell(title: "Use Token", enabled: !manual.trimmingCharacters(in: .whitespaces).isEmpty) { useToken() } - } header: { Header(text: "Or Paste an X-Plex-Token") } - } else { - Section { - HStack(spacing: 14) { - SettingsIcon(symbol: "person.fill", bg: 0xE5A00D) - Text(s.plexUser.isEmpty ? "Signed In" : s.plexUser).foregroundStyle(p.label) - } - .themedRow() - } header: { Header(text: "Account") } - Section { - if let list = sections { - if list.isEmpty { Text("No music libraries found").foregroundStyle(p.label).themedRow() } - ForEach(list) { sec in - Button { - // Tapping adds or drops a library; the picked ones are pooled. One always stays. - var keys = s.musicSection.split(separator: ",").map(String.init) - if let i = keys.firstIndex(of: sec.key) { if keys.count > 1 { keys.remove(at: i) } } else { keys.append(sec.key) } - s.musicSection = keys.joined(separator: ",") - s.musicSectionTitle = keys.compactMap { k in list.first { $0.key == k }?.title }.joined(separator: " + ") - } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(sec.title).foregroundStyle(p.label) - if !sec.locations.isEmpty { - Text(sec.locations.joined(separator: ", ")).font(.footnote).foregroundStyle(p.label2).lineLimit(2) - } - } - Spacer() - if s.musicSection.split(separator: ",").contains(Substring(sec.key)) { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .themedRow() + if let secs = libs[s.id] { + if secs.isEmpty { Text("No music libraries").foregroundStyle(p.label2).themedRow() } + ForEach(secs) { sec in + ToggleCell(title: sec.title, icon: "music.note.house", iconBg: s.kind == .plex ? 0xE5A00D : 0xAA5CC3, + isOn: Binding(get: { s.sections.contains(sec.key) }, set: { on in set(s, sec, on, secs) })) } } else { ProgressView().frame(maxWidth: .infinity).themedRow() } - } header: { Header(text: "Music Libraries") } footer: { Footer(text: "Pick more than one to pool them into one library.") } + } header: { Header(text: s.label + " Libraries") } + } + Section { + NavCell(title: "Add Plex Server", icon: "plus", iconBg: 0xE5A00D, route: .Server("new-plex")) + NavCell(title: "Add Jellyfin Server", icon: "plus", iconBg: 0xAA5CC3, route: .Server("new-jellyfin")) + } header: { Header(text: "Add") } footer: { Footer(text: matchNote) } + } + .listStyle(.insetGrouped) + .themedList() + .navigationTitle("Music Servers") + .navigationBarTitleDisplayMode(.inline) + .task { + for s in list where s.signedIn && libs[s.id] == nil { + libs[s.id] = (try? await g.plex.make(s).musicSections()) ?? [] + } + } + } + + /// Turns one library of a server on or off. All off keeps the sign-in. + private func set(_ s: MediaServer, _ sec: MusicSection, _ on: Bool, _ secs: [MusicSection]) { + var srv = s + if on { if !srv.sections.contains(sec.key) { srv.sections.append(sec.key) } } else { srv.sections.removeAll { $0 == sec.key } } + srv.sectionTitles = srv.sections.compactMap { k in secs.first { $0.key == k }?.title }.joined(separator: " + ") + g.settings.save(srv) + } +} + +/// One Plex or Jellyfin server: address and sign-in. Libraries are switched on in Music Servers. +struct ServerScreen: View { + @Environment(Ui.self) private var ui + @Environment(\.palette) private var p + @Environment(\.openURL) private var openURL + @State private var draft: MediaServer + @State private var manual = "" + @State private var user = "" + @State private var password = "" + @State private var pinCode: String? + @State private var pinId: Int64 = 0 + @State private var signError: String? + @State private var testing = false + private var g: AppGraph { .shared } + + init(id: String) { + let s = AppGraph.shared.settings + if let saved = s.servers.first(where: { $0.id == id }) { + _draft = State(initialValue: saved) + } else { + _draft = State(initialValue: MediaServer(id: s.nextServerId(), kind: id == "new-jellyfin" ? .jellyfin : .plex)) + } + } + + private var plex: Bool { draft.kind == .plex } + + var body: some View { + List { + Section { + FieldCell(label: "Name", text: $draft.name, placeholder: draft.kindLabel) + FieldCell(label: "Address", text: $draft.url, placeholder: plex ? "http://192.168.86.2:32400" : "http://192.168.86.2:8096") + ActionCell(title: testing ? "Testing…" : "Test Connection") { test() } + } header: { Header(text: draft.kindLabel + " Server") } footer: { + Footer(text: "The server's LAN address (port \(plex ? "32400" : "8096")). Add each server or IP as its own entry.") + } + if !draft.signedIn { + if plex { plexSignIn } else { jellyfinSignIn } + } else { Section { - ActionCell(title: "Sign Out", color: p.red, centered: true) { - ui.overlays.confirm("Sign Out of Plex?", "Downloads stay on this phone.", "Sign Out") { - s.plexToken = "" - s.plexUser = "" - s.musicSection = "" - s.musicSectionTitle = "" - sections = nil + ActionCell(title: "Remove Server", color: p.red, centered: true) { + ui.overlays.confirm("Remove \(draft.label)?", "Downloads stay on this phone.", "Remove") { + g.settings.removeServer(draft.id) + ui.pop() } } } @@ -311,81 +506,157 @@ struct PlexAccountScreen: View { } .listStyle(.insetGrouped) .themedList() - .navigationTitle("Plex") + .navigationTitle(draft.label) .navigationBarTitleDisplayMode(.inline) - .task(id: s.plexToken) { - if !s.plexToken.isEmpty && sections == nil { sections = try? await g.plex.musicSections() } - } + .onChange(of: draft.name) { persist() } // Poll plex.tv while a PIN is showing. .task(id: pinId) { guard pinId != 0 else { return } for _ in 0..<300 { try? await Task.sleep(nanoseconds: 2_000_000_000) if Task.isCancelled { return } - if let t = (try? await g.plex.checkPin(pinId))?.authToken, !t.isEmpty { + if let t = (try? await g.plex.plexTv().checkPin(pinId))?.authToken, !t.isEmpty { pinCode = nil pinId = 0 - await finish(t) - ui.overlays.hud("Signed In") + await finishPlex(t) return } } pinCode = nil pinId = 0 - pinError = "The code expired. Try again." + signError = "The code expired. Try again." } } + @ViewBuilder private var plexSignIn: some View { + Section { + ActionCell(title: pinCode == nil ? "Sign In with plex.tv" : "Get a New Code") { newPin() } + if let pinCode { + VStack(spacing: 0) { + Text(pinCode).font(.system(size: 44, weight: .bold)).tracking(8).foregroundStyle(p.label).textSelection(.enabled) + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Waiting for plex.tv/link…").font(.footnote).foregroundStyle(p.label2) + } + .padding(.top, 10) + Button { openURL(URL(string: "https://plex.tv/link")!) } label: { + Text("Open plex.tv/link").font(.headline).foregroundStyle(.white) + .padding(.horizontal, 18).padding(.vertical, 9).background(p.accent, in: Capsule()) + } + .buttonStyle(.plain) + .padding(.top, 14) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .themedRow() + } + } header: { Header(text: "Sign In") } footer: { + if let f = signError ?? (pinCode == nil ? "Enter the code at plex.tv/link while signed in to Plex." : nil) { Footer(text: f) } + } + Section { + FieldCell(label: "Token", text: $manual, placeholder: "Required", secret: true) + ActionCell(title: "Use Token", enabled: !manual.trimmingCharacters(in: .whitespaces).isEmpty) { usePlexToken() } + } header: { Header(text: "Or Paste an X-Plex-Token") } + } + + @ViewBuilder private var jellyfinSignIn: some View { + Section { + FieldCell(label: "Username", text: $user, placeholder: "Required") + FieldCell(label: "Password", text: $password, placeholder: "Optional", secret: true) + ActionCell(title: "Sign In", enabled: !user.trimmingCharacters(in: .whitespaces).isEmpty) { + jellyfinSignIn { try await $0.login(user: user.trimmingCharacters(in: .whitespaces), password: password) } + } + } header: { Header(text: "Sign In") } footer: { if let signError { Footer(text: signError) } } + Section { + FieldCell(label: "API Key", text: $manual, placeholder: "Required", secret: true) + ActionCell(title: "Use API Key", enabled: !manual.trimmingCharacters(in: .whitespaces).isEmpty) { + jellyfinSignIn { try await $0.loginWithKey(manual.trimmingCharacters(in: .whitespaces)) } + } + } header: { Header(text: "Or Use an API Key") } footer: { Footer(text: "Dashboard → API Keys on the Jellyfin server. It signs in as the first admin user.") } + } + + /// Saves the draft once it's signed in; before that there's nothing worth keeping. + private func persist() { + if draft.signedIn { g.settings.save(draft) } + } + private func test() { - let s = g.settings testing = true + draft.url = clean(draft.url) Task { - let ok = await g.plex.probe(url, token: s.plexToken) + let ok = plex ? await g.plex.plexTv(draft.url).probe(draft.url, token: draft.token) : await g.plex.jellyfin(draft).probe() testing = false if ok { - s.plexUrl = clean(url) + persist() ui.overlays.hud("Connected") } else { - ui.overlays.hud(s.plexToken.isEmpty ? "Sign in first" : "Couldn't reach Plex at that address", "exclamationmark.triangle") + ui.overlays.hud(plex && !draft.signedIn ? "Sign in first" : "Couldn't reach \(draft.kindLabel) at that address", "exclamationmark.triangle") } } } private func newPin() { - pinError = nil + signError = nil Task { do { - let pin = try await g.plex.createPin() + let pin = try await g.plex.plexTv().createPin() pinCode = pin.code pinId = pin.id } catch { - pinError = "Couldn't reach plex.tv: \(error.localizedDescription)" + signError = "Couldn't reach plex.tv: \(error.localizedDescription)" } } } - private func useToken() { + private func usePlexToken() { let t = manual.trimmingCharacters(in: .whitespaces) + draft.url = clean(draft.url) Task { - if await g.plex.probe(url, token: t) { await finish(t) } + if await g.plex.plexTv(draft.url).probe(draft.url, token: t) { await finishPlex(t) } else { ui.overlays.hud("That token didn't work on this server", "exclamationmark.triangle") } } } - private func finish(_ token: String) async { - let s = g.settings - s.plexUrl = clean(url) - s.plexToken = token - s.plexUser = await { - guard let u = try? await g.plex.user(token: token) else { return "Plex user" } + private func finishPlex(_ token: String) async { + draft.url = clean(draft.url) + let tv = g.plex.plexTv(draft.url) + draft.user = await { + guard let u = try? await tv.user(token: token) else { return "Plex user" } return u.title ?? u.username ?? "Plex user" }() - s.plexMachineId = (try? await g.plex.machineId()) ?? "" - sections = try? await g.plex.musicSections() - if let only = sections, only.count == 1 { - s.musicSection = only[0].key - s.musicSectionTitle = only[0].title + draft.machineId = (try? await tv.machineId()) ?? "" + draft.token = token + await finish() + } + + private func jellyfinSignIn(_ f: @escaping (JellyfinClient) async throws -> JellyfinLogin) { + signError = nil + draft.url = clean(draft.url) + Task { + do { + let l = try await f(g.plex.jellyfin(draft)) + draft.user = l.user + draft.userId = l.userId + draft.machineId = l.serverId + draft.token = l.token + password = "" + await finish() + } catch { + signError = "Couldn't sign in: \(error.localizedDescription)" + } } + } + + /// Signed in: pick the only library if there's just one, and save. The rest are switched on +/// in Music Servers. + private func finish() async { + manual = "" + if let only = try? await g.plex.make(draft).musicSections(), only.count == 1 { + draft.sections = [only[0].key] + draft.sectionTitles = only[0].title + } + persist() + ui.overlays.hud("Signed In") await g.stems.refresh() } } @@ -408,7 +679,7 @@ struct StemServerScreen: View { FieldCell(label: "Token", text: $token, placeholder: "Optional", secret: true) ActionCell(title: busy ? "Connecting…" : "Save & Connect") { connect() } } header: { Header(text: "stemserve") } footer: { - Footer(text: "stemserve shares the Stems folder that stemd fills (port 8765). The app checks it for new stems every minute.") + Footer(text: "stemserve shares the Stems folder that stemd fills (port 8765). The app checks it for new stems every minute.\n\n" + matchNote) } Section { ValueCell(title: "Connection", value: { @@ -607,8 +878,16 @@ struct AppearanceScreen: View { .tint(Color(hex: 0x34C759)) .themedRow() } header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") } + Section { + Toggle(isOn: $s.acapella) { Text("Spell It “Acapella”").foregroundStyle(c.label) } + .tint(Color(hex: 0x34C759)) + .themedRow() + } header: { Header(text: "Mix Names") } footer: { + Footer(text: "Shows the A Cappella mix as Acapella in the mixer, CarPlay, the lock screen and widgets.") + } Section { NavigationLink(value: Route.TabBar) { Text("Tab Bar").foregroundStyle(c.label) }.themedRow() + NavigationLink(value: Route.NowPlayingOptions) { Text("Now Playing Buttons").foregroundStyle(c.label) }.themedRow() } } .listStyle(.insetGrouped) @@ -622,11 +901,11 @@ struct AppearanceScreen: View { struct TabBarScreen: View { @Environment(\.palette) private var c + @State private var shown: [Tab] = Tab.layout(AppGraph.shared.settings.tabLayout) + private func commit() { g.settings.tabLayout = Tab.encode(shown) } private var g: AppGraph { .shared } var body: some View { - @Bindable var s = g.settings - let shown = Tab.layout(s.tabLayout) let hidden = Tab.allCases.filter { !shown.contains($0) } List { Section { @@ -639,18 +918,20 @@ struct TabBarScreen: View { .deleteDisabled(t == .Settings) } .onMove { from, to in - var l = shown - l.move(fromOffsets: from, toOffset: to) - s.tabLayout = Tab.encode(l) + shown.move(fromOffsets: from, toOffset: to) + commit() } .onDelete { idx in - s.tabLayout = Tab.encode(shown.enumerated().filter { !idx.contains($0.offset) }.map(\.element)) + let gone = Set(idx.map { shown[$0] }).subtracting([.Settings]) + shown.removeAll { gone.contains($0) } + if shown.count < 2 { shown = Tab.defaultLayout } + commit() } } header: { Header(text: "In the bar") } footer: { Footer(text: "Drag to reorder, swipe to remove. The bar holds \(Tab.maxVisible) tabs.") } if !hidden.isEmpty { Section { ForEach(hidden, id: \.self) { t in - Button { s.tabLayout = Tab.encode(shown + [t]) } label: { + Button { if shown.count < Tab.maxVisible { shown.append(t); commit() } } label: { HStack { Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label) Spacer() @@ -663,7 +944,7 @@ struct TabBarScreen: View { } header: { Header(text: "Available") } } Section { - Button("Reset to default") { s.tabLayout = Tab.encode(Tab.defaultLayout) }.themedRow() + Button("Reset to default") { shown = Tab.defaultLayout; commit() }.themedRow() } } .environment(\.editMode, .constant(.active)) diff --git a/ios/Stemify/UI/Screens/StemEditor.swift b/ios/Stemify/UI/Screens/StemEditor.swift new file mode 100644 index 0000000..2c675ff --- /dev/null +++ b/ios/Stemify/UI/Screens/StemEditor.swift @@ -0,0 +1,163 @@ +import SwiftUI + +/// The mixer bar's presets: order them, delete or add them, bring back deleted built-ins and unlock a locked one. +/// This list is also the rotation for the Mix button in CarPlay, on the lock screen and in the widgets. +struct StemEditorScreen: View { + @Environment(\.palette) private var p + @Environment(Ui.self) private var ui + /// The preset being made or edited; a blank one for New Preset. + @State private var editing: CustomPreset? + private var g: AppGraph { .shared } + + var body: some View { + let s = g.settings + let hidden = MixPresets.hidden(s) + let order = MixPresets.ordered(s) + let shown = order.filter { !hidden.contains($0.id) } + let off = order.filter { hidden.contains($0.id) } + let mine = shown.filter(\.custom).count + List { + if let l = MixPresets.locked(s) { + Section { + HStack(spacing: 14) { + SettingsIcon(symbol: "lock.fill", bg: 0xFF9500) + Text(l.label).foregroundStyle(p.label) + Spacer() + Button("Unlock") { s.lockedPreset = "" } + } + .themedRow() + } header: { Text("Locked").foregroundStyle(p.label2) } footer: { + Text("Every song starts with this mix, whether or not Keep Stem Mix Between Songs is on. Long press a preset in the mixer to lock or unlock it.") + .foregroundStyle(p.label2) + } + } + Section { + ForEach(shown) { pr in + HStack(spacing: 14) { + SettingsIcon(symbol: pr.custom ? "person.fill" : "slider.horizontal.3", bg: pr.custom ? 0xAF52DE : 0x5AC8FA) + Text(pr.label).foregroundStyle(p.label) + Spacer() + if pr.custom { Image(systemName: "slider.vertical.3").foregroundStyle(p.label3) } + } + .contentShape(Rectangle()) + .onTapGesture { if pr.custom { editing = CustomPreset.load(s).first { $0.name == pr.label } } } + .themedRow() + .deleteDisabled(pr == MixPresets.full) + } + .onMove { from, to in + var l = shown + l.move(fromOffsets: from, toOffset: to) + MixPresets.setOrder(s, l + off) + } + .onDelete { rows in + for pr in rows.map({ shown[$0] }) { + if pr.custom { CustomPreset.remove(pr.label) } else { MixPresets.setHidden(s, pr.id, true) } + if s.lockedPreset == pr.id { s.lockedPreset = "" } + } + } + Button { editing = CustomPreset(name: "", mix: [:]) } label: { Label("New Preset", systemImage: "plus.circle.fill") } + .themedRow() + } header: { Text("Mixer Bar").foregroundStyle(p.label2) } footer: { + Text("Drag to reorder and tap one of yours to change it. The Mix button in CarPlay, on the lock screen and in the widgets goes through this list in this order.") + .foregroundStyle(p.label2) + } + Section { + Button { MixPresets.restoreDefaults(s) } label: { + HStack(spacing: 14) { + SettingsIcon(symbol: "arrow.counterclockwise", bg: 0x34C759) + Text("Restore Deleted Presets").foregroundStyle(off.isEmpty ? p.label3 : p.label) + Spacer() + if !off.isEmpty { Text("\(off.count)").foregroundStyle(p.label2) } + } + } + .disabled(off.isEmpty) + .themedRow() + Button { + let what = mine == 1 ? "your preset" : "all \(mine) of your presets" + ui.overlays.confirm("Delete \(what)?", "This can’t be undone. The built-in presets stay.", "Delete") { + CustomPreset.removeAll() + } + } label: { + HStack(spacing: 14) { + SettingsIcon(symbol: "trash", bg: 0xFF3B30) + Text("Delete All Custom Presets").foregroundStyle(mine == 0 ? p.label3 : p.red) + } + } + .disabled(mine == 0) + .themedRow() + } footer: { + Text(off.isEmpty ? "Every built-in preset is in the list." : "Brings back \(off.map(\.label).joined(separator: ", ")).") + .foregroundStyle(p.label2) + } + } + .listStyle(.insetGrouped) + .themedList() + .environment(\.editMode, .constant(.active)) + .navigationTitle("Stem Editor") + .sheet(item: $editing) { PresetSheet(original: $0) } + } +} + +/// Builds a preset on the same faders and M / S buttons as the Now Playing mixer, plus a name. +private struct PresetSheet: View { + let original: CustomPreset + @Environment(\.dismiss) private var dismiss + @Environment(\.palette) private var p + @State private var name = "" + @State private var mix: [String: StemSetting] = [:] + + var body: some View { + let n = name.trimmingCharacters(in: .whitespaces) + let anySolo = mix.values.contains { $0.solo } + NavigationStack { + List { + Section { TextField("Preset name", text: $name).themedRow() } + Section { + ForEach(StemPlaylists.allStems, id: \.self) { st in + let set = mix[st] ?? StemSetting() + let silent = set.muted || (anySolo && !set.solo) + let color = Color(hex: Stems.color(st)) + FaderRow(dot: silent ? p.label3 : color, label: Stems.label(st), percent: Int(stemTaper(set.level) * 100), dim: silent) { + FaderSlider(value: set.level, fill: silent ? p.label3 : color, detent: StemSetting.unity) { mix[st, default: StemSetting()].level = $0 } + } trailing: { + ToggleBox(text: "M", on: set.muted, onColor: p.red) { mix[st, default: StemSetting()].muted.toggle() } + ToggleBox(text: "S", on: set.solo, onColor: Color(hex: 0xFFCC00)) { mix[st, default: StemSetting()].solo.toggle() } + } + .themedRow() + } + } header: { + HStack { + Text("Stems").foregroundStyle(p.label2) + Spacer() + Button("Reset") { mix = [:] }.font(.footnote) + } + } footer: { + Text("Stems a song doesn't have are skipped when the preset is applied.").foregroundStyle(p.label2) + } + } + .listStyle(.insetGrouped) + .themedList() + .navigationTitle(original.name.isEmpty ? "New Preset" : "Edit Preset") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let s = AppGraph.shared.settings + let old = original.name + let locked = !old.isEmpty && s.lockedPreset == "c:" + old + if !old.isEmpty && old != n { + CustomPreset.remove(old) + s.presetOrder = s.presetOrder.split(separator: "\n").map { $0 == "c:" + old ? "c:" + n : String($0) }.joined(separator: "\n") + } + CustomPreset.add(n, mix) + if locked { s.lockedPreset = "c:" + n } + dismiss() + } + .disabled(n.isEmpty) + } + } + } + .onAppear { name = original.name; mix = original.mix } + } +} diff --git a/ios/Stemify/UI/Screens/WebRadioScreen.swift b/ios/Stemify/UI/Screens/WebRadioScreen.swift new file mode 100644 index 0000000..969df03 --- /dev/null +++ b/ios/Stemify/UI/Screens/WebRadioScreen.swift @@ -0,0 +1,116 @@ +import SwiftUI + +/// Saved internet radio stations. Tap to play, swipe to delete, long press to edit. +struct WebRadioScreen: View { + @Environment(\.palette) private var p + @State private var editing: WebStation? + private var g: AppGraph { .shared } + + var body: some View { + let s = g.settings + let radio = g.radio + let list = s.stations + List { + if let on = radio.station { + Section { + HStack(spacing: 14) { + SettingsIcon(symbol: "antenna.radiowaves.left.and.right", bg: 0xFF2D55) + VStack(alignment: .leading, spacing: 1) { + Text(on.name).foregroundStyle(p.label).lineLimit(1) + Text(radio.playing ? "Live" : "Paused").font(.footnote).foregroundStyle(p.label2) + } + Spacer() + Button { radio.toggle() } label: { + Image(systemName: radio.playing ? "pause.fill" : "play.fill").font(.system(size: 20)).frame(width: 40, height: 40) + } + .buttonStyle(.borderless) + Button { radio.stop() } label: { + Image(systemName: "stop.fill").font(.system(size: 18)).frame(width: 40, height: 40) + } + .buttonStyle(.borderless) + } + .themedRow() + } header: { Text("Now Playing").foregroundStyle(p.label2) } + } + Section { + ForEach(list) { st in + Button { radio.play(st) } label: { + HStack(spacing: 14) { + SettingsIcon(symbol: "dot.radiowaves.left.and.right", bg: 0x5856D6) + VStack(alignment: .leading, spacing: 1) { + Text(st.name).foregroundStyle(p.label).lineLimit(1) + Text(st.url).font(.footnote).foregroundStyle(p.label2).lineLimit(1) + } + Spacer() + if radio.station?.id == st.id { PlayingBars(playing: radio.playing) } + } + } + .themedRow() + .swipeActions(edge: .leading) { Button("Edit") { editing = st }.tint(.orange) } + .contextMenu { + Button { editing = st } label: { Label("Edit", systemImage: "pencil") } + Button(role: .destructive) { remove(st) } label: { Label("Delete", systemImage: "trash") } + } + } + .onDelete { rows in rows.map { list[$0] }.forEach(remove) } + Button { editing = WebStation(name: "", url: "") } label: { Label("Add Station", systemImage: "plus.circle.fill") } + .themedRow() + } header: { Text("Stations").foregroundStyle(p.label2) } footer: { + Text("Any direct stream URL works (MP3, AAC or HLS). Playing a station pauses your queue, and playing a song stops the station.") + .foregroundStyle(p.label2) + } + } + .listStyle(.insetGrouped) + .themedList() + .navigationTitle("Web Radio") + .sheet(item: $editing) { st in StationSheet(station: st) } + } + + private func remove(_ st: WebStation) { + if g.radio.station?.id == st.id { g.radio.stop() } + g.settings.stations.removeAll { $0.id == st.id } + } +} + +/// Name and stream URL for a new or existing station. +private struct StationSheet: View { + let station: WebStation + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var url = "" + + var body: some View { + let n = name.trimmingCharacters(in: .whitespaces) + let u = url.trimmingCharacters(in: .whitespaces) + let valid = !n.isEmpty && URL(string: u).map { ["http", "https"].contains($0.scheme?.lowercased() ?? "") } == true + NavigationStack { + List { + Section { + TextField("Name", text: $name).themedRow() + TextField("Stream URL", text: $url) + .keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled() + .themedRow() + } + } + .listStyle(.insetGrouped) + .themedList() + .navigationTitle(station.name.isEmpty ? "New Station" : "Edit Station") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let s = AppGraph.shared.settings + var list = s.stations + let st = WebStation(id: station.id, name: n, url: u) + if let i = list.firstIndex(where: { $0.id == station.id }) { list[i] = st } else { list.append(st) } + s.stations = list + dismiss() + } + .disabled(!valid) + } + } + } + .onAppear { name = station.name; url = station.url } + } +} diff --git a/ios/TODO.md b/ios/TODO.md new file mode 100644 index 0000000..4819bb6 --- /dev/null +++ b/ios/TODO.md @@ -0,0 +1,14 @@ +# iOS to-do + +Planned, not started. + +1. Album shuffle: random order of whole albums, tracks in order inside each (like Plexamp). +2. Customizable stem presets: let the user edit and remove presets (No Drums, Rhythm, etc.) and add their own. +3. Bug: a saved mix's stem selection carries over to the next track. +4. Continuous preloading: user sets how many songs to preload in Settings, up to 50. +5. Let the user hide options on the now-playing screen. +6. CarPlay stem change text: show only the mix type instead of "Mix: ". +7. Add Genre to the Library tab. +8. Remove the "Only show songs with stems" setting, since sorting already covers it. +9. Add a "No Stems" sort option. +10. Optional donate buttons in Settings. diff --git a/ios/Widgets/MixerViews.swift b/ios/Widgets/MixerViews.swift new file mode 100644 index 0000000..75b93c5 --- /dev/null +++ b/ios/Widgets/MixerViews.swift @@ -0,0 +1,342 @@ +import AppIntents +import SwiftUI +import WidgetKit + +// ---- look --------------------------------------------------------------------------------- + +enum Look { + static let bg = Color(light: 0xFFFFFF, dark: 0x151518) + static let panel = Color(light: 0xEFEFF3, dark: 0x26262B) + static let well = Color(light: 0xE2E2E8, dark: 0x34343A) +} + +extension Color { + init(hex: UInt32) { + self.init(red: Double(hex >> 16 & 0xFF) / 255, green: Double(hex >> 8 & 0xFF) / 255, blue: Double(hex & 0xFF) / 255) + } + + init(light: UInt32, dark: UInt32) { + self.init(UIColor { t in UIColor(Color(hex: t.userInterfaceStyle == .dark ? dark : light)) }) + } +} + +// ---- pieces ------------------------------------------------------------------------------- + +/// A mixer knob: a 270° arc filled to the fader position, a tick at unity and a pointer. +struct Knob: View { + let stem: WidgetStem + let silent: Bool + let size: CGFloat + + private var tint: Color { silent ? .secondary.opacity(0.5) : Color(hex: stem.color) } + private var line: CGFloat { max(3, size * 0.085) } + + var body: some View { + let v = min(max(stem.level, 0), 1) + ZStack { + Circle().trim(from: 0, to: 0.75) + .stroke(Look.well, style: StrokeStyle(lineWidth: line, lineCap: .round)) + .rotationEffect(.degrees(135)) + Circle().trim(from: 0, to: 0.75 * v) + .stroke(tint, style: StrokeStyle(lineWidth: line, lineCap: .round)) + .rotationEffect(.degrees(135)) + .widgetAccentable() + // Unity sits at 80% of the travel. + Capsule().fill(.secondary.opacity(0.6)) + .frame(width: 1.5, height: line * 0.9) + .offset(y: -size / 2 - line * 0.95) + .rotationEffect(.degrees(270 * 0.8 - 135)) + Circle().fill(Look.panel) + .frame(width: size * 0.66, height: size * 0.66) + .overlay { + Capsule().fill(tint) + .frame(width: 2.5, height: size * 0.12) + .offset(y: -size * 0.33 + size * 0.1) + .rotationEffect(.degrees(270 * v - 135)) + .widgetAccentable() + } + Group { + if silent { + Image(systemName: "speaker.slash.fill").font(.system(size: size * 0.2, weight: .semibold)) + } else { + Text("\(stem.percent)").font(.system(size: size * 0.2, weight: .semibold, design: .rounded)).monospacedDigit() + } + } + .foregroundStyle(silent ? .secondary : .primary) + .offset(y: size * 0.03) + } + .frame(width: size, height: size) + .padding(line / 2) + } +} + +/// A knob you tap to mute, its name, and a − / + pair that turns it one step. +struct KnobCell: View { + let stem: WidgetStem + let silent: Bool + let size: CGFloat + + var body: some View { + VStack(spacing: 4) { + Button(intent: MuteStemIntent(stem: stem.name)) { + Knob(stem: stem, silent: silent, size: size) + } + .buttonStyle(.plain) + Text(stem.label) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(silent ? .secondary : .primary) + .lineLimit(1).minimumScaleFactor(0.7) + HStack(spacing: 0) { + step(-1, "minus") + Rectangle().fill(.secondary.opacity(0.25)).frame(width: 1, height: 10) + step(1, "plus") + } + .background(Capsule().fill(Look.panel)) + } + .frame(maxWidth: .infinity) + } + + private func step(_ n: Int, _ icon: String) -> some View { + Button(intent: NudgeStemIntent(stem: stem.name, steps: n)) { + Image(systemName: icon) + .font(.system(size: 10, weight: .bold)) + .frame(width: 24, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + .disabled(n < 0 ? stem.level <= 0.001 : stem.level >= 0.999) + } +} + +struct Art: View { + let image: UIImage? + let size: CGFloat + + var body: some View { + Group { + if let image { + Image(uiImage: image).resizable().aspectRatio(contentMode: .fill) + } else { + Look.panel.overlay { + Image(systemName: "music.note").font(.system(size: size * 0.4)).foregroundStyle(.secondary) + } + } + } + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: size * 0.18, style: .continuous)) + } +} + +/// The primary-coloured capsule that moves to the next mix. +struct MixPill: View { + let text: String + var icon = "chevron.right" + + var body: some View { + Button(intent: CycleMixIntent()) { + HStack(spacing: 4) { + Text(text).lineLimit(1) + Image(systemName: icon).font(.system(size: 9, weight: .heavy)) + } + .font(.system(size: 12, weight: .semibold)) + .padding(.horizontal, 10).padding(.vertical, 6) + .foregroundStyle(Look.bg) + .background(Capsule().fill(.primary)) + } + .buttonStyle(.plain) + } +} + +struct Empty: View { + let snap: WidgetSnapshot + + var body: some View { + VStack(spacing: 6) { + Image(systemName: snap.hasTrack ? "slider.vertical.3" : "music.note") + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(.secondary) + Text(snap.hasTrack ? "No stems" : "Nothing playing") + .font(.system(size: 14, weight: .semibold)) + Text(snap.hasTrack ? "Switch this song to Stems to mix it." : "Play a song in Stemify.") + .font(.system(size: 11)).foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// ---- mixer (medium, large) ---------------------------------------------------------------- + +struct MixerView: View { + let entry: MixerEntry + @Environment(\.widgetFamily) private var family + + private var snap: WidgetSnapshot { entry.snap } + + var body: some View { + if !snap.hasTrack { + Empty(snap: snap) + } else if family == .systemLarge { + large + } else { + medium + } + } + + private var medium: some View { + VStack(spacing: 10) { + HStack(spacing: 8) { + Art(image: entry.art, size: 30) + titles(13) + Spacer(minLength: 4) + if snap.hasStems { MixPill(text: snap.mix) } + } + if snap.hasStems { + knobs(size: snap.stems.count > 4 ? 34 : 42, columns: snap.stems.count) + } else { + Empty(snap: snap) + } + } + } + + private var large: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + Art(image: entry.art, size: 56) + titles(16) + Spacer(minLength: 4) + Button(intent: PlayPauseIntent()) { + Image(systemName: snap.playing ? "pause.fill" : "play.fill") + .font(.system(size: 16, weight: .bold)) + .frame(width: 40, height: 40) + .background(Circle().fill(Look.panel)) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + } + if snap.hasStems { + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 1) { + Text("Mix").font(.system(size: 11, weight: .medium)).foregroundStyle(.secondary) + Text(snap.mix).font(.system(size: 24, weight: .bold, design: .rounded)) + .lineLimit(1).minimumScaleFactor(0.6) + } + Spacer(minLength: 8) + MixPill(text: snap.next, icon: "forward.fill") + } + Spacer(minLength: 0) + knobs(size: 50, columns: snap.stems.count <= 4 ? snap.stems.count : 3) + Spacer(minLength: 0) + } else { + Empty(snap: snap) + } + } + } + + private func titles(_ size: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(snap.title).font(.system(size: size, weight: .semibold)).lineLimit(1) + Text(snap.artist).font(.system(size: size - 2)).foregroundStyle(.secondary).lineLimit(1) + } + } + + private func knobs(size: CGFloat, columns: Int) -> some View { + let grid = Array(repeating: GridItem(.flexible(), spacing: 6), count: max(columns, 1)) + return LazyVGrid(columns: grid, spacing: 12) { + ForEach(snap.stems) { s in + KnobCell(stem: s, silent: snap.silent(s), size: size) + } + } + } +} + +// ---- mix (small, lock screen) ------------------------------------------------------------- + +struct MixView: View { + let entry: MixerEntry + @Environment(\.widgetFamily) private var family + + private var snap: WidgetSnapshot { entry.snap } + + var body: some View { + switch family { + case .accessoryCircular: circular + case .accessoryRectangular: rectangular + default: small + } + } + + private var small: some View { + Group { + if snap.hasStems { + VStack(alignment: .leading, spacing: 0) { + bars.frame(height: 34) + Spacer(minLength: 6) + Text("Mix").font(.system(size: 11, weight: .medium)).foregroundStyle(.secondary) + Text(snap.mix).font(.system(size: 20, weight: .bold, design: .rounded)) + .lineLimit(2).minimumScaleFactor(0.6) + Spacer(minLength: 6) + MixPill(text: snap.next, icon: "forward.fill") + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Empty(snap: snap) + } + } + .containerBackground(for: .widget) { Look.bg } + } + + /// One bar per stem, as tall as its fader. + private var bars: some View { + HStack(alignment: .bottom, spacing: 4) { + ForEach(snap.stems) { s in + let silent = snap.silent(s) + GeometryReader { g in + VStack { + Spacer(minLength: 0) + Capsule() + .fill(silent ? AnyShapeStyle(Look.well) : AnyShapeStyle(Color(hex: s.color))) + .frame(height: max(4, g.size.height * min(max(s.level, 0), 1))) + .widgetAccentable() + } + } + .frame(maxWidth: 9) + } + Spacer(minLength: 0) + } + } + + private var rectangular: some View { + Button(intent: CycleMixIntent()) { + HStack(spacing: 8) { + Image(systemName: "slider.vertical.3").font(.system(size: 18, weight: .semibold)) + VStack(alignment: .leading, spacing: 0) { + Text(snap.hasStems ? snap.mix : "Stemify").font(.headline).lineLimit(1).widgetAccentable() + Text(snap.hasStems ? "Tap for \(snap.next)" : snap.hasTrack ? "No stems" : "Nothing playing") + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer(minLength: 0) + } + } + .buttonStyle(.plain) + .containerBackground(for: .widget) { Color.clear } + } + + private var circular: some View { + Button(intent: CycleMixIntent()) { + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 1) { + Image(systemName: "slider.vertical.3").font(.system(size: 15, weight: .semibold)) + if snap.hasStems { + Text(snap.mix).font(.system(size: 9, weight: .semibold)).lineLimit(1).minimumScaleFactor(0.6) + .padding(.horizontal, 6) + } + } + } + } + .buttonStyle(.plain) + .containerBackground(for: .widget) { Color.clear } + } +} diff --git a/ios/Widgets/StemifyWidgets.swift b/ios/Widgets/StemifyWidgets.swift new file mode 100644 index 0000000..e81b72e --- /dev/null +++ b/ios/Widgets/StemifyWidgets.swift @@ -0,0 +1,100 @@ +import SwiftUI +import WidgetKit + +@main +struct StemifyWidgets: WidgetBundle { + var body: some Widget { + MixerWidget() + MixWidget() + if #available(iOS 18.0, *) { MixControl() } + } +} + +/// Audio intents run in the app's process, so the extension's copy never does anything. +@MainActor +enum MixRemote { + static func run(_ a: MixAction) {} +} + +// ---- timeline ----------------------------------------------------------------------------- + +struct MixerEntry: TimelineEntry { + var date = Date() + var snap: WidgetSnapshot + var art: UIImage? + + /// The app reloads the widgets whenever the song or mix changes, so one entry is enough. + static func now() -> MixerEntry { + let s = WidgetStore.load() + let art = s.art == nil ? nil : WidgetStore.artURL.flatMap { UIImage(contentsOfFile: $0.path) } + return MixerEntry(snap: s, art: art) + } + + static let sample = MixerEntry(snap: WidgetSnapshot( + title: "Midnight City", artist: "M83", mix: "Karaoke", next: "A Cappella", playing: true, + stems: [ + WidgetStem(name: "vocals", label: "Vocals", color: 0xFF7AB6, level: 0.8, percent: 100, muted: true), + WidgetStem(name: "drums", label: "Drums", color: 0xFFB547, level: 0.8, percent: 100), + WidgetStem(name: "bass", label: "Bass", color: 0x7C8CFF, level: 0.9, percent: 127), + WidgetStem(name: "other", label: "Other", color: 0x4CD07D, level: 0.7, percent: 77), + ])) +} + +struct MixerProvider: TimelineProvider { + func placeholder(in context: Context) -> MixerEntry { .sample } + + func getSnapshot(in context: Context, completion: @escaping (MixerEntry) -> Void) { + let e = MixerEntry.now() + completion(context.isPreview && !e.snap.hasStems ? .sample : e) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + completion(Timeline(entries: [.now()], policy: .never)) + } +} + +// ---- widgets ------------------------------------------------------------------------------ + +struct MixerWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: WidgetStore.mixerKind, provider: MixerProvider()) { e in + MixerView(entry: e).containerBackground(for: .widget) { Look.bg } + } + .configurationDisplayName("Stem Mixer") + .description("A knob for each stem. Tap the arrows to turn it, tap the knob to mute it.") + .supportedFamilies([.systemMedium, .systemLarge]) + } +} + +struct MixWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: WidgetStore.mixKind, provider: MixerProvider()) { e in + MixView(entry: e) + } + .configurationDisplayName("Mix") + .description("The mix that's playing, and a button for the next one.") + .supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryCircular]) + } +} + +/// Swaps the Lock Screen's flashlight or camera for a Mix button. Also works in Control Center and on the Action button. +@available(iOS 18.0, *) +struct MixControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: WidgetStore.controlKind, provider: Provider()) { mix in + ControlWidgetButton(action: CycleMixIntent()) { + Label(mix, systemImage: "slider.vertical.3") + } + } + .displayName("Change Mix") + .description("Moves to the next stem mix while stems play.") + } + + struct Provider: ControlValueProvider { + var previewValue: String { "Karaoke" } + func currentValue() async throws -> String { + let s = WidgetStore.load() + return s.hasStems ? s.mix : "Mix" + } + } +} diff --git a/ios/project.yml b/ios/project.yml index 099f227..99b0549 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -6,13 +6,15 @@ options: settings: base: SWIFT_VERSION: "5.0" - MARKETING_VERSION: "1.0.17" - CURRENT_PROJECT_VERSION: "18" + MARKETING_VERSION: "1.1" + CURRENT_PROJECT_VERSION: "19" targets: Stemify: type: application platform: iOS - sources: [Stemify] + sources: [Stemify, Shared] + dependencies: + - target: StemifyWidgets settings: base: PRODUCT_BUNDLE_IDENTIFIER: org.cdnutter.stemify @@ -24,6 +26,7 @@ targets: properties: com.apple.developer.carplay-audio: true com.apple.developer.siri: true + com.apple.security.application-groups: [group.org.cdnutter.stemify] info: path: Stemify/Info.plist properties: @@ -51,3 +54,24 @@ targets: CPTemplateApplicationSceneSessionRoleApplication: - UISceneConfigurationName: CarPlay UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).CarPlayScene + StemifyWidgets: + type: app-extension + platform: iOS + sources: [Widgets, Shared] + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: org.cdnutter.stemify.widgets + TARGETED_DEVICE_FAMILY: "1" + CODE_SIGN_ENTITLEMENTS: Widgets/StemifyWidgets.entitlements + entitlements: + path: Widgets/StemifyWidgets.entitlements + properties: + com.apple.security.application-groups: [group.org.cdnutter.stemify] + info: + path: Widgets/Info.plist + properties: + CFBundleDisplayName: Stemify + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension diff --git a/ios/scripts/build-ipa.sh b/ios/scripts/build-ipa.sh index b51f1e0..16a4d6c 100755 --- a/ios/scripts/build-ipa.sh +++ b/ios/scripts/build-ipa.sh @@ -1,44 +1,36 @@ #!/bin/bash -# Builds build/stemify-.ipa on a Mac: unsigned, for re-signing with Sideloadly/AltStore. +# Builds build/public/stemify-.ipa on a Mac: ad-hoc signed, for releases and re-signing with Sideloadly/AltStore. # With prov/Stemify.mobileprovision, prov/key.p12 and P12_PASS set, it also writes a signed -# prov/stemify--signed.ipa (keeps CarPlay/Siri) for the owner's own device. -# Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch. +# build/prov/stemify--signed.ipa (keeps CarPlay/Siri) for the owner's own device. +# Finished IPAs are kept per version; build/work is scratch and is wiped each run. +# ios/scripts/build-remote.sh runs this on White-M1 and copies build/public and build/prov back to DevOps. set -euo pipefail cd "$(dirname "$0")/.." export PATH=/opt/homebrew/bin:$PATH -OUT=build; APP=$OUT/Payload/Stemify.app -rm -rf $OUT && mkdir -p $APP -SDK=$(xcrun --sdk iphoneos --show-sdk-path) -# Version and build number come from project.yml. +B=build; OUT=$B/work; APP=$OUT/Payload/Stemify.app; EXT=$APP/PlugIns/StemifyWidgets.appex +rm -rf $OUT && mkdir -p $OUT/Payload $B/public $B/prov VERSION=$(sed -nE 's/^ *MARKETING_VERSION: "(.*)"/\1/p' project.yml) -BUILD=$(sed -nE 's/^ *CURRENT_PROJECT_VERSION: "(.*)"/\1/p' project.yml) -xcrun -sdk iphoneos swiftc -target arm64-apple-ios17.0 -sdk "$SDK" -O -wmo -swift-version 5 \ - -module-name Stemify -parse-as-library $(find Stemify -name '*.swift') -o $APP/Stemify - -# Info.plist: xcodegen's generated one with the build settings filled in. +# xcodebuild compiles the app, the widget extension, the asset catalog and the App Intents metadata. xcodegen generate -q -sed -e 's/$(DEVELOPMENT_LANGUAGE)/en/; s/$(EXECUTABLE_NAME)/Stemify/; s/$(PRODUCT_BUNDLE_IDENTIFIER)/org.cdnutter.stemify/' \ - -e "s/\$(PRODUCT_NAME)/Stemify/; s/\$(MARKETING_VERSION)/$VERSION/; s/\$(CURRENT_PROJECT_VERSION)/$BUILD/" \ - -e 's/$(PRODUCT_MODULE_NAME)/Stemify/' Stemify/Info.plist > $APP/Info.plist -P=$APP/Info.plist -plutil -insert MinimumOSVersion -string 17.0 $P -plutil -insert CFBundleSupportedPlatforms -json '["iPhoneOS"]' $P -plutil -insert UIDeviceFamily -json '[1]' $P -plutil -insert UIRequiredDeviceCapabilities -json '["arm64"]' $P -plutil -insert DTPlatformName -string iphoneos $P -plutil -insert CFBundleIcons -json '{"CFBundlePrimaryIcon":{"CFBundleIconFiles":["AppIcon60x60"]}}' $P -plutil -lint $P -printf 'APPL????' > $APP/PkgInfo +xcodebuild -project Stemify.xcodeproj -scheme Stemify -configuration Release -sdk iphoneos \ + -destination generic/platform=iOS -derivedDataPath $B/dd CODE_SIGNING_ALLOWED=NO -quiet build +cp -R $B/dd/Build/Products/Release-iphoneos/Stemify.app $APP +[ -d $EXT ] || { echo "widget extension missing" >&2; exit 1; } -# Loose icon files, since actool also needs -runFirstLaunch. -SRC=Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png -sips -z 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null -sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null +# Ad-hoc signature carrying only the App Group, so Sideloadly/AltStore keep the widgets' shared data when re-signing. +cat > $OUT/group.plist < + +com.apple.security.application-groupsgroup.org.cdnutter.stemify +PL +codesign -f -s - --entitlements $OUT/group.plist $EXT +codesign -f -s - --entitlements $OUT/group.plist $APP -# Unsigned build, for the releases and AltStore/Sideloadly. -(cd $OUT && zip -qry stemify-$VERSION.ipa Payload) -echo "$OUT/stemify-$VERSION.ipa" +# Public build, for the releases and AltStore/Sideloadly. +rm -f $B/public/stemify-$VERSION.ipa +(cd $OUT && zip -qry ../public/stemify-$VERSION.ipa Payload) +echo "$B/public/stemify-$VERSION.ipa" if [ -f prov/Stemify.mobileprovision ] && [ -f prov/key.p12 ] && [ -n "${P12_PASS:-}" ]; then KC=$PWD/$OUT/sign.keychain-db; KP=$(uuidgen) @@ -54,12 +46,22 @@ if [ -f prov/Stemify.mobileprovision ] && [ -f prov/key.p12 ] && [ -n "${P12_PAS ID=$(security find-identity -v -p codesigning $KC | sed -nE 's/.*"(Apple Development:.*)"/\1/p' | head -1) security cms -D -i prov/Stemify.mobileprovision > $OUT/prov.plist /usr/libexec/PlistBuddy -x -c 'Print :Entitlements' $OUT/prov.plist > $OUT/ent.plist + # The widgets need their own profile (org.cdnutter.stemify.widgets with the App Group). Without one they're left out. + if [ -f prov/StemifyWidgets.mobileprovision ]; then + security cms -D -i prov/StemifyWidgets.mobileprovision > $OUT/wprov.plist + /usr/libexec/PlistBuddy -x -c 'Print :Entitlements' $OUT/wprov.plist > $OUT/went.plist + cp prov/StemifyWidgets.mobileprovision $EXT/embedded.mobileprovision + codesign -f -s "$ID" --entitlements $OUT/went.plist --timestamp=none $EXT + else + rm -rf $APP/PlugIns + fi cp prov/Stemify.mobileprovision $APP/embedded.mobileprovision codesign -f -s "$ID" --entitlements $OUT/ent.plist --timestamp=none $APP security list-keychains -d user -s $(security list-keychains -d user | tr -d "\"" | grep -v sign.keychain) security delete-keychain $KC - # Personal build for this device only: stays in prov/ (gitignored), never released. + # Personal build for this device only: stays in build/prov (gitignored), never released. + rm -f $B/prov/stemify-$VERSION-signed.ipa (cd $OUT && zip -qry ../prov/stemify-$VERSION-signed.ipa Payload) - echo "prov/stemify-$VERSION-signed.ipa (signed as $ID)" + echo "$B/prov/stemify-$VERSION-signed.ipa (signed as $ID)" fi diff --git a/ios/scripts/build-remote.sh b/ios/scripts/build-remote.sh new file mode 100755 index 0000000..98c1d76 --- /dev/null +++ b/ios/scripts/build-remote.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Run from DevOps: syncs ios/ to White-M1, builds there, and pulls the IPAs back into ios/build/{public,prov}. +set -euo pipefail +cd "$(dirname "$0")/.." +H=${BUILD_HOST:-white}; R=stemify-build/ios +rsync -a --exclude build --exclude prov --exclude Stemify.xcodeproj ./ $H:$R/ +ssh $H "cd $R && P12_PASS=\$(cat prov/cert_pass.txt 2>/dev/null) bash scripts/build-ipa.sh" +mkdir -p build/public build/prov +rsync -a $H:$R/build/public/ build/public/ +rsync -a $H:$R/build/prov/ build/prov/ +ls build/public build/prov diff --git a/server/stemd.Dockerfile b/server/stemd.Dockerfile index 2d5b55b..917fda7 100644 --- a/server/stemd.Dockerfile +++ b/server/stemd.Dockerfile @@ -12,9 +12,9 @@ COPY server/stemd-requirements.txt /tmp/ RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev \ && pip install --no-cache-dir --no-deps -r /tmp/stemd-requirements.txt && pip check \ && apt-get purge -y gcc libc6-dev && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* -COPY stemify/__init__.py stemify/catalog.py stemify/library_core.py /app/stemify/ -COPY stemify/worker/__init__.py stemify/worker/__main__.py stemify/worker/audio_io.py \ - stemify/worker/backends.py /app/stemify/worker/ +COPY desktop/stemify/__init__.py desktop/stemify/catalog.py desktop/stemify/library_core.py /app/stemify/ +COPY desktop/stemify/worker/__init__.py desktop/stemify/worker/__main__.py desktop/stemify/worker/audio_io.py \ + desktop/stemify/worker/backends.py /app/stemify/worker/ COPY server/stemd.py /app/server/ ENV MUSIC_DIR=/music STEMS_DIR=/stems STATE_DIR=/state MODELS_DIR=/models \ TORCH_HOME=/models/torch HF_HOME=/models/hf NUMBA_CACHE_DIR=/tmp/numba HOME=/tmp \ diff --git a/server/stemd.py b/server/stemd.py index 2bb2b96..bdd94a7 100644 --- a/server/stemd.py +++ b/server/stemd.py @@ -35,7 +35,9 @@ import tempfile import threading import time -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(_ROOT, "desktop")) # in the image the package sits at /app/stemify +sys.path.insert(0, _ROOT) from stemify.catalog import MODELS_BY_ID # noqa: E402 from stemify.library_core import (MARKER, MAX_SECONDS, SOURCE_RANK, duration, is_done, is_share_error, # noqa: E402 diff --git a/web/app.js b/web/app.js index f5a2fac..069a457 100644 --- a/web/app.js +++ b/web/app.js @@ -1,11 +1,12 @@ // Stemify web player: views, the player bar, drawers (mixer, lyrics, queue), the context // menu and settings. Playback lives in player.js, Plex in plex.js, stemserve in stems.js. -import { $, $$, esc, fmtTime, fmtBytes, plural, on, emit, toast, ICON, store } from "./util.js"; +import { $, $$, esc, fmtTime, fmtBytes, plural, on, emit, toast, ICON, store, uuid } from "./util.js"; import { S, save, songMixes, saveSongMix, forgetSongMix, playlists, savePlaylists, newPlaylist, addToPlaylist, Offline } from "./store.js"; import { px, img, track, sources, liveServers, server, refreshServer, addServer, manualServer, signIn, sortState, pickSort, comparator, sortTracks, Pager, everything } from "./plex.js"; import { Stems, Live, stemNow, stemTrack, stemBase, api } from "./stems.js"; -import { E, Q, PRESETS, setting, stemGain, applyPreset, currentPreset, mixChanged, setKaraoke, setKaraokeMode, sameMix, setSpeed, saveQueue, saveOffline } from "./player.js"; +import { E, Q, setting, stemGain, presets, allPresets, presetLabel, applyPreset, currentPreset, nextPreset, savePreset, deletePreset, movePreset, restorePresets, deleteAllCustom, toggleLock, + mixChanged, setKaraoke, setKaraokeMode, sameMix, setSpeed, saveQueue, saveOffline, Radio } from "./player.js"; import { lyricsFor, lineAt } from "./lyrics.js"; const SPEEDS = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2]; @@ -36,7 +37,7 @@ function applyTheme() { Wave.colors(); } -// ---- stems-only filter ------------------------------------------------------------------------ +// ---- stem filter (Stems Only / No Stems) --------------------------------------------------------------------- // Tracks know whether they have stems. Album and artist cards are matched by folder name // (stems are stored as Artist/Album/Track), which is close enough for a filter. @@ -55,9 +56,10 @@ function stemSets() { } const cardHasStems = (kind, title) => stemSets()[kind]?.has(nameKey(title)) ?? true; -function applyStemsOnly() { - document.body.classList.toggle("stems-only", S.stemsOnly); - $$("[data-stemsonly]").forEach(b => b.classList.toggle("on", S.stemsOnly)); +function applyStemFilter() { + document.body.classList.toggle("stems-only", S.stemFilter === "stems"); + document.body.classList.toggle("no-stems", S.stemFilter === "nostems"); + $$("[data-stemfilter]").forEach(b => b.classList.toggle("on", S.stemFilter === b.dataset.stemfilter)); kick?.(); } @@ -134,6 +136,17 @@ const artOf = (t, size) => t?.srv ? img(t.srv, t.thumb, size) : ""; const UI = { all() { this.track(); this.transport(); this.progress(); this.drawer(); marks(); }, track() { + const st = Radio.station; + $("#player").classList.toggle("radio", !!st); + if (st) { + $("#player").classList.remove("empty"); + $("#np-title").textContent = st.name; + $("#np-sub").textContent = "Web Radio"; + $("#np-art").removeAttribute("src"); + document.title = `${st.name} · Stemify`; + if ("mediaSession" in navigator) navigator.mediaSession.metadata = new MediaMetadata({ title: st.name, artist: "Web Radio" }); + return; + } const t = Q.cur(); $("#player").classList.toggle("empty", !t); $("#np-title").textContent = t?.title || "Nothing playing"; @@ -148,9 +161,9 @@ const UI = { } }, transport() { - const p = $("#b-play"); - p.innerHTML = E.buffering ? ICON.busy : E.playing ? ICON.pause : ICON.play; - p.classList.toggle("busy", E.buffering); + const p = $("#b-play"), radio = !!Radio.station, playing = radio ? Radio.playing : E.playing, busy = !radio && E.buffering; + p.innerHTML = busy ? ICON.busy : playing ? ICON.pause : ICON.play; + p.classList.toggle("busy", busy); p.title = E.loading ? `Loading stems ${Math.round(E.loadFrac * 100)}%` : "Play (Space)"; $("#b-shuffle").classList.toggle("on", S.shuffle); $("#b-repeat").innerHTML = S.repeat === "one" ? ICON.repeat1 : ICON.repeat; @@ -160,14 +173,14 @@ const UI = { $("#speed").classList.toggle("on", S.speed !== 1); for (const d of ["mixer", "lyrics", "queue"]) $(`#b-${d}`).classList.toggle("on", drawer === d); $("#b-mixer").classList.toggle("dot", S.karaoke); - if ("mediaSession" in navigator) navigator.mediaSession.playbackState = E.playing ? "playing" : "paused"; + if ("mediaSession" in navigator) navigator.mediaSession.playbackState = playing ? "playing" : "paused"; }, progress() { $("#t-pos").textContent = E.loading ? `${Math.round(E.loadFrac * 100)}%` : fmtTime(E.position()); $("#t-dur").textContent = fmtTime(E.duration()); Wave.draw(); const d = E.duration(); - if (d && navigator.mediaSession?.setPositionState) { + if (d && !Radio.station && navigator.mediaSession?.setPositionState) { try { navigator.mediaSession.setPositionState({ duration: d, position: Math.min(d, E.position()), playbackRate: S.speed }); } catch {} } }, @@ -196,9 +209,11 @@ function mixerHtml() { const names = mixNames(); if (!names.length) return `

${E.loading ? "Loading stems…" : Q.cur() ? "This track has no stems yet. Right-click it and pick Stem Now." : "Play something with stems to mix it."}

`; const cur = currentPreset(names), key = E.entry.key, saved = songMixes[key]; + const lock = S.lockedPreset && allPresets().find(p => p.id === S.lockedPreset); const savedNote = saved ? (sameMix(saved, S.mix, names) ? "Using this song's saved mix." : "Changed from this song's saved mix.") : ""; return karaokeHtml() + ` -
${PRESETS.map(([label, b], i) => ``).join("")}
+
${presets().map(p => ``).join("")}
${names.map(n => { const s = setting(n), g = stemGain(n, names); return `
${esc(n)} @@ -211,7 +226,8 @@ function mixerHtml() { ${saved ? `` : ""}
-

${savedNote} ${S.karaoke ? "Karaoke is on, so saved mixes are ignored until you turn it off." : S.keepMix ? "Songs without a saved mix keep this mix." : "Songs without a saved mix start at full mix."}

`; +

${savedNote} ${S.karaoke ? "Karaoke is on, so saved mixes are ignored until you turn it off." : lock ? `${esc(presetLabel(lock.name))} is locked, so every song starts with it. Long press it to unlock.` + : (S.keepMix ? "Songs without a saved mix keep this mix." : "Songs without a saved mix start at full mix.") + " Long press a preset to lock it."}

`; } function queueHtml() { @@ -256,7 +272,8 @@ d$.addEventListener("click", ev => { if (!b) return; const names = mixNames(), ds = b.dataset; if (ds.drawer != null) toggleDrawer(ds.drawer || null); - else if (ds.preset) applyPreset(PRESETS[+ds.preset][1], names); + else if (ds.preset) applyPreset(currentPreset(names)?.id === ds.preset ? allPresets().find(p => p.id === "b:Full Mix") : presets().find(p => p.id === ds.preset), names); + else if ("newpreset" in ds) newPreset(); else if (ds.mute) { S.mix[ds.mute] = { ...setting(ds.mute), muted: !setting(ds.mute).muted }; mixChanged(); } else if (ds.solo) { S.mix[ds.solo] = { ...setting(ds.solo), solo: !setting(ds.solo).solo }; mixChanged(); } else if ("kara" in ds) setKaraoke(!S.karaoke); @@ -278,6 +295,30 @@ d$.addEventListener("change", ev => { if (ev.target.dataset.level) UI.drawer(); if (ev.target.dataset.softdb) setKaraokeMode("soft", +ev.target.value); }); +// Long press (or right-click) a preset chip to lock it. +{ + let timer = 0, fired = false; + const lock = id => { fired = true; clearTimeout(timer); toggleLock(id, mixNames()); }; + d$.addEventListener("pointerdown", ev => { + fired = false; + const b = ev.target.closest("[data-preset]"); + if (b && !ev.button) timer = setTimeout(() => lock(b.dataset.preset), 550); + }); + for (const e of ["pointerup", "pointerleave", "pointercancel"]) d$.addEventListener(e, () => clearTimeout(timer)); + d$.addEventListener("contextmenu", ev => { + const b = ev.target.closest("[data-preset]"); + if (!b) return; + ev.preventDefault(); + if (!fired) lock(b.dataset.preset); + }); + d$.addEventListener("click", ev => { if (fired && ev.target.closest("[data-preset]")) { ev.stopImmediatePropagation(); fired = false; } }, true); +} +function newPreset() { + const n = prompt("Name this preset"); + if (n == null) return; + const err = savePreset(n, S.mix); + toast(err || `Saved “${n.trim()}”`); +} { let from = -1; d$.addEventListener("dragstart", ev => { from = +ev.target.closest("[data-q]")?.dataset.q; }); @@ -295,10 +336,18 @@ $("#b-queue").innerHTML = ICON.queue; $("#speed").innerHTML = SPEEDS.map(v => ``).join(""); $("#volume").value = S.volume; -const togglePlay = () => { if (!Q.cur()) return; E.playing ? E.pause() : E.play(); saveQueue(); }; +const togglePlay = () => { if (Radio.station) return Radio.toggle(); if (!Q.cur()) return; E.playing ? E.pause() : E.play(); saveQueue(); }; $("#b-play").onclick = togglePlay; $("#b-prev").onclick = () => Q.prev(); -$("#b-next").onclick = () => Q.next(); +{ + // Hold Next to skip the rest of the album. + const nb = $("#b-next"); + let timer = 0, held = false; + nb.addEventListener("pointerdown", ev => { held = false; if (!ev.button) timer = setTimeout(() => { held = true; Q.skipAlbum(); }, 600); }); + for (const e of ["pointerup", "pointerleave", "pointercancel"]) nb.addEventListener(e, () => clearTimeout(timer)); + nb.addEventListener("contextmenu", ev => ev.preventDefault()); + nb.onclick = () => { if (held) held = false; else Q.next(); }; +} $("#b-shuffle").onclick = () => Q.toggleShuffle(); $("#b-repeat").onclick = () => Q.cycleRepeat(); $("#b-mixer").onclick = () => toggleDrawer("mixer"); @@ -306,12 +355,16 @@ $("#b-lyrics").onclick = () => toggleDrawer("lyrics"); $("#b-queue").onclick = () => toggleDrawer("queue"); $("#speed").onchange = ev => setSpeed(+ev.target.value); $("#volume").oninput = ev => { S.volume = +ev.target.value; E.applyGains(); save(); }; +function applyBar() { + $("#speed").style.display = S.bar.speed ? "" : "none"; + for (const k of ["mixer", "lyrics", "queue"]) $(`#b-${k}`).style.display = S.bar[k] ? "" : "none"; +} $(".np").onclick = () => { const t = Q.cur(); if (t?.albumKey) location.hash = `#/album/${t.srv}:${t.albumKey}`; }; if ("mediaSession" in navigator) { const ms = navigator.mediaSession; - ms.setActionHandler("play", () => E.play()); - ms.setActionHandler("pause", () => E.pause()); + ms.setActionHandler("play", () => Radio.station ? Radio.playing || Radio.toggle() : E.play()); + ms.setActionHandler("pause", () => Radio.station ? Radio.playing && Radio.toggle() : E.pause()); ms.setActionHandler("previoustrack", () => Q.prev()); ms.setActionHandler("nexttrack", () => Q.next()); try { ms.setActionHandler("seekto", d => E.seek(d.seekTime)); } catch {} @@ -330,6 +383,7 @@ document.addEventListener("keydown", ev => { else if (k === "l") toggleDrawer("lyrics"); else if (k === "q") toggleDrawer("queue"); else if (k === "k") setKaraoke(!S.karaoke); + else if (k === "p") nextPreset(mixNames()); else if (k === "]" || k === "[") { const i = SPEEDS.indexOf(S.speed) + (k === "]" ? 1 : -1); if (SPEEDS[i]) setSpeed(SPEEDS[i]); } }); @@ -345,6 +399,7 @@ on("progress", () => UI.progress()); on("loading", () => { UI.transport(); UI.progress(); }); on("attached", () => { UI.progress(); if (drawer === "mixer") UI.drawer(); }); on("queue", () => { if (drawer === "queue") UI.drawer(); }); +on("radio", () => { UI.track(); UI.transport(); const el = $("#stations"); if (el) el.innerHTML = stationsHtml(); }); on("mix", () => { Wave.draw(); UI.transport(); if (drawer === "mixer") UI.drawer(); @@ -411,7 +466,7 @@ addEventListener("pointerdown", ev => { if (Menu.el && !Menu.el.contains(ev.targ addEventListener("blur", () => Menu.close()); view.addEventListener("scroll", () => Menu.close(), { passive: true }); -const download = tracks => saveOffline(tracks, m => m && toast(m, 120000)); +const download = (tracks, group = null) => saveOffline(tracks, m => m && toast(m, 120000), group); function stemItem(tracks) { const need = tracks.filter(t => !Stems.find(t)); if (!need.length) return { label: tracks.length === 1 ? "Has stems" : "All have stems" }; @@ -452,7 +507,7 @@ function trackMenu(x, y, listId, i) { ]); } async function leaves(srv, key) { return ((await px(srv, `/library/metadata/${key}/allLeaves`)).Metadata || []).map(m => track(srv, m)); } -function listMenu(x, y, get) { +function listMenu(x, y, get, group = null) { const run = fn => async () => { try { fn(visible(await get())); } catch (e) { toast(e.message); } }; Menu.open(x, y, [ { label: "Play", run: run(ts => Q.playList(ts, 0, false)) }, @@ -462,14 +517,14 @@ function listMenu(x, y, get) { "-", { label: "Stem Now", run: run(ts => stemNow(ts)) }, { label: "Add to stem playlist…", run: run(ts => pickPlaylist(x, y, ts)) }, - { label: "Download for offline", run: run(ts => download(ts)) }, + { label: "Download for offline", run: run(ts => download(ts, group)) }, ]); } // ---- views -------------------------------------------------------------------------------------- let lists = {}, listItems = {}, seq = 0, kick = null, genreCache = null; -const visible = tracks => S.stemsOnly ? tracks.filter(t => (t.t || t) && Stems.find(t.t || t)) : tracks; +const visible = tracks => S.stemFilter ? tracks.filter(t => !!Stems.find(t.t || t) === (S.stemFilter === "stems")) : tracks; function playFrom(listId, i) { const all = listItems[listId] || lists[listId], pick = all[i]; const vis = visible(all); @@ -497,10 +552,11 @@ function trackTable(listId, tracks, opts = {}) { lists[listId] = tracks; if (!tracks.length) return `

Nothing here.

`; return `${tracks.map((t, i) => rowHtml(listId, t, i, { ...opts, mix: listItems[listId]?.[i]?.mix })).join("")}
-

None of these have stems yet.

`; +

None of these have stems yet.

All of these have stems.

`; } -const stemsChip = () => ``; +const stemsChip = () => `
${[["stems", "Stems Only"], ["nostems", "No Stems"]].map(([v, l]) => + ``).join("")}
`; function sortControl(screen) { const st = sortState(screen); return `
@@ -563,7 +619,89 @@ async function genres() { } const hue = s => [...s].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 7); +// Downloads by the album or playlist they came from, newest first. Single songs go under Songs. +function dlGroups(recs) { + const by = new Map(); + for (const r of recs) { + const k = r.group?.href || ""; + if (!by.has(k)) by.set(k, { g: r.group, recs: [] }); + by.get(k).recs.push(r); + } + const gs = [...by.values()].sort((a, b) => !a.g - !b.g); + return gs.map(({ g, recs }, n) => { + if (g) recs.sort((a, b) => a.saved - b.saved); + return `

${g ? `${esc(g.title)}` : "Songs"}

+ ${plural(recs.length, "track")}, ${fmtBytes(recs.reduce((a, r) => a + r.size, 0))} +
${trackTable("dl:" + n, recs.map(r => r.track))}
`; + }).join(""); +} + +// Albums whose original release date falls today (or within three days) in an earlier year. +function birthdays(albums) { + const now = new Date(), y = now.getFullYear(), today = new Date(y, now.getMonth(), now.getDate()); + const leap = new Date(y, 1, 29).getMonth() === 1, onDay = [], week = []; + for (const m of albums) { + const d = m.originallyAvailableAt || ""; + if (d.length < 10) continue; + const ry = +d.slice(0, 4), mo = +d.slice(5, 7) - 1; + let da = +d.slice(8, 10); + if (!ry || ry >= y) continue; + if (mo === 1 && da === 29 && !leap) da = 28; + const when = new Date(y, mo, da), diff = Math.round((when - today) / 864e5), years = y - ry; + if (!diff) onDay.push({ m, years }); + else if (Math.abs(diff) <= 3) week.push({ m, years, when, diff }); + } + onDay.sort((a, b) => b.years - a.years); + week.sort((a, b) => a.diff - b.diff || b.years - a.years); + return { onDay, week }; +} +const bdayCard = ({ m, years, when, diff }) => card({ + href: `#/album/${m._srv}:${m.ratingKey}`, img: img(m._srv, m.thumb), t: m.title, kind: "album", srv: m._srv, key: m.ratingKey, + s: when ? `${diff < 0 ? "Turned" : "Turns"} ${years} · ${when.toLocaleDateString(undefined, { month: "short", day: "numeric" })}` : `${m.parentTitle} · ${years === 1 ? "1 year ago" : `${years} years ago`}`, +}); +const shelf = cards => `
${cards.join("")}
`; + +const stationsHtml = () => S.stations.length ? `
${S.stations.map(st => { + const on = Radio.station?.id === st.id, playing = on && Radio.playing; + return `
+
${esc(st.name)}${esc(st.url)}
+
`; +}).join("")}
` : `

No stations yet. Add one with its stream URL.

`; + const routes = { + async home(my) { + const srcs = sources(); + if (!srcs.length) return needPlex(); + const today = new Date().toLocaleDateString(undefined, { month: "long", day: "numeric" }); + view.innerHTML = `

Home

+

Recent Plays

Loading…

+

Recently Added

Loading…

+

On This Day

${esc(today)}

Loading…

+

Stations

+ +
`; + const fill = (id, html) => { const el = $(id); if (el && my === seq) { el.querySelector(".note").outerHTML = html; marks(); } }; + const fail = id => e => fill(id, `

${esc(e.message)}

`); + await Promise.all([ + new Pager(srcs, secPath, { type: 9, sort: "lastViewedAt:desc", "viewCount>>": 0 }, 24).next(24) + .then(ms => fill("#h-recent", ms.length ? shelf(ms.map(albumCard)) : `

Nothing played yet.

`), fail("#h-recent")), + new Pager(srcs, secPath, { type: 10, sort: "addedAt:desc" }, 10).next(10) + .then(ms => my === seq && fill("#h-added", trackTable("added", ms.map(m => track(m._srv, m)))), fail("#h-added")), + everything(srcs, secPath, { type: 9 }).then(all => { + const { onDay, week } = birthdays(all); + fill("#h-otd", (onDay.length ? shelf(onDay.map(bdayCard)) : `

${week.length ? "No album birthdays today. Coming up and just past:" : "No albums in your library came out this week in any year."}

`) + + (onDay.length && week.length ? `

Coming up and just past

` : "") + (week.length ? shelf(week.map(bdayCard)) : "")); + }, fail("#h-otd")), + ]); + }, + radio() { + view.innerHTML = bar("Web Radio", { filter: false }) + `
${stationsHtml()}
+

Add a station

+ + +
+

Any stream the browser can play, like MP3, AAC or Ogg. Playing a station pauses the queue, and playing the queue stops the station.

`; + }, async albums(my) { const srcs = sources(); if (!srcs.length) return needPlex(); @@ -580,9 +718,9 @@ const routes = { const srcs = sources(); if (!srcs.length) return needPlex(); view.innerHTML = bar("Songs", { sort: "songs", extra: `` }) - + `

No songs with stems loaded yet. Scroll for more.

`; + + `

No songs with stems loaded yet. Scroll for more.

Every song loaded so far has stems. Scroll for more.

`; lists.songs = []; - const size = S.stemsOnly ? 1000 : 150; + const size = S.stemFilter ? 1000 : 150; await paged(my, $("tbody", view), new Pager(srcs, secPath, { type: 10, sort: sortState("songs").param }, size), size, (ms, off) => { const ts = ms.map(m => track(m._srv, m)); lists.songs.push(...ts); @@ -684,7 +822,7 @@ const routes = { view.innerHTML = bar("Downloads", { filter: false, extra: recs.length ? `` : "" }) + `

${plural(recs.length, "track")}, ${fmtBytes(total)} stored in this browser${est?.quota ? ` (${fmtBytes(est.usage)} of ${fmtBytes(est.quota)} used)` : ""}. Downloads play without the network. ${isSecureContext ? "" : "This page isn't on HTTPS, so the browser may clear them when space runs low."}

` - + (recs.length ? trackTable("downloads", recs.map(r => r.track)) : `

Nothing downloaded. Right-click a song or album and pick Download for offline.

`); + + (recs.length ? dlGroups(recs) : `

Nothing downloaded. Right-click a song or album and pick Download for offline.

`); count(recs.length, "track"); }, async stems(my) { @@ -706,14 +844,14 @@ const routes = { async function route() { const my = ++seq; - const [, name = "albums", ...rest] = (location.hash || "#/albums").split("/"); + const [, name = "home", ...rest] = (location.hash || "#/home").split("/"); const arg = decodeURIComponent(rest.join("/")); $$("#nav a").forEach(a => a.classList.toggle("on", a.dataset.nav === name || (a.dataset.nav === "genres" && name === "genre") || (a.dataset.nav === "playlists" && /playlist$/.test(name)))); lists = {}; listItems = {}; kick = null; delete view.dataset.genre; view.scrollTop = 0; Menu.close(); - try { await (routes[name] || routes.albums)(my, arg); } + try { await (routes[name] || routes.home)(my, arg); } catch (e) { if (my === seq) view.innerHTML = `

Something went wrong

${esc(e.message)}

`; } if (my === seq) marks(); } @@ -728,7 +866,7 @@ view.addEventListener("click", async ev => { const el = ev.target.closest("button, tr[data-list]"); if (!el) return; const ds = el.dataset, row = ev.target.closest("tr[data-list]"); - if ("stemsonly" in ds) { S.stemsOnly = !S.stemsOnly; save(); applyStemsOnly(); return; } + if (ds.stemfilter) { S.stemFilter = S.stemFilter === ds.stemfilter ? "" : ds.stemfilter; save(); applyStemFilter(); return; } if ("sortdir" in ds) { pickSort(ds.sortdir, sortState(ds.sortdir).i); return route(); } if ("reshuffle" in ds) return route(); if ("add" in ds && row) return Q.add([listItems[row.dataset.list]?.[+row.dataset.i] || lists[row.dataset.list][+row.dataset.i]], ev.shiftKey); @@ -737,10 +875,41 @@ view.addEventListener("click", async ev => { if (ds.play) { const all = listItems[ds.play] || lists[ds.play] || [], tracks = visible(all); if (ds.mode === "stem") return stemNow(all.map(x => x.t || x)); - if (!tracks.length) return toast(all.length ? "None of these have stems yet" : "Still loading…"); + if (!tracks.length) return toast(!all.length ? "Still loading…" : S.stemFilter === "nostems" ? "All of these have stems" : "None of these have stems yet"); return Q.playList(tracks, ds.mode === "shuffle" ? -1 : 0, ds.mode === "shuffle"); } - if (ds.listmenu) { const r = el.getBoundingClientRect(); return listMenu(r.left, r.bottom, async () => listItems[ds.listmenu] || lists[ds.listmenu] || []); } + if (ds.listmenu) { const r = el.getBoundingClientRect(); return listMenu(r.left, r.bottom, async () => listItems[ds.listmenu] || lists[ds.listmenu] || [], { title: $(".hero h1", view)?.textContent || "", href: location.hash }); } + if (ds.station) { + try { + let ts; + if (ds.station === "library") ts = (await new Pager(sources(), secPath, { type: 10, sort: "random" }, 200).next(200)).map(m => track(m._srv, m)); + else ts = (await Promise.all((await new Pager(sources(), secPath, { type: 9, sort: "random" }, 10).next(10)).map(m => leaves(m._srv, m.ratingKey)))).flat(); + return ts.length ? Q.playList(ts, 0, false) : toast("Nothing to play"); + } catch { return toast("Couldn't start station"); } + } + if ("rdadd" in ds) { + const name = $("#rname").value.trim(), url = $("#rurl").value.trim(); + if (!name || !/^https?:\/\//i.test(url)) return toast("Need a name and an http(s) stream URL"); + S.stations.push({ id: uuid(), name, url }); save(); + return route(); + } + if (ds.radio) { const st = S.stations.find(x => x.id === ds.radio); return Radio.station?.id === st.id && Radio.playing ? Radio.stop() : Radio.play(st); } + if (ds.rdrename) { + const st = S.stations.find(x => x.id === ds.rdrename), n = prompt("Rename", st.name); + if (n?.trim()) { st.name = n.trim(); save(); if (Radio.station?.id === st.id) Radio.station.name = st.name; UI.track(); route(); } + return; + } + if (ds.rddelete) { + if (!confirm("Delete this station?")) return; + if (Radio.station?.id === ds.rddelete) Radio.stop(); + S.stations = S.stations.filter(x => x.id !== ds.rddelete); save(); + return route(); + } + if (ds.dlgroup) { + const ts = lists["dl:" + ds.dlgroup] || []; + if (confirm(`Remove ${plural(ts.length, "download")}?`)) { for (const t of ts) await Offline.remove(t.id); emit("offline"); route(); } + return; + } if ("shuffleall" in ds) { const ts = (await new Pager(sources(), secPath, { type: 10, sort: "random" }, 200).next(200)).map(m => track(m._srv, m)); return Q.playList(visible(ts), -1, true); @@ -763,12 +932,31 @@ view.addEventListener("change", ev => { view.addEventListener("contextmenu", ev => { const row = ev.target.closest("tr[data-list]"), c = ev.target.closest(".card[data-kind]"); if (row) { ev.preventDefault(); trackMenu(ev.clientX, ev.clientY, row.dataset.list, +row.dataset.i); } - else if (c) { ev.preventDefault(); listMenu(ev.clientX, ev.clientY, () => leaves(c.dataset.srv, c.dataset.key)); } + else if (c) { ev.preventDefault(); listMenu(ev.clientX, ev.clientY, () => leaves(c.dataset.srv, c.dataset.key), { title: $(".t", c).textContent, href: c.getAttribute("href") }); } }); // ---- settings ----------------------------------------------------------------------------------- -const SECTIONS = [["plex", "Plex"], ["stems", "Stem server"], ["playback", "Playback"], ["karaoke", "Karaoke"], ["look", "Appearance"], ["offline", "Offline"], ["backup", "Backup"], ["keys", "Keys"]]; +const SECTIONS = [["plex", "Plex"], ["stems", "Stem server"], ["stemopts", "Stems"], ["presets", "Stem Editor"], ["karaoke", "Karaoke"], ["look", "Appearance"], + ["offline", "Offline"], ["backup", "Backup"], ["keys", "Keys"], ["about", "About"]]; +const BAR = [["speed", "Speed"], ["mixer", "Mixer"], ["lyrics", "Lyrics"], ["queue", "Queue"]]; + +function presetEditor() { + const ps = presets(), lock = S.lockedPreset && allPresets().find(p => p.id === S.lockedPreset); + return `

Locked

+ ${lock ? `
${ICON.lock}${esc(presetLabel(lock.name))}
` : `

Nothing locked.

`} +

Every song starts with this mix, whether or not Keep Stem Mix Between Songs is on. Long press a preset in the mixer to lock or unlock it.

+

Mixer Bar

${ps.map((p, i) => `
${esc(presetLabel(p.name))}${p.custom ? ' CUSTOM' : ""} + + + +
`).join("")}
+

Stems

+ + +
`; +} +on("presets", () => { const el = $("#pedit"); if (el) el.innerHTML = presetEditor(); }); const FORMATS = [["opus96", "Opus 96 kbps"], ["opus160", "Opus 160 kbps"], ["opus256", "Opus 256 kbps"], ["flac", "FLAC (original)"]]; function serverHtml(srv) { @@ -800,12 +988,16 @@ function renderSettings() {
-

Playback

+

Stems

Stems are downloaded whole before a song starts. Opus 160 is about 1 MB per stem-minute; FLAC is around eight times that.

- + +

How many upcoming songs load their stems in the background. Each one is held in memory, so keep it low on phones. FLAC loads one at most.

+ +

Songs without a saved mix keep the current mix. When it's off they start at full mix, and karaoke turns off.

${plural(Object.keys(songMixes).length, "saved song mix")}${Object.keys(songMixes).length ? `` : ""}
+

Stem Editor

${presetEditor()}

Karaoke

@@ -815,6 +1007,8 @@ function renderSettings() {

Appearance

${Object.entries(THEMES).map(([k, t]) => ``).join("")}
${S.accent ? `` : ""}
+ +
Player bar${BAR.map(([k, l]) => ``).join("")}

Applied live, after the theme. Variables: --bg --panel --raised --line --text --muted --accent --accent-ink --radius --wave --wave-ghost.

@@ -826,12 +1020,27 @@ function renderSettings() {

Settings, saved mixes and stem playlists. The file includes your Plex tokens.

-

Keys

Space play/pause · ←/→ seek 5s (shift 30s) · S shuffle · R repeat · [ ] speed · M mixer · L lyrics · Q queue · K karaoke · right-click a song or album for more

+

Keys

Space play/pause · ←/→ seek 5s (shift 30s) · S shuffle · R repeat · [ ] speed · M mixer · L lyrics · Q queue · K karaoke · P next preset · hold Next to skip the album · right-click a song or album for more

+

About

+

Stemify

+

Your Plex and Jellyfin music, split into stems you can mix.

+
+
Chris NutterStemify WebUI and the iOS app
+
Kevin Viersstemd and the Android app
+ GitHub +
+
+

Stemify is free. A coffee goes straight to the person you pick.

+
`; // Slow bits fill in after the page is up. fetch(api("/api/info"), { signal: AbortSignal.timeout(5000) }).then(r => r.ok ? r.json() : null).catch(() => null).then(info => { const el = $("#stem-info"); + const v = $("#about-ver"); + if (v && info) v.textContent = `stemserve v${info.version}`; if (el) el.textContent = info ? `stemserve v${info.version} at ${stemBase()}, ${info.tracks} tracks with stems${info.ffmpeg ? ", Opus available" : ""}.` : `Can't reach stemserve at ${stemBase()}.`; }); navigator.storage?.estimate?.().then(e => { const el = $("#storage-info"); if (el && e?.quota) el.insertAdjacentText("afterbegin", `${fmtBytes(e.usage)} of ${fmtBytes(e.quota)} used. `); }).catch(() => {}); @@ -855,7 +1064,11 @@ function renderSettings() { Stems.etag = null; save(); await Stems.refresh(); Live.poll(); again(); }; $("#fmt").onchange = ev => { S.fmt = ev.target.value; save(); }; - $("#keepmix").onchange = ev => { S.keepMix = ev.target.checked; save(); }; + $("#keepmix").onchange = ev => { S.keepMix = ev.target.checked; if (!S.keepMix && S.karaoke) setKaraoke(false); save(); }; + const ahead = v => { S.precache = Math.max(0, Math.min(50, Math.round(+v || 0))); $("#precache").value = $("#precachen").value = S.precache; save(); }; + $("#precache").oninput = ev => ahead(ev.target.value); + $("#precachen").onchange = ev => ahead(ev.target.value); + $("#acapella").onchange = ev => { S.acapella = ev.target.checked; save(); emit("presets"); emit("mix"); }; if ($("#forgetall")) $("#forgetall").onclick = () => { for (const k of Object.keys(songMixes)) forgetSongMix(k); again(); }; $("#kon").onchange = ev => setKaraoke(ev.target.checked); $("#kmode").onchange = ev => setKaraokeMode(ev.target.value); @@ -883,15 +1096,23 @@ function renderSettings() { }; const sv = $(".settings"); sv.addEventListener("click", async ev => { - const b = ev.target.closest("[data-jump], [data-theme], [data-refsrv], [data-rmsrv]"); + const b = ev.target.closest("[data-jump], [data-theme], [data-refsrv], [data-rmsrv], [data-plock], [data-pmove], [data-pdel], [data-padd], [data-prestore], [data-pclear]"); if (!b) return; const ds = b.dataset; if (ds.jump) { ev.preventDefault(); $("#set-" + ds.jump).scrollIntoView({ behavior: "smooth" }); } else if (ds.theme) { S.theme = ds.theme; S.accent = ""; applyTheme(); save(); again(); } else if (ds.refsrv) { try { await refreshServer(server(ds.refsrv)); toast("Refreshed"); } catch (e) { toast(e.message); } plexChanged(); again(); } else if (ds.rmsrv && confirm("Remove this server?")) { S.servers = S.servers.filter(s => s.id !== ds.rmsrv); plexChanged(); again(); } + else if (ds.plock) toggleLock(ds.plock, mixNames()); + else if (ds.pmove) movePreset(ds.pmove, +ds.pdir); + else if (ds.pdel) { if (ds.pdel.startsWith("b:") || confirm("Delete this preset?")) deletePreset(ds.pdel); } + else if ("padd" in ds) newPreset(); + else if ("prestore" in ds) restorePresets(); + else if ("pclear" in ds && confirm("Delete every custom preset?")) deleteAllCustom(); }); sv.addEventListener("change", ev => { + const bk = ev.target.dataset.bar; + if (bk) { S.bar = { ...S.bar, [bk]: ev.target.checked }; save(); applyBar(); return; } const k = ev.target.dataset.sec; if (!k) return; const [id, key] = k.split("|"), sec = server(id)?.sections.find(s => s.key === key); @@ -925,7 +1146,8 @@ async function connect() { (async () => { applyTheme(); - applyStemsOnly(); + applyStemFilter(); + applyBar(); await Offline.init(); await Promise.race([connect(), new Promise(r => setTimeout(r, 5000))]); Q.restore(); diff --git a/web/index.html b/web/index.html index e451749..42e0ebd 100644 --- a/web/index.html +++ b/web/index.html @@ -14,12 +14,14 @@ @@ -36,7 +38,7 @@ - +
diff --git a/web/player.js b/web/player.js index f93e05b..f4f7ac8 100644 --- a/web/player.js +++ b/web/player.js @@ -28,14 +28,77 @@ export function stemGain(name, names, mix = S.mix) { } const normMix = (m, names) => JSON.stringify(names.map(n => { const s = { ...unity(), ...m?.[n] }; return [+s.level.toFixed(3), s.muted, s.solo]; })); export const sameMix = (a, b, names) => normMix(a, names) === normMix(b, names); -export function applyPreset(build, names) { - const m = build(names); + +// Presets in the mixer: the built-ins ("b:Name") and saved custom mixes ("c:Name"), in the +// user's order. Deleting a built-in hides it; Full Mix can't be deleted. +const customPreset = c => ({ id: `c:${c.name}`, name: c.name, custom: true, build: n => Object.keys(c.mix).some(k => n.includes(k)) && c.mix }); +export function allPresets() { + const all = [...PRESETS.map(([name, build]) => ({ id: `b:${name}`, name, build })), ...S.customPresets.map(customPreset)]; + const rank = (p, i) => { const r = S.presetOrder.indexOf(p.id); return r < 0 ? 1e6 + i : r; }; + return all.map((p, i) => [rank(p, i), p]).sort((a, b) => a[0] - b[0]).map(x => x[1]); +} +export const presets = () => allPresets().filter(p => !S.hiddenPresets.includes(p.id)); +export const presetLabel = name => S.acapella && name === "A Cappella" ? "Acapella" : name; +const presetsChanged = () => { emit("presets"); emit("mix"); save(); }; +export function applyPreset(p, names) { + const m = p?.build(names); if (!m) return; S.mix = {}; for (const [k, v] of Object.entries(m)) S.mix[k] = { ...unity(), ...v }; mixChanged(); } -export const currentPreset = names => PRESETS.find(([, b]) => { const m = b(names); return m && sameMix(m, S.mix, names); })?.[0]; +export const currentPreset = names => presets().find(p => { const m = p.build(names); return m && sameMix(m, S.mix, names); }); +// Steps to the next preset that fits this song (the P key). +export function nextPreset(names) { + const ok = presets().filter(p => p.build(names)); + const cur = currentPreset(names); + applyPreset(ok[(ok.indexOf(cur) + 1) % ok.length], names); +} +// Saves `mix` as a custom preset, keeping only stems that differ from the default. Returns an +// error message, or "" when saved. +export function savePreset(name, mix) { + name = name.trim(); + if (!name) return "Give the preset a name"; + if (PRESETS.some(([n]) => n.toLowerCase() === name.toLowerCase())) return `“${name}” is a built-in preset`; + const kept = {}; + for (const [k, v] of Object.entries(mix || {})) { + const st = { ...unity(), ...v }; + if (Math.abs(st.level - 1) > 0.001 || st.muted || st.solo) kept[k] = st; + } + if (!Object.keys(kept).length) return "This is the full mix. Change a stem first."; + const i = S.customPresets.findIndex(c => c.name === name); + if (i >= 0) S.customPresets[i].mix = kept; else S.customPresets.push({ name, mix: kept }); + presetsChanged(); + return ""; +} +export function deletePreset(id) { + if (id === "b:Full Mix") return; + if (id.startsWith("b:")) { if (!S.hiddenPresets.includes(id)) S.hiddenPresets.push(id); } + else S.customPresets = S.customPresets.filter(c => `c:${c.name}` !== id); + if (S.lockedPreset === id) S.lockedPreset = ""; + S.presetOrder = S.presetOrder.filter(x => x !== id); + presetsChanged(); +} +export function movePreset(id, d) { + const order = presets().map(p => p.id), i = order.indexOf(id), j = i + d; + if (i < 0 || j < 0 || j >= order.length) return; + [order[i], order[j]] = [order[j], order[i]]; + S.presetOrder = order; + presetsChanged(); +} +export function restorePresets() { S.hiddenPresets = []; presetsChanged(); } +export function deleteAllCustom() { + S.customPresets = []; + if (S.lockedPreset.startsWith("c:")) S.lockedPreset = ""; + S.presetOrder = S.presetOrder.filter(x => !x.startsWith("c:")); + presetsChanged(); +} +// Locking a preset applies it now and to every song after. +export function toggleLock(id, names) { + S.lockedPreset = S.lockedPreset === id ? "" : id; + if (S.lockedPreset) applyPreset(allPresets().find(p => p.id === id), names); + presetsChanged(); +} export function mixChanged() { E.applyGains(); emit("mix"); save(); } // Karaoke: vocals on, soft (default -12 dB) or off. The iOS app does the same. @@ -64,11 +127,14 @@ export function setKaraokeMode(mode, softDb) { if (wasKaraoke) S.mix = karaokeMix(); mixChanged(); } -// The mix a track starts with: karaoke, the queue item's own mix (stem playlists), the song's -// saved mix, or whatever was playing before. +// The mix a track starts with: karaoke (only kept between songs when the mix is), the locked +// preset, the queue item's own mix (stem playlists), the song's saved mix, or whatever was +// playing before. function startMix(item, entry) { + if (S.karaoke && !S.keepMix) { S.karaoke = false; delete S.preKaraokeMix; } if (S.karaoke) return karaokeMix(); - return clone(item.mix || songMixes[entry.key] || (S.keepMix ? S.mix : {})); + const lock = S.lockedPreset && allPresets().find(p => p.id === S.lockedPreset)?.build(entry.stems.map(s => s.name)); + return clone(lock || item.mix || songMixes[entry.key] || (S.keepMix ? S.mix : {})); } // ---- fetching stems --------------------------------------------------------------------------- @@ -100,19 +166,28 @@ export async function fetchStems(entry, fmt, { conc = 4, signal, onProgress } = return out; } -// One prefetched track, so the next song starts instantly. Kept to Opus (FLAC is too big). +// Cache Ahead: the next S.precache songs, fetched one at a time and held in memory, so they +// start instantly. Kept to Opus (FLAC is too big). const Pre = { - key: null, promise: null, ctl: null, - get(entry) { return this.key === `${entry.key}|${S.fmt}` ? this.promise : null; }, - start(entry) { - const k = `${entry.key}|${S.fmt}`; - if (this.key === k) return; - this.ctl?.abort(); - this.key = k; this.ctl = new AbortController(); - this.promise = fetchStems(entry, S.fmt, { conc: 2, signal: this.ctl.signal }); - this.promise.catch(() => { if (this.key === k) this.key = null; }); + map: new Map(), run: 0, // "key|fmt" -> {promise, ctl} + k: entry => `${entry.key}|${S.fmt}`, + get(entry) { return this.map.get(this.k(entry))?.promise || null; }, + // Drops songs no longer coming up and fetches the missing ones in queue order. + async keep(entries) { + const run = ++this.run, want = new Set(entries.map(this.k)); + for (const [k, v] of this.map) if (!want.has(k)) { v.ctl.abort(); this.map.delete(k); } + for (const e of entries) { + if (run !== this.run) return; + const k = this.k(e); + let p = this.map.get(k)?.promise; + if (!p) { + const ctl = new AbortController(); + p = fetchStems(e, S.fmt, { conc: 2, signal: ctl.signal }); + this.map.set(k, { promise: p, ctl }); + } + await p.catch(() => { if (this.map.get(k)?.promise === p) this.map.delete(k); }); + } }, - clear() { this.ctl?.abort(); this.key = this.promise = null; }, }; // ---- engine ----------------------------------------------------------------------------------- @@ -137,6 +212,7 @@ export const E = { // Loads a track: its stems (offline copy, prefetched, or downloaded now) or the Plex file. async load(t, entry, startSec, play) { + if (play) Radio.stop(); this.unload(); const gen = ++this.gen; this.want = play; this.stalled = play; this.pendingPos = startSec || 0; @@ -220,9 +296,10 @@ export const E = { else el.volume = Math.min(1, g * S.volume); }); if (this.master) this.master.gain.value = S.volume; + if (Radio.el) Radio.el.volume = S.volume; }, - play() { this.audioCtx(); this.want = true; this.stalled = true; this.tick(); emit("transport"); }, + play() { Radio.stop(); this.audioCtx(); this.want = true; this.stalled = true; this.tick(); emit("transport"); }, pause() { this.want = false; this.stalled = false; this.els.forEach(e => e.pause()); emit("transport"); }, seek(sec) { this.pendingPos = sec; @@ -336,22 +413,38 @@ export const Q = { // Reloads the current track in place, e.g. when it just got stems. reload() { if (this.cur()) this.start(E.position(), E.playing); }, - // Fetch the next track only once this one is playing and fully loaded, so they don't compete. + // Cache Ahead, only once this one is playing and fully loaded, so they don't compete. prepareNext() { clearInterval(this.nextTimer); - const nt = this.items[this.i + 1]?.t; - if (!nt) return; + const opus = S.fmt.startsWith("opus"), want = opus ? S.precache : Math.min(1, S.precache), next = []; + for (let j = this.i + 1; j < this.items.length && next.length < want; j++) { + const t = this.items[j].t, e = Stems.find(t); + if (e && !Offline.has(t.id)) next.push(e); + } + if (!next.length) return; const ready = () => E.playing && !E.buffering && E.ready && E.els.every(el => el.duration > 0 && el.buffered.length && el.buffered.end(el.buffered.length - 1) >= el.duration - 0.5); this.nextTimer = setInterval(() => { if (!ready()) return; clearInterval(this.nextTimer); - const e = Stems.find(nt); - if (!e || Offline.has(nt.id)) return; - if (S.fmt.startsWith("opus")) Pre.start(e); else Stems.prepare(e); + if (opus) Pre.keep(next); else Stems.prepare(next[0]); }, 1000); }, + // Hold Next: jump to the first song from a different album. + skipAlbum() { + const album = x => `${x.t.srv || ""}|${x.t.albumKey || x.t.album || ""}`; + const cur = this.curItem(); + if (!cur) return; + const n = this.items.length, a = album(cur); + for (let d = 1; d < n; d++) { + const j = this.i + d; + if (j >= n && S.repeat === "off") break; + if (album(this.items[j % n]) !== a) { this.i = j % n; this.start(0, true); return; } + } + toast("No more albums in the queue"); + }, + next(auto = false) { if (auto && S.repeat === "one") { E.seek(0); E.play(); return; } if (this.i + 1 < this.items.length) { this.i++; this.start(0, true); } @@ -375,7 +468,7 @@ export const Q = { this.orig = null; } S.shuffle = !S.shuffle; - Pre.clear(); this.prepareNext(); + this.prepareNext(); // Cache Ahead drops what's no longer coming up emit("transport"); emit("queue"); saveQueue(); save(); }, cycleRepeat() { @@ -402,9 +495,46 @@ export const Q = { }; E.onEnded = () => Q.next(true); +// ---- web radio -------------------------------------------------------------------------------- + +// A station plays on its own element. Starting it pauses the queue; playing the queue stops it. +export const Radio = { + el: null, station: null, want: false, + get playing() { return this.want; }, + play(st) { + E.pause(); + if (!this.el) { + this.el = new Audio(); + this.el.addEventListener("error", () => { if (this.want) { toast(`Couldn't play “${this.station?.name}”`); this.stop(); } }); + } + this.station = st; + this.resume(); + }, + // Live streams restart at the live edge rather than where they were paused. + resume() { + this.el.src = this.station.url; + this.el.volume = S.volume; + this.want = true; + this.el.play().catch(e => { if (e.name === "NotAllowedError") { this.want = false; emit("transport"); } }); + emit("radio"); emit("transport"); + }, + toggle() { + if (!this.station) return; + if (this.want) { this.want = false; this.el.pause(); emit("radio"); emit("transport"); } + else { E.pause(); this.resume(); } + }, + stop() { + if (!this.station) return; + this.want = false; this.station = null; + this.el.pause(); this.el.removeAttribute("src"); this.el.load(); + emit("radio"); emit("transport"); + }, +}; + // ---- offline ---------------------------------------------------------------------------------- -export async function saveOffline(tracks, onStatus) { +// group: the album or playlist these came from, {title, href}, so Downloads can list them that way. +export async function saveOffline(tracks, onStatus, group = null) { let n = 0; for (const t of tracks) { if (Offline.has(t.id)) continue; @@ -423,7 +553,7 @@ export async function saveOffline(tracks, onStatus) { blobs = { file: await r.blob() }; } const size = Object.values(blobs).reduce((a, b) => a + b.size, 0); - await Offline.put({ id: t.id, track: t, entry, fmt: entry ? S.fmt : "original", size, peaks: peaks?.stems || null, blobs, saved: Date.now() }); + await Offline.put({ id: t.id, track: t, entry, fmt: entry ? S.fmt : "original", size, peaks: peaks?.stems || null, blobs, group, saved: Date.now() }); } catch (e) { toast(`Couldn't download “${t.title}”: ${e.message}`); } } emit("offline"); diff --git a/web/plex.js b/web/plex.js index 78d809f..2431262 100644 --- a/web/plex.js +++ b/web/plex.js @@ -25,6 +25,10 @@ export async function px(srv, path, params = {}) { export function img(srvId, thumb, size = 300) { const s = server(srvId); + if (!thumb || !s) return ""; + // Playlist composites are already images Plex renders at any size; the photo transcoder + // can't fetch them, so ask for them directly. + if (thumb.includes("/composite/")) return `${s.url}${thumb}${thumb.includes("?") ? "&" : "?"}width=${size}&height=${size}&X-Plex-Token=${s.token}`; return thumb && s ? `${s.url}/photo/:/transcode?width=${size}&height=${size}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}&X-Plex-Token=${s.token}` : ""; diff --git a/web/store.js b/web/store.js index ec3c346..01caf46 100644 --- a/web/store.js +++ b/web/store.js @@ -15,7 +15,15 @@ export const S = Object.assign({ keepMix: true, // carry the mix to the next track when it has no saved mix karaoke: false, karaokeMode: "off", softDb: -12, sorts: {}, // screen -> sort value - stemsOnly: false, + stemFilter: "", // "" | "stems" (only songs with stems) | "nostems" + precache: 5, // Cache Ahead: songs fetched ahead of the one playing (0-50) + presetOrder: [], // preset ids in mixer order: "b:Name" built-in, "c:Name" custom + hiddenPresets: [], // deleted built-ins (deleted custom presets are just gone) + customPresets: [], // [{name, mix}] + lockedPreset: "", // every song starts with this preset + acapella: false, // spell "A Cappella" as "Acapella" + bar: { speed: true, mixer: true, lyrics: true, queue: true }, // player bar buttons + stations: [], // web radio: [{id, name, url}] theme: "amber", accent: "", css: "", }, load(KEY) || {}); @@ -28,6 +36,7 @@ if (S.plex) { if (!p.env) S.servers.push({ id: "m" + Date.now().toString(36), url: p.url, token: p.token, name: p.name || p.url, sections: S.section ? [{ key: String(S.section), title: "Music", on: true }] : [] }); delete S.plex; delete S.section; } +if ("stemsOnly" in S) { if (S.stemsOnly) S.stemFilter = "stems"; delete S.stemsOnly; } if (S.albumSort) { S.sorts.albums = S.albumSort; delete S.albumSort; } let timer; @@ -61,7 +70,8 @@ export function addToPlaylist(p, tracks, mix) { } // ---- offline downloads ----------------------------------------------------------------- -// One record per track: {id, track, entry, fmt, size, peaks, lyrics, blobs: {name: Blob}}. +// One record per track: {id, track, entry, fmt, size, peaks, lyrics, blobs: {name: Blob}, group}. +// group is the album or playlist it was downloaded with: {title, href}. let dbp; function db() { diff --git a/web/style.css b/web/style.css index 219eb37..ab3fd79 100644 --- a/web/style.css +++ b/web/style.css @@ -103,6 +103,11 @@ select, textarea, input[type=text], input[type=url], input[type=password] { back body.stems-only .tracks tr.nostem, body.stems-only .card.nostem { display: none; } .only-stems { display: none; } body.stems-only .tracks:not(:has(tr:not(.nostem))) + .only-stems { display: block; } +.only-nostems { display: none; } +body.no-stems .tracks tr:not(.nostem) { display: none; } +body.no-stems .tracks:not(:has(tr.nostem)) ~ .only-nostems { display: block; } +.seg.stemfilter { display: inline-flex; } +.seg.stemfilter button { padding: 5px 10px; white-space: nowrap; } .genres { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px; } .genre { display: flex; align-items: flex-end; aspect-ratio: 16 / 9; padding: 14px; border-radius: 10px; font-weight: 700; font-size: 16px; color: #fff; @@ -243,3 +248,38 @@ input[type=range] { accent-color: var(--accent); } .hero img { width: 160px; height: 160px; } .grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 16px 12px; } } + +/* presets */ +.presets .chip { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; } +.chip.locked { box-shadow: inset 0 0 0 1.5px var(--accent); } +.chip.on.locked { box-shadow: inset 0 0 0 1.5px var(--accent-ink); } +.chip svg, .prow svg { width: 13px; height: 13px; fill: currentColor; } +.plist { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; } +.prow { display: flex; align-items: center; gap: 6px; padding: 6px 10px; background: var(--raised); border-radius: 8px; } +.prow .pn { display: inline-flex; align-items: center; gap: 6px; } +.prow .btn.on { background: var(--accent); color: var(--accent-ink); } + +/* home */ +.shelf { display: grid; grid-auto-flow: column; grid-auto-columns: 150px; gap: 16px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 20px; } +.shelf.two { grid-template-rows: auto auto; } +.stations { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-bottom: 20px; } +.station { display: flex; flex-direction: column; gap: 4px; text-align: left; padding: 16px; border-radius: 12px; background: var(--raised); } +.station:hover { box-shadow: inset 0 0 0 1.5px var(--accent); } +.station span { color: var(--muted); font-size: 13px; } + +/* downloads */ +.dlgroup { margin-bottom: 24px; } + +/* web radio */ +#player.radio .seek, #player.radio #b-prev, #player.radio #b-next, #player.radio #b-shuffle { visibility: hidden; } +.stationlist { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; } +.stn { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; background: var(--raised); } +.stn.on { box-shadow: inset 0 0 0 1.5px var(--accent); } +.stn .tx { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.stn .tx .note { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +input[type=number]#precachen { width: 64px; background: var(--raised); border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; } +#precache { flex: 1; } + +/* about */ +.people { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin: 12px 0; } +.person { display: flex; flex-direction: column; gap: 6px; padding: 14px; border-radius: 12px; background: var(--raised); } diff --git a/web/util.js b/web/util.js index e1a3a66..df65afe 100644 --- a/web/util.js +++ b/web/util.js @@ -33,6 +33,7 @@ export function toast(msg, ms = 2400) { } export const ICON = { + lock: '', play: '', pause: '', prev: '',