Compare commits
18
Commits
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,7 +169,11 @@ 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.
|
||||||
|
- **AirPlay:** the AirPlay button on Now Playing picks the speaker or TV. Stems are mixed on the phone, so mixer changes apply live while it plays over AirPlay.
|
||||||
|
- **Tab bar:** Settings → Tab Bar. Pick and reorder up to five of Library, Songs, Albums, Artists, Genres, Playlists, Search, Downloads and Settings. Settings always stays.
|
||||||
|
- **Debug logs:** Settings → Debug. Turn on Verbose Logging, then View, Export or Clear the log. Export opens the share sheet with the app version, device, iOS version and audio output at the top. Tokens are stripped from the log.
|
||||||
- **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.
|
||||||
- Plex playlists sync with Plex and Plexamp.
|
- Plex playlists sync with Plex and Plexamp.
|
||||||
@@ -211,6 +226,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 +301,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 +317,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
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// What an export file holds: stem playlists (with each song's mix) and the non-secret sound settings.
|
||||||
|
/// Plex and stem server tokens and URLs are never included.
|
||||||
|
struct StemifyExport: Codable {
|
||||||
|
static let format = "stemify-export"
|
||||||
|
static let version = 1
|
||||||
|
|
||||||
|
struct Prefs: Codable {
|
||||||
|
var stemQuality: String?
|
||||||
|
var keepStemMix: Bool?
|
||||||
|
var karaokeVocals: Int?
|
||||||
|
var karaokeSoftDb: Int?
|
||||||
|
var playbackSpeed: Double?
|
||||||
|
var downloadsWifiOnly: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
var format = StemifyExport.format
|
||||||
|
var version = StemifyExport.version
|
||||||
|
var exported = Date()
|
||||||
|
var playlists: [StemPlaylist]
|
||||||
|
var prefs: Prefs
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func capture(_ lists: StemPlaylists, _ s: Settings) -> StemifyExport {
|
||||||
|
StemifyExport(playlists: lists.lists, prefs: Prefs(
|
||||||
|
stemQuality: s.stemQuality.rawValue, keepStemMix: s.keepStemMix, karaokeVocals: s.karaokeVocals,
|
||||||
|
karaokeSoftDb: s.karaokeSoftDb, playbackSpeed: s.playbackSpeed, downloadsWifiOnly: s.downloadsWifiOnly))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encoded() throws -> Data {
|
||||||
|
let e = JSONEncoder()
|
||||||
|
e.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
e.dateEncodingStrategy = .iso8601
|
||||||
|
return try e.encode(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) throws -> StemifyExport {
|
||||||
|
let d = JSONDecoder()
|
||||||
|
d.dateDecodingStrategy = .iso8601
|
||||||
|
let x = try d.decode(StemifyExport.self, from: data)
|
||||||
|
guard x.format == Self.format else { throw BackupError.notAnExport }
|
||||||
|
guard x.version <= Self.version else { throw BackupError.newerVersion }
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func apply(to s: Settings) {
|
||||||
|
if let v = prefs.stemQuality.flatMap(StemQuality.init(rawValue:)) { s.stemQuality = v }
|
||||||
|
if let v = prefs.keepStemMix { s.keepStemMix = v }
|
||||||
|
if let v = prefs.karaokeVocals { s.karaokeVocals = v }
|
||||||
|
if let v = prefs.karaokeSoftDb { s.karaokeSoftDb = v }
|
||||||
|
if let v = prefs.playbackSpeed { s.playbackSpeed = v }
|
||||||
|
if let v = prefs.downloadsWifiOnly { s.downloadsWifiOnly = v }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BackupError: LocalizedError {
|
||||||
|
case notAnExport, newerVersion
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notAnExport: "That file isn't a Stemify export."
|
||||||
|
case .newerVersion: "That export is from a newer version of Stemify."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an export goes and comes from. The Files sheet covers local storage and iCloud Drive;
|
||||||
|
/// a direct iCloud (ubiquity container / CloudKit) backend would be another conformer.
|
||||||
|
protocol BackupDestination {
|
||||||
|
func write(_ data: Data, name: String) async throws
|
||||||
|
func read() async throws -> Data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The export as a document for fileExporter, which also reaches iCloud Drive through Files.
|
||||||
|
struct ExportDocument: FileDocument {
|
||||||
|
static var readableContentTypes: [UTType] { [.json] }
|
||||||
|
var data: Data
|
||||||
|
init(data: Data = Data()) { self.data = data }
|
||||||
|
init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
|
||||||
|
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// App log kept in a size-capped file so a tester can send it. Tokens are stripped before anything is written.
|
||||||
|
final class Log: @unchecked Sendable {
|
||||||
|
static let shared = Log()
|
||||||
|
enum Level: String { case debug = "D", info = "I", error = "E" }
|
||||||
|
|
||||||
|
static let verboseKey = "verboseLogging"
|
||||||
|
static var verbose: Bool {
|
||||||
|
get { UserDefaults.standard.bool(forKey: verboseKey) }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: verboseKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let url: URL
|
||||||
|
private let maxBytes = 512 * 1024
|
||||||
|
private let stamp: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "MM-dd HH:mm:ss.SSS"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||||
|
url = dir.appendingPathComponent("stemify.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let secret = try! NSRegularExpression(
|
||||||
|
pattern: #"(X-Plex-Token|X-Stem-Token|token|Authorization)([=:"' ]+)[^&\s"',}]+"#, options: [.caseInsensitive])
|
||||||
|
|
||||||
|
static func redact(_ s: String) -> String {
|
||||||
|
secret.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "$1$2<redacted>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func write(_ level: Level, _ tag: String, _ msg: String) {
|
||||||
|
if level == .debug && !Log.verbose { return }
|
||||||
|
let line = "\(stamp.string(from: Date())) \(level.rawValue) [\(tag)] \(Log.redact(msg))\n"
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
guard let data = line.data(using: .utf8) else { return }
|
||||||
|
let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0
|
||||||
|
if size > maxBytes { trim() }
|
||||||
|
if FileManager.default.fileExists(atPath: url.path) { append(data) } else { try? data.write(to: url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the newest half of the file.
|
||||||
|
private func trim() {
|
||||||
|
guard let all = try? Data(contentsOf: url) else { return }
|
||||||
|
let tail = all.suffix(maxBytes / 2)
|
||||||
|
let cut = tail.firstIndex(of: 0x0A).map { tail.index(after: $0) } ?? tail.startIndex
|
||||||
|
try? Data(tail[cut...]).write(to: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func append(_ data: Data) {
|
||||||
|
guard let h = try? FileHandle(forWritingTo: url) else { return }
|
||||||
|
defer { try? h.close() }
|
||||||
|
_ = try? h.seekToEnd()
|
||||||
|
try? h.write(contentsOf: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func text() -> String {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device and app details followed by the log, written to a temp file for the share sheet.
|
||||||
|
func export() -> URL {
|
||||||
|
let b = Bundle.main
|
||||||
|
let d = UIDevice.current
|
||||||
|
var head = "Stemify \(b.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?") "
|
||||||
|
head += "(\(b.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"))\n"
|
||||||
|
head += "\(d.model) · \(d.systemName) \(d.systemVersion) · \(UIScreen.main.maximumFramesPerSecond) Hz\n"
|
||||||
|
head += "Verbose logging: \(Log.verbose ? "on" : "off")\n"
|
||||||
|
head += "Output: \(AirRoute.describe())\n\n"
|
||||||
|
let out = FileManager.default.temporaryDirectory.appendingPathComponent("stemify-log.txt")
|
||||||
|
try? (head + text()).write(to: out, atomically: true, encoding: .utf8)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func debug(_ tag: String, _ msg: @autoclosure () -> String) { if Log.verbose { write(.debug, tag, msg()) } }
|
||||||
|
func info(_ tag: String, _ msg: String) { write(.info, tag, msg) }
|
||||||
|
func error(_ tag: String, _ msg: String) { write(.error, tag, msg) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where audio is currently going (speaker, AirPlay, Bluetooth…).
|
||||||
|
enum AirRoute {
|
||||||
|
static func describe() -> String {
|
||||||
|
let outs = AVAudioSession.sharedInstance().currentRoute.outputs.map { "\($0.portName) (\($0.portType.rawValue))" }
|
||||||
|
return outs.isEmpty ? "none" : outs.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ final class Settings: @unchecked Sendable {
|
|||||||
private static let keys = [
|
private static let keys = [
|
||||||
"plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
|
"plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
|
||||||
"stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
|
"stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
|
||||||
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume",
|
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout",
|
||||||
]
|
]
|
||||||
|
|
||||||
private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T {
|
private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T {
|
||||||
@@ -175,6 +175,7 @@ final class Settings: @unchecked Sendable {
|
|||||||
/// Plex path prefixes that matched the stems index, newline-separated.
|
/// Plex path prefixes that matched the stems index, newline-separated.
|
||||||
var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } }
|
var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } }
|
||||||
|
|
||||||
|
var tabLayout: String { get { read(\.tabLayout, "tabLayout", Tab.encode(Tab.defaultLayout)) } set { write(\.tabLayout, "tabLayout", newValue, Tab.encode(Tab.defaultLayout)) } }
|
||||||
var theme: ThemeMode { get { readEnum(\.theme, "theme", .Black) } set { writeEnum(\.theme, "theme", newValue, .Black) } }
|
var theme: ThemeMode { get { readEnum(\.theme, "theme", .Black) } set { writeEnum(\.theme, "theme", newValue, .Black) } }
|
||||||
var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } }
|
var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } }
|
||||||
var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } }
|
var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } }
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ final class StemDecoder: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if !released {
|
if !released {
|
||||||
NSLog("StemDecoder %@ failed: %@", name, error.localizedDescription)
|
Log.shared.error("decoder", "\(name) failed: \(error.localizedDescription)")
|
||||||
state.with { _error = error }
|
state.with { _error = error }
|
||||||
}
|
}
|
||||||
ring.eos = true
|
ring.eos = true
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
|
|
||||||
/// Surfaces a message wherever messages show (toasts on the phone, an alert in the car).
|
/// Surfaces a message wherever messages show (toasts on the phone, an alert in the car).
|
||||||
func report(_ text: String) {
|
func report(_ text: String) {
|
||||||
|
Log.shared.error("report", text)
|
||||||
message = AppMessage(text: text)
|
message = AppMessage(text: text)
|
||||||
messageListeners.values.forEach { $0(text) }
|
messageListeners.values.forEach { $0(text) }
|
||||||
}
|
}
|
||||||
@@ -797,6 +798,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
guard let self, let info = n.userInfo,
|
guard let self, let info = n.userInfo,
|
||||||
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
|
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
|
||||||
|
Log.shared.info("session", "interruption \(type == .began ? "began" : "ended")")
|
||||||
switch type {
|
switch type {
|
||||||
case .began:
|
case .began:
|
||||||
// A stale "began" arrives after the app was suspended; the session wasn't really taken.
|
// A stale "began" arrives after the app was suspended; the session wasn't really taken.
|
||||||
@@ -813,12 +815,14 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
nc.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] n in
|
nc.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] n in
|
||||||
guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||||
let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }
|
let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }
|
||||||
|
Log.shared.info("route", "change reason=\(reason.rawValue) now \(AirRoute.describe())")
|
||||||
// Headphones unplugged or Bluetooth gone: don't carry on out of the speaker.
|
// Headphones unplugged or Bluetooth gone: don't carry on out of the speaker.
|
||||||
if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() }
|
if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() }
|
||||||
self.eq.refreshRoute()
|
self.eq.refreshRoute()
|
||||||
}
|
}
|
||||||
nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
|
nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
Log.shared.error("session", "media services reset")
|
||||||
let was = self.playWhenReady
|
let was = self.playWhenReady
|
||||||
let pos = self.positionMs()
|
let pos = self.positionMs()
|
||||||
self.currentToken = -1
|
self.currentToken = -1
|
||||||
|
|||||||
@@ -70,6 +70,24 @@ final class StemPlaylists {
|
|||||||
return MixPresets.current(allStems, mix)?.label ?? "Custom Mix"
|
return MixPresets.current(allStems, mix)?.label ?? "Custom Mix"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Brings in playlists from an export: new ids are added, ids already here are replaced when the
|
||||||
|
/// import was updated more recently. Returns how many lists were added or replaced.
|
||||||
|
@discardableResult
|
||||||
|
func merge(_ incoming: [StemPlaylist]) -> Int {
|
||||||
|
var n = 0
|
||||||
|
for pl in incoming {
|
||||||
|
if let i = lists.firstIndex(where: { $0.id == pl.id }) {
|
||||||
|
guard pl.updated > lists[i].updated else { continue }
|
||||||
|
lists[i] = pl
|
||||||
|
} else {
|
||||||
|
lists.append(pl)
|
||||||
|
}
|
||||||
|
n += 1
|
||||||
|
}
|
||||||
|
if n > 0 { save() }
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
|
private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
|
||||||
guard let i = lists.firstIndex(where: { $0.id == id }) else { return }
|
guard let i = lists.firstIndex(where: { $0.id == id }) else { return }
|
||||||
change(&lists[i])
|
change(&lists[i])
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import AVKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
let MARGIN: CGFloat = 20
|
let MARGIN: CGFloat = 20
|
||||||
|
|
||||||
@@ -330,3 +332,21 @@ private struct ListRow: ViewModifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The system AirPlay / Bluetooth output picker. The mix is rendered on the phone, so stem edits keep working over AirPlay.
|
||||||
|
struct AirPlayButton: UIViewRepresentable {
|
||||||
|
var tint: UIColor = .label
|
||||||
|
var active: UIColor = .systemBlue
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> AVRoutePickerView {
|
||||||
|
let v = AVRoutePickerView()
|
||||||
|
v.prioritizesVideoDevices = false
|
||||||
|
v.backgroundColor = .clear
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ v: AVRoutePickerView, context: Context) {
|
||||||
|
v.tintColor = tint
|
||||||
|
v.activeTintColor = active
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,32 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum Tab: String, CaseIterable, Hashable {
|
enum Tab: String, CaseIterable, Hashable {
|
||||||
case Library, Playlists, Search, Downloads, Settings
|
case Library, Songs, Albums, Artists, Genres, Playlists, Search, Downloads, Settings
|
||||||
|
|
||||||
|
static let defaultLayout: [Tab] = [.Library, .Playlists, .Search, .Downloads, .Settings]
|
||||||
|
/// iOS folds anything past five tabs into "More", so the bar holds at most this many.
|
||||||
|
static let maxVisible = 5
|
||||||
|
|
||||||
|
/// Parses the saved comma list; Settings is always kept so the app can't lock itself out.
|
||||||
|
static func layout(_ raw: String) -> [Tab] {
|
||||||
|
var out: [Tab] = []
|
||||||
|
for part in raw.split(separator: ",") {
|
||||||
|
if let t = Tab(rawValue: String(part)), !out.contains(t) { out.append(t) }
|
||||||
|
}
|
||||||
|
if out.isEmpty { return defaultLayout }
|
||||||
|
if !out.contains(.Settings) { out = Array(out.prefix(maxVisible - 1)) + [.Settings] }
|
||||||
|
return Array(out.prefix(maxVisible))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func encode(_ tabs: [Tab]) -> String { tabs.map(\.rawValue).joined(separator: ",") }
|
||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .Library: "square.stack.fill"
|
case .Library: "square.stack.fill"
|
||||||
|
case .Songs: "music.note"
|
||||||
|
case .Albums: "square.stack"
|
||||||
|
case .Artists: "music.mic"
|
||||||
|
case .Genres: "guitars.fill"
|
||||||
case .Playlists: "music.note.list"
|
case .Playlists: "music.note.list"
|
||||||
case .Search: "magnifyingglass"
|
case .Search: "magnifyingglass"
|
||||||
case .Downloads: "arrow.down.circle.fill"
|
case .Downloads: "arrow.down.circle.fill"
|
||||||
@@ -15,10 +36,10 @@ enum Tab: String, CaseIterable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum Route: Hashable {
|
enum Route: Hashable {
|
||||||
case Artists, Albums, Songs, Playlists
|
case Artists, Albums, Songs, Playlists, Genres, Downloads, TabBar
|
||||||
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
||||||
case PlexAccount, StemServer, Quality, Storage, Appearance
|
case PlexAccount, StemServer, Quality, Storage, Appearance
|
||||||
case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
|
case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
|
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
|
||||||
@@ -47,13 +68,30 @@ final class Ui {
|
|||||||
if t == tab { paths[t] = [] } else { tab = t }
|
if t == tab { paths[t] = [] } else { tab = t }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tabs currently in the bar, in order.
|
||||||
|
var tabs: [Tab] { Tab.layout(AppGraph.shared.settings.tabLayout) }
|
||||||
|
|
||||||
|
/// Where library pages open: Library if it's in the bar, else the first tab that is.
|
||||||
|
private var home: Tab { tabs.contains(.Library) ? .Library : (tabs.first ?? .Settings) }
|
||||||
|
|
||||||
|
/// Downloads as its own tab, or pushed onto the current one when it's hidden.
|
||||||
|
func openDownloads() {
|
||||||
|
if tabs.contains(.Downloads) { selectTab(.Downloads) } else { push(.Downloads) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Falls back to the first tab when the selected one was just removed from the bar.
|
||||||
|
func ensureValidTab() {
|
||||||
|
if !tabs.contains(tab) { tab = tabs.first ?? .Settings }
|
||||||
|
}
|
||||||
|
|
||||||
func openNowPlaying() { nowPlayingOpen = true }
|
func openNowPlaying() { nowPlayingOpen = true }
|
||||||
|
|
||||||
/// Switches to the Library tab and pushes [r] there.
|
/// Switches to the Library tab and pushes [r] there.
|
||||||
func showInLibrary(_ r: Route) {
|
func showInLibrary(_ r: Route) {
|
||||||
nowPlayingOpen = false
|
nowPlayingOpen = false
|
||||||
tab = .Library
|
let t = home
|
||||||
paths[.Library, default: []].append(r)
|
tab = t
|
||||||
|
paths[t, default: []].append(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switches to Settings and opens [r] (the Equalizer) there.
|
/// Switches to Settings and opens [r] (the Equalizer) there.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ struct RootView: View {
|
|||||||
let s = g.settings
|
let s = g.settings
|
||||||
let p = Palette.of(s.theme, s.accent)
|
let p = Palette.of(s.theme, s.accent)
|
||||||
TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) {
|
TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) {
|
||||||
ForEach(Tab.allCases, id: \.self) { t in
|
ForEach(Tab.layout(s.tabLayout), id: \.self) { t in
|
||||||
NavigationStack(path: ui.path(t)) {
|
NavigationStack(path: ui.path(t)) {
|
||||||
root(t).miniPlayerRoom()
|
root(t).miniPlayerRoom()
|
||||||
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
|
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
|
||||||
@@ -20,6 +20,7 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modifier(GlassMiniPlayerHost())
|
.modifier(GlassMiniPlayerHost())
|
||||||
|
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
|
||||||
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
||||||
NowPlayingScreen()
|
NowPlayingScreen()
|
||||||
.overlayHost(ui.overlays, level: 1)
|
.overlayHost(ui.overlays, level: 1)
|
||||||
@@ -41,6 +42,10 @@ struct RootView: View {
|
|||||||
@ViewBuilder private func root(_ t: Tab) -> some View {
|
@ViewBuilder private func root(_ t: Tab) -> some View {
|
||||||
switch t {
|
switch t {
|
||||||
case .Library: LibraryHome()
|
case .Library: LibraryHome()
|
||||||
|
case .Songs: SongsScreen()
|
||||||
|
case .Albums: AlbumsScreen()
|
||||||
|
case .Artists: ArtistsScreen()
|
||||||
|
case .Genres: GenresScreen()
|
||||||
case .Playlists: PlaylistsScreen()
|
case .Playlists: PlaylistsScreen()
|
||||||
case .Search: SearchScreen()
|
case .Search: SearchScreen()
|
||||||
case .Downloads: DownloadsScreen()
|
case .Downloads: DownloadsScreen()
|
||||||
@@ -54,6 +59,10 @@ struct RootView: View {
|
|||||||
case .Albums: AlbumsScreen()
|
case .Albums: AlbumsScreen()
|
||||||
case .Songs: SongsScreen()
|
case .Songs: SongsScreen()
|
||||||
case .Playlists: PlaylistsScreen()
|
case .Playlists: PlaylistsScreen()
|
||||||
|
case .Genres: GenresScreen()
|
||||||
|
case .Downloads: DownloadsScreen()
|
||||||
|
case .TabBar: TabBarScreen()
|
||||||
|
case .GenrePage(let g): GenreScreen(genre: g)
|
||||||
case .Eq: EqScreen()
|
case .Eq: EqScreen()
|
||||||
case .EqFilters(let id): EqFiltersScreen(profileId: id)
|
case .EqFilters(let id): EqFiltersScreen(profileId: id)
|
||||||
case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f)
|
case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f)
|
||||||
|
|||||||
@@ -642,3 +642,56 @@ struct DownloadsScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- genres -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct GenresScreen: View {
|
||||||
|
@Environment(Ui.self) private var ui
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
@State private var albums: LoadState<[Album]> = .loading
|
||||||
|
@State private var reload = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Page(title: "Genres") {
|
||||||
|
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||||
|
let counts = Dictionary(grouping: list.compactMap(\.genre).filter { !$0.isEmpty }, by: { $0 }).mapValues(\.count)
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(counts.keys.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, id: \.self) { g in
|
||||||
|
Button { ui.push(.GenrePage(g)) } label: {
|
||||||
|
HStack {
|
||||||
|
Text(g).foregroundStyle(p.label)
|
||||||
|
Spacer()
|
||||||
|
Text("\(counts[g] ?? 0)").foregroundStyle(p.label2)
|
||||||
|
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label2)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Divider().padding(.leading, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||||
|
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GenreScreen: View {
|
||||||
|
@Environment(Ui.self) private var ui
|
||||||
|
let genre: String
|
||||||
|
@State private var albums: LoadState<[Album]> = .loading
|
||||||
|
@State private var reload = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Page(title: genre) {
|
||||||
|
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||||
|
AlbumGrid(albums: list.filter { $0.genre == genre }, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||||
|
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,21 +98,32 @@ struct NowPlayingScreen: View {
|
|||||||
let pm = g.playback
|
let pm = g.playback
|
||||||
let c = tint.map { base.tinted($0) } ?? base
|
let c = tint.map { base.tinted($0) } ?? base
|
||||||
let track = pm.current?.track
|
let track = pm.current?.track
|
||||||
VStack(spacing: 0) {
|
// Wide when there's no room to stack art over controls: landscape, or iPhone Duo's
|
||||||
|
// outer display. Tall layouts (including the unfolded Duo) cap the controls' width.
|
||||||
|
GeometryReader { geo in
|
||||||
|
let wide = geo.size.width > geo.size.height * 0.9
|
||||||
if let track {
|
if let track {
|
||||||
Group {
|
if wide {
|
||||||
switch panel {
|
HStack(spacing: 0) {
|
||||||
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady)
|
panelView(track, header: false)
|
||||||
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() }
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() }
|
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
||||||
case .Lyrics: VStack(spacing: 0) { MiniHeader(track: track); LyricsPanel(track: track) }
|
controls(track, wide: true)
|
||||||
}
|
.frame(width: min(geo.size.width * 0.48, 440))
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
panelView(track, header: true)
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
||||||
controls(track)
|
controls(track, wide: false).frame(maxWidth: 560)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2).frame(maxHeight: .infinity)
|
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
@@ -131,11 +142,21 @@ struct NowPlayingScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func controls(_ track: Track) -> some View {
|
@ViewBuilder
|
||||||
|
private func panelView(_ track: Track, header: Bool) -> some View {
|
||||||
|
switch panel {
|
||||||
|
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady)
|
||||||
|
case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() }
|
||||||
|
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track) }; QueuePanel() }
|
||||||
|
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track) }; LyricsPanel(track: track) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func controls(_ track: Track, wide: Bool) -> some View {
|
||||||
let pm = g.playback
|
let pm = g.playback
|
||||||
let c = tint.map { base.tinted($0) } ?? base
|
let c = tint.map { base.tinted($0) } ?? base
|
||||||
return VStack(spacing: 0) {
|
return VStack(spacing: 0) {
|
||||||
if panel == .Art {
|
if wide || panel == .Art {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(track.title).font(.title3.weight(.semibold)).foregroundStyle(c.label).lineLimit(1)
|
Text(track.title).font(.title3.weight(.semibold)).foregroundStyle(c.label).lineLimit(1)
|
||||||
@@ -151,7 +172,7 @@ struct NowPlayingScreen: View {
|
|||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
}
|
}
|
||||||
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
|
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
|
||||||
.padding(.top, panel == .Art ? 34 : 18)
|
.padding(.top, wide ? 24 : panel == .Art ? 34 : 30)
|
||||||
HStack {
|
HStack {
|
||||||
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -173,10 +194,12 @@ 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()
|
||||||
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
||||||
|
AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40)
|
||||||
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
|
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
|
||||||
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
||||||
}
|
}
|
||||||
@@ -560,6 +583,9 @@ private struct MixerPanel: View {
|
|||||||
let pm = g.playback
|
let pm = g.playback
|
||||||
let names = pm.stemNames
|
let names = pm.stemNames
|
||||||
let stems = pm.source == .Stems
|
let stems = pm.source == .Stems
|
||||||
|
GeometryReader { geo in
|
||||||
|
// Spread spare height across the rows (stems + master) instead of leaving it all at the bottom.
|
||||||
|
let rowH = min(62, max(46, (geo.size.height - 80) / CGFloat(names.count + 1)))
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
if pm.source == .Original {
|
if pm.source == .Original {
|
||||||
@@ -599,21 +625,14 @@ private struct MixerPanel: View {
|
|||||||
let levels = stems ? meters.step(pm.engine, names.count) : []
|
let levels = stems ? meters.step(pm.engine, names.count) : []
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(Array(names.enumerated()), id: \.element) { i, name in
|
ForEach(Array(names.enumerated()), id: \.element) { i, name in
|
||||||
StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems)
|
StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems, height: rowH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.separator.frame(height: 0.5).padding(.vertical, 10)
|
p.separator.frame(height: 0.5).padding(.vertical, 6)
|
||||||
HStack {
|
FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false, height: rowH) {
|
||||||
Text("Master").font(.headline).foregroundStyle(p.label)
|
|
||||||
Spacer()
|
|
||||||
Text("\(Int(pm.master * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2)
|
|
||||||
}
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "speaker.fill").font(.system(size: 13)).foregroundStyle(p.label2)
|
|
||||||
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
|
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
|
||||||
Image(systemName: "speaker.wave.3.fill").font(.system(size: 14)).foregroundStyle(p.label2)
|
} trailing: { EmptyView() }
|
||||||
}
|
|
||||||
Spacer(minLength: 16)
|
Spacer(minLength: 16)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
@@ -621,6 +640,7 @@ private struct MixerPanel: View {
|
|||||||
.scrollIndicators(.hidden)
|
.scrollIndicators(.hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
|
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
|
||||||
private struct PresetChip: View {
|
private struct PresetChip: View {
|
||||||
@@ -649,22 +669,14 @@ private struct StemFader: View {
|
|||||||
let setting: StemSetting
|
let setting: StemSetting
|
||||||
let level: Float
|
let level: Float
|
||||||
let enabled: Bool
|
let enabled: Bool
|
||||||
|
var height: CGFloat = 46
|
||||||
@Environment(\.palette) private var p
|
@Environment(\.palette) private var p
|
||||||
private var pm: PlaybackManager { AppGraph.shared.playback }
|
private var pm: PlaybackManager { AppGraph.shared.playback }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let color = Color(hex: Stems.color(name))
|
let color = Color(hex: Stems.color(name))
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted, height: height) {
|
||||||
HStack(spacing: 8) {
|
VStack(spacing: 0) {
|
||||||
Circle().fill(setting.muted ? p.label3 : color).frame(width: 10, height: 10)
|
|
||||||
Text(Stems.label(name)).font(.headline).foregroundStyle(setting.muted ? p.label2 : p.label)
|
|
||||||
Spacer()
|
|
||||||
Text("\(Int(stemTaper(setting.level) * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2).padding(.trailing, 4)
|
|
||||||
if enabled {
|
|
||||||
ToggleBox(text: "M", on: setting.muted, onColor: p.red) { pm.toggleMute(name) }
|
|
||||||
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00)) { pm.toggleSolo(name) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
|
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
|
||||||
pm.setStemLevel(name, $0)
|
pm.setStemLevel(name, $0)
|
||||||
}
|
}
|
||||||
@@ -679,7 +691,40 @@ private struct StemFader: View {
|
|||||||
.frame(height: 3)
|
.frame(height: 3)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.padding(.top, 10)
|
} trailing: {
|
||||||
|
if enabled {
|
||||||
|
ToggleBox(text: "M", on: setting.muted, onColor: p.red) { pm.toggleMute(name) }
|
||||||
|
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00)) { pm.toggleSolo(name) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One mixer line: name, fader, level and buttons side by side, so all six stems and the
|
||||||
|
/// master fit without scrolling (the faders grab drags, so the list can't scroll well).
|
||||||
|
private struct FaderRow<Fader: View, Trailing: View>: View {
|
||||||
|
let dot: Color?
|
||||||
|
let label: String
|
||||||
|
let percent: Int
|
||||||
|
let dim: Bool
|
||||||
|
var height: CGFloat = 46
|
||||||
|
@ViewBuilder let fader: Fader
|
||||||
|
@ViewBuilder let trailing: Trailing
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if let dot { Circle().fill(dot).frame(width: 8, height: 8) }
|
||||||
|
Text(label).font(.subheadline.weight(.semibold)).foregroundStyle(dim ? p.label2 : p.label)
|
||||||
|
.lineLimit(1).minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.frame(width: 70, alignment: .leading)
|
||||||
|
fader
|
||||||
|
Text("\(percent)%").font(.caption.monospacedDigit()).foregroundStyle(p.label2)
|
||||||
|
.frame(width: 36, alignment: .trailing)
|
||||||
|
trailing
|
||||||
|
}
|
||||||
|
.frame(height: height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
/// A Settings.app row that pushes [route]: icon, title, grey value, chevron.
|
/// A Settings.app row that pushes [route]: icon, title, grey value, chevron.
|
||||||
private struct NavCell: View {
|
private struct NavCell: View {
|
||||||
@@ -147,6 +148,16 @@ struct SettingsScreen: View {
|
|||||||
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
|
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
|
||||||
ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
|
ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
|
||||||
} header: { Header(text: "Storage") }
|
} header: { Header(text: "Storage") }
|
||||||
|
Section {
|
||||||
|
BackupCells()
|
||||||
|
} header: { Header(text: "Backup") } footer: {
|
||||||
|
Footer(text: "Stem playlists, their mixes and sound settings. Tokens and server addresses are left out. Save to iCloud Drive from the Files sheet.")
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
DebugCells()
|
||||||
|
} header: { Header(text: "Debug") } footer: {
|
||||||
|
Footer(text: "Verbose adds playback and network detail. Tokens are removed from the log.")
|
||||||
|
}
|
||||||
Section {
|
Section {
|
||||||
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
|
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
|
||||||
} header: { Header(text: "Display") } footer: {
|
} header: { Header(text: "Display") } footer: {
|
||||||
@@ -159,6 +170,42 @@ struct SettingsScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct BackupCells: View {
|
||||||
|
private var g: AppGraph { .shared }
|
||||||
|
@State private var doc = ExportDocument()
|
||||||
|
@State private var exporting = false
|
||||||
|
@State private var importing = false
|
||||||
|
@State private var message: String?
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
do { doc = ExportDocument(data: try StemifyExport.capture(g.stemPlaylists, g.settings).encoded()); exporting = true }
|
||||||
|
catch { message = error.localizedDescription }
|
||||||
|
} label: { ValueCell(title: "Export", value: "\(g.stemPlaylists.lists.count) playlists", icon: "square.and.arrow.up", iconBg: 0x007AFF) }
|
||||||
|
.themedRow()
|
||||||
|
Button { importing = true } label: { ValueCell(title: "Import", value: nil, icon: "square.and.arrow.down", iconBg: 0x34C759) }
|
||||||
|
.themedRow()
|
||||||
|
.fileExporter(isPresented: $exporting, document: doc, contentType: .json, defaultFilename: "Stemify \(Date().formatted(.iso8601.year().month().day()))") { r in
|
||||||
|
if case .failure(let e) = r { message = e.localizedDescription }
|
||||||
|
}
|
||||||
|
.fileImporter(isPresented: $importing, allowedContentTypes: [.json]) { r in
|
||||||
|
do {
|
||||||
|
let url = try r.get()
|
||||||
|
let scoped = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
let x = try StemifyExport.decode(Data(contentsOf: url))
|
||||||
|
let n = g.stemPlaylists.merge(x.playlists)
|
||||||
|
x.apply(to: g.settings)
|
||||||
|
message = "Imported \(n) playlist\(n == 1 ? "" : "s") and your sound settings."
|
||||||
|
} catch { message = error.localizedDescription }
|
||||||
|
}
|
||||||
|
.alert("Backup", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: { Text(message ?? "") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- plex -----------------------------------------------------------------------------------
|
// ---- plex -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
struct PlexAccountScreen: View {
|
struct PlexAccountScreen: View {
|
||||||
@@ -466,7 +513,7 @@ struct StorageScreen: View {
|
|||||||
let usage = g.cache.usage
|
let usage = g.cache.usage
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
Button { ui.selectTab(.Downloads) } label: {
|
Button { ui.openDownloads() } label: {
|
||||||
HStack {
|
HStack {
|
||||||
ValueCell(title: "Downloads", value: gb(usage.pinnedBytes))
|
ValueCell(title: "Downloads", value: gb(usage.pinnedBytes))
|
||||||
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3)
|
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3)
|
||||||
@@ -556,6 +603,9 @@ struct AppearanceScreen: View {
|
|||||||
.tint(Color(hex: 0x34C759))
|
.tint(Color(hex: 0x34C759))
|
||||||
.themedRow()
|
.themedRow()
|
||||||
} header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") }
|
} header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") }
|
||||||
|
Section {
|
||||||
|
NavigationLink(value: Route.TabBar) { Text("Tab Bar").foregroundStyle(c.label) }.themedRow()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.themedList()
|
.themedList()
|
||||||
@@ -563,3 +613,111 @@ struct AppearanceScreen: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- tab bar editor ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct TabBarScreen: View {
|
||||||
|
@Environment(\.palette) private var c
|
||||||
|
private var g: AppGraph { .shared }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var s = g.settings
|
||||||
|
let shown = Tab.layout(s.tabLayout)
|
||||||
|
let hidden = Tab.allCases.filter { !shown.contains($0) }
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(shown, id: \.self) { t in
|
||||||
|
HStack {
|
||||||
|
Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label)
|
||||||
|
if t == .Settings { Spacer(); Text("Always on").font(.footnote).foregroundStyle(c.label2) }
|
||||||
|
}
|
||||||
|
.themedRow()
|
||||||
|
.deleteDisabled(t == .Settings)
|
||||||
|
}
|
||||||
|
.onMove { from, to in
|
||||||
|
var l = shown
|
||||||
|
l.move(fromOffsets: from, toOffset: to)
|
||||||
|
s.tabLayout = Tab.encode(l)
|
||||||
|
}
|
||||||
|
.onDelete { idx in
|
||||||
|
s.tabLayout = Tab.encode(shown.enumerated().filter { !idx.contains($0.offset) }.map(\.element))
|
||||||
|
}
|
||||||
|
} header: { Header(text: "In the bar") } footer: { Footer(text: "Drag to reorder, swipe to remove. The bar holds \(Tab.maxVisible) tabs.") }
|
||||||
|
if !hidden.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(hidden, id: \.self) { t in
|
||||||
|
Button { s.tabLayout = Tab.encode(shown + [t]) } label: {
|
||||||
|
HStack {
|
||||||
|
Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "plus.circle.fill").foregroundStyle(shown.count < Tab.maxVisible ? c.accent : c.label2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(shown.count >= Tab.maxVisible)
|
||||||
|
.themedRow()
|
||||||
|
}
|
||||||
|
} header: { Header(text: "Available") }
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button("Reset to default") { s.tabLayout = Tab.encode(Tab.defaultLayout) }.themedRow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.editMode, .constant(.active))
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.themedList()
|
||||||
|
.navigationTitle("Tab Bar")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---- debug ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private struct DebugCells: View {
|
||||||
|
@AppStorage(Log.verboseKey) private var verbose = false
|
||||||
|
@State private var logURL: URL?
|
||||||
|
@State private var viewing = false
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Toggle(isOn: $verbose) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
SettingsIcon(symbol: "ladybug.fill", bg: 0xFF9500)
|
||||||
|
Text("Verbose Logging").foregroundStyle(p.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(Color(hex: 0x34C759))
|
||||||
|
.themedRow()
|
||||||
|
Button { viewing = true } label: { Text("View Log").foregroundStyle(p.label) }.themedRow()
|
||||||
|
if let logURL {
|
||||||
|
ShareLink(item: logURL) { Text("Export Log").foregroundStyle(p.accent) }.themedRow()
|
||||||
|
} else {
|
||||||
|
Button { logURL = Log.shared.export() } label: { Text("Prepare Log for Export").foregroundStyle(p.accent) }.themedRow()
|
||||||
|
}
|
||||||
|
Button(role: .destructive) { Log.shared.clear(); logURL = nil } label: { Text("Clear Log") }.themedRow()
|
||||||
|
.sheet(isPresented: $viewing) { LogViewer() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LogViewer: View {
|
||||||
|
@State private var text = Log.shared.text()
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
Text(text.isEmpty ? "Nothing logged yet." : text)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.defaultScrollAnchor(.bottom)
|
||||||
|
.navigationTitle("Log")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } }
|
||||||
|
ToolbarItem(placement: .primaryAction) { Button("Refresh") { text = Log.shared.text() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -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.15"
|
||||||
CURRENT_PROJECT_VERSION: "11"
|
CURRENT_PROJECT_VERSION: "16"
|
||||||
targets:
|
targets:
|
||||||
Stemify:
|
Stemify:
|
||||||
type: application
|
type: application
|
||||||
@@ -32,7 +32,7 @@ targets:
|
|||||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIBackgroundModes: [audio, fetch]
|
UIBackgroundModes: [audio, fetch]
|
||||||
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
|
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
||||||
CADisableMinimumFrameDurationOnPhone: true
|
CADisableMinimumFrameDurationOnPhone: true
|
||||||
ITSAppUsesNonExemptEncryption: false
|
ITSAppUsesNonExemptEncryption: false
|
||||||
NSLocalNetworkUsageDescription: Stemify talks to Plex and the stems server on your home network.
|
NSLocalNetworkUsageDescription: Stemify talks to Plex and the stems server on your home network.
|
||||||
@@ -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,51 @@
|
|||||||
"tintColor": "#FA2D48",
|
"tintColor": "#FA2D48",
|
||||||
"category": "entertainment",
|
"category": "entertainment",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.14",
|
||||||
|
"buildVersion": "15",
|
||||||
|
"date": "2026-10-20T14:14:26-07:00",
|
||||||
|
"localizedDescription": "Reorderable tab bar (Songs, Albums, Artists, Genres, Playlists, Search, Downloads), AirPlay route picker in Now Playing, debug logging with log export in Settings, more room above the seeker. Unsigned IPA.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.14/stemify-1.0.14.ipa",
|
||||||
|
"size": 1625201,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.13",
|
||||||
|
"buildVersion": "14",
|
||||||
|
"date": "2026-10-20T13:42:31-07:00",
|
||||||
|
"localizedDescription": "- Export and import of stem playlists and sound settings (Settings > Backup)\n- Mixer: one-line faders, every stem fits without scrolling\n- Wide and landscape now-playing layout for iPhone Duo\n\nUnsigned IPA: re-sign with Sideloadly or AltStore.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.13/stemify-1.0.13.ipa",
|
||||||
|
"size": 1559179,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.12",
|
||||||
|
"buildVersion": "13",
|
||||||
|
"date": "2026-10-20T13:29:05-07:00",
|
||||||
|
"localizedDescription": "More space between the seek bar and the transport buttons on Now Playing. Unsigned IPA: re-sign with Sideloadly, AltStore, SideStore or Feather.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.12/stemify-1.0.12.ipa",
|
||||||
|
"size": 1529939,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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