Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d773b3e5a5 | ||
|
|
9c1e70da4d | ||
|
|
4e2816673d | ||
|
|
9646b4c010 | ||
|
|
99c4de91c4 | ||
|
|
1a5dba78f7 | ||
|
|
7ca9d0374d | ||
|
|
4b53a720c4 | ||
|
|
2a4e848788 | ||
|
|
629901616b | ||
|
|
4fe7f6d16a |
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.
|
||||||
@@ -132,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
||||||
|
controls(track, wide: false).frame(maxWidth: 560)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
|
||||||
controls(track)
|
|
||||||
} 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 : 18)
|
||||||
HStack {
|
HStack {
|
||||||
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -173,6 +194,7 @@ struct NowPlayingScreen: View {
|
|||||||
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
|
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
|
||||||
}
|
}
|
||||||
.frame(height: 84)
|
.frame(height: 84)
|
||||||
|
.padding(.top, 14)
|
||||||
HStack {
|
HStack {
|
||||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
||||||
SpeedMenu()
|
SpeedMenu()
|
||||||
@@ -603,18 +625,11 @@ private struct MixerPanel: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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) {
|
||||||
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: 12)
|
||||||
Spacer(minLength: 16)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
}
|
}
|
||||||
@@ -654,32 +669,55 @@ private struct StemFader: View {
|
|||||||
|
|
||||||
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) {
|
||||||
HStack(spacing: 8) {
|
VStack(spacing: 0) {
|
||||||
Circle().fill(setting.muted ? p.label3 : color).frame(width: 10, height: 10)
|
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
|
||||||
Text(Stems.label(name)).font(.headline).foregroundStyle(setting.muted ? p.label2 : p.label)
|
pm.setStemLevel(name, $0)
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
}
|
// Live level under the fader, -48..0 dBFS.
|
||||||
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
|
GeometryReader { geo in
|
||||||
pm.setStemLevel(name, $0)
|
let f = CGFloat(min(1, max(0, (20 * log10(level + 1e-5) + 48) / 48)))
|
||||||
}
|
ZStack(alignment: .leading) {
|
||||||
// Live level under the fader, -48..0 dBFS.
|
Capsule().fill(p.fill)
|
||||||
GeometryReader { geo in
|
Capsule().fill(color).frame(width: geo.size.width * f)
|
||||||
let f = CGFloat(min(1, max(0, (20 * log10(level + 1e-5) + 48) / 48)))
|
}
|
||||||
ZStack(alignment: .leading) {
|
|
||||||
Capsule().fill(p.fill)
|
|
||||||
Capsule().fill(color).frame(width: geo.size.width * f)
|
|
||||||
}
|
}
|
||||||
|
.frame(height: 3)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
} 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) }
|
||||||
}
|
}
|
||||||
.frame(height: 3)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
}
|
}
|
||||||
.padding(.top, 10)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
@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: 46)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 @@ 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 {
|
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 +165,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 {
|
||||||
|
|||||||
+3
-3
@@ -6,8 +6,8 @@ options:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
MARKETING_VERSION: "1.0.11"
|
MARKETING_VERSION: "1.0.13"
|
||||||
CURRENT_PROJECT_VERSION: "12"
|
CURRENT_PROJECT_VERSION: "14"
|
||||||
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.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Builds an unsigned build/stemify-<version>.ipa on a Mac, for re-signing with Sideloadly/AltStore.
|
# Builds build/stemify-<version>.ipa on a Mac: unsigned, for re-signing with Sideloadly/AltStore.
|
||||||
|
# With prov/Stemify.mobileprovision, prov/key.p12 and P12_PASS set, it also writes a signed
|
||||||
|
# prov/stemify-<version>-signed.ipa (keeps CarPlay/Siri) for the owner's own device.
|
||||||
# Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch.
|
# Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
@@ -34,5 +36,30 @@ SRC=Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png
|
|||||||
sips -z 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null
|
sips -z 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null
|
||||||
sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null
|
sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null
|
||||||
|
|
||||||
|
# Unsigned build, for the releases and AltStore/Sideloadly.
|
||||||
(cd $OUT && zip -qry stemify-$VERSION.ipa Payload)
|
(cd $OUT && zip -qry stemify-$VERSION.ipa Payload)
|
||||||
echo "$OUT/stemify-$VERSION.ipa"
|
echo "$OUT/stemify-$VERSION.ipa"
|
||||||
|
|
||||||
|
if [ -f prov/Stemify.mobileprovision ] && [ -f prov/key.p12 ] && [ -n "${P12_PASS:-}" ]; then
|
||||||
|
KC=$PWD/$OUT/sign.keychain-db; KP=$(uuidgen)
|
||||||
|
security create-keychain -p "$KP" $KC
|
||||||
|
security set-keychain-settings -lut 3600 $KC
|
||||||
|
security unlock-keychain -p "$KP" $KC
|
||||||
|
security import prov/key.p12 -P "$P12_PASS" -A -t agg -f pkcs12 -k $KC >/dev/null
|
||||||
|
# The dev cert chains to Apple's WWDR G3 intermediate, which a fresh keychain doesn't have.
|
||||||
|
[ -f prov/wwdr.cer ] || curl -fsSL -o prov/wwdr.cer https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer
|
||||||
|
security import prov/wwdr.cer -A -k $KC >/dev/null 2>&1 || true
|
||||||
|
security set-key-partition-list -S apple-tool:,apple: -s -k "$KP" $KC >/dev/null
|
||||||
|
security list-keychains -d user -s $KC $(security list-keychains -d user | tr -d '"')
|
||||||
|
ID=$(security find-identity -v -p codesigning $KC | sed -nE 's/.*"(Apple Development:.*)"/\1/p' | head -1)
|
||||||
|
security cms -D -i prov/Stemify.mobileprovision > $OUT/prov.plist
|
||||||
|
/usr/libexec/PlistBuddy -x -c 'Print :Entitlements' $OUT/prov.plist > $OUT/ent.plist
|
||||||
|
cp prov/Stemify.mobileprovision $APP/embedded.mobileprovision
|
||||||
|
codesign -f -s "$ID" --entitlements $OUT/ent.plist --timestamp=none $APP
|
||||||
|
security list-keychains -d user -s $(security list-keychains -d user | tr -d "\"" | grep -v sign.keychain)
|
||||||
|
security delete-keychain $KC
|
||||||
|
# Personal build for this device only: stays in prov/ (gitignored), never released.
|
||||||
|
(cd $OUT && zip -qry ../prov/stemify-$VERSION-signed.ipa Payload)
|
||||||
|
echo "prov/stemify-$VERSION-signed.ipa (signed as $ID)"
|
||||||
|
fi
|
||||||
|
|
||||||
@@ -15,6 +15,24 @@
|
|||||||
"tintColor": "#FA2D48",
|
"tintColor": "#FA2D48",
|
||||||
"category": "entertainment",
|
"category": "entertainment",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.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",
|
"version": "1.0.10",
|
||||||
"buildVersion": "11",
|
"buildVersion": "11",
|
||||||
|
|||||||
@@ -43,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
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 @@ 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
|
/ 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".
|
||||||
@@ -32,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
|
||||||
@@ -44,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
|
||||||
@@ -259,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)
|
||||||
@@ -273,6 +305,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._send(status, json.dumps({"error": msg}).encode())
|
self._send(status, json.dumps({"error": msg}).encode())
|
||||||
|
|
||||||
def _web(self, path):
|
def _web(self, path):
|
||||||
|
if not SERVE_WEB:
|
||||||
|
return self._error(404, "not found")
|
||||||
if path == "/":
|
if path == "/":
|
||||||
path = "/index.html"
|
path = "/index.html"
|
||||||
full = os.path.realpath(os.path.join(WEB_DIR, path.lstrip("/")))
|
full = os.path.realpath(os.path.join(WEB_DIR, path.lstrip("/")))
|
||||||
@@ -293,6 +327,8 @@ 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/")):
|
if not path.startswith(("/api/", "/file/")):
|
||||||
return self._web(path)
|
return self._web(path)
|
||||||
if not self._authorized(query):
|
if not self._authorized(query):
|
||||||
@@ -304,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:
|
||||||
|
|||||||
+25
-3
@@ -60,7 +60,7 @@ const plexHeaders = token => ({
|
|||||||
|
|
||||||
async function px(path, params = {}) {
|
async function px(path, params = {}) {
|
||||||
if (!S.plex) throw new Error("Not signed in to Plex");
|
if (!S.plex) throw new Error("Not signed in to Plex");
|
||||||
const u = new URL(S.plex.url + path);
|
const u = new URL(S.plex.url + path, location.href);
|
||||||
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
|
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
|
||||||
const r = await fetch(u, { headers: plexHeaders(S.plex.token) });
|
const r = await fetch(u, { headers: plexHeaders(S.plex.token) });
|
||||||
if (!r.ok) throw new Error(`Plex ${r.status}`);
|
if (!r.ok) throw new Error(`Plex ${r.status}`);
|
||||||
@@ -782,7 +782,7 @@ const routes = {
|
|||||||
if (S.plex) secs = ((await px("/library/sections").catch(() => ({}))).Directory || []).filter(d => d.type === "artist");
|
if (S.plex) secs = ((await px("/library/sections").catch(() => ({}))).Directory || []).filter(d => d.type === "artist");
|
||||||
view.innerHTML = `<h1>Settings</h1>
|
view.innerHTML = `<h1>Settings</h1>
|
||||||
<h2>Plex</h2><div class="form">
|
<h2>Plex</h2><div class="form">
|
||||||
<p class="note">${S.plex ? `Connected to <b>${esc(S.plex.name || S.plex.url)}</b> at ${esc(S.plex.url)}` : "Not signed in."}</p>
|
<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>
|
<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>` : ""}
|
${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">
|
<details><summary class="note">Enter a server and token by hand</summary><div class="form" style="margin-top:12px">
|
||||||
@@ -835,10 +835,32 @@ $("#search").onsubmit = ev => {
|
|||||||
if (q) location.hash = "#/search/" + encodeURIComponent(q);
|
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 -----------------------------------------------------------------------------
|
// ---- start -----------------------------------------------------------------------------
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await Promise.race([Stems.refresh(), new Promise(r => setTimeout(r, 4000))]);
|
await Promise.race([autoConnect(), new Promise(r => setTimeout(r, 4000))]);
|
||||||
const saved = load(KEY + "-queue");
|
const saved = load(KEY + "-queue");
|
||||||
if (saved?.items?.length) {
|
if (saved?.items?.length) {
|
||||||
Q.items = saved.items; Q.orig = saved.orig; Q.i = Math.min(saved.i, saved.items.length - 1);
|
Q.items = saved.items; Q.orig = saved.orig; Q.i = Math.min(saved.i, saved.items.length - 1);
|
||||||
|
|||||||
Reference in new issue
Block a user