Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99c4de91c4 | ||
|
|
1a5dba78f7 | ||
|
|
7ca9d0374d | ||
|
|
4b53a720c4 | ||
|
|
2a4e848788 | ||
|
|
629901616b | ||
|
|
4fe7f6d16a | ||
|
|
555a79657c | ||
|
|
9299fee9a0 | ||
|
|
605b88f1eb |
No files matched your search
@@ -22,3 +22,4 @@ ios/build/
|
|||||||
ios/Stemify.xcodeproj/
|
ios/Stemify.xcodeproj/
|
||||||
ios/Stemify/Info.plist
|
ios/Stemify/Info.plist
|
||||||
ios/Stemify/Stemify.entitlements
|
ios/Stemify/Stemify.entitlements
|
||||||
|
ios/prov
|
||||||
@@ -30,6 +30,12 @@ The stems folder mirrors the music folder. If a song lives at `Kansas/Leftovertu
|
|||||||
Plex Media Server :32400 ───────────────────────────────▶ library, artwork, playlists
|
Plex Media Server :32400 ───────────────────────────────▶ library, artwork, playlists
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Three parts, and only stemd is required:
|
||||||
|
|
||||||
|
- **stemd** is the daemon. It does the actual separating and nothing else.
|
||||||
|
- **Stemify web UI** is optional, for desktop users. stemserve serves it at `/`. It plays the stems streamed from the server, and picks up a local stem server on `localhost:8765` by itself.
|
||||||
|
- **iOS and Android apps** are optional and built from this repo too.
|
||||||
|
|
||||||
- **stemd** works through the library on the GPU, one song at a time, and rescans every 30 minutes for new music.
|
- **stemd** works through the library on the GPU, one song at a time, and rescans every 30 minutes for new music.
|
||||||
- **stemserve** serves the finished stems over HTTP with range requests, waveforms and optional Opus transcodes.
|
- **stemserve** serves the finished stems over HTTP with range requests, waveforms and optional Opus transcodes.
|
||||||
- **Plex** handles the library, metadata, artwork and playlists.
|
- **Plex** handles the library, metadata, artwork and playlists.
|
||||||
@@ -37,19 +43,21 @@ The stems folder mirrors the music folder. If a song lives at `Kansas/Leftovertu
|
|||||||
|
|
||||||
## Apps
|
## Apps
|
||||||
|
|
||||||
| | iOS | Android | Desktop player |
|
| | iOS | Android | Desktop player | Web |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Per-stem faders, mute, solo, mix presets | yes | yes | yes |
|
| Per-stem faders, mute, solo, mix presets | yes | yes | yes | yes |
|
||||||
| Waveform seek bar, live stem meters | yes | yes | |
|
| Waveform seek bar, live stem meters | yes | yes | | waveform |
|
||||||
| Saved mix per song | yes | | |
|
| Shuffle, repeat all/one | yes | yes | | yes |
|
||||||
| Karaoke mode with synced lyrics | yes | | |
|
| Playback speed (keeps pitch) | yes | | | yes |
|
||||||
| Stem playlists (a mix per song) | yes | | |
|
| Saved mix per song | yes | | | |
|
||||||
| Plex playlists | yes | yes | yes |
|
| Karaoke mode with synced lyrics | yes | | | |
|
||||||
| Downloads for offline | yes | yes | |
|
| Stem playlists (a mix per song) | yes | | | |
|
||||||
| 10-band and parametric EQ, AutoEQ import | yes | yes | |
|
| Plex playlists | yes | yes | yes | yes |
|
||||||
| Car | CarPlay | Android Auto | |
|
| Downloads for offline | yes | yes | | |
|
||||||
| Voice | Siri | Google Assistant | |
|
| 10-band and parametric EQ, AutoEQ import | yes | yes | | |
|
||||||
| Stem Now | yes | yes | |
|
| Car | CarPlay | Android Auto | | |
|
||||||
|
| Voice | Siri | Google Assistant | | |
|
||||||
|
| Stem Now | yes | yes | | |
|
||||||
|
|
||||||
Mix presets are Full, Karaoke, A Cappella, No Drums, Rhythm, No Guitar and No Bass.
|
Mix presets are Full, Karaoke, A Cappella, No Drums, Rhythm, No Guitar and No Bass.
|
||||||
|
|
||||||
@@ -85,10 +93,10 @@ docker run -d --name stemd --restart unless-stopped --stop-timeout 60 \
|
|||||||
|
|
||||||
The first song downloads the model (about 700 MB). After that the log shows what's left, what it's on, and how fast it's going.
|
The first song downloads the model (about 700 MB). After that the log shows what's left, what it's on, and how fast it's going.
|
||||||
|
|
||||||
**stemserve** is one Python file using only the standard library. Copy `server/` to `/mnt/user/appdata/stemserve` and run `docker compose up -d --build` there, or by hand:
|
**stemserve** is one Python file using only the standard library. It also serves the web player from `web/`. Copy the repo's `server/` and `web/` folders to `/mnt/user/appdata/stemserve` and run `docker compose up -d --build` in `server/`, or by hand:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t stemserve /mnt/user/appdata/stemserve
|
docker build -f /mnt/user/appdata/stemserve/server/Dockerfile -t stemserve /mnt/user/appdata/stemserve
|
||||||
docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \
|
docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \
|
||||||
-v /mnt/user/Music/Stems:/stems:ro \
|
-v /mnt/user/Music/Stems:/stems:ro \
|
||||||
-v /mnt/user/appdata/stemserve/cache:/cache \
|
-v /mnt/user/appdata/stemserve/cache:/cache \
|
||||||
@@ -130,6 +138,9 @@ To update, copy the new files, rebuild, `docker rm -f` the container and run it
|
|||||||
| `PORT` | `8765` | Listen port |
|
| `PORT` | `8765` | Listen port |
|
||||||
| `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` |
|
| `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` |
|
||||||
| `INDEX_TTL` | `30` | Seconds between rescans of the stems folder |
|
| `INDEX_TTL` | `30` | Seconds between rescans of the stems folder |
|
||||||
|
| `PLEX_URL` | empty | Plex address. With `PLEX_TOKEN`, the web UI connects to Plex without signing in |
|
||||||
|
| `PLEX_TOKEN` | empty | Plex token. Only handed out to clients that pass `TOKEN`, so set `TOKEN` too |
|
||||||
|
| `WEB` | `1` | Set to `0` to turn off the web UI and serve the API only |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -158,6 +169,7 @@ CarPlay and Siri need the `carplay-audio` and `siri` entitlements, which a free
|
|||||||
|
|
||||||
### Using it
|
### Using it
|
||||||
|
|
||||||
|
- **Now Playing:** shuffle and repeat sit either side of the play controls; tap repeat again for repeat one. The speed button (next to the mixer) goes from 0.5× to 2× without changing pitch.
|
||||||
- **Mixer:** on Now Playing, under the waveform. Tap **Save** to keep a mix for that song; it comes back every time the song plays.
|
- **Mixer:** on Now Playing, under the waveform. Tap **Save** to keep a mix for that song; it comes back every time the song plays.
|
||||||
- **Karaoke:** the mic button mutes vocals on every song and shows synced lyrics from [LRCLIB](https://lrclib.net). Tap a line to jump to it. Vocals can be On, Soft or Off, and Soft has a slider for how quiet.
|
- **Karaoke:** the mic button mutes vocals on every song and shows synced lyrics from [LRCLIB](https://lrclib.net). Tap a line to jump to it. Vocals can be On, Soft or Off, and Soft has a slider for how quiet.
|
||||||
- **Playlists:** the Playlists tab switches between Plex and Stem.
|
- **Playlists:** the Playlists tab switches between Plex and Stem.
|
||||||
@@ -211,6 +223,18 @@ adb forward tcp:5277 tcp:5277
|
|||||||
~/Android/Sdk/extras/google/auto/desktop-head-unit -c ~/Android/Sdk/extras/google/auto/config/rotary.ini
|
~/Android/Sdk/extras/google/auto/desktop-head-unit -c ~/Android/Sdk/extras/google/auto/config/rotary.ini
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Web
|
||||||
|
|
||||||
|
stemserve also serves a web player at its root, so `http://<server>:8765/` opens it. Sign in with Plex in Settings (or paste a server URL and token), then browse albums, artists, playlists and search like the apps. **Stems only** lists everything in the stems folder and works without Plex.
|
||||||
|
|
||||||
|
- Mixer, presets, shuffle, repeat, speed, queue and the waveform seek bar work like the phone apps. The mix carries over to the next song.
|
||||||
|
- Songs without stems play straight from Plex.
|
||||||
|
- Keys: Space play/pause, arrows seek, S shuffle, R repeat, `[` `]` speed, M mixer, Q queue.
|
||||||
|
- Settings, queue and mix are kept in the browser. Nothing syncs with the apps.
|
||||||
|
- If stemserve has `TOKEN` set, enter it in Settings.
|
||||||
|
|
||||||
|
It's plain HTML and JavaScript in `web/` with no build step. To run it somewhere else, serve `web/` and proxy `/api/` and `/file/` to stemserve on the same origin; with a different origin the stem faders fall back to element volume, which iOS Safari ignores.
|
||||||
|
|
||||||
## Desktop
|
## 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 `pyproject.toml` elsewhere for NVIDIA or CPU.
|
||||||
@@ -274,6 +298,7 @@ Stems/Kansas/Leftoverture/01 Carry On Wayward Son/
|
|||||||
| `POST /api/prepare?key=K&fmt=opus&br=256` | Starts Opus transcodes in the background |
|
| `POST /api/prepare?key=K&fmt=opus&br=256` | Starts Opus transcodes in the background |
|
||||||
| `POST /api/stem` with `{"paths": [...]}` | Stem Now for these Plex files |
|
| `POST /api/stem` with `{"paths": [...]}` | Stem Now for these Plex files |
|
||||||
| `GET /file/K/<file>.flac[?fmt=opus&br=256]` | The stem, with range support |
|
| `GET /file/K/<file>.flac[?fmt=opus&br=256]` | The stem, with range support |
|
||||||
|
| `GET /` | The web player from `web/` (or `WEB_DIR`), no token needed |
|
||||||
|
|
||||||
`K` is the song's folder under the stems root, like `Kansas/Leftoverture/01 Carry On Wayward Son`. Outside Docker:
|
`K` is the song's folder under the stems root, like `Kansas/Leftoverture/01 Carry On Wayward Son`. Outside Docker:
|
||||||
|
|
||||||
@@ -289,6 +314,7 @@ ios/ iOS and CarPlay app (SwiftUI, xcodegen project)
|
|||||||
scripts/ build-ipa.sh, make-source.py
|
scripts/ build-ipa.sh, make-source.py
|
||||||
android/ Android and Android Auto app (Kotlin, Compose, Media3)
|
android/ Android and Android Auto app (Kotlin, Compose, Media3)
|
||||||
server/ stemd.py, stemserve.py, Dockerfiles, compose file
|
server/ stemd.py, stemserve.py, Dockerfiles, compose file
|
||||||
|
web/ web player (served by stemserve)
|
||||||
stemify/ desktop separator (PySide6 + QML) and shared separation code
|
stemify/ desktop separator (PySide6 + QML) and shared separation code
|
||||||
worker/ Demucs and RoFormer backends, run in a separate process
|
worker/ Demucs and RoFormer backends, run in a separate process
|
||||||
player/ Stemify Player
|
player/ Stemify Player
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ struct NowPlayingScreen: View {
|
|||||||
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
|
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
|
||||||
}
|
}
|
||||||
.frame(height: 84)
|
.frame(height: 84)
|
||||||
|
.padding(.top, 14)
|
||||||
HStack {
|
HStack {
|
||||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
||||||
SpeedMenu()
|
SpeedMenu()
|
||||||
|
|||||||
+3
-3
@@ -6,8 +6,8 @@ options:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
MARKETING_VERSION: "1.0.10"
|
MARKETING_VERSION: "1.0.12"
|
||||||
CURRENT_PROJECT_VERSION: "11"
|
CURRENT_PROJECT_VERSION: "13"
|
||||||
targets:
|
targets:
|
||||||
Stemify:
|
Stemify:
|
||||||
type: application
|
type: application
|
||||||
@@ -44,7 +44,7 @@ targets:
|
|||||||
INIntentsSupported: [INPlayMediaIntent]
|
INIntentsSupported: [INPlayMediaIntent]
|
||||||
INSupportedMediaCategories: [INMediaCategoryMusic]
|
INSupportedMediaCategories: [INMediaCategoryMusic]
|
||||||
UIApplicationSceneManifest:
|
UIApplicationSceneManifest:
|
||||||
UIApplicationSupportsMultipleScenes: true
|
UIApplicationSupportsMultipleScenes: false
|
||||||
UISceneConfigurations:
|
UISceneConfigurations:
|
||||||
UIWindowSceneSessionRoleApplication:
|
UIWindowSceneSessionRoleApplication:
|
||||||
- UISceneConfigurationName: Phone
|
- UISceneConfigurationName: Phone
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Builds an unsigned build/stemify-<version>.ipa on a Mac, for re-signing with Sideloadly/AltStore.
|
# Builds build/stemify-<version>.ipa on a Mac: unsigned, for re-signing with Sideloadly/AltStore.
|
||||||
|
# With prov/Stemify.mobileprovision, prov/key.p12 and P12_PASS set, it also writes a signed
|
||||||
|
# prov/stemify-<version>-signed.ipa (keeps CarPlay/Siri) for the owner's own device.
|
||||||
# Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch.
|
# Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
@@ -34,5 +36,30 @@ SRC=Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png
|
|||||||
sips -z 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null
|
sips -z 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null
|
||||||
sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null
|
sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null
|
||||||
|
|
||||||
|
# Unsigned build, for the releases and AltStore/Sideloadly.
|
||||||
(cd $OUT && zip -qry stemify-$VERSION.ipa Payload)
|
(cd $OUT && zip -qry stemify-$VERSION.ipa Payload)
|
||||||
echo "$OUT/stemify-$VERSION.ipa"
|
echo "$OUT/stemify-$VERSION.ipa"
|
||||||
|
|
||||||
|
if [ -f prov/Stemify.mobileprovision ] && [ -f prov/key.p12 ] && [ -n "${P12_PASS:-}" ]; then
|
||||||
|
KC=$PWD/$OUT/sign.keychain-db; KP=$(uuidgen)
|
||||||
|
security create-keychain -p "$KP" $KC
|
||||||
|
security set-keychain-settings -lut 3600 $KC
|
||||||
|
security unlock-keychain -p "$KP" $KC
|
||||||
|
security import prov/key.p12 -P "$P12_PASS" -A -t agg -f pkcs12 -k $KC >/dev/null
|
||||||
|
# The dev cert chains to Apple's WWDR G3 intermediate, which a fresh keychain doesn't have.
|
||||||
|
[ -f prov/wwdr.cer ] || curl -fsSL -o prov/wwdr.cer https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer
|
||||||
|
security import prov/wwdr.cer -A -k $KC >/dev/null 2>&1 || true
|
||||||
|
security set-key-partition-list -S apple-tool:,apple: -s -k "$KP" $KC >/dev/null
|
||||||
|
security list-keychains -d user -s $KC $(security list-keychains -d user | tr -d '"')
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
(cd $OUT && zip -qry ../prov/stemify-$VERSION-signed.ipa Payload)
|
||||||
|
echo "prov/stemify-$VERSION-signed.ipa (signed as $ID)"
|
||||||
|
fi
|
||||||
|
|
||||||
@@ -15,6 +15,24 @@
|
|||||||
"tintColor": "#FA2D48",
|
"tintColor": "#FA2D48",
|
||||||
"category": "entertainment",
|
"category": "entertainment",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.11",
|
||||||
|
"buildVersion": "12",
|
||||||
|
"date": "2026-10-20T12:24:42-07:00",
|
||||||
|
"localizedDescription": "Tapping the lock screen / Control Center player opens the app again.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.11/stemify-1.0.11.ipa",
|
||||||
|
"size": 1529241,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.10",
|
||||||
|
"buildVersion": "11",
|
||||||
|
"date": "2026-10-20T11:21:34-07:00",
|
||||||
|
"localizedDescription": "Shuffle and repeat (list and song) on Now Playing. Playback speed from 0.5x to 2x, pitch kept.\n\nUnsigned IPA, not tested on device.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.10/stemify-1.0.10.ipa",
|
||||||
|
"size": 1529239,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"buildVersion": "10",
|
"buildVersion": "10",
|
||||||
|
|||||||
+5
-2
@@ -1,8 +1,11 @@
|
|||||||
|
# Build from the repo root so the web player comes along:
|
||||||
|
# docker build -f server/Dockerfile -t stemserve .
|
||||||
FROM python:3.12-alpine
|
FROM python:3.12-alpine
|
||||||
RUN apk add --no-cache ffmpeg
|
RUN apk add --no-cache ffmpeg
|
||||||
COPY stemserve.py /app/stemserve.py
|
COPY server/stemserve.py /app/server/stemserve.py
|
||||||
|
COPY web /app/web
|
||||||
ENV STEMS_DIR=/stems CACHE_DIR=/cache PORT=8765 PYTHONUNBUFFERED=1
|
ENV STEMS_DIR=/stems CACHE_DIR=/cache PORT=8765 PYTHONUNBUFFERED=1
|
||||||
EXPOSE 8765
|
EXPOSE 8765
|
||||||
# Unraid shares belong to nobody:users.
|
# Unraid shares belong to nobody:users.
|
||||||
USER 99:100
|
USER 99:100
|
||||||
CMD ["python", "/app/stemserve.py"]
|
CMD ["python", "/app/server/stemserve.py"]
|
||||||
+6
-2
@@ -15,8 +15,9 @@ from the desktop). From an unraid terminal:
|
|||||||
```sh
|
```sh
|
||||||
mkdir -p /mnt/user/appdata/stemserve/cache /mnt/user/appdata/stemd/state/queue
|
mkdir -p /mnt/user/appdata/stemserve/cache /mnt/user/appdata/stemd/state/queue
|
||||||
chown -R 99:100 /mnt/user/appdata/stemserve /mnt/user/appdata/stemd
|
chown -R 99:100 /mnt/user/appdata/stemserve /mnt/user/appdata/stemd
|
||||||
cp -r /path/to/stemify/server /mnt/user/appdata/stemserve/src # or copy over SMB
|
mkdir -p /mnt/user/appdata/stemserve/src # needs server/ and web/ from the repo
|
||||||
docker build -t stemserve /mnt/user/appdata/stemserve/src
|
cp -r /path/to/stemify/{server,web} /mnt/user/appdata/stemserve/src/ # or copy over SMB
|
||||||
|
docker build -f /mnt/user/appdata/stemserve/src/server/Dockerfile -t stemserve /mnt/user/appdata/stemserve/src
|
||||||
docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \
|
docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \
|
||||||
-v /mnt/user/Music/Stems:/stems:ro \
|
-v /mnt/user/Music/Stems:/stems:ro \
|
||||||
-v /mnt/user/appdata/stemserve/cache:/cache \
|
-v /mnt/user/appdata/stemserve/cache:/cache \
|
||||||
@@ -42,6 +43,9 @@ curl http://192.168.86.2:8765/api/info
|
|||||||
| `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` |
|
| `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` |
|
||||||
| `INDEX_TTL` | `30` | Seconds between rescans of the stems folder |
|
| `INDEX_TTL` | `30` | Seconds between rescans of the stems folder |
|
||||||
| `QUEUE_DIR` | `/queue` | stemd's Stem Now folder (`stemd/state/queue` on unraid) |
|
| `QUEUE_DIR` | `/queue` | stemd's Stem Now folder (`stemd/state/queue` on unraid) |
|
||||||
|
| `PLEX_URL` | empty | Plex address; the web UI reaches it through `/plex/` on this server, so HTTPS pages work |
|
||||||
|
| `PLEX_TOKEN`| empty | Plex token for the web UI (set `TOKEN` too) |
|
||||||
|
| `WEB` | `1` | `0` turns off the web UI |
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
stemserve:
|
stemserve:
|
||||||
build: .
|
build: {context: .., dockerfile: server/Dockerfile}
|
||||||
image: stemserve:latest
|
image: stemserve:latest
|
||||||
container_name: stemserve
|
container_name: stemserve
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -14,3 +14,5 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# Optional shared secret; set the same token in the app (Settings > Stem Server).
|
# Optional shared secret; set the same token in the app (Settings > Stem Server).
|
||||||
- TOKEN=
|
- TOKEN=
|
||||||
|
- PLEX_URL=
|
||||||
|
- PLEX_TOKEN=
|
||||||
@@ -16,6 +16,9 @@ Endpoints (all GET unless noted; add ?token=... or X-Stem-Token when TOKEN is se
|
|||||||
POST /api/prepare?key=<key>&fmt=opus&br=160 start transcodes in the background
|
POST /api/prepare?key=<key>&fmt=opus&br=160 start transcodes in the background
|
||||||
POST /api/stem {"paths": [...]} "Stem Now": stemd separates these Plex files next
|
POST /api/stem {"paths": [...]} "Stem Now": stemd separates these Plex files next
|
||||||
/file/<key>/<file>[?fmt=opus&br=160] the stem itself, with Range support
|
/file/<key>/<file>[?fmt=opus&br=160] the stem itself, with Range support
|
||||||
|
/plex/... proxy to PLEX_URL (needs TOKEN or the Plex token)
|
||||||
|
/api/config Plex URL and token from PLEX_URL / PLEX_TOKEN, if set
|
||||||
|
/ the web player (web/, or WEB_DIR); no token needed
|
||||||
|
|
||||||
<key> is the track's folder relative to STEMS_DIR, e.g. "Artist/Album/01 Song".
|
<key> is the track's folder relative to STEMS_DIR, e.g. "Artist/Album/01 Song".
|
||||||
"""
|
"""
|
||||||
@@ -31,7 +34,9 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
from array import array
|
from array import array
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
@@ -43,6 +48,9 @@ PORT = int(os.environ.get("PORT", "8765"))
|
|||||||
TOKEN = os.environ.get("TOKEN", "")
|
TOKEN = os.environ.get("TOKEN", "")
|
||||||
INDEX_TTL = float(os.environ.get("INDEX_TTL", "30"))
|
INDEX_TTL = float(os.environ.get("INDEX_TTL", "30"))
|
||||||
QUEUE_DIR = os.environ.get("QUEUE_DIR", "/queue") # stemd's request folder, shared with it
|
QUEUE_DIR = os.environ.get("QUEUE_DIR", "/queue") # stemd's request folder, shared with it
|
||||||
|
PLEX_URL = os.environ.get("PLEX_URL", "").rstrip("/")
|
||||||
|
PLEX_TOKEN = os.environ.get("PLEX_TOKEN", "")
|
||||||
|
SERVE_WEB = os.environ.get("WEB", "1") != "0" # WEB=0: API only, no web player
|
||||||
MAX_BODY = 8 << 20
|
MAX_BODY = 8 << 20
|
||||||
MARKER = ".stemify.json"
|
MARKER = ".stemify.json"
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
@@ -50,6 +58,10 @@ FFMPEG = shutil.which("ffmpeg")
|
|||||||
OPUS_RATES = (64, 96, 128, 160, 192, 256)
|
OPUS_RATES = (64, 96, 128, 160, 192, 256)
|
||||||
PEAK_RATE = 2000 # Hz the envelope is computed at; plenty for a few hundred bars
|
PEAK_RATE = 2000 # Hz the envelope is computed at; plenty for a few hundred bars
|
||||||
CHUNK = 1 << 20
|
CHUNK = 1 << 20
|
||||||
|
# The web player. Served without the token; it asks for one in its settings when TOKEN is set.
|
||||||
|
WEB_DIR = os.path.realpath(os.environ.get("WEB_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "web")))
|
||||||
|
WEB_TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".json": "application/json"}
|
||||||
|
|
||||||
_pool = ThreadPoolExecutor(max_workers=max(2, (os.cpu_count() or 2) // 2))
|
_pool = ThreadPoolExecutor(max_workers=max(2, (os.cpu_count() or 2) // 2))
|
||||||
_locks: dict[str, threading.Lock] = {}
|
_locks: dict[str, threading.Lock] = {}
|
||||||
@@ -254,6 +266,31 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return True
|
return True
|
||||||
return TOKEN in (self.headers.get("X-Stem-Token"), query.get("token", [None])[0])
|
return TOKEN in (self.headers.get("X-Stem-Token"), query.get("token", [None])[0])
|
||||||
|
|
||||||
|
def _plex_proxy(self, path, rawquery, query):
|
||||||
|
"""Forward to PLEX_URL so an HTTPS page can reach an HTTP Plex. Needs the stem or Plex token."""
|
||||||
|
given = self.headers.get("X-Plex-Token") or query.get("X-Plex-Token", [None])[0]
|
||||||
|
if not (self._authorized(query) or (PLEX_TOKEN and given == PLEX_TOKEN)):
|
||||||
|
return self._error(HTTPStatus.UNAUTHORIZED, "bad token")
|
||||||
|
pairs = [(k, v) for k, v in urllib.parse.parse_qsl(rawquery, keep_blank_values=True) if k not in ("token", "X-Plex-Token")]
|
||||||
|
req = urllib.request.Request(PLEX_URL + path + ("?" + urllib.parse.urlencode(pairs) if pairs else ""))
|
||||||
|
req.add_header("X-Plex-Token", PLEX_TOKEN or given or "")
|
||||||
|
for h in ("Range", "Accept", "X-Plex-Client-Identifier", "X-Plex-Product", "If-None-Match"):
|
||||||
|
if self.headers.get(h):
|
||||||
|
req.add_header(h, self.headers[h])
|
||||||
|
try:
|
||||||
|
r = urllib.request.urlopen(req, timeout=30)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
r = e
|
||||||
|
except OSError as e:
|
||||||
|
return self._error(502, f"plex unreachable: {e}")
|
||||||
|
self.send_response(r.status if hasattr(r, "status") else r.code)
|
||||||
|
for h in ("Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"):
|
||||||
|
if r.headers.get(h):
|
||||||
|
self.send_header(h, r.headers[h])
|
||||||
|
self.end_headers()
|
||||||
|
while chunk := r.read(65536):
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
|
||||||
def _send(self, status, body=b"", ctype="application/json", headers=None):
|
def _send(self, status, body=b"", ctype="application/json", headers=None):
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", ctype)
|
self.send_header("Content-Type", ctype)
|
||||||
@@ -267,6 +304,19 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def _error(self, status, msg):
|
def _error(self, status, msg):
|
||||||
self._send(status, json.dumps({"error": msg}).encode())
|
self._send(status, json.dumps({"error": msg}).encode())
|
||||||
|
|
||||||
|
def _web(self, path):
|
||||||
|
if not SERVE_WEB:
|
||||||
|
return self._error(404, "not found")
|
||||||
|
if path == "/":
|
||||||
|
path = "/index.html"
|
||||||
|
full = os.path.realpath(os.path.join(WEB_DIR, path.lstrip("/")))
|
||||||
|
if not full.startswith(WEB_DIR + os.sep) or not os.path.isfile(full):
|
||||||
|
return self._error(404, "not found")
|
||||||
|
with open(full, "rb") as f:
|
||||||
|
body = f.read()
|
||||||
|
ctype = WEB_TYPES.get(os.path.splitext(full)[1], "application/octet-stream")
|
||||||
|
self._send(200, body, ctype, {"Cache-Control": "no-cache"})
|
||||||
|
|
||||||
def do_HEAD(self):
|
def do_HEAD(self):
|
||||||
self.do_GET()
|
self.do_GET()
|
||||||
|
|
||||||
@@ -277,6 +327,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
url = urllib.parse.urlsplit(self.path)
|
url = urllib.parse.urlsplit(self.path)
|
||||||
query = urllib.parse.parse_qs(url.query)
|
query = urllib.parse.parse_qs(url.query)
|
||||||
path = urllib.parse.unquote(url.path)
|
path = urllib.parse.unquote(url.path)
|
||||||
|
if path.startswith("/plex/") and PLEX_URL:
|
||||||
|
return self._plex_proxy(path[len("/plex"):], url.query, query)
|
||||||
|
if not path.startswith(("/api/", "/file/")):
|
||||||
|
return self._web(path)
|
||||||
if not self._authorized(query):
|
if not self._authorized(query):
|
||||||
return self._error(HTTPStatus.UNAUTHORIZED, "bad token")
|
return self._error(HTTPStatus.UNAUTHORIZED, "bad token")
|
||||||
try:
|
try:
|
||||||
@@ -286,6 +340,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"name": "stemserve", "version": VERSION, "tracks": INDEX.count,
|
"name": "stemserve", "version": VERSION, "tracks": INDEX.count,
|
||||||
"ffmpeg": bool(FFMPEG), "opus": list(OPUS_RATES) if FFMPEG else [],
|
"ffmpeg": bool(FFMPEG), "opus": list(OPUS_RATES) if FFMPEG else [],
|
||||||
"stemNow": os.access(QUEUE_DIR, os.W_OK)}).encode())
|
"stemNow": os.access(QUEUE_DIR, os.W_OK)}).encode())
|
||||||
|
if path == "/api/config":
|
||||||
|
plex = {"url": PLEX_URL, "token": PLEX_TOKEN} if PLEX_URL and PLEX_TOKEN else None
|
||||||
|
return self._send(200, json.dumps({"plex": plex}).encode(), headers={"Cache-Control": "no-store"})
|
||||||
if path == "/api/index":
|
if path == "/api/index":
|
||||||
body, etag = INDEX.get()
|
body, etag = INDEX.get()
|
||||||
if self.headers.get("If-None-Match") == etag:
|
if self.headers.get("If-None-Match") == etag:
|
||||||
|
|||||||
+874
@@ -0,0 +1,874 @@
|
|||||||
|
// Stemify web player. Browses Plex, plays stems from stemserve, no build step.
|
||||||
|
// Each stem is its own <audio> element so speed changes keep pitch; a tick keeps them lined up.
|
||||||
|
|
||||||
|
const $ = (s, r = document) => r.querySelector(s);
|
||||||
|
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
||||||
|
const fmtTime = s => { s = Math.max(0, Math.floor(s || 0)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
|
||||||
|
const shuffled = a => { a = a.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
|
||||||
|
|
||||||
|
const ICON = {
|
||||||
|
play: '<svg viewBox="0 0 24 24"><path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z"/></svg>',
|
||||||
|
pause: '<svg viewBox="0 0 24 24"><rect x="5.5" y="4" width="4.5" height="16" rx="1.2"/><rect x="14" y="4" width="4.5" height="16" rx="1.2"/></svg>',
|
||||||
|
prev: '<svg viewBox="0 0 24 24"><rect x="4" y="5" width="2.5" height="14" rx="1"/><path d="M20 5.7v12.6a1 1 0 0 1-1.55.83L9 12.83a1 1 0 0 1 0-1.66l9.45-6.3A1 1 0 0 1 20 5.7z"/></svg>',
|
||||||
|
next: '<svg viewBox="0 0 24 24"><rect x="17.5" y="5" width="2.5" height="14" rx="1"/><path d="M4 5.7v12.6a1 1 0 0 0 1.55.83L15 12.83a1 1 0 0 0 0-1.66l-9.45-6.3A1 1 0 0 0 4 5.7z"/></svg>',
|
||||||
|
shuffle: '<svg viewBox="0 0 24 24"><path d="M17 3l4 4-4 4V8h-2.2c-1 0-1.9.5-2.5 1.3L7.6 16c-.9 1.3-2.4 2-4 2H2v-2h1.6c.9 0 1.8-.5 2.3-1.2l4.7-6.7C11.5 6.8 13 6 14.8 6H17V3zM2 6h1.6c1.6 0 3.1.8 4 2l.9 1.3-1.2 1.7-1.4-1.9A2.8 2.8 0 0 0 3.6 8H2V6zm12.3 9.7c.6.8 1.5 1.3 2.5 1.3H17v-3l4 4-4 4v-3h-.2c-1.7 0-3.3-.8-4.2-2.2l-.6-.8 1.2-1.7.1.4z"/></svg>',
|
||||||
|
repeat: '<svg viewBox="0 0 24 24"><path d="M17 1l4 4-4 4V6H7a2 2 0 0 0-2 2v3H3V8a4 4 0 0 1 4-4h10V1zM7 23l-4-4 4-4v3h10a2 2 0 0 0 2-2v-3h2v3a4 4 0 0 1-4 4H7v3z"/></svg>',
|
||||||
|
repeat1: '<svg viewBox="0 0 24 24"><path d="M17 1l4 4-4 4V6H7a2 2 0 0 0-2 2v3H3V8a4 4 0 0 1 4-4h10V1zM7 23l-4-4 4-4v3h10a2 2 0 0 0 2-2v-3h2v3a4 4 0 0 1-4 4H7v3z"/><path d="M11.2 9.6l1.8-.9h1.2v6.6h-1.6v-4.9l-1.4.6z"/></svg>',
|
||||||
|
mixer: '<svg viewBox="0 0 24 24"><path d="M6 3h2v6.2a3 3 0 0 1 0 5.6V21H6v-6.2a3 3 0 0 1 0-5.6V3zm10 0h2v2.2a3 3 0 0 1 0 5.6V21h-2V10.8a3 3 0 0 1 0-5.6V3zM7 11a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm10-4a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/></svg>',
|
||||||
|
queue: '<svg viewBox="0 0 24 24"><path d="M3 5h14v2H3zm0 6h14v2H3zm0 6h9v2H3zm13-1.5v-1.5l6 3.5-6 3.5V17z"/></svg>',
|
||||||
|
busy: '<svg viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9h-2.2A6.8 6.8 0 1 1 12 5.2V3z"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- settings --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const KEY = "stemify-web";
|
||||||
|
const S = Object.assign({
|
||||||
|
plex: null, // {url, token, name}
|
||||||
|
section: null,
|
||||||
|
stemUrl: "", // empty = same origin as this page
|
||||||
|
stemToken: "",
|
||||||
|
fmt: "opus160",
|
||||||
|
volume: 0.85, speed: 1, shuffle: false, repeat: "off",
|
||||||
|
mix: {}, // stem name -> {level, muted, solo}; kept across tracks
|
||||||
|
albumSort: "titleSort",
|
||||||
|
}, load(KEY) || {});
|
||||||
|
if (!S.clientId) S.clientId = crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2);
|
||||||
|
if (S.fmt.startsWith("opus") && !new Audio().canPlayType('audio/ogg; codecs="opus"')) S.fmt = "flac";
|
||||||
|
|
||||||
|
function load(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }
|
||||||
|
let saveTimer;
|
||||||
|
function save() {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(S));
|
||||||
|
localStorage.setItem(KEY + "-queue", JSON.stringify({ items: Q.items.slice(0, 2000), orig: Q.orig?.slice(0, 2000), i: Q.i, pos: E.position() }));
|
||||||
|
} catch {}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEEDS = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2];
|
||||||
|
|
||||||
|
// ---- Plex ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const plexHeaders = token => ({
|
||||||
|
Accept: "application/json",
|
||||||
|
"X-Plex-Product": "Stemify Web",
|
||||||
|
"X-Plex-Client-Identifier": S.clientId,
|
||||||
|
...(token ? { "X-Plex-Token": token } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function px(path, params = {}) {
|
||||||
|
if (!S.plex) throw new Error("Not signed in to Plex");
|
||||||
|
const u = new URL(S.plex.url + path, location.href);
|
||||||
|
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
|
||||||
|
const r = await fetch(u, { headers: plexHeaders(S.plex.token) });
|
||||||
|
if (!r.ok) throw new Error(`Plex ${r.status}`);
|
||||||
|
return (await r.json()).MediaContainer || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const plexImg = (thumb, size = 300) => thumb && S.plex
|
||||||
|
? `${S.plex.url}/photo/:/transcode?width=${size}&height=${size}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}&X-Plex-Token=${S.plex.token}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
function plexTrack(m) {
|
||||||
|
const part = m.Media?.[0]?.Part?.[0];
|
||||||
|
return {
|
||||||
|
id: "p" + m.ratingKey, title: m.title, artist: m.originalTitle || m.grandparentTitle || "", album: m.parentTitle || "",
|
||||||
|
albumKey: m.parentRatingKey, thumb: m.thumb || m.parentThumb || m.grandparentThumb, duration: m.duration || part?.duration || 0,
|
||||||
|
part: part?.key, file: part?.file, index: m.index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function plexSignIn(status) {
|
||||||
|
const pin = await (await fetch("https://plex.tv/api/v2/pins?strong=true", { method: "POST", headers: plexHeaders() })).json();
|
||||||
|
const auth = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(S.clientId)}&code=${encodeURIComponent(pin.code)}&context%5Bdevice%5D%5Bproduct%5D=Stemify%20Web`;
|
||||||
|
window.open(auth, "_blank", "width=800,height=740");
|
||||||
|
status("Waiting for Plex sign-in…");
|
||||||
|
let token = null;
|
||||||
|
for (let n = 0; n < 150 && !token; n++) {
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
token = (await (await fetch(`https://plex.tv/api/v2/pins/${pin.id}`, { headers: plexHeaders() })).json()).authToken;
|
||||||
|
}
|
||||||
|
if (!token) throw new Error("Sign-in timed out");
|
||||||
|
status("Finding your server…");
|
||||||
|
const res = await (await fetch("https://plex.tv/api/v2/resources?includeHttps=1", { headers: plexHeaders(token) })).json();
|
||||||
|
const https = location.protocol === "https:";
|
||||||
|
for (const srv of res.filter(r => r.provides?.includes("server"))) {
|
||||||
|
const conns = (srv.connections || []).filter(c => !https || c.protocol === "https")
|
||||||
|
.sort((a, b) => (b.local - a.local) || (a.relay - b.relay));
|
||||||
|
for (const c of conns) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${c.uri}/identity`, { headers: plexHeaders(srv.accessToken || token), signal: AbortSignal.timeout(3500) });
|
||||||
|
if (r.ok) return { url: c.uri, token: srv.accessToken || token, name: srv.name };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Signed in, but no Plex server answered from this browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function musicSection() {
|
||||||
|
if (S.section) return S.section;
|
||||||
|
const secs = (await px("/library/sections")).Directory || [];
|
||||||
|
S.section = secs.find(d => d.type === "artist")?.key || null;
|
||||||
|
save();
|
||||||
|
if (!S.section) throw new Error("No music library on this Plex server");
|
||||||
|
return S.section;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stemserve -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const stemBase = () => (S.stemUrl || location.origin).replace(/\/+$/, "");
|
||||||
|
const stemQuery = extra => {
|
||||||
|
const q = new URLSearchParams(extra);
|
||||||
|
if (S.stemToken) q.set("token", S.stemToken);
|
||||||
|
const s = q.toString();
|
||||||
|
return s ? "?" + s : "";
|
||||||
|
};
|
||||||
|
const keyPath = k => k.split("/").map(encodeURIComponent).join("/");
|
||||||
|
const fmtParams = () => S.fmt.startsWith("opus") ? { fmt: "opus", br: S.fmt.slice(4) } : {};
|
||||||
|
|
||||||
|
// Same matching as the apps: the stems folder mirrors the music folder, so the Plex path's
|
||||||
|
// suffix identifies a track. Short or duration-mismatched suffixes only count under a root
|
||||||
|
// learned from an earlier confident match.
|
||||||
|
const Stems = {
|
||||||
|
entries: [], bySource: new Map(), roots: [], etag: null, ok: false,
|
||||||
|
norm(p) {
|
||||||
|
p = p.normalize("NFC").replaceAll("\\", "/");
|
||||||
|
const slash = p.lastIndexOf("/"), dot = p.lastIndexOf(".");
|
||||||
|
if (dot > slash + 1) p = p.slice(0, dot);
|
||||||
|
return p.toLowerCase();
|
||||||
|
},
|
||||||
|
async refresh() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(stemBase() + "/api/index" + stemQuery(), { headers: this.etag ? { "If-None-Match": this.etag } : {} });
|
||||||
|
this.ok = r.ok || r.status === 304;
|
||||||
|
if (r.status !== 200) return false;
|
||||||
|
this.etag = r.headers.get("ETag");
|
||||||
|
this.entries = (await r.json()).tracks || [];
|
||||||
|
this.bySource = new Map();
|
||||||
|
for (const e of this.entries) { const k = this.norm(e.source); if (!this.bySource.has(k)) this.bySource.set(k, e); }
|
||||||
|
return true;
|
||||||
|
} catch { this.ok = false; return false; }
|
||||||
|
},
|
||||||
|
find(t) {
|
||||||
|
if (t.stem) return t.stem;
|
||||||
|
if (!t.file || !this.entries.length) return null;
|
||||||
|
const path = this.norm(t.file);
|
||||||
|
for (const root of this.roots) if (path.startsWith(root)) { const hit = this.bySource.get(path.slice(root.length)); if (hit) return hit; }
|
||||||
|
const parts = path.split("/");
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const hit = this.bySource.get(parts.slice(i).join("/"));
|
||||||
|
if (!hit) continue;
|
||||||
|
const okDur = !hit.duration || !t.duration || Math.abs(hit.duration * 1000 - t.duration) < 3000;
|
||||||
|
if (parts.length - i >= 2 && okDur) {
|
||||||
|
const root = parts.slice(0, i).join("/") + "/";
|
||||||
|
if (!this.roots.includes(root)) this.roots.push(root);
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
url(e, s) { return `${stemBase()}/file/${keyPath(e.key)}/${encodeURIComponent(s.file)}${stemQuery(fmtParams())}`; },
|
||||||
|
prepare(e) {
|
||||||
|
if (e && S.fmt.startsWith("opus")) fetch(`${stemBase()}/api/prepare${stemQuery({ key: e.key, ...fmtParams() })}`, { method: "POST" }).catch(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const stemTrack = e => {
|
||||||
|
const parts = e.key.split("/");
|
||||||
|
return { id: "s" + e.key, title: parts.at(-1), artist: parts.at(-3) || "", album: parts.at(-2) || "", duration: (e.duration || 0) * 1000, stem: e };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- mix -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const unity = () => ({ level: 1, muted: false, solo: false });
|
||||||
|
const PRESETS = [
|
||||||
|
["Full Mix", () => ({})],
|
||||||
|
["Karaoke", n => n.includes("vocals") && { vocals: { muted: true } }],
|
||||||
|
["A Cappella", n => n.includes("vocals") && { vocals: { solo: true } }],
|
||||||
|
["No Drums", n => n.includes("drums") && { drums: { muted: true } }],
|
||||||
|
["Rhythm", n => n.includes("drums") && n.includes("bass") && { drums: { solo: true }, bass: { solo: true } }],
|
||||||
|
["No Guitar", n => n.includes("guitar") && { guitar: { muted: true } }],
|
||||||
|
["No Bass", n => n.includes("bass") && { bass: { muted: true } }],
|
||||||
|
];
|
||||||
|
const setting = name => ({ ...unity(), ...S.mix[name] });
|
||||||
|
function stemGain(name, names) {
|
||||||
|
const s = setting(name), anySolo = names.some(n => setting(n).solo);
|
||||||
|
return anySolo ? (s.solo ? s.level : 0) : (s.muted ? 0 : s.level);
|
||||||
|
}
|
||||||
|
function applyPreset(build, names) {
|
||||||
|
const m = build(names);
|
||||||
|
if (!m) return;
|
||||||
|
S.mix = {};
|
||||||
|
for (const [k, v] of Object.entries(m)) S.mix[k] = { ...unity(), ...v };
|
||||||
|
mixChanged();
|
||||||
|
}
|
||||||
|
function currentPreset(names) {
|
||||||
|
const norm = m => JSON.stringify(names.map(n => { const s = { ...unity(), ...m[n] }; return [s.level, s.muted, s.solo]; }));
|
||||||
|
const now = norm(S.mix);
|
||||||
|
return PRESETS.find(([, b]) => { const m = b(names); return m && norm(Object.fromEntries(Object.entries(m).map(([k, v]) => [k, { ...unity(), ...v }]))) === now; })?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- audio -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const E = {
|
||||||
|
els: [], names: [], gains: [], ctx: null, master: null, graph: false,
|
||||||
|
want: false, stalled: false, onEnded: null,
|
||||||
|
|
||||||
|
audioCtx() {
|
||||||
|
if (!this.ctx) {
|
||||||
|
const C = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!C) return null;
|
||||||
|
this.ctx = new C();
|
||||||
|
this.master = this.ctx.createGain();
|
||||||
|
this.master.connect(this.ctx.destination);
|
||||||
|
this.master.gain.value = S.volume;
|
||||||
|
}
|
||||||
|
if (this.ctx.state === "suspended") this.ctx.resume();
|
||||||
|
return this.ctx;
|
||||||
|
},
|
||||||
|
|
||||||
|
load(sources, startSec, play) {
|
||||||
|
this.unload();
|
||||||
|
// Web Audio gains work everywhere (iOS ignores element volume) but need same-origin media.
|
||||||
|
const sameOrigin = sources.every(s => new URL(s.url, location.href).origin === location.origin);
|
||||||
|
this.graph = sameOrigin && !!this.audioCtx();
|
||||||
|
for (const s of sources) {
|
||||||
|
const el = new Audio();
|
||||||
|
el.preload = "auto";
|
||||||
|
el.preservesPitch = true;
|
||||||
|
el.src = s.url;
|
||||||
|
el.playbackRate = el.defaultPlaybackRate = S.speed;
|
||||||
|
if (startSec) el.currentTime = startSec;
|
||||||
|
let gain = null;
|
||||||
|
if (this.graph) {
|
||||||
|
gain = this.ctx.createGain();
|
||||||
|
this.ctx.createMediaElementSource(el).connect(gain).connect(this.master);
|
||||||
|
}
|
||||||
|
this.els.push(el); this.gains.push(gain); this.names.push(s.name);
|
||||||
|
}
|
||||||
|
const lead = this.els[0];
|
||||||
|
lead.addEventListener("ended", () => this.onEnded?.());
|
||||||
|
lead.addEventListener("durationchange", () => UI.progress());
|
||||||
|
lead.addEventListener("error", () => { if (this.want) toast("Couldn't load this track"); });
|
||||||
|
this.applyGains();
|
||||||
|
this.want = play;
|
||||||
|
this.stalled = play;
|
||||||
|
},
|
||||||
|
|
||||||
|
unload() {
|
||||||
|
for (const el of this.els) { el.pause(); el.removeAttribute("src"); el.load(); }
|
||||||
|
this.els = []; this.names = []; this.gains = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
applyGains() {
|
||||||
|
const named = this.names.filter(Boolean);
|
||||||
|
this.els.forEach((el, i) => {
|
||||||
|
const g = this.names[i] ? stemGain(this.names[i], named) : 1;
|
||||||
|
if (this.gains[i]) this.gains[i].gain.value = g;
|
||||||
|
else el.volume = Math.min(1, g * (this.graph ? 1 : S.volume));
|
||||||
|
});
|
||||||
|
if (this.master) this.master.gain.value = S.volume;
|
||||||
|
},
|
||||||
|
|
||||||
|
play() { this.audioCtx(); this.want = true; this.stalled = true; this.tick(); },
|
||||||
|
pause() { this.want = false; this.stalled = false; this.els.forEach(e => e.pause()); },
|
||||||
|
seek(sec) { this.els.forEach(e => { e.currentTime = sec; }); if (this.want) this.stalled = true; UI.progress(); },
|
||||||
|
setSpeed(v) { this.els.forEach(e => { e.playbackRate = e.defaultPlaybackRate = v; }); },
|
||||||
|
position() { return this.els[0]?.currentTime || 0; },
|
||||||
|
duration() { const d = this.els[0]?.duration; return isFinite(d) ? d : (Q.cur()?.duration || 0) / 1000; },
|
||||||
|
get playing() { return this.want; },
|
||||||
|
get buffering() { return this.want && this.stalled; },
|
||||||
|
|
||||||
|
// Holds everything until every stem can play, then starts them together and keeps followers
|
||||||
|
// within ~15 ms of the lead by nudging their rate (pitch is preserved, so it's inaudible).
|
||||||
|
tick() {
|
||||||
|
if (!this.els.length || !this.want) return;
|
||||||
|
const lead = this.els[0];
|
||||||
|
const notReady = this.els.some(e => e.readyState < 3 && !e.ended);
|
||||||
|
if (notReady) {
|
||||||
|
if (!this.stalled) { this.els.forEach(e => e.pause()); this.stalled = true; UI.transport(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.stalled) {
|
||||||
|
this.stalled = false;
|
||||||
|
const t = lead.currentTime;
|
||||||
|
this.els.forEach(e => { if (e !== lead && Math.abs(e.currentTime - t) > 0.01) e.currentTime = t; e.playbackRate = S.speed; });
|
||||||
|
Promise.all(this.els.map(e => e.play())).catch(err => {
|
||||||
|
if (err.name === "NotAllowedError") { this.pause(); UI.transport(); }
|
||||||
|
});
|
||||||
|
UI.transport();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const f of this.els.slice(1)) {
|
||||||
|
if (f.ended || lead.ended) continue;
|
||||||
|
const d = f.currentTime - lead.currentTime;
|
||||||
|
if (Math.abs(d) > 0.25) f.currentTime = lead.currentTime;
|
||||||
|
else if (Math.abs(d) > 0.015) f.playbackRate = S.speed * (1 - Math.max(-0.06, Math.min(0.06, d * 1.5)));
|
||||||
|
else if (f.playbackRate !== S.speed) f.playbackRate = S.speed;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setInterval(() => E.tick(), 150);
|
||||||
|
|
||||||
|
// ---- queue -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let uidSeq = Date.now();
|
||||||
|
const wrap = t => ({ uid: uidSeq++, t });
|
||||||
|
const Q = {
|
||||||
|
items: [], orig: null, i: -1,
|
||||||
|
cur() { return this.items[this.i]?.t; },
|
||||||
|
|
||||||
|
playList(tracks, start = 0, shuffle = S.shuffle) {
|
||||||
|
if (!tracks.length) return;
|
||||||
|
const all = tracks.map(wrap);
|
||||||
|
if (shuffle) {
|
||||||
|
const first = all[start] && start >= 0 ? all[start] : all[Math.floor(Math.random() * all.length)];
|
||||||
|
this.orig = all;
|
||||||
|
this.items = [first, ...shuffled(all.filter(x => x !== first))];
|
||||||
|
this.i = 0;
|
||||||
|
} else { this.orig = null; this.items = all; this.i = start; }
|
||||||
|
S.shuffle = shuffle;
|
||||||
|
this.start(0, true);
|
||||||
|
},
|
||||||
|
add(tracks, next = false) {
|
||||||
|
const w = tracks.map(wrap);
|
||||||
|
if (next) { this.items.splice(this.i + 1, 0, ...w); this.orig?.push(...w); }
|
||||||
|
else { this.items.push(...w); this.orig?.push(...w); }
|
||||||
|
if (this.i < 0) { this.i = 0; this.start(0, false); }
|
||||||
|
UI.drawer(); save();
|
||||||
|
toast(tracks.length === 1 ? `Added “${tracks[0].title}”` : `Added ${tracks.length} tracks`);
|
||||||
|
},
|
||||||
|
remove(idx) {
|
||||||
|
if (idx === this.i) return;
|
||||||
|
const [x] = this.items.splice(idx, 1);
|
||||||
|
if (this.orig) this.orig = this.orig.filter(o => o !== x);
|
||||||
|
if (idx < this.i) this.i--;
|
||||||
|
UI.drawer(); save();
|
||||||
|
},
|
||||||
|
jump(idx) { this.i = idx; this.start(0, true); },
|
||||||
|
|
||||||
|
start(posSec, play) {
|
||||||
|
const t = this.cur();
|
||||||
|
if (!t) { E.unload(); UI.all(); return; }
|
||||||
|
const e = Stems.find(t);
|
||||||
|
const srcs = e
|
||||||
|
? e.stems.map(s => ({ name: s.name, url: Stems.url(e, s) }))
|
||||||
|
: [{ name: null, url: `${S.plex?.url}${t.part}?X-Plex-Token=${S.plex?.token}` }];
|
||||||
|
E.load(srcs, posSec, play);
|
||||||
|
Wave.load(e);
|
||||||
|
Stems.prepare(e);
|
||||||
|
const nt = this.items[this.i + 1]?.t;
|
||||||
|
if (nt) Stems.prepare(Stems.find(nt));
|
||||||
|
UI.all(); save();
|
||||||
|
},
|
||||||
|
|
||||||
|
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); }
|
||||||
|
else if (S.repeat !== "off" && this.items.length) { this.i = 0; this.start(0, true); }
|
||||||
|
else if (auto) { E.pause(); E.seek(0); UI.transport(); }
|
||||||
|
},
|
||||||
|
prev() {
|
||||||
|
if (E.position() > 3 || (this.i === 0 && S.repeat === "off")) { E.seek(0); return; }
|
||||||
|
this.i = this.i > 0 ? this.i - 1 : this.items.length - 1;
|
||||||
|
this.start(0, E.playing);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleShuffle() {
|
||||||
|
if (!S.shuffle) {
|
||||||
|
this.orig = this.items.slice();
|
||||||
|
this.items = [...this.items.slice(0, this.i + 1), ...shuffled(this.items.slice(this.i + 1))];
|
||||||
|
} else if (this.orig) {
|
||||||
|
const cur = this.items[this.i];
|
||||||
|
this.items = this.orig;
|
||||||
|
this.i = Math.max(0, this.items.indexOf(cur));
|
||||||
|
this.orig = null;
|
||||||
|
}
|
||||||
|
S.shuffle = !S.shuffle;
|
||||||
|
UI.transport(); UI.drawer(); save();
|
||||||
|
},
|
||||||
|
cycleRepeat() {
|
||||||
|
S.repeat = { off: "all", all: "one", one: "off" }[S.repeat];
|
||||||
|
UI.transport(); save();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
E.onEnded = () => Q.next(true);
|
||||||
|
|
||||||
|
function setSpeed(v) {
|
||||||
|
S.speed = v;
|
||||||
|
E.setSpeed(v);
|
||||||
|
UI.transport(); save();
|
||||||
|
}
|
||||||
|
function mixChanged() { E.applyGains(); Wave.draw(); UI.drawer(); save(); }
|
||||||
|
|
||||||
|
// ---- waveform --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const Wave = {
|
||||||
|
peaks: null, key: null, canvas: $("#wave"),
|
||||||
|
async load(e) {
|
||||||
|
this.peaks = null; this.key = e?.key || null;
|
||||||
|
this.draw();
|
||||||
|
if (!e) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${stemBase()}/api/peaks${stemQuery({ key: e.key, n: 300 })}`);
|
||||||
|
if (!r.ok || this.key !== e.key) return;
|
||||||
|
this.peaks = (await r.json()).stems;
|
||||||
|
this.draw();
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
draw() {
|
||||||
|
const c = this.canvas, dpr = devicePixelRatio || 1;
|
||||||
|
const w = c.clientWidth, h = c.clientHeight;
|
||||||
|
if (!w) return;
|
||||||
|
if (c.width !== w * dpr) { c.width = w * dpr; c.height = h * dpr; }
|
||||||
|
const x = c.getContext("2d");
|
||||||
|
x.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
x.clearRect(0, 0, w, h);
|
||||||
|
const dur = E.duration(), frac = dur ? Math.min(1, E.position() / dur) : 0;
|
||||||
|
const accent = getComputedStyle(document.documentElement).getPropertyValue("--accent");
|
||||||
|
if (!this.peaks) {
|
||||||
|
x.fillStyle = "#ffffff22"; x.fillRect(0, h / 2 - 2, w, 4);
|
||||||
|
x.fillStyle = accent; x.fillRect(0, h / 2 - 2, w * frac, 4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const names = Object.keys(this.peaks), n = this.peaks[names[0]].length;
|
||||||
|
const full = new Float32Array(n), mixed = new Float32Array(n);
|
||||||
|
for (const nm of names) {
|
||||||
|
const g = stemGain(nm, names);
|
||||||
|
this.peaks[nm].forEach((p, i) => { const v = p / 1000; full[i] += v * v; mixed[i] += v * v * g * g; });
|
||||||
|
}
|
||||||
|
const max = Math.sqrt(Math.max(...full)) || 1;
|
||||||
|
const bars = Math.max(20, Math.floor(w / 3)), bw = w / bars;
|
||||||
|
for (let b = 0; b < bars; b++) {
|
||||||
|
const i = Math.floor(b / bars * n);
|
||||||
|
const v = Math.sqrt(mixed[i]) / max, ghost = Math.sqrt(full[i]) / max;
|
||||||
|
const cx = b * bw;
|
||||||
|
x.fillStyle = "#ffffff12";
|
||||||
|
const gh = Math.max(2, ghost * h);
|
||||||
|
x.fillRect(cx, (h - gh) / 2, bw - 1, gh);
|
||||||
|
x.fillStyle = b / bars < frac ? accent : "#ffffff40";
|
||||||
|
const bh = Math.max(2, v * h);
|
||||||
|
x.fillRect(cx, (h - bh) / 2, bw - 1, bh);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let drag = false;
|
||||||
|
const at = ev => { const r = Wave.canvas.getBoundingClientRect(); return Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)) * E.duration(); };
|
||||||
|
Wave.canvas.addEventListener("pointerdown", ev => { drag = true; Wave.canvas.setPointerCapture(ev.pointerId); E.seek(at(ev)); });
|
||||||
|
Wave.canvas.addEventListener("pointermove", ev => { if (drag) E.seek(at(ev)); });
|
||||||
|
Wave.canvas.addEventListener("pointerup", () => { drag = false; save(); });
|
||||||
|
new ResizeObserver(() => Wave.draw()).observe(Wave.canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- player UI -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let drawerMode = null; // "mixer" | "queue"
|
||||||
|
|
||||||
|
const UI = {
|
||||||
|
all() { this.track(); this.transport(); this.progress(); this.drawer(); markPlaying(); },
|
||||||
|
track() {
|
||||||
|
const t = Q.cur();
|
||||||
|
$("#player").classList.toggle("empty", !t);
|
||||||
|
$("#np-title").textContent = t?.title || "Nothing playing";
|
||||||
|
$("#np-sub").textContent = t ? [t.artist, t.album].filter(Boolean).join(" · ") : "";
|
||||||
|
const art = t ? plexImg(t.thumb, 120) : "";
|
||||||
|
if (art) $("#np-art").src = art; else $("#np-art").removeAttribute("src");
|
||||||
|
document.title = t ? `${t.title} · Stemify` : "Stemify";
|
||||||
|
if ("mediaSession" in navigator) {
|
||||||
|
navigator.mediaSession.metadata = t ? new MediaMetadata({
|
||||||
|
title: t.title, artist: t.artist, album: t.album,
|
||||||
|
artwork: t.thumb ? [{ src: plexImg(t.thumb, 512), sizes: "512x512" }] : [],
|
||||||
|
}) : null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transport() {
|
||||||
|
const p = $("#b-play");
|
||||||
|
p.innerHTML = E.buffering ? ICON.busy : E.playing ? ICON.pause : ICON.play;
|
||||||
|
p.classList.toggle("busy", E.buffering);
|
||||||
|
$("#b-shuffle").classList.toggle("on", S.shuffle);
|
||||||
|
$("#b-repeat").innerHTML = S.repeat === "one" ? ICON.repeat1 : ICON.repeat;
|
||||||
|
$("#b-repeat").classList.toggle("on", S.repeat !== "off");
|
||||||
|
$("#b-repeat").title = `Repeat: ${S.repeat} (R)`;
|
||||||
|
$("#speed").value = String(S.speed);
|
||||||
|
$("#speed").classList.toggle("on", S.speed !== 1);
|
||||||
|
$("#b-mixer").classList.toggle("on", drawerMode === "mixer");
|
||||||
|
$("#b-queue").classList.toggle("on", drawerMode === "queue");
|
||||||
|
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = E.playing ? "playing" : "paused";
|
||||||
|
},
|
||||||
|
progress() {
|
||||||
|
$("#t-pos").textContent = fmtTime(E.position());
|
||||||
|
$("#t-dur").textContent = fmtTime(E.duration());
|
||||||
|
Wave.draw();
|
||||||
|
const d = E.duration();
|
||||||
|
if (d && navigator.mediaSession?.setPositionState) {
|
||||||
|
try { navigator.mediaSession.setPositionState({ duration: d, position: Math.min(d, E.position()), playbackRate: S.speed }); } catch {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
drawer() {
|
||||||
|
const d = $("#drawer");
|
||||||
|
d.hidden = !drawerMode;
|
||||||
|
if (drawerMode === "mixer") d.innerHTML = mixerHtml();
|
||||||
|
else if (drawerMode === "queue") d.innerHTML = queueHtml();
|
||||||
|
this.transport();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function mixerHtml() {
|
||||||
|
const names = E.names.filter(Boolean);
|
||||||
|
if (!names.length) return `<h2>Mixer</h2><p class="note">${Q.cur() ? "This track has no stems yet." : "Play something with stems to mix it."}</p>`;
|
||||||
|
const cur = currentPreset(names);
|
||||||
|
return `<h2>Mixer</h2>
|
||||||
|
<div class="presets">${PRESETS.map(([label, b], i) => `<button class="chip ${cur === label ? "on" : ""}" data-preset="${i}" ${b(names) ? "" : "disabled"}>${label}</button>`).join("")}</div>
|
||||||
|
${names.map(n => {
|
||||||
|
const s = setting(n), g = stemGain(n, names);
|
||||||
|
return `<div class="stem ${g ? "" : "silent"}"><span class="name">${esc(n)}</span>
|
||||||
|
<button class="ms ${s.muted ? "on" : ""}" data-mute="${esc(n)}" title="Mute">M</button>
|
||||||
|
<button class="ms solo ${s.solo ? "on" : ""}" data-solo="${esc(n)}" title="Solo">S</button>
|
||||||
|
<input type="range" min="0" max="1" step="0.01" value="${s.level}" data-level="${esc(n)}"></div>`;
|
||||||
|
}).join("")}
|
||||||
|
<p class="note">The mix carries over to the next track.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueHtml() {
|
||||||
|
if (!Q.items.length) return `<h2>Queue</h2><p class="note">Empty.</p>`;
|
||||||
|
return `<h2>Queue</h2>${Q.items.map((x, i) => `
|
||||||
|
<div class="q-item ${i === Q.i ? "cur" : i < Q.i ? "past" : ""}" data-q="${i}">
|
||||||
|
<img loading="lazy" ${x.t.thumb ? `src="${esc(plexImg(x.t.thumb, 80))}"` : ""} alt="">
|
||||||
|
<div class="tx"><div>${esc(x.t.title)}</div><div>${esc(x.t.artist)}</div></div>
|
||||||
|
${i === Q.i ? "" : `<button class="x" data-qx="${i}" title="Remove">×</button>`}
|
||||||
|
</div>`).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#drawer").addEventListener("click", ev => {
|
||||||
|
const b = ev.target.closest("button, .q-item");
|
||||||
|
if (!b) return;
|
||||||
|
const names = E.names.filter(Boolean);
|
||||||
|
if (b.dataset.preset) applyPreset(PRESETS[+b.dataset.preset][1], names);
|
||||||
|
else if (b.dataset.mute) { const n = b.dataset.mute; S.mix[n] = { ...setting(n), muted: !setting(n).muted }; mixChanged(); }
|
||||||
|
else if (b.dataset.solo) { const n = b.dataset.solo; S.mix[n] = { ...setting(n), solo: !setting(n).solo }; mixChanged(); }
|
||||||
|
else if (b.dataset.qx) Q.remove(+b.dataset.qx);
|
||||||
|
else if (b.dataset.q) Q.jump(+b.dataset.q);
|
||||||
|
});
|
||||||
|
$("#drawer").addEventListener("input", ev => {
|
||||||
|
const n = ev.target.dataset.level;
|
||||||
|
if (!n) return;
|
||||||
|
S.mix[n] = { ...setting(n), level: +ev.target.value };
|
||||||
|
E.applyGains(); Wave.draw(); save();
|
||||||
|
});
|
||||||
|
$("#drawer").addEventListener("change", ev => { if (ev.target.dataset.level) UI.drawer(); });
|
||||||
|
|
||||||
|
$("#b-play").innerHTML = ICON.play;
|
||||||
|
$("#b-prev").innerHTML = ICON.prev;
|
||||||
|
$("#b-next").innerHTML = ICON.next;
|
||||||
|
$("#b-shuffle").innerHTML = ICON.shuffle;
|
||||||
|
$("#b-mixer").innerHTML = ICON.mixer;
|
||||||
|
$("#b-queue").innerHTML = ICON.queue;
|
||||||
|
$("#speed").innerHTML = SPEEDS.map(v => `<option value="${v}">${v}×</option>`).join("");
|
||||||
|
$("#volume").value = S.volume;
|
||||||
|
|
||||||
|
const togglePlay = () => { if (!Q.cur()) return; E.playing ? E.pause() : E.play(); UI.transport(); save(); };
|
||||||
|
const toggleDrawer = m => { drawerMode = drawerMode === m ? null : m; UI.drawer(); };
|
||||||
|
$("#b-play").onclick = togglePlay;
|
||||||
|
$("#b-prev").onclick = () => Q.prev();
|
||||||
|
$("#b-next").onclick = () => Q.next();
|
||||||
|
$("#b-shuffle").onclick = () => Q.toggleShuffle();
|
||||||
|
$("#b-repeat").onclick = () => Q.cycleRepeat();
|
||||||
|
$("#b-mixer").onclick = () => toggleDrawer("mixer");
|
||||||
|
$("#b-queue").onclick = () => toggleDrawer("queue");
|
||||||
|
$("#speed").onchange = ev => setSpeed(+ev.target.value);
|
||||||
|
$("#volume").oninput = ev => { S.volume = +ev.target.value; E.applyGains(); save(); };
|
||||||
|
|
||||||
|
if ("mediaSession" in navigator) {
|
||||||
|
const ms = navigator.mediaSession;
|
||||||
|
ms.setActionHandler("play", () => { E.play(); UI.transport(); });
|
||||||
|
ms.setActionHandler("pause", () => { E.pause(); UI.transport(); });
|
||||||
|
ms.setActionHandler("previoustrack", () => Q.prev());
|
||||||
|
ms.setActionHandler("nexttrack", () => Q.next());
|
||||||
|
try { ms.setActionHandler("seekto", d => E.seek(d.seekTime)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", ev => {
|
||||||
|
if (ev.target.closest("input, select, textarea") || ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
||||||
|
const k = ev.key.toLowerCase();
|
||||||
|
if (k === " ") { ev.preventDefault(); togglePlay(); }
|
||||||
|
else if (k === "arrowright") E.seek(Math.min(E.duration(), E.position() + (ev.shiftKey ? 30 : 5)));
|
||||||
|
else if (k === "arrowleft") E.seek(Math.max(0, E.position() - (ev.shiftKey ? 30 : 5)));
|
||||||
|
else if (k === "s") Q.toggleShuffle();
|
||||||
|
else if (k === "r") Q.cycleRepeat();
|
||||||
|
else if (k === "m") toggleDrawer("mixer");
|
||||||
|
else if (k === "q") toggleDrawer("queue");
|
||||||
|
else if (k === "]" || k === "[") { const i = SPEEDS.indexOf(S.speed) + (k === "]" ? 1 : -1); if (SPEEDS[i]) setSpeed(SPEEDS[i]); }
|
||||||
|
});
|
||||||
|
|
||||||
|
(function frame() {
|
||||||
|
if (E.playing) {
|
||||||
|
$("#t-pos").textContent = fmtTime(E.position());
|
||||||
|
Wave.draw();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
})();
|
||||||
|
setInterval(() => { if (E.playing) { UI.progress(); save(); } }, 5000);
|
||||||
|
|
||||||
|
function toast(msg) {
|
||||||
|
let t = $("#toast");
|
||||||
|
if (!t) {
|
||||||
|
t = document.createElement("div");
|
||||||
|
t.id = "toast";
|
||||||
|
t.style.cssText = "position:fixed;left:50%;bottom:calc(var(--player-h) + 16px);transform:translateX(-50%);background:var(--raised);padding:9px 16px;border-radius:999px;box-shadow:0 8px 30px #0008;z-index:9;transition:opacity .3s";
|
||||||
|
document.body.append(t);
|
||||||
|
}
|
||||||
|
t.textContent = msg;
|
||||||
|
t.style.opacity = 1;
|
||||||
|
clearTimeout(t._h);
|
||||||
|
t._h = setTimeout(() => { t.style.opacity = 0; }, 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- views -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const view = $("#view");
|
||||||
|
let lists = {}; // track lists rendered in the current view, by id
|
||||||
|
let routeSeq = 0;
|
||||||
|
|
||||||
|
function markPlaying() {
|
||||||
|
const id = Q.cur()?.id;
|
||||||
|
view.querySelectorAll("tr[data-id]").forEach(r => r.classList.toggle("playing", r.dataset.id === id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function card(href, img, t, s, round = false) {
|
||||||
|
return `<a class="card ${round ? "round" : ""}" href="${href}">${img ? `<img loading="lazy" src="${esc(img)}" alt="">` : `<div class="ph"></div>`}
|
||||||
|
<div class="t">${esc(t)}</div><div class="s">${esc(s || "")}</div></a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackTable(listId, tracks, { numbers = false, album = true } = {}) {
|
||||||
|
lists[listId] = tracks;
|
||||||
|
return `<table class="tracks"><tbody>${tracks.map((t, i) => `
|
||||||
|
<tr data-list="${listId}" data-i="${i}" data-id="${esc(t.id)}">
|
||||||
|
<td class="n">${numbers ? t.index ?? i + 1 : i + 1}</td>
|
||||||
|
<td class="ti">${esc(t.title)}${Stems.find(t) ? '<span class="stem-dot">STEMS</span>' : ""}${album ? `<small>${esc([t.artist, t.album].filter(Boolean).join(" · "))}</small>` : ""}</td>
|
||||||
|
<td class="d">${fmtTime(t.duration / 1000)}</td>
|
||||||
|
<td><button class="add" data-add="${i}" title="Add to queue (shift: play next)">+</button></td>
|
||||||
|
</tr>`).join("")}</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.addEventListener("click", ev => {
|
||||||
|
const add = ev.target.closest("[data-add]");
|
||||||
|
const row = ev.target.closest("tr[data-list]");
|
||||||
|
if (add && row) { Q.add([lists[row.dataset.list][+add.dataset.add]], ev.shiftKey); return; }
|
||||||
|
if (row) { Q.playList(lists[row.dataset.list], +row.dataset.i, false); return; }
|
||||||
|
const act = ev.target.closest("[data-play]");
|
||||||
|
if (act) {
|
||||||
|
const tracks = lists[act.dataset.play];
|
||||||
|
if (act.dataset.mode === "queue") Q.add(tracks);
|
||||||
|
else Q.playList(tracks, act.dataset.mode === "shuffle" ? -1 : 0, act.dataset.mode === "shuffle");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const playButtons = id => `<div class="actions">
|
||||||
|
<button class="btn primary" data-play="${id}">${ICON.play} Play</button>
|
||||||
|
<button class="btn" data-play="${id}" data-mode="shuffle">${ICON.shuffle} Shuffle</button>
|
||||||
|
<button class="btn" data-play="${id}" data-mode="queue">${ICON.queue} Add to queue</button></div>`;
|
||||||
|
|
||||||
|
function needPlex() {
|
||||||
|
view.innerHTML = `<h1>Welcome</h1><p class="sub">Sign in to Plex in <a href="#/settings" style="color:var(--accent)">Settings</a> to browse your library, or open <a href="#/stems" style="color:var(--accent)">Stems only</a>.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid of Plex items, loaded 120 at a time as you scroll.
|
||||||
|
async function pagedGrid(seq, path, params, cardFn) {
|
||||||
|
const grid = $(".grid", view), more = document.createElement("div");
|
||||||
|
more.className = "more";
|
||||||
|
grid.after(more);
|
||||||
|
let start = 0, total = Infinity, busy = false;
|
||||||
|
const next = async () => {
|
||||||
|
if (busy || start >= total || seq !== routeSeq) return;
|
||||||
|
busy = true;
|
||||||
|
const mc = await px(path, { ...params, "X-Plex-Container-Start": start, "X-Plex-Container-Size": 120 });
|
||||||
|
if (seq !== routeSeq) return;
|
||||||
|
total = mc.totalSize ?? mc.size ?? 0;
|
||||||
|
const items = mc.Metadata || [];
|
||||||
|
start += items.length;
|
||||||
|
if (!items.length) total = start;
|
||||||
|
grid.insertAdjacentHTML("beforeend", items.map(cardFn).join(""));
|
||||||
|
if (!start) grid.outerHTML = `<p class="empty-msg">Nothing here.</p>`;
|
||||||
|
busy = false;
|
||||||
|
if (more.getBoundingClientRect().top < innerHeight * 1.5) next();
|
||||||
|
};
|
||||||
|
new IntersectionObserver(es => { if (es[0].isIntersecting) next(); }, { root: view, rootMargin: "600px" }).observe(more);
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumCard = m => card(`#/album/${m.ratingKey}`, plexImg(m.thumb), m.title, [m.parentTitle, m.year].filter(Boolean).join(" · "));
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
async albums(seq) {
|
||||||
|
if (!S.plex) return needPlex();
|
||||||
|
const sec = await musicSection();
|
||||||
|
view.innerHTML = `<div class="bar"><h1>Albums</h1>
|
||||||
|
<select id="sort">${[["titleSort", "Title"], ["addedAt:desc", "Recently added"], ["year:desc", "Year"], ["artist.titleSort", "Artist"]]
|
||||||
|
.map(([v, l]) => `<option value="${v}" ${S.albumSort === v ? "selected" : ""}>${l}</option>`).join("")}</select></div><div class="grid"></div>`;
|
||||||
|
$("#sort").onchange = ev => { S.albumSort = ev.target.value; save(); route(); };
|
||||||
|
await pagedGrid(seq, `/library/sections/${sec}/all`, { type: 9, sort: S.albumSort }, albumCard);
|
||||||
|
},
|
||||||
|
async artists(seq) {
|
||||||
|
if (!S.plex) return needPlex();
|
||||||
|
const sec = await musicSection();
|
||||||
|
view.innerHTML = `<div class="bar"><h1>Artists</h1></div><div class="grid"></div>`;
|
||||||
|
await pagedGrid(seq, `/library/sections/${sec}/all`, { type: 8, sort: "titleSort" },
|
||||||
|
m => card(`#/artist/${m.ratingKey}`, plexImg(m.thumb), m.title, "", true));
|
||||||
|
},
|
||||||
|
async playlists() {
|
||||||
|
if (!S.plex) return needPlex();
|
||||||
|
const pls = (await px("/playlists", { playlistType: "audio" })).Metadata || [];
|
||||||
|
view.innerHTML = `<div class="bar"><h1>Playlists</h1></div>` + (pls.length
|
||||||
|
? `<div class="grid">${pls.map(p => card(`#/playlist/${p.ratingKey}`, plexImg(p.composite || p.thumb), p.title, `${p.leafCount} tracks`)).join("")}</div>`
|
||||||
|
: `<p class="empty-msg">No music playlists.</p>`);
|
||||||
|
},
|
||||||
|
async album(seq, key) {
|
||||||
|
const [info, kids] = await Promise.all([px(`/library/metadata/${key}`), px(`/library/metadata/${key}/children`)]);
|
||||||
|
const a = info.Metadata?.[0] || {}, tracks = (kids.Metadata || []).map(plexTrack);
|
||||||
|
const stems = tracks.filter(t => Stems.find(t)).length;
|
||||||
|
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(a.thumb, 420))}" alt="">
|
||||||
|
<div class="meta"><div class="kind">Album</div><h1>${esc(a.title)}</h1>
|
||||||
|
<div class="sub"><a href="#/artist/${a.parentRatingKey}">${esc(a.parentTitle)}</a>${a.year ? " · " + a.year : ""} · ${tracks.length} tracks${stems ? ` · ${stems} with stems` : ""}</div>
|
||||||
|
${playButtons("album")}</div></div>${trackTable("album", tracks, { numbers: true, album: false })}`;
|
||||||
|
},
|
||||||
|
async artist(seq, key) {
|
||||||
|
const [info, kids] = await Promise.all([px(`/library/metadata/${key}`), px(`/library/metadata/${key}/children`)]);
|
||||||
|
const a = info.Metadata?.[0] || {};
|
||||||
|
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(a.thumb, 420))}" alt="" style="border-radius:50%">
|
||||||
|
<div class="meta"><div class="kind">Artist</div><h1>${esc(a.title)}</h1>${playButtons("artist")}</div></div>
|
||||||
|
<h2>Albums</h2><div class="grid">${(kids.Metadata || []).map(albumCard).join("")}</div>`;
|
||||||
|
lists.artist = [];
|
||||||
|
const all = await px(`/library/metadata/${key}/allLeaves`);
|
||||||
|
if (seq === routeSeq) lists.artist = (all.Metadata || []).map(plexTrack);
|
||||||
|
},
|
||||||
|
async playlist(seq, key) {
|
||||||
|
const [info, items] = await Promise.all([px(`/playlists/${key}`), px(`/playlists/${key}/items`)]);
|
||||||
|
const p = info.Metadata?.[0] || {}, tracks = (items.Metadata || []).map(plexTrack);
|
||||||
|
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(p.composite || p.thumb, 420))}" alt="">
|
||||||
|
<div class="meta"><div class="kind">Playlist</div><h1>${esc(p.title)}</h1><div class="sub">${tracks.length} tracks</div>${playButtons("playlist")}</div></div>
|
||||||
|
${trackTable("playlist", tracks)}`;
|
||||||
|
},
|
||||||
|
async search(seq, q) {
|
||||||
|
if (!S.plex) return needPlex();
|
||||||
|
const hubs = (await px("/hubs/search", { query: q, limit: 12, sectionId: await musicSection() })).Hub || [];
|
||||||
|
const hub = t => hubs.find(h => h.type === t)?.Metadata || [];
|
||||||
|
const artists = hub("artist"), albums = hub("album"), tracks = hub("track").map(plexTrack);
|
||||||
|
view.innerHTML = `<div class="bar"><h1>“${esc(q)}”</h1></div>` +
|
||||||
|
(artists.length ? `<h2>Artists</h2><div class="grid">${artists.map(m => card(`#/artist/${m.ratingKey}`, plexImg(m.thumb), m.title, "", true)).join("")}</div>` : "") +
|
||||||
|
(albums.length ? `<h2>Albums</h2><div class="grid">${albums.map(albumCard).join("")}</div>` : "") +
|
||||||
|
(tracks.length ? `<h2>Songs</h2>${trackTable("search", tracks)}` : "") +
|
||||||
|
(!artists.length && !albums.length && !tracks.length ? `<p class="empty-msg">No results.</p>` : "");
|
||||||
|
},
|
||||||
|
async stems(seq) {
|
||||||
|
if (!Stems.entries.length) await Stems.refresh();
|
||||||
|
const all = Stems.entries.map(stemTrack).sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
view.innerHTML = `<div class="bar"><h1>Stems only</h1><input type="text" id="sf" placeholder="Filter ${all.length} tracks"></div><div id="sl"></div>`;
|
||||||
|
const render = () => {
|
||||||
|
const f = $("#sf").value.toLowerCase();
|
||||||
|
const hits = f ? all.filter(t => t.stem.key.toLowerCase().includes(f)) : all;
|
||||||
|
$("#sl").innerHTML = (hits.length ? trackTable("stems", hits.slice(0, 400)) : `<p class="empty-msg">${Stems.ok ? "No matches." : "Can't reach stemserve. Check Settings."}</p>`) +
|
||||||
|
(hits.length > 400 ? `<p class="note">Showing 400 of ${hits.length}. Filter to narrow it down.</p>` : "");
|
||||||
|
markPlaying();
|
||||||
|
};
|
||||||
|
$("#sf").oninput = render;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
async settings() {
|
||||||
|
const info = await fetch(stemBase() + "/api/info" + stemQuery()).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||||
|
let secs = [];
|
||||||
|
if (S.plex) secs = ((await px("/library/sections").catch(() => ({}))).Directory || []).filter(d => d.type === "artist");
|
||||||
|
view.innerHTML = `<h1>Settings</h1>
|
||||||
|
<h2>Plex</h2><div class="form">
|
||||||
|
<p class="note">${S.plex ? `Plex: <b>${esc(S.plex.env ? "via this server" : S.plex.name && S.plex.name !== S.plex.url ? S.plex.name + " (" + S.plex.url + ")" : S.plex.url)}</b>${S.plex.env ? " (set by the server)" : ""}` : "Not signed in."}</p>
|
||||||
|
<div class="row"><button class="btn primary" id="signin">Sign in with Plex</button>${S.plex ? `<button class="btn" id="signout">Sign out</button>` : ""}<span class="note" id="sstatus"></span></div>
|
||||||
|
${secs.length > 1 ? `<label>Music library<select id="sec">${secs.map(d => `<option value="${d.key}" ${d.key == S.section ? "selected" : ""}>${esc(d.title)}</option>`).join("")}</select></label>` : ""}
|
||||||
|
<details><summary class="note">Enter a server and token by hand</summary><div class="form" style="margin-top:12px">
|
||||||
|
<label>Server URL<input type="url" id="purl" placeholder="http://10.0.0.2:32400" value="${esc(S.plex?.url || "")}"></label>
|
||||||
|
<label>Token<input type="password" id="ptok" value="${esc(S.plex?.token || "")}"></label>
|
||||||
|
<div class="row"><button class="btn" id="psave">Save</button></div></div></details>
|
||||||
|
</div>
|
||||||
|
<h2>Stem server</h2><div class="form">
|
||||||
|
<p class="note">${info ? `stemserve v${info.version}, ${info.tracks} tracks with stems${info.ffmpeg ? ", Opus available" : ""}.` : "Can't reach stemserve."}</p>
|
||||||
|
<label>URL (blank = this page's server)<input type="url" id="surl" placeholder="${esc(location.origin)}" value="${esc(S.stemUrl)}"></label>
|
||||||
|
<label>Token (if stemserve has TOKEN set)<input type="password" id="stok" value="${esc(S.stemToken)}"></label>
|
||||||
|
<label>Stream format<select id="fmt">${[["opus96", "Opus 96 kbps"], ["opus160", "Opus 160 kbps"], ["opus256", "Opus 256 kbps"], ["flac", "FLAC (original)"]]
|
||||||
|
.map(([v, l]) => `<option value="${v}" ${S.fmt === v ? "selected" : ""}>${l}</option>`).join("")}</select></label>
|
||||||
|
<div class="row"><button class="btn" id="ssave">Save</button></div>
|
||||||
|
</div>
|
||||||
|
<h2>Keys</h2><p class="note">Space play/pause · ←/→ seek 5s (shift 30s) · S shuffle · R repeat · [ ] speed · M mixer · Q queue</p>`;
|
||||||
|
$("#signin").onclick = async () => {
|
||||||
|
const st = $("#sstatus");
|
||||||
|
try { S.plex = await plexSignIn(m => { st.textContent = m; }); S.section = null; save(); route(); }
|
||||||
|
catch (e) { st.textContent = e.message; }
|
||||||
|
};
|
||||||
|
if ($("#signout")) $("#signout").onclick = () => { S.plex = null; S.section = null; save(); route(); };
|
||||||
|
if ($("#sec")) $("#sec").onchange = ev => { S.section = ev.target.value; save(); };
|
||||||
|
$("#psave").onclick = () => {
|
||||||
|
const url = $("#purl").value.trim().replace(/\/+$/, ""), token = $("#ptok").value.trim();
|
||||||
|
S.plex = url && token ? { url, token, name: "" } : null; S.section = null; save(); route();
|
||||||
|
};
|
||||||
|
$("#ssave").onclick = async () => {
|
||||||
|
S.stemUrl = $("#surl").value.trim(); S.stemToken = $("#stok").value.trim(); S.fmt = $("#fmt").value;
|
||||||
|
Stems.etag = null; save(); await Stems.refresh(); route();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function route() {
|
||||||
|
const seq = ++routeSeq;
|
||||||
|
const [, name = "albums", ...rest] = (location.hash || "#/albums").split("/");
|
||||||
|
const arg = decodeURIComponent(rest.join("/"));
|
||||||
|
document.querySelectorAll("#nav a").forEach(a => a.classList.toggle("on", a.dataset.nav === name));
|
||||||
|
lists = {};
|
||||||
|
view.scrollTop = 0;
|
||||||
|
try { await (routes[name] || routes.albums)(seq, arg); }
|
||||||
|
catch (e) { if (seq === routeSeq) view.innerHTML = `<h1>Something went wrong</h1><p class="sub">${esc(e.message)}</p>`; }
|
||||||
|
if (seq === routeSeq) markPlaying();
|
||||||
|
}
|
||||||
|
addEventListener("hashchange", route);
|
||||||
|
$("#search").onsubmit = ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const q = ev.target.q.value.trim();
|
||||||
|
if (q) location.hash = "#/search/" + encodeURIComponent(q);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plex from the server's PLEX_URL / PLEX_TOKEN when nothing was signed in by hand, and a
|
||||||
|
// local stemserve when this page's own server has none.
|
||||||
|
async function autoConnect() {
|
||||||
|
const ok = await Stems.refresh();
|
||||||
|
if (!ok && !Stems.ok && !S.stemUrl && !/^(localhost|127\.0\.0\.1)$/.test(location.hostname)) {
|
||||||
|
for (const u of ["http://localhost:8765", "http://127.0.0.1:8765"]) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(u + "/api/info" + stemQuery(), { signal: AbortSignal.timeout(1500) });
|
||||||
|
if (r.ok) { S.stemUrl = u; save(); await Stems.refresh(); break; }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!S.plex || S.plex.env) {
|
||||||
|
try {
|
||||||
|
const c = await fetch(stemBase() + "/api/config" + stemQuery()).then(r => r.ok ? r.json() : null);
|
||||||
|
if (c?.plex) S.plex = { token: c.plex.token, url: stemBase() + "/plex", name: "", env: true };
|
||||||
|
else if (S.plex?.env) S.plex = null;
|
||||||
|
save();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- start -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await Promise.race([autoConnect(), new Promise(r => setTimeout(r, 4000))]);
|
||||||
|
const saved = load(KEY + "-queue");
|
||||||
|
if (saved?.items?.length) {
|
||||||
|
Q.items = saved.items; Q.orig = saved.orig; Q.i = Math.min(saved.i, saved.items.length - 1);
|
||||||
|
uidSeq = Math.max(uidSeq, ...saved.items.map(x => x.uid + 1));
|
||||||
|
if (Q.orig) Q.orig = Q.orig.map(o => Q.items.find(x => x.uid === o.uid) || o);
|
||||||
|
Q.start(saved.pos || 0, false);
|
||||||
|
}
|
||||||
|
UI.all();
|
||||||
|
route();
|
||||||
|
setInterval(() => Stems.refresh(), 5 * 60 * 1000);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#0d0e12">
|
||||||
|
<title>Stemify</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<aside id="nav">
|
||||||
|
<div class="brand">stemify</div>
|
||||||
|
<a href="#/albums" data-nav="albums">Albums</a>
|
||||||
|
<a href="#/artists" data-nav="artists">Artists</a>
|
||||||
|
<a href="#/playlists" data-nav="playlists">Playlists</a>
|
||||||
|
<a href="#/stems" data-nav="stems">Stems only</a>
|
||||||
|
<form id="search"><input type="search" name="q" placeholder="Search" autocomplete="off"></form>
|
||||||
|
<a href="#/settings" data-nav="settings" class="settings-link">Settings</a>
|
||||||
|
</aside>
|
||||||
|
<main id="view"></main>
|
||||||
|
<aside id="drawer" hidden></aside>
|
||||||
|
</div>
|
||||||
|
<footer id="player" class="empty">
|
||||||
|
<div class="np">
|
||||||
|
<img id="np-art" alt="">
|
||||||
|
<div class="np-text"><div id="np-title"></div><div id="np-sub"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="center">
|
||||||
|
<div class="transport">
|
||||||
|
<button id="b-shuffle" class="side" title="Shuffle (S)"></button>
|
||||||
|
<button id="b-prev" title="Previous"></button>
|
||||||
|
<button id="b-play" class="play" title="Play (Space)"></button>
|
||||||
|
<button id="b-next" title="Next"></button>
|
||||||
|
<button id="b-repeat" class="side" title="Repeat (R)"></button>
|
||||||
|
</div>
|
||||||
|
<div class="seek">
|
||||||
|
<span id="t-pos">0:00</span>
|
||||||
|
<canvas id="wave"></canvas>
|
||||||
|
<span id="t-dur">0:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<select id="speed" title="Speed"></select>
|
||||||
|
<button id="b-mixer" class="pill" title="Mixer (M)"></button>
|
||||||
|
<button id="b-queue" class="pill" title="Queue (Q)"></button>
|
||||||
|
<input id="volume" type="range" min="0" max="1" step="0.01" title="Volume">
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<script type="module" src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0d0e12;
|
||||||
|
--panel: #15171d;
|
||||||
|
--raised: #1d2028;
|
||||||
|
--line: #262a33;
|
||||||
|
--text: #eceef3;
|
||||||
|
--muted: #8a8f9c;
|
||||||
|
--accent: #f2b544;
|
||||||
|
--accent-ink: #1a1306;
|
||||||
|
--radius: 12px;
|
||||||
|
--player-h: 96px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
|
||||||
|
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif; }
|
||||||
|
button, input, select { font: inherit; color: inherit; }
|
||||||
|
button { background: none; border: 0; cursor: pointer; padding: 0; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
svg { width: 1em; height: 1em; fill: currentColor; display: block; }
|
||||||
|
|
||||||
|
#app { display: grid; grid-template-columns: 210px 1fr auto; height: calc(100% - var(--player-h)); }
|
||||||
|
#nav { background: var(--panel); border-right: 1px solid var(--line); padding: 20px 12px; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.brand { white-space: nowrap; font-weight: 800; font-size: 20px; letter-spacing: -0.02em; padding: 0 10px 18px; }
|
||||||
|
.brand::after { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); margin-left: 3px; }
|
||||||
|
#nav a { padding: 8px 10px; border-radius: 8px; color: var(--muted); font-weight: 500; }
|
||||||
|
#nav a:hover { color: var(--text); }
|
||||||
|
#nav a.on { background: var(--raised); color: var(--text); }
|
||||||
|
#search input { width: 100%; margin: 12px 0; padding: 8px 10px; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; outline: none; }
|
||||||
|
#search input:focus { border-color: var(--accent); }
|
||||||
|
.settings-link { margin-top: auto; }
|
||||||
|
|
||||||
|
#view { overflow-y: auto; padding: 28px 32px 48px; }
|
||||||
|
h1 { font-size: 28px; letter-spacing: -0.02em; margin: 0 0 4px; }
|
||||||
|
h2 { font-size: 17px; margin: 28px 0 12px; }
|
||||||
|
.sub { color: var(--muted); }
|
||||||
|
.bar { display: flex; align-items: center; gap: 10px; margin: 0 0 20px; flex-wrap: wrap; }
|
||||||
|
.bar h1 { margin-right: auto; }
|
||||||
|
select, input[type=text], input[type=url], input[type=password] { background: var(--raised); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; outline: none; }
|
||||||
|
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 22px 18px; }
|
||||||
|
.card { display: block; min-width: 0; }
|
||||||
|
.card img, .card .ph { width: 100%; aspect-ratio: 1; border-radius: 10px; object-fit: cover; background: var(--raised); display: block; transition: transform .15s; }
|
||||||
|
.card:hover img { transform: translateY(-2px); }
|
||||||
|
.card.round img, .card.round .ph { border-radius: 50%; }
|
||||||
|
.card .t { margin-top: 8px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.card .s { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.hero { display: flex; gap: 28px; align-items: flex-end; margin-bottom: 26px; }
|
||||||
|
.hero img { width: 210px; height: 210px; border-radius: var(--radius); object-fit: cover; background: var(--raised); box-shadow: 0 20px 50px #0008; }
|
||||||
|
.hero .meta { min-width: 0; }
|
||||||
|
.hero .kind { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; font-weight: 600; }
|
||||||
|
.actions { display: flex; gap: 10px; margin-top: 16px; }
|
||||||
|
.btn { background: var(--raised); border-radius: 999px; padding: 9px 18px; font-weight: 600; display: inline-flex; gap: 8px; align-items: center; }
|
||||||
|
.btn.primary { background: var(--accent); color: var(--accent-ink); }
|
||||||
|
.btn:hover { filter: brightness(1.12); }
|
||||||
|
|
||||||
|
.tracks { width: 100%; border-collapse: collapse; }
|
||||||
|
.tracks tr { border-radius: 8px; }
|
||||||
|
.tracks td { padding: 8px 10px; border-bottom: 1px solid #ffffff08; white-space: nowrap; }
|
||||||
|
.tracks tr:hover td { background: var(--panel); }
|
||||||
|
.tracks tr.playing td { color: var(--accent); }
|
||||||
|
.tracks .n { width: 36px; color: var(--muted); text-align: right; }
|
||||||
|
.tracks .ti { width: 100%; max-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.tracks .ti small { color: var(--muted); margin-left: 8px; }
|
||||||
|
.tracks .d { color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.tracks .add { opacity: 0; color: var(--muted); font-size: 18px; }
|
||||||
|
.tracks tr:hover .add { opacity: 1; }
|
||||||
|
.tracks .add:hover { color: var(--text); }
|
||||||
|
.stem-dot { display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: .05em; color: var(--accent); border: 1px solid currentColor; border-radius: 4px; padding: 0 4px; margin-left: 8px; vertical-align: 1px; }
|
||||||
|
.more { display: block; margin: 24px auto; }
|
||||||
|
.empty-msg { color: var(--muted); padding: 40px 0; }
|
||||||
|
|
||||||
|
.form { max-width: 520px; display: grid; gap: 14px; }
|
||||||
|
.form label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
||||||
|
.form label input, .form label select { color: var(--text); font-size: 14px; }
|
||||||
|
.form .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.note { color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
#drawer { width: 340px; background: var(--panel); border-left: 1px solid var(--line); overflow-y: auto; padding: 22px 20px; }
|
||||||
|
#drawer h2 { margin: 0 0 14px; }
|
||||||
|
.presets { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
|
||||||
|
.chip { background: var(--raised); border-radius: 999px; padding: 5px 12px; font-size: 13px; font-weight: 500; }
|
||||||
|
.chip.on { background: var(--accent); color: var(--accent-ink); }
|
||||||
|
.chip:disabled { opacity: .35; cursor: default; }
|
||||||
|
.stem { display: grid; grid-template-columns: 1fr auto auto; gap: 8px 6px; align-items: center; padding: 12px 0; border-top: 1px solid var(--line); }
|
||||||
|
.stem .name { text-transform: capitalize; font-weight: 600; }
|
||||||
|
.stem.silent .name { color: var(--muted); }
|
||||||
|
.stem input { grid-column: 1 / -1; width: 100%; }
|
||||||
|
.ms { width: 30px; height: 26px; border-radius: 6px; background: var(--raised); font-size: 12px; font-weight: 700; color: var(--muted); }
|
||||||
|
.ms.on { background: var(--text); color: var(--bg); }
|
||||||
|
.ms.solo.on { background: var(--accent); color: var(--accent-ink); }
|
||||||
|
.q-item { display: flex; gap: 10px; align-items: center; padding: 7px 6px; border-radius: 8px; cursor: pointer; }
|
||||||
|
.q-item:hover { background: var(--raised); }
|
||||||
|
.q-item img { width: 38px; height: 38px; border-radius: 6px; object-fit: cover; background: var(--raised); flex: none; }
|
||||||
|
.q-item .tx { min-width: 0; flex: 1; }
|
||||||
|
.q-item .tx div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.q-item .tx div + div { color: var(--muted); font-size: 12px; }
|
||||||
|
.q-item.cur { background: var(--raised); }
|
||||||
|
.q-item.cur .tx div:first-child { color: var(--accent); }
|
||||||
|
.q-item.past { opacity: .45; }
|
||||||
|
.q-item .x { color: var(--muted); font-size: 18px; padding: 0 4px; opacity: 0; }
|
||||||
|
.q-item:hover .x { opacity: 1; }
|
||||||
|
|
||||||
|
#player { position: fixed; left: 0; right: 0; bottom: 0; height: var(--player-h); background: #101116ee; backdrop-filter: blur(20px);
|
||||||
|
border-top: 1px solid var(--line); display: grid; grid-template-columns: minmax(180px, 1fr) minmax(320px, 1.6fr) minmax(180px, 1fr); align-items: center; gap: 20px; padding: 0 20px; }
|
||||||
|
#player.empty .center, #player.empty .right { opacity: .35; pointer-events: none; }
|
||||||
|
.np { display: flex; gap: 12px; align-items: center; min-width: 0; }
|
||||||
|
#np-art { width: 58px; height: 58px; border-radius: 8px; object-fit: cover; background: var(--raised); flex: none; }
|
||||||
|
#np-art:not([src]) { display: none; }
|
||||||
|
.np-text { min-width: 0; }
|
||||||
|
#np-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
#np-sub { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.center { display: grid; gap: 4px; }
|
||||||
|
.transport { display: flex; justify-content: center; align-items: center; gap: 18px; font-size: 20px; }
|
||||||
|
.transport button { color: var(--text); }
|
||||||
|
.transport .side { color: var(--muted); font-size: 17px; position: relative; }
|
||||||
|
.transport .side.on { color: var(--accent); }
|
||||||
|
.transport .side.on::after { content: ""; position: absolute; left: 50%; bottom: -6px; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: currentColor; }
|
||||||
|
.transport .play { width: 40px; height: 40px; border-radius: 50%; background: var(--text); color: var(--bg); display: grid; place-items: center; font-size: 18px; }
|
||||||
|
.transport .play.busy svg { animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.seek { display: grid; grid-template-columns: 42px 1fr 42px; align-items: center; gap: 10px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.seek span:last-child { text-align: right; }
|
||||||
|
#wave { width: 100%; height: 32px; cursor: pointer; display: block; }
|
||||||
|
.right { display: flex; justify-content: flex-end; align-items: center; gap: 10px; }
|
||||||
|
.pill { color: var(--muted); font-size: 18px; width: 34px; height: 34px; border-radius: 8px; display: grid; place-items: center; }
|
||||||
|
.pill.on { background: var(--raised); color: var(--accent); }
|
||||||
|
#speed { padding: 5px 6px; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
#speed.on { color: var(--accent); border-color: var(--accent); }
|
||||||
|
#volume { width: 96px; }
|
||||||
|
input[type=range] { accent-color: var(--accent); }
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
:root { --player-h: 150px; }
|
||||||
|
#app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
|
||||||
|
#nav { flex-direction: row; overflow-x: auto; padding: 10px 12px; border-right: 0; border-bottom: 1px solid var(--line); align-items: center; }
|
||||||
|
.brand { padding: 0 8px 0 4px; }
|
||||||
|
#nav a { white-space: nowrap; }
|
||||||
|
#search input { margin: 0; width: 120px; }
|
||||||
|
.settings-link { margin-top: 0; }
|
||||||
|
#view { padding: 20px 16px 40px; }
|
||||||
|
#drawer { position: fixed; inset: auto 0 var(--player-h) 0; width: auto; max-height: 60vh; border-left: 0; border-top: 1px solid var(--line); z-index: 5; }
|
||||||
|
#player { grid-template-columns: 1fr auto; grid-template-rows: auto auto; gap: 8px 12px; padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); }
|
||||||
|
.center { grid-column: 1 / -1; grid-row: 2; }
|
||||||
|
.right #volume { display: none; }
|
||||||
|
.hero { flex-direction: column; align-items: flex-start; }
|
||||||
|
.hero img { width: 160px; height: 160px; }
|
||||||
|
.grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 16px 12px; }
|
||||||
|
}
|
||||||
Reference in new issue
Block a user