Compare commits
8
Commits
ios-v1.0.5
...
ios-v1.0.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb541b9a16 | ||
|
|
790f8a35a2 | ||
|
|
e2cf62cb07 | ||
|
|
6f634522e5 | ||
|
|
d52a3e257f | ||
|
|
e98d595365 | ||
|
|
41b8171df2 | ||
|
|
8e5ebb70b1 |
No files matched your search
@@ -20,6 +20,7 @@ final class AppGraph {
|
||||
let cache: MediaCache
|
||||
let eq: EqController
|
||||
let playback: PlaybackManager
|
||||
let stemPlaylists: StemPlaylists
|
||||
let downloads: Downloads
|
||||
let coverage: StemCoverage
|
||||
let nowPlaying: NowPlaying
|
||||
@@ -59,6 +60,7 @@ final class AppGraph {
|
||||
cache = MediaCache(dir: media, session: mediaSession) { Int64(settings.cacheLimitGb) << 30 }
|
||||
|
||||
eq = EqController(dir: support)
|
||||
stemPlaylists = StemPlaylists(dir: support)
|
||||
playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support)
|
||||
downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support)
|
||||
coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support)
|
||||
|
||||
@@ -185,6 +185,9 @@ final class Settings: @unchecked Sendable {
|
||||
var downloadsWifiOnly: Bool { get { read(\.downloadsWifiOnly, "downloadsWifiOnly", true) } set { write(\.downloadsWifiOnly, "downloadsWifiOnly", newValue, true) } }
|
||||
/// Downloads page order: 0 download order, 1 artist, 2 release date.
|
||||
var downloadSort: Int { get { read(\.downloadSort, "downloadSort", 0) } set { write(\.downloadSort, "downloadSort", newValue, 0) } }
|
||||
var karaokeVocals: Int { get { read(\.karaokeVocals, "karaokeVocals", KaraokeVocals.Off.rawValue) } set { write(\.karaokeVocals, "karaokeVocals", newValue, KaraokeVocals.Off.rawValue) } }
|
||||
/// How far down Soft karaoke puts the vocals, in dB.
|
||||
var karaokeSoftDb: Int { get { read(\.karaokeSoftDb, "karaokeSoftDb", -12) } set { write(\.karaokeSoftDb, "karaokeSoftDb", newValue, -12) } }
|
||||
var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
|
||||
var masterVolume: Float {
|
||||
get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 }
|
||||
|
||||
@@ -19,6 +19,8 @@ enum Stems {
|
||||
}
|
||||
}
|
||||
|
||||
enum KaraokeVocals: Int, CaseIterable { case On, Soft, Off }
|
||||
|
||||
/// One stem's fader. [level] is the slider position; 0.8 is unity gain.
|
||||
struct StemSetting: Codable, Hashable {
|
||||
static let unity: Float = 0.8
|
||||
@@ -44,6 +46,8 @@ enum SourceKind { case None, Stems, Original }
|
||||
struct QueueItem: Codable, Hashable, Identifiable {
|
||||
var uid: Int64
|
||||
var track: Track
|
||||
/// Set when the song came from a stem playlist; it plays with this mix instead of its saved one.
|
||||
var mix: [String: StemSetting]?
|
||||
var id: Int64 { uid }
|
||||
}
|
||||
|
||||
@@ -124,7 +128,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
private(set) var message: AppMessage?
|
||||
/// Mixes saved for single songs (by rating key), put back whenever that song starts.
|
||||
private(set) var songMixes: [String: [String: StemSetting]] = [:]
|
||||
/// Karaoke mode: vocals out on every song, lyrics on Now Playing.
|
||||
/// Karaoke mode: vocals on, soft or out (per settings) on every song, lyrics on Now Playing.
|
||||
private(set) var karaoke = false
|
||||
@ObservationIgnored private var preKaraokeMix: [String: StemSetting]?
|
||||
|
||||
@@ -202,16 +206,17 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
|
||||
// ---- queue --------------------------------------------------------------------------
|
||||
|
||||
private func items(_ tracks: [Track]) -> [QueueItem] {
|
||||
tracks.map { t in
|
||||
private func items(_ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) -> [QueueItem] {
|
||||
tracks.enumerated().map { i, t in
|
||||
defer { nextUid += 1 }
|
||||
return QueueItem(uid: nextUid, track: t)
|
||||
return QueueItem(uid: nextUid, track: t, mix: mixes.flatMap { i < $0.count ? $0[i] : nil })
|
||||
}
|
||||
}
|
||||
|
||||
func playTracks(_ tracks: [Track], start: Int = 0, shuffle doShuffle: Bool = false) {
|
||||
/// `mixes` lines up with `tracks`: each song plays with its own mix (stem playlists).
|
||||
func playTracks(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil, start: Int = 0, shuffle doShuffle: Bool = false) {
|
||||
if tracks.isEmpty { return }
|
||||
let items = items(tracks)
|
||||
let items = items(tracks, mixes)
|
||||
if doShuffle {
|
||||
unshuffled = items
|
||||
let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
|
||||
@@ -226,12 +231,12 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func playNext(_ tracks: [Track]) { insert(index + 1, tracks) }
|
||||
func addToQueue(_ tracks: [Track]) { insert(queue.count, tracks) }
|
||||
func playNext(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(index + 1, tracks, mixes) }
|
||||
func addToQueue(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(queue.count, tracks, mixes) }
|
||||
|
||||
private func insert(_ at: Int, _ tracks: [Track]) {
|
||||
private func insert(_ at: Int, _ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) {
|
||||
if tracks.isEmpty { return }
|
||||
let items = items(tracks)
|
||||
let items = items(tracks, mixes)
|
||||
var q = queue
|
||||
q.insert(contentsOf: items, at: min(max(at, 0), q.count))
|
||||
queue = q
|
||||
@@ -413,13 +418,33 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
pushMix()
|
||||
}
|
||||
|
||||
/// The mix a song starts with: karaoke if that's on, else the song's saved mix, else cleared (or kept, per settings).
|
||||
private func startMix(_ t: Track) -> [String: StemSetting] {
|
||||
if karaoke { return Self.karaokeMix }
|
||||
return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:])
|
||||
/// The mix a song starts with: karaoke if that's on, else its stem playlist mix, else the song's saved mix,
|
||||
/// else cleared (or kept, per settings).
|
||||
private func startMix(_ item: QueueItem) -> [String: StemSetting] {
|
||||
if karaoke { return karaokeMix }
|
||||
return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? mix : [:])
|
||||
}
|
||||
|
||||
private static let karaokeMix = ["vocals": StemSetting(muted: true)]
|
||||
/// How much of the singer is left in karaoke mode. Soft is a guide under your own voice, as quiet as settings say.
|
||||
private var karaokeMix: [String: StemSetting] {
|
||||
switch karaokeVocals {
|
||||
case .On: return [:]
|
||||
case .Soft: return ["vocals": StemSetting(level: stemUntaper(Float(pow(10, Double(settings.karaokeSoftDb) / 20))))]
|
||||
case .Off: return ["vocals": StemSetting(muted: true)]
|
||||
}
|
||||
}
|
||||
|
||||
var karaokeVocals: KaraokeVocals { KaraokeVocals(rawValue: settings.karaokeVocals) ?? .Off }
|
||||
|
||||
func setKaraokeVocals(_ v: KaraokeVocals) {
|
||||
settings.karaokeVocals = v.rawValue
|
||||
if karaoke { applyMix(karaokeMix) }
|
||||
}
|
||||
|
||||
func setKaraokeSoftDb(_ db: Int) {
|
||||
settings.karaokeSoftDb = db
|
||||
if karaoke && karaokeVocals == .Soft { applyMix(karaokeMix) }
|
||||
}
|
||||
|
||||
/// Turning it off puts back the song's saved mix, or the one from before, unless the mix was changed meanwhile.
|
||||
func setKaraoke(_ on: Bool) {
|
||||
@@ -427,9 +452,9 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
karaoke = on
|
||||
if on {
|
||||
preKaraokeMix = mix
|
||||
applyMix(Self.karaokeMix)
|
||||
applyMix(karaokeMix)
|
||||
} else {
|
||||
if MixPresets.normalized(mix) == Self.karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) }
|
||||
if MixPresets.normalized(mix) == karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) }
|
||||
preKaraokeMix = nil
|
||||
changed()
|
||||
}
|
||||
@@ -506,7 +531,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
if play && !activateSession() { return }
|
||||
playWhenReady = play
|
||||
buffering = true
|
||||
mix = startMix(item.track)
|
||||
mix = startMix(item)
|
||||
changed()
|
||||
save()
|
||||
loadTask = Task { @MainActor [weak self] in
|
||||
@@ -679,7 +704,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
guard let item = current else { return }
|
||||
durationMs = item.track.durationMs
|
||||
waveform = nil
|
||||
mix = startMix(item.track)
|
||||
mix = startMix(item)
|
||||
source = currentEntry != nil ? .Stems : .Original
|
||||
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
|
||||
pushMix()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// One song in a stem playlist, saved with the mix it plays with.
|
||||
struct StemPlaylistItem: Codable, Hashable, Identifiable {
|
||||
var uid = UUID().uuidString
|
||||
var track: Track
|
||||
var mix: [String: StemSetting]
|
||||
var id: String { uid }
|
||||
}
|
||||
|
||||
struct StemPlaylist: Codable, Hashable, Identifiable {
|
||||
var id = UUID().uuidString
|
||||
var title: String
|
||||
var items: [StemPlaylistItem] = []
|
||||
var created = Date()
|
||||
var updated = Date()
|
||||
|
||||
var tracks: [Track] { items.map(\.track) }
|
||||
var mixes: [[String: StemSetting]] { items.map(\.mix) }
|
||||
var thumb: String? { items.lazy.compactMap(\.track.thumb).first }
|
||||
var durationMs: Int64 { items.reduce(0) { $0 + $1.track.durationMs } }
|
||||
}
|
||||
|
||||
/// Playlists that live on this device only: each song keeps its own stem mix (instrumentals, a cappellas…).
|
||||
/// Plex playlists can't hold a mix, so these are separate.
|
||||
@MainActor @Observable
|
||||
final class StemPlaylists {
|
||||
private(set) var lists: [StemPlaylist] = []
|
||||
@ObservationIgnored private let file: URL
|
||||
|
||||
/// Every stem a song can have, for naming saved mixes without loading the song.
|
||||
static let allStems = ["vocals", "drums", "bass", "guitar", "piano", "other"]
|
||||
|
||||
init(dir: URL) {
|
||||
file = dir.appendingPathComponent("stem-playlists.json")
|
||||
lists = (try? JSONDecoder().decode([StemPlaylist].self, from: Data(contentsOf: file))) ?? []
|
||||
}
|
||||
|
||||
func list(_ id: String) -> StemPlaylist? { lists.first { $0.id == id } }
|
||||
|
||||
@discardableResult
|
||||
func create(_ title: String, tracks: [Track] = [], mix: [String: StemSetting] = [:]) -> StemPlaylist {
|
||||
let pl = StemPlaylist(title: title, items: tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) })
|
||||
lists.insert(pl, at: 0)
|
||||
save()
|
||||
return pl
|
||||
}
|
||||
|
||||
func add(_ id: String, tracks: [Track], mix: [String: StemSetting]) {
|
||||
edit(id) { $0.items += tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) } }
|
||||
}
|
||||
|
||||
func setItems(_ id: String, _ items: [StemPlaylistItem]) { edit(id) { $0.items = items } }
|
||||
func remove(_ id: String, item: String) { edit(id) { $0.items.removeAll { $0.uid == item } } }
|
||||
func setMix(_ id: String, item: String, _ mix: [String: StemSetting]) {
|
||||
edit(id) { pl in if let i = pl.items.firstIndex(where: { $0.uid == item }) { pl.items[i].mix = MixPresets.normalized(mix) } }
|
||||
}
|
||||
func rename(_ id: String, _ title: String) { edit(id) { $0.title = title } }
|
||||
|
||||
func delete(_ id: String) {
|
||||
lists.removeAll { $0.id == id }
|
||||
save()
|
||||
}
|
||||
|
||||
/// "Instrumental", "A Cappella"… or "Custom Mix" for a hand-made one.
|
||||
static func label(_ mix: [String: StemSetting]) -> String {
|
||||
if MixPresets.normalized(mix).isEmpty { return MixPresets.full.label }
|
||||
if MixPresets.current(allStems, mix) == MixPresets.karaoke { return "Instrumental" }
|
||||
return MixPresets.current(allStems, mix)?.label ?? "Custom Mix"
|
||||
}
|
||||
|
||||
private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
|
||||
guard let i = lists.firstIndex(where: { $0.id == id }) else { return }
|
||||
change(&lists[i])
|
||||
lists[i].updated = Date()
|
||||
save()
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let all = lists, file = file
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -405,7 +405,7 @@ extension PlexItem {
|
||||
func toArtist() -> Artist { Artist(ratingKey: ratingKey, title: title, sortTitle: titleSort ?? title, thumb: thumb) }
|
||||
|
||||
func toPlaylist() -> Playlist {
|
||||
Playlist(ratingKey: ratingKey, title: title, thumb: composite ?? thumb, trackCount: leafCount ?? 0,
|
||||
Playlist(ratingKey: ratingKey, title: title, thumb: thumb ?? composite, trackCount: leafCount ?? 0,
|
||||
durationMs: duration ?? 0, smart: smart ?? false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import SwiftUI
|
||||
|
||||
/// One sheet for adding songs to a playlist: Plex or stem, new or existing, and for stem playlists the mix.
|
||||
/// Replaces the chain of dialogs, each of which had to wait for the last to go away.
|
||||
struct AddToPlaylistSheet: View {
|
||||
let req: Overlays.AddToPlaylist
|
||||
let overlays: Overlays
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.palette) private var p
|
||||
@AppStorage("addPlaylistKind") private var kind = PlaylistKind.Plex
|
||||
@State private var plex: LoadState<[Playlist]> = .loading
|
||||
@State private var naming = false
|
||||
@State private var name = ""
|
||||
@State private var mixIndex = 0
|
||||
@State private var busy: String?
|
||||
@FocusState private var nameFocused: Bool
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
private struct MixOption { var label: String; var mix: [String: StemSetting] }
|
||||
|
||||
/// What's playing now and the song's saved mix (for a single song), then the presets.
|
||||
private var mixes: [MixOption] {
|
||||
let pm = g.playback
|
||||
var o: [MixOption] = []
|
||||
if req.tracks.count == 1, let t = req.tracks.first {
|
||||
if pm.current?.track.ratingKey == t.ratingKey { o.append(MixOption(label: "Now: \(StemPlaylists.label(pm.mix))", mix: pm.mix)) }
|
||||
if let m = pm.songMixes[t.ratingKey] { o.append(MixOption(label: "Saved: \(StemPlaylists.label(m))", mix: m)) }
|
||||
}
|
||||
for preset in MixPresets.all {
|
||||
if let m = preset.build(StemPlaylists.allStems) { o.append(MixOption(label: StemPlaylists.label(m), mix: m)) }
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
private var plexKeys: [String] { req.tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") } }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
songHeader
|
||||
Picker("Kind", selection: $kind) {
|
||||
Text("Plex").tag(PlaylistKind.Plex)
|
||||
Text("Stem").tag(PlaylistKind.Stem)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||
}
|
||||
if kind == .Stem {
|
||||
Section("Mix") { mixChips }
|
||||
}
|
||||
Section {
|
||||
newRow
|
||||
if kind == .Plex { plexRows } else { stemRows }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add to Playlist")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } }
|
||||
.animation(.default, value: kind)
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.task {
|
||||
let plexClient = g.plex
|
||||
let skip = req.skip
|
||||
await LoadState.load($plex) { try await plexClient.playlists().filter { !$0.smart && $0.ratingKey != skip } }
|
||||
}
|
||||
.onChange(of: kind) { _, _ in naming = false; name = "" }
|
||||
}
|
||||
|
||||
private var songHeader: some View {
|
||||
let t = req.tracks.first
|
||||
return HStack(spacing: 12) {
|
||||
Artwork(thumb: t?.thumb, size: 48, corner: 6, placeholder: "music.note")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(req.tracks.count == 1 ? t?.title ?? "" : songs(req.tracks.count)).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(req.tracks.count == 1 ? t?.artist ?? "" : req.title).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
private var mixChips: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(mixes.enumerated()), id: \.offset) { i, o in
|
||||
let on = i == mixIndex
|
||||
Button { mixIndex = i } label: {
|
||||
Text(o.label).font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 12).padding(.vertical, 7)
|
||||
.foregroundStyle(on ? Color.white : p.label)
|
||||
.background(on ? p.accent : p.fill, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
}
|
||||
|
||||
@ViewBuilder private var newRow: some View {
|
||||
if naming {
|
||||
HStack {
|
||||
TextField(kind == .Plex ? "Playlist name" : "Stem playlist name", text: $name)
|
||||
.focused($nameFocused).submitLabel(.done).onSubmit(create)
|
||||
Button("Create", action: create).bold()
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || busy != nil)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
naming = true
|
||||
nameFocused = true
|
||||
} label: {
|
||||
Label(kind == .Plex ? "New Playlist" : "New Stem Playlist", systemImage: "plus").foregroundStyle(p.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var plexRows: some View {
|
||||
if plexKeys.isEmpty {
|
||||
Text("These songs aren't in Plex. Use a stem playlist instead.").foregroundStyle(p.label2)
|
||||
} else {
|
||||
switch plex {
|
||||
case .loading: HStack { Spacer(); ProgressView(); Spacer() }
|
||||
case .err(let e): Text("Couldn't load playlists: \(e)").foregroundStyle(p.label2)
|
||||
case .ok(let lists):
|
||||
ForEach(lists) { pl in
|
||||
row(pl.title, pl.thumb, songs(pl.trackCount), "music.note.list", busy: busy == pl.ratingKey) { addPlex(pl) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var stemRows: some View {
|
||||
ForEach(g.stemPlaylists.lists) { pl in
|
||||
row(pl.title, pl.thumb, songs(pl.items.count), "slider.horizontal.3", busy: false) {
|
||||
g.stemPlaylists.add(pl.id, tracks: req.tracks, mix: mixes[mixIndex].mix)
|
||||
done("Added to \(pl.title)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ title: String, _ thumb: String?, _ sub: String, _ placeholder: String, busy: Bool, _ tap: @escaping () -> Void) -> some View {
|
||||
Button(action: tap) {
|
||||
HStack(spacing: 12) {
|
||||
Artwork(thumb: thumb, size: 44, corner: 5, placeholder: placeholder)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(sub).font(.caption).foregroundStyle(p.label2)
|
||||
}
|
||||
Spacer()
|
||||
if busy { ProgressView() }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(self.busy != nil)
|
||||
}
|
||||
|
||||
private func create() {
|
||||
let n = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if n.isEmpty || busy != nil { return }
|
||||
if kind == .Stem {
|
||||
g.stemPlaylists.create(n, tracks: req.tracks, mix: mixes[mixIndex].mix)
|
||||
done("Created “\(n)”")
|
||||
return
|
||||
}
|
||||
busy = "new"
|
||||
let plexClient = g.plex, keys = plexKeys
|
||||
Task {
|
||||
do {
|
||||
_ = try await plexClient.createPlaylist(n, trackKeys: keys)
|
||||
done("Created “\(n)”")
|
||||
} catch {
|
||||
busy = nil
|
||||
overlays.hud("Couldn't create: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addPlex(_ pl: Playlist) {
|
||||
busy = pl.ratingKey
|
||||
let plexClient = g.plex, keys = plexKeys
|
||||
Task {
|
||||
do {
|
||||
try await plexClient.addToPlaylist(pl.ratingKey, trackKeys: keys)
|
||||
done("Added to \(pl.title)")
|
||||
} catch {
|
||||
busy = nil
|
||||
overlays.hud("Couldn't add: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func done(_ msg: String) {
|
||||
dismiss()
|
||||
overlays.hud(msg)
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,49 @@ struct MediaMenu {
|
||||
|
||||
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
|
||||
let plex = g.plex
|
||||
return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: extra,
|
||||
let copy = SheetAction("Copy to Stem Playlist…", icon: "doc.on.doc") { copyToStems(pl) }
|
||||
return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: [copy] + extra,
|
||||
confirmDownload: pl.trackCount > 50)
|
||||
}
|
||||
|
||||
func stemPlaylist(_ pl: StemPlaylist, extra: [SheetAction] = [], onPage: Bool = false) -> [SheetAction] {
|
||||
var a = [
|
||||
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
|
||||
g.playback.playNext(pl.tracks, mixes: pl.mixes); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward")
|
||||
},
|
||||
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
|
||||
g.playback.addToQueue(pl.tracks, mixes: pl.mixes); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward")
|
||||
},
|
||||
]
|
||||
let missing = pl.tracks.filter { !g.downloads.isDownloaded($0.ratingKey) }
|
||||
if !missing.isEmpty {
|
||||
a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download(missing); overlays.hud("Downloading", "arrow.down.circle") })
|
||||
}
|
||||
if pl.items.isEmpty { a = [] }
|
||||
let lists = g.stemPlaylists
|
||||
a.append(SheetAction("Delete Stem Playlist", icon: "trash", destructive: true) {
|
||||
overlays.confirm("Delete “\(pl.title)”?", "Stem playlists are only on this device.", "Delete") { lists.delete(pl.id); if onPage { ui.pop() } }
|
||||
})
|
||||
return extra + a
|
||||
}
|
||||
|
||||
/// A song in a stem playlist: queue it with its mix, change the mix, or take it out.
|
||||
func stemItem(_ item: StemPlaylistItem, in listId: String) -> [SheetAction] {
|
||||
let lists = g.stemPlaylists
|
||||
return [
|
||||
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
|
||||
g.playback.playNext([item.track], mixes: [item.mix]); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward")
|
||||
},
|
||||
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
|
||||
g.playback.addToQueue([item.track], mixes: [item.mix]); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward")
|
||||
},
|
||||
SheetAction("Change Mix…", icon: "slider.horizontal.3") {
|
||||
chooseMix([item.track]) { lists.setMix(listId, item: item.uid, $0) }
|
||||
},
|
||||
SheetAction("Remove from Stem Playlist", icon: "minus.circle", destructive: true) { lists.remove(listId, item: item.uid) },
|
||||
]
|
||||
}
|
||||
|
||||
/// For songs already in hand (downloads with no Plex album behind them).
|
||||
func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] {
|
||||
let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) }
|
||||
@@ -89,7 +128,7 @@ struct MediaMenu {
|
||||
overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(downloaded) }
|
||||
})
|
||||
}
|
||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey) } })
|
||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey, title: title) } })
|
||||
if !stemmed { a.append(stemNow(load)) }
|
||||
return a + extra
|
||||
}
|
||||
@@ -132,32 +171,57 @@ struct MediaMenu {
|
||||
}
|
||||
}
|
||||
|
||||
func addToPlaylist(_ tracks: [Track], skip: String? = nil) {
|
||||
Task {
|
||||
let lists: [Playlist]
|
||||
do { lists = try await g.plex.playlists().filter { !$0.smart && $0.ratingKey != skip } } catch {
|
||||
overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
|
||||
/// Plex or stem playlist, picked in one sheet.
|
||||
func addToPlaylist(_ tracks: [Track], skip: String? = nil, title: String = "") {
|
||||
overlays.addToPlaylist(tracks, title: title, skip: skip)
|
||||
}
|
||||
|
||||
/// Picks the mix the songs get in a stem playlist: what's playing now, the song's saved mix, or a preset.
|
||||
func chooseMix(_ tracks: [Track], _ then: @escaping ([String: StemSetting]) -> Void) {
|
||||
let pm = g.playback
|
||||
var acts: [SheetAction] = []
|
||||
if tracks.count == 1, let t = tracks.first {
|
||||
if pm.current?.track.ratingKey == t.ratingKey {
|
||||
let m = pm.mix
|
||||
acts.append(SheetAction("Current Mix (\(StemPlaylists.label(m)))", icon: "play.circle") { then(m) })
|
||||
}
|
||||
if let m = pm.songMixes[t.ratingKey] {
|
||||
acts.append(SheetAction("This Song’s Mix (\(StemPlaylists.label(m)))", icon: "bookmark") { then(m) })
|
||||
}
|
||||
}
|
||||
for p in MixPresets.all {
|
||||
guard let m = p.build(StemPlaylists.allStems) else { continue }
|
||||
acts.append(SheetAction(StemPlaylists.label(m)) { then(m) })
|
||||
}
|
||||
overlays.actions("Choose a Mix", acts)
|
||||
}
|
||||
|
||||
func newStemPlaylist(_ tracks: [Track] = [], onCreated: @escaping (StemPlaylist) -> Void = { _ in }) {
|
||||
let lists = g.stemPlaylists
|
||||
overlays.prompt("New Stem Playlist", "Every song in it plays with the mix you pick.", "Create", placeholder: "Playlist name") { name in
|
||||
if tracks.isEmpty {
|
||||
onCreated(lists.create(name))
|
||||
return
|
||||
}
|
||||
let keys = tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") }
|
||||
if keys.isEmpty {
|
||||
overlays.hud("These songs aren't in Plex, so they can't go in a playlist", "info.circle")
|
||||
return
|
||||
chooseMix(tracks) { m in
|
||||
onCreated(lists.create(name, tracks: tracks, mix: m))
|
||||
overlays.hud("Created “\(name)”")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes a stem playlist from a Plex one, every song with the same mix.
|
||||
func copyToStems(_ pl: Playlist, onCreated: @escaping (StemPlaylist) -> Void = { _ in }) {
|
||||
let plex = g.plex
|
||||
overlays.actions("Add \(songs(keys.count)) to Playlist", [SheetAction("New Playlist…") { newPlaylist(tracks) }] + lists.map { pl in
|
||||
SheetAction(pl.title) {
|
||||
Task {
|
||||
do {
|
||||
try await plex.addToPlaylist(pl.ratingKey, trackKeys: keys)
|
||||
overlays.hud("Added to \(pl.title)")
|
||||
} catch {
|
||||
overlays.hud("Couldn't add: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
let lists = g.stemPlaylists
|
||||
overlays.prompt("Copy to Stem Playlist", "Every song gets the mix you pick next; change single songs later.", initial: pl.title, "Next") { name in
|
||||
withTracks({ try await plex.playlistTracks(pl.ratingKey) }) { tracks in
|
||||
chooseMix(tracks) { m in
|
||||
onCreated(lists.create(name, tracks: tracks, mix: m))
|
||||
overlays.hud("Copied \(songs(tracks.count))")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newPlaylist(_ tracks: [Track], onCreated: @escaping () -> Void = {}) {
|
||||
|
||||
@@ -22,11 +22,13 @@ final class Overlays {
|
||||
struct Confirm { var title: String; var message: String?; var button: String; var destructive: Bool; var run: () -> Void }
|
||||
struct Prompt { var title: String; var message: String?; var initial: String; var button: String; var placeholder: String; var run: (String) -> Void }
|
||||
struct Hud: Equatable { let id = UUID(); var text: String; var icon: String }
|
||||
struct AddToPlaylist: Identifiable { let id = UUID(); var tracks: [Track]; var title: String; var skip: String? }
|
||||
|
||||
var actions: Actions?
|
||||
var confirm: Confirm?
|
||||
var prompt: Prompt?
|
||||
var hud: Hud?
|
||||
var addToPlaylist: AddToPlaylist?
|
||||
/// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's.
|
||||
var level = 0
|
||||
|
||||
@@ -59,6 +61,10 @@ final class Overlays {
|
||||
present { self.prompt = Prompt(title: title, message: message, initial: initial, button: button, placeholder: placeholder, run: run) }
|
||||
}
|
||||
|
||||
func addToPlaylist(_ tracks: [Track], title: String, skip: String?) {
|
||||
present { self.addToPlaylist = AddToPlaylist(tracks: tracks, title: title, skip: skip) }
|
||||
}
|
||||
|
||||
func hud(_ text: String, _ icon: String = "checkmark") {
|
||||
hudTask?.cancel()
|
||||
withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) }
|
||||
@@ -75,6 +81,7 @@ struct OverlayHost: ViewModifier {
|
||||
let overlays: Overlays
|
||||
let level: Int
|
||||
@State private var text = ""
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
private var mine: Bool { overlays.level == level }
|
||||
|
||||
@@ -109,6 +116,9 @@ struct OverlayHost: ViewModifier {
|
||||
if let m = p.message { Text(m) }
|
||||
}
|
||||
.onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" }
|
||||
.sheet(item: Binding(get: { mine ? overlays.addToPlaylist : nil }, set: { overlays.addToPlaylist = $0 })) { r in
|
||||
AddToPlaylistSheet(req: r, overlays: overlays).environment(\.palette, p).tint(p.accent)
|
||||
}
|
||||
}
|
||||
|
||||
private func binding<T>(_ kp: ReferenceWritableKeyPath<Overlays, T?>) -> Binding<Bool> {
|
||||
|
||||
@@ -18,7 +18,7 @@ enum Route: Hashable {
|
||||
case Artists, Albums, Songs, Playlists
|
||||
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
||||
case PlexAccount, StemServer, Quality, Storage, Appearance
|
||||
case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist)
|
||||
case 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.
|
||||
|
||||
@@ -14,15 +14,12 @@ struct RootView: View {
|
||||
root(t).miniPlayerRoom()
|
||||
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
|
||||
}
|
||||
// Floats over the screens; each screen leaves room for it so nothing ends up underneath.
|
||||
.overlay(alignment: .bottom) {
|
||||
if g.playback.current != nil { MiniPlayer().transition(.move(edge: .bottom).combined(with: .opacity)) }
|
||||
}
|
||||
.animation(.spring(duration: 0.35), value: g.playback.current != nil)
|
||||
.modifier(FloatingMiniPlayer())
|
||||
.tabItem { Label(t.rawValue, systemImage: t.icon) }
|
||||
.tag(t)
|
||||
}
|
||||
}
|
||||
.modifier(GlassMiniPlayerHost())
|
||||
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
||||
NowPlayingScreen()
|
||||
.overlayHost(ui.overlays, level: 1)
|
||||
@@ -68,6 +65,39 @@ struct RootView: View {
|
||||
case .ArtistPage(let a): ArtistScreen(artist: a)
|
||||
case .AlbumPage(let a): AlbumScreen(album: a)
|
||||
case .PlaylistPage(let pl): PlaylistScreen(playlist: pl)
|
||||
case .StemPlaylistPage(let id): StemPlaylistScreen(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS 26.1 and up put the mini player in the tab bar's glass accessory; older ones float a card over each tab.
|
||||
private let glassAccessory: Bool = { if #available(iOS 26.1, *) { return true } else { return false } }()
|
||||
|
||||
private struct GlassMiniPlayerHost: ViewModifier {
|
||||
private var g: AppGraph { .shared }
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.1, *) {
|
||||
content
|
||||
.tabViewBottomAccessory(isEnabled: g.playback.current != nil) { MiniPlayer() }
|
||||
.tabBarMinimizeBehavior(.onScrollDown)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Floats over the screens; each screen leaves room for it so nothing ends up underneath.
|
||||
private struct FloatingMiniPlayer: ViewModifier {
|
||||
private var g: AppGraph { .shared }
|
||||
func body(content: Content) -> some View {
|
||||
if glassAccessory {
|
||||
content
|
||||
} else {
|
||||
content
|
||||
.overlay(alignment: .bottom) {
|
||||
if g.playback.current != nil { MiniPlayer().transition(.move(edge: .bottom).combined(with: .opacity)) }
|
||||
}
|
||||
.animation(.spring(duration: 0.35), value: g.playback.current != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,7 +106,7 @@ private struct MiniPlayerRoom: ViewModifier {
|
||||
private var g: AppGraph { .shared }
|
||||
func body(content: Content) -> some View {
|
||||
content.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if g.playback.current != nil { Color.clear.frame(height: 66) }
|
||||
if !glassAccessory && g.playback.current != nil { Color.clear.frame(height: 66) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ struct NowPlayingScreen: View {
|
||||
}
|
||||
|
||||
/// Opens a panel, or goes back to the art if it's already open.
|
||||
/// Karaoke: vocals out and lyrics up. If it's on but another panel is showing, brings the lyrics back first.
|
||||
/// Karaoke: vocals down and lyrics up. If it's on but another panel is showing, brings the lyrics back first.
|
||||
private func karaoke() {
|
||||
let pm = g.playback
|
||||
withAnimation(.spring(duration: 0.38, bounce: 0.12)) {
|
||||
@@ -794,8 +794,14 @@ struct MiniPlayer: View {
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
if let t = g.playback.current?.track {
|
||||
if #available(iOS 26.1, *) { GlassMiniPlayer(track: t) } else { card(t) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Before iOS 26: a card floating over each tab.
|
||||
@ViewBuilder private func card(_ t: Track) -> some View {
|
||||
let pm = g.playback
|
||||
if let t = pm.current?.track {
|
||||
HStack(spacing: 0) {
|
||||
Artwork(thumb: t.thumb, size: 44, corner: 8).padding(.trailing, 12)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
@@ -803,19 +809,7 @@ struct MiniPlayer: View {
|
||||
Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Button(action: pm.togglePlay) {
|
||||
ZStack {
|
||||
if pm.buffering && pm.playWhenReady { ProgressView() }
|
||||
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 22)).contentTransition(.symbolEffect(.replace)) }
|
||||
}
|
||||
.animation(.snappy(duration: 0.25), value: pm.playWhenReady)
|
||||
.foregroundStyle(p.label).frame(width: 44, height: 44).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
Button(action: pm.next) {
|
||||
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(p.label).frame(width: 44, height: 44).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
MiniTransport(color: p.label, next: true)
|
||||
}
|
||||
.padding(.leading, 8).padding(.trailing, 6)
|
||||
.frame(height: 60)
|
||||
@@ -846,6 +840,56 @@ struct MiniPlayer: View {
|
||||
.actionsMenu(ui.menu.track(t))
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS 26: lives in the tab bar's Liquid Glass accessory, like Music. Shrinks to title and play when the bar collapses.
|
||||
@available(iOS 26.1, *)
|
||||
private struct GlassMiniPlayer: View {
|
||||
let track: Track
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.tabViewBottomAccessoryPlacement) private var placement
|
||||
private var inline: Bool { placement == .inline }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Artwork(thumb: track.thumb, size: inline ? 28 : 34, corner: 6)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(track.title).font(.subheadline.weight(.semibold)).lineLimit(1)
|
||||
if !inline { Text(track.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
MiniTransport(color: .primary, next: !inline)
|
||||
}
|
||||
.padding(.leading, 10).padding(.trailing, 6)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { ui.openNowPlaying() }
|
||||
.actionsMenu(ui.menu.track(track))
|
||||
}
|
||||
}
|
||||
|
||||
/// Play/pause (spinner while buffering) and next, for the mini player.
|
||||
private struct MiniTransport: View {
|
||||
let color: Color
|
||||
let next: Bool
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let pm = g.playback
|
||||
Button(action: pm.togglePlay) {
|
||||
ZStack {
|
||||
if pm.buffering && pm.playWhenReady { ProgressView() }
|
||||
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 22)).contentTransition(.symbolEffect(.replace)) }
|
||||
}
|
||||
.animation(.snappy(duration: 0.25), value: pm.playWhenReady)
|
||||
.foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
if next {
|
||||
Button(action: pm.next) {
|
||||
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Synced lyrics follow the song (tap a line to jump there); plain ones just scroll.
|
||||
@@ -858,6 +902,31 @@ private struct LyricsPanel: View {
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Soft leaves the singer in quietly as a guide.
|
||||
HStack(spacing: 12) {
|
||||
Text("Vocals").font(.subheadline.weight(.semibold)).foregroundStyle(p.label2)
|
||||
Picker("Vocals", selection: Binding(get: { g.playback.karaokeVocals }, set: { g.playback.setKaraokeVocals($0) })) {
|
||||
ForEach(KaraokeVocals.allCases, id: \.self) { Text(String(describing: $0)).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.horizontal, 28).padding(.top, 8)
|
||||
if g.playback.karaokeVocals == .Soft {
|
||||
let db = g.settings.karaokeSoftDb
|
||||
HStack(spacing: 12) {
|
||||
Text("Softness").font(.subheadline.weight(.semibold)).foregroundStyle(p.label2)
|
||||
Slider(value: Binding(get: { Double(db) }, set: { g.playback.setKaraokeSoftDb(Int($0.rounded())) }), in: -30...(-3), step: 1)
|
||||
Text("\(db) dB").font(.caption).monospacedDigit().foregroundStyle(p.label2).frame(width: 46, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 28).padding(.top, 8)
|
||||
}
|
||||
content
|
||||
}
|
||||
.task(id: track.ratingKey) { await follow() }
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
Group {
|
||||
if loading {
|
||||
ProgressView()
|
||||
@@ -869,7 +938,9 @@ private struct LyricsPanel: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.task(id: track.ratingKey) {
|
||||
}
|
||||
|
||||
private func follow() async {
|
||||
loading = true
|
||||
at = -1
|
||||
lyrics = await LyricsSource.shared.lyrics(track)
|
||||
@@ -883,7 +954,6 @@ private struct LyricsPanel: View {
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func lines(_ l: Lyrics) -> some View {
|
||||
ScrollViewReader { proxy in
|
||||
|
||||
@@ -2,15 +2,25 @@ import SwiftUI
|
||||
|
||||
// ---- playlists ------------------------------------------------------------------------------
|
||||
|
||||
enum PlaylistKind: String, CaseIterable { case Plex, Stem }
|
||||
|
||||
struct PlaylistsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@State private var lists: LoadState<[Playlist]> = .loading
|
||||
@State private var reload = 0
|
||||
@AppStorage("playlistKind") private var kind = PlaylistKind.Plex
|
||||
private let sort = SortState("playlists", PLAYLIST_SORTS)
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
Page(title: "Playlists") {
|
||||
Picker("Kind", selection: $kind) {
|
||||
Text("Plex").tag(PlaylistKind.Plex)
|
||||
Text("Stem").tag(PlaylistKind.Stem)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, MARGIN).padding(.bottom, 8)
|
||||
if kind == .Plex {
|
||||
Loaded(state: lists, retry: { reload += 1 }) { list in
|
||||
if list.isEmpty {
|
||||
EmptyState(icon: "music.note.list", title: "No Playlists",
|
||||
@@ -20,11 +30,21 @@ struct PlaylistsScreen: View {
|
||||
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let stems = g.stemPlaylists.lists
|
||||
if stems.isEmpty {
|
||||
EmptyState(icon: "slider.horizontal.3", title: "No Stem Playlists",
|
||||
message: "Each song plays with its own mix, like an instrumental or a cappella. Hold any song and choose “Add to Stem Playlist”, or copy a Plex playlist with +.")
|
||||
}
|
||||
ForEach(stems) { pl in
|
||||
StemPlaylistRow(playlist: pl).onTapGesture { ui.push(.StemPlaylistPage(pl.id)) }.actionsMenu(ui.menu.stemPlaylist(pl))
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
|
||||
if kind == .Plex { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { newPlaylist() } label: { Image(systemName: "plus") }
|
||||
Button { kind == .Plex ? newPlaylist() : newStemPlaylist() } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
.task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") {
|
||||
@@ -45,6 +65,130 @@ struct PlaylistsScreen: View {
|
||||
if acts.isEmpty { ui.overlays.hud("Play something first, or use “Add to Playlist” on any song.", "info.circle") }
|
||||
else { ui.overlays.actions("New Playlist", acts) }
|
||||
}
|
||||
|
||||
private func newStemPlaylist() {
|
||||
let menu = ui.menu
|
||||
let open: (StemPlaylist) -> Void = { ui.push(.StemPlaylistPage($0.id)) }
|
||||
ui.overlays.actions("New Stem Playlist", [
|
||||
SheetAction("Empty Stem Playlist…", icon: "plus") { menu.newStemPlaylist(onCreated: open) },
|
||||
SheetAction("Copy from Plex Playlist…", icon: "doc.on.doc") {
|
||||
Task {
|
||||
guard let plex = try? await g.plex.playlists() else {
|
||||
ui.overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
|
||||
return
|
||||
}
|
||||
if plex.isEmpty { ui.overlays.hud("No Plex playlists to copy", "info.circle"); return }
|
||||
ui.overlays.actions("Copy from Plex", plex.map { pl in SheetAction(pl.title) { menu.copyToStems(pl, onCreated: open) } })
|
||||
}
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
struct StemPlaylistRow: View {
|
||||
let playlist: StemPlaylist
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Artwork(thumb: playlist.thumb, size: 60, corner: 6, placeholder: "slider.horizontal.3")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(playlist.title).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(songs(playlist.items.count)).font(.subheadline).foregroundStyle(p.label2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Tag(text: "STEMS")
|
||||
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
||||
}
|
||||
.frame(height: 76)
|
||||
.contentShape(Rectangle())
|
||||
.listRow(inset: 94, trailing: 16)
|
||||
}
|
||||
}
|
||||
|
||||
/// A stem playlist: lives on this device, and every song plays with its saved mix.
|
||||
struct StemPlaylistScreen: View {
|
||||
let id: String
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var editing = false
|
||||
@State private var items: [StemPlaylistItem] = []
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
if let pl = g.stemPlaylists.list(id) {
|
||||
page(pl)
|
||||
} else {
|
||||
EmptyState(icon: "slider.horizontal.3", title: "Playlist Gone", message: "This stem playlist was deleted.")
|
||||
}
|
||||
}
|
||||
|
||||
private func page(_ pl: StemPlaylist) -> some View {
|
||||
Group {
|
||||
if editing {
|
||||
List {
|
||||
header(pl).listRowBackground(Color.clear).listRowSeparator(.hidden)
|
||||
ForEach(items) { it in
|
||||
TrackRow(track: it.track, subtitle: StemPlaylists.label(it.mix)) {}.listRowBackground(p.bg)
|
||||
}
|
||||
.onDelete { items.remove(atOffsets: $0) }
|
||||
.onMove { items.move(fromOffsets: $0, toOffset: $1) }
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(p.bg)
|
||||
.environment(\.editMode, .constant(.active))
|
||||
.navigationTitle(pl.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} else {
|
||||
Page(title: pl.title, large: false) {
|
||||
header(pl)
|
||||
if pl.items.isEmpty {
|
||||
EmptyState(icon: "slider.horizontal.3", title: "No Songs",
|
||||
message: "Hold any song, album or playlist and choose “Add to Stem Playlist”.")
|
||||
}
|
||||
ForEach(Array(pl.items.enumerated()), id: \.element.id) { i, it in
|
||||
TrackRow(track: it.track, subtitle: "\(it.track.artist) · \(StemPlaylists.label(it.mix))", menu: ui.menu.stemItem(it, in: pl.id)) {
|
||||
g.playback.playTracks(pl.tracks, mixes: pl.mixes, start: i)
|
||||
}
|
||||
.listRow(inset: MARGIN + 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
if editing { Button("Done") { g.stemPlaylists.setItems(pl.id, items); editing = false }.bold() }
|
||||
else if !pl.items.isEmpty { Button("Edit") { items = pl.items; editing = true } }
|
||||
if !editing { MoreMenu(items: moreItems(pl)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func header(_ pl: StemPlaylist) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
Artwork(thumb: pl.thumb, size: 230, corner: 10, placeholder: "slider.horizontal.3")
|
||||
Text(pl.title).font(.title2.bold()).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2)
|
||||
.padding(.horizontal, 32).padding(.top, 14)
|
||||
Text(pl.items.isEmpty ? "Stem Playlist" : "\(songs(pl.items.count)) · \(formatMinutes(pl.durationMs))").font(.subheadline).foregroundStyle(p.label2)
|
||||
if !editing && !pl.items.isEmpty {
|
||||
PlayShuffle(play: { g.playback.playTracks(pl.tracks, mixes: pl.mixes) },
|
||||
shuffle: { g.playback.playTracks(pl.tracks, mixes: pl.mixes, shuffle: true) }).padding(.top, 14)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8).padding(.bottom, 16)
|
||||
}
|
||||
|
||||
private func moreItems(_ pl: StemPlaylist) -> [SheetAction] {
|
||||
let lists = g.stemPlaylists
|
||||
let rename = SheetAction("Rename…", icon: "pencil") {
|
||||
ui.overlays.prompt("Rename Stem Playlist", nil, initial: pl.title, "Save") { lists.rename(pl.id, $0) }
|
||||
}
|
||||
let mixAll = SheetAction("Set Mix for All Songs…", icon: "slider.horizontal.3") {
|
||||
ui.menu.chooseMix(pl.tracks) { m in lists.setItems(pl.id, pl.items.map { var it = $0; it.mix = MixPresets.normalized(m); return it }) }
|
||||
}
|
||||
return ui.menu.stemPlaylist(pl, extra: pl.items.isEmpty ? [rename] : [rename, mixAll], onPage: true)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaylistRow: View {
|
||||
|
||||
@@ -30,6 +30,32 @@ private struct ToggleCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// How quiet karaoke's Soft vocals are.
|
||||
private struct SoftVocalsCell: View {
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
var body: some View {
|
||||
let db = g.settings.karaokeSoftDb
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "music.mic", bg: 0xFA2D48)
|
||||
Text("Karaoke Soft Vocals").foregroundStyle(p.label)
|
||||
Spacer()
|
||||
Text("\(db) dB").monospacedDigit().foregroundStyle(p.label2)
|
||||
}
|
||||
Slider(value: Binding(get: { Double(db) }, set: { g.playback.setKaraokeSoftDb(Int($0.rounded())) }), in: -30...(-3), step: 1) {
|
||||
Text("Karaoke Soft Vocals")
|
||||
} minimumValueLabel: {
|
||||
Text("Quieter").font(.caption).foregroundStyle(p.label2)
|
||||
} maximumValueLabel: {
|
||||
Text("Louder").font(.caption).foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
/// A tappable row in the accent (or red), optionally centred.
|
||||
private struct ActionCell: View {
|
||||
let title: String
|
||||
@@ -115,6 +141,7 @@ struct SettingsScreen: View {
|
||||
NavCell(title: "Equalizer", value: !g.eq.state.enabled ? "Off" : g.eq.active?.name ?? "On", icon: "slider.vertical.3", iconBg: 0x34C759, route: .Eq)
|
||||
NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality)
|
||||
ToggleCell(title: "Keep Stem Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
|
||||
SoftVocalsCell()
|
||||
} header: { Header(text: "Sound") }
|
||||
Section {
|
||||
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ options:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
MARKETING_VERSION: "1.0.5"
|
||||
CURRENT_PROJECT_VERSION: "6"
|
||||
MARKETING_VERSION: "1.0.9"
|
||||
CURRENT_PROJECT_VERSION: "10"
|
||||
targets:
|
||||
Stemify:
|
||||
type: application
|
||||
|
||||
@@ -15,6 +15,42 @@
|
||||
"tintColor": "#FA2D48",
|
||||
"category": "entertainment",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.8",
|
||||
"buildVersion": "9",
|
||||
"date": "2026-10-20T10:02:44-07:00",
|
||||
"localizedDescription": "- Playlists tab now has Plex and Stem sections. Plex playlists are unchanged and use their custom Plex poster when there is one.\n- Stem playlists: each song keeps its own mix (instrumental, a cappella, no drums, custom...). Add songs from any menu, or copy a Plex playlist over in one go. Change one song's mix or the whole list's.\n- Softness slider under the Vocals picker in the lyrics view when vocals are set to Soft.\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
|
||||
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.8/stemify-1.0.8.ipa",
|
||||
"size": 1462423,
|
||||
"minOSVersion": "17.0"
|
||||
},
|
||||
{
|
||||
"version": "1.0.7",
|
||||
"buildVersion": "8",
|
||||
"date": "2026-10-20T09:39:03-07:00",
|
||||
"localizedDescription": "- Mini player now sits in the tab bar's Liquid Glass accessory on iOS 26.1 and later, like Music. The tab bar shrinks when you scroll down and the player collapses to title and play/pause. Older iOS keeps the floating card.\n- Settings > Sound > Karaoke Soft Vocals: set how quiet Soft vocals are (-30 to -3 dB, default -12).\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
|
||||
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.7/stemify-1.0.7.ipa",
|
||||
"size": 1390623,
|
||||
"minOSVersion": "17.0"
|
||||
},
|
||||
{
|
||||
"version": "1.0.6",
|
||||
"buildVersion": "7",
|
||||
"date": "2026-10-20T09:31:01-07:00",
|
||||
"localizedDescription": "- Karaoke vocals option on the lyrics panel: On (full vocals), Soft (vocals about 12 dB down as a guide), Off (no vocals). Remembered between songs and launches.\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
|
||||
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.6/stemify-1.0.6.ipa",
|
||||
"size": 1375443,
|
||||
"minOSVersion": "17.0"
|
||||
},
|
||||
{
|
||||
"version": "1.0.5",
|
||||
"buildVersion": "6",
|
||||
"date": "2026-10-20T09:23:39-07:00",
|
||||
"localizedDescription": "- Sort menu on Artists, Albums, Songs and Playlists (Name, Date Added, Last Played, Plays, Rating, Year, Duration and more). Tap the current field again to flip direction (A to Z / Z to A, newest / oldest).\n- Each screen remembers its sort.\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
|
||||
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.5/stemify-1.0.5.ipa",
|
||||
"size": 1370686,
|
||||
"minOSVersion": "17.0"
|
||||
},
|
||||
{
|
||||
"version": "1.0.4",
|
||||
"buildVersion": "5",
|
||||
|
||||
Reference in new issue
Block a user