iOS: Duo layout fixes, swipe-up option, fuller backup restore
- DuoRail sits at the bottom of the strip, clear of the back buttons - Setting to turn off swipe up for Playing Next - Tighter Now Playing on iPhone Duo (folded and docked), regular phones unchanged - Bigger stacked stem faders in the Duo deck - Backup v2 restores song mixes, custom presets and stem playlist covers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e8bc920454
commit
fabca5d1aa
9 files changed
+166
-54
No files matched your search
@@ -2,11 +2,13 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
/// What an export file holds: stem playlists (with each song's mix) and the non-secret sound settings.
|
/// What an export file holds: stem playlists (with each song's mix and any cover picked from
|
||||||
/// Plex and stem server tokens and URLs are never included.
|
/// Photos), each song's saved mix, custom presets and the preset bar, and the non-secret sound
|
||||||
|
/// settings. Plex and stem server tokens and URLs are never included.
|
||||||
struct StemifyExport: Codable {
|
struct StemifyExport: Codable {
|
||||||
static let format = "stemify-export"
|
static let format = "stemify-export"
|
||||||
static let version = 1
|
/// 2 added song mixes, presets and playlist covers. Version 1 files still import.
|
||||||
|
static let version = 2
|
||||||
|
|
||||||
struct Prefs: Codable {
|
struct Prefs: Codable {
|
||||||
var stemQuality: String?
|
var stemQuality: String?
|
||||||
@@ -15,6 +17,10 @@ struct StemifyExport: Codable {
|
|||||||
var karaokeSoftDb: Int?
|
var karaokeSoftDb: Int?
|
||||||
var playbackSpeed: Double?
|
var playbackSpeed: Double?
|
||||||
var downloadsWifiOnly: Bool?
|
var downloadsWifiOnly: Bool?
|
||||||
|
var presets: String?
|
||||||
|
var presetOrder: String?
|
||||||
|
var hiddenPresets: String?
|
||||||
|
var lockedPreset: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
var format = StemifyExport.format
|
var format = StemifyExport.format
|
||||||
@@ -22,12 +28,23 @@ struct StemifyExport: Codable {
|
|||||||
var exported = Date()
|
var exported = Date()
|
||||||
var playlists: [StemPlaylist]
|
var playlists: [StemPlaylist]
|
||||||
var prefs: Prefs
|
var prefs: Prefs
|
||||||
|
/// Saved per-song mixes, by rating key.
|
||||||
|
var songMixes: [String: [String: StemSetting]]?
|
||||||
|
/// Covers picked from Photos ("local:<file>"), base64 by file name.
|
||||||
|
var playlistArt: [String: String]?
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func capture(_ lists: StemPlaylists, _ s: Settings) -> StemifyExport {
|
static func capture(_ lists: StemPlaylists, _ pm: PlaybackManager, _ s: Settings) -> StemifyExport {
|
||||||
StemifyExport(playlists: lists.lists, prefs: Prefs(
|
var art: [String: String] = [:]
|
||||||
|
for pl in lists.lists {
|
||||||
|
guard let t = pl.artThumb, let u = StemPlaylists.localArt(t), let d = try? Data(contentsOf: u) else { continue }
|
||||||
|
art[u.lastPathComponent] = d.base64EncodedString()
|
||||||
|
}
|
||||||
|
return StemifyExport(playlists: lists.lists, prefs: Prefs(
|
||||||
stemQuality: s.stemQuality.rawValue, keepStemMix: s.keepStemMix, karaokeVocals: s.karaokeVocals,
|
stemQuality: s.stemQuality.rawValue, keepStemMix: s.keepStemMix, karaokeVocals: s.karaokeVocals,
|
||||||
karaokeSoftDb: s.karaokeSoftDb, playbackSpeed: s.playbackSpeed, downloadsWifiOnly: s.downloadsWifiOnly))
|
karaokeSoftDb: s.karaokeSoftDb, playbackSpeed: s.playbackSpeed, downloadsWifiOnly: s.downloadsWifiOnly,
|
||||||
|
presets: s.presets, presetOrder: s.presetOrder, hiddenPresets: s.hiddenPresets, lockedPreset: s.lockedPreset),
|
||||||
|
songMixes: pm.songMixes, playlistArt: art)
|
||||||
}
|
}
|
||||||
|
|
||||||
func encoded() throws -> Data {
|
func encoded() throws -> Data {
|
||||||
@@ -46,14 +63,42 @@ struct StemifyExport: Codable {
|
|||||||
return x
|
return x
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restores everything: playlists and song mixes from the file replace the ones with the same
|
||||||
|
/// id or song, presets merge by name, and the rest is left alone.
|
||||||
@MainActor
|
@MainActor
|
||||||
func apply(to s: Settings) {
|
func restore(_ lists: StemPlaylists, _ pm: PlaybackManager, _ s: Settings) -> (playlists: Int, mixes: Int) {
|
||||||
|
if let art = playlistArt, !art.isEmpty {
|
||||||
|
let dir = StemPlaylists.artDir
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
for (name, b64) in art where !name.isEmpty && !name.contains("/") {
|
||||||
|
if let d = Data(base64Encoded: b64) { try? d.write(to: dir.appendingPathComponent(name), options: .atomic) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let n = lists.restore(playlists)
|
||||||
|
let m = songMixes ?? [:]
|
||||||
|
pm.importSongMixes(m)
|
||||||
|
apply(to: s)
|
||||||
|
return (n, m.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func apply(to s: Settings) {
|
||||||
if let v = prefs.stemQuality.flatMap(StemQuality.init(rawValue:)) { s.stemQuality = v }
|
if let v = prefs.stemQuality.flatMap(StemQuality.init(rawValue:)) { s.stemQuality = v }
|
||||||
if let v = prefs.keepStemMix { s.keepStemMix = v }
|
if let v = prefs.keepStemMix { s.keepStemMix = v }
|
||||||
if let v = prefs.karaokeVocals { s.karaokeVocals = v }
|
if let v = prefs.karaokeVocals { s.karaokeVocals = v }
|
||||||
if let v = prefs.karaokeSoftDb { s.karaokeSoftDb = v }
|
if let v = prefs.karaokeSoftDb { s.karaokeSoftDb = v }
|
||||||
if let v = prefs.playbackSpeed { s.playbackSpeed = v }
|
if let v = prefs.playbackSpeed { s.playbackSpeed = v }
|
||||||
if let v = prefs.downloadsWifiOnly { s.downloadsWifiOnly = v }
|
if let v = prefs.downloadsWifiOnly { s.downloadsWifiOnly = v }
|
||||||
|
if let v = prefs.presets {
|
||||||
|
// Merge by name: the file's presets win, ones only on this phone stay.
|
||||||
|
let incoming = (v.data(using: .utf8)).flatMap { try? JSONDecoder().decode([CustomPreset].self, from: $0) } ?? []
|
||||||
|
let names = Set(incoming.map(\.name))
|
||||||
|
let merged = CustomPreset.load(s).filter { !names.contains($0.name) } + incoming
|
||||||
|
if let d = try? JSONEncoder().encode(merged), let str = String(data: d, encoding: .utf8) { s.presets = str }
|
||||||
|
}
|
||||||
|
if let v = prefs.presetOrder, !v.isEmpty { s.presetOrder = v }
|
||||||
|
if let v = prefs.hiddenPresets { s.hiddenPresets = v }
|
||||||
|
if let v = prefs.lockedPreset { s.lockedPreset = v }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ final class Settings: @unchecked Sendable {
|
|||||||
"servers", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
|
"servers", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
|
||||||
"stemUrl", "stemToken", "plexAccountToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
|
"stemUrl", "stemToken", "plexAccountToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
|
||||||
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout",
|
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout",
|
||||||
"karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", "noStems", "precacheCount", "hiddenNowPlaying", "presets", "presetOrder", "hiddenPresets", "lockedPreset", "webStations", "lockScreenMix", "acapella", "crossfadeSec", "sweetFades",
|
"karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", "noStems", "precacheCount", "hiddenNowPlaying", "presets", "presetOrder", "hiddenPresets", "lockedPreset", "webStations", "lockScreenMix", "swipeUpQueue", "acapella", "crossfadeSec", "sweetFades",
|
||||||
]
|
]
|
||||||
|
|
||||||
private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T {
|
private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T {
|
||||||
@@ -216,6 +216,8 @@ final class Settings: @unchecked Sendable {
|
|||||||
var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } }
|
var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } }
|
||||||
/// Spells the A Cappella mix "Acapella". MixPresets reads the key directly.
|
/// Spells the A Cappella mix "Acapella". MixPresets reads the key directly.
|
||||||
var acapella: Bool { get { read(\.acapella, "acapella", false) } set { write(\.acapella, "acapella", newValue, false) } }
|
var acapella: Bool { get { read(\.acapella, "acapella", false) } set { write(\.acapella, "acapella", newValue, false) } }
|
||||||
|
/// Swipe up on Now Playing opens Playing Next.
|
||||||
|
var swipeUpQueue: Bool { get { read(\.swipeUpQueue, "swipeUpQueue", true) } set { write(\.swipeUpQueue, "swipeUpQueue", newValue, true) } }
|
||||||
var lockScreenMix: Bool { get { read(\.lockScreenMix, "lockScreenMix", false) } set { write(\.lockScreenMix, "lockScreenMix", newValue, false) } }
|
var lockScreenMix: Bool { get { read(\.lockScreenMix, "lockScreenMix", false) } set { write(\.lockScreenMix, "lockScreenMix", newValue, false) } }
|
||||||
var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } }
|
var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } }
|
||||||
var highRefreshRate: Bool { get { read(\.highRefreshRate, "highRefreshRate", true) } set { write(\.highRefreshRate, "highRefreshRate", newValue, true) } }
|
var highRefreshRate: Bool { get { read(\.highRefreshRate, "highRefreshRate", true) } set { write(\.highRefreshRate, "highRefreshRate", newValue, true) } }
|
||||||
|
|||||||
@@ -592,9 +592,22 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
func saveSongMix() { setSongMix(MixPresets.normalized(mix)) }
|
func saveSongMix() { setSongMix(MixPresets.normalized(mix)) }
|
||||||
func forgetSongMix() { setSongMix(nil) }
|
func forgetSongMix() { setSongMix(nil) }
|
||||||
|
|
||||||
|
/// From a backup: its saved mixes replace this phone's for the same songs.
|
||||||
|
func importSongMixes(_ m: [String: [String: StemSetting]]) {
|
||||||
|
guard !m.isEmpty else { return }
|
||||||
|
songMixes.merge(m) { _, new in new }
|
||||||
|
saveSongMixes()
|
||||||
|
// The playing song picks up its restored mix right away.
|
||||||
|
if let s = songMix { applyMix(s) }
|
||||||
|
}
|
||||||
|
|
||||||
private func setSongMix(_ m: [String: StemSetting]?) {
|
private func setSongMix(_ m: [String: StemSetting]?) {
|
||||||
guard let key = current?.track.ratingKey else { return }
|
guard let key = current?.track.ratingKey else { return }
|
||||||
songMixes[key] = m
|
songMixes[key] = m
|
||||||
|
saveSongMixes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveSongMixes() {
|
||||||
let all = songMixes, file = songMixFile
|
let all = songMixes, file = songMixFile
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) }
|
if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) }
|
||||||
|
|||||||
@@ -163,22 +163,14 @@ 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 from a backup: the backup's copy of each list wins, lists only on this phone stay.
|
||||||
/// import was updated more recently. Returns how many lists were added or replaced.
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func merge(_ incoming: [StemPlaylist]) -> Int {
|
func restore(_ incoming: [StemPlaylist]) -> Int {
|
||||||
var n = 0
|
|
||||||
for pl in incoming {
|
for pl in incoming {
|
||||||
if let i = lists.firstIndex(where: { $0.id == pl.id }) {
|
if let i = lists.firstIndex(where: { $0.id == pl.id }) { lists[i] = pl } else { lists.append(pl) }
|
||||||
guard pl.updated > lists[i].updated else { continue }
|
|
||||||
lists[i] = pl
|
|
||||||
} else {
|
|
||||||
lists.append(pl)
|
|
||||||
}
|
|
||||||
n += 1
|
|
||||||
}
|
}
|
||||||
if n > 0 { save() }
|
if !incoming.isEmpty { save() }
|
||||||
return n
|
return incoming.count
|
||||||
}
|
}
|
||||||
|
|
||||||
private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
|
private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
|
||||||
|
|||||||
@@ -98,7 +98,11 @@ final class Ui {
|
|||||||
func setSplit(_ on: Bool) {
|
func setSplit(_ on: Bool) {
|
||||||
guard on != split else { return }
|
guard on != split else { return }
|
||||||
split = on
|
split = on
|
||||||
if on { nowPlayingOpen = false }
|
if on {
|
||||||
|
nowPlayingOpen = false
|
||||||
|
// Only an iPhone Duo ever splits, so from here on folded Now Playing uses its tighter layout.
|
||||||
|
UserDefaults.standard.set(true, forKey: "duoSeen")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switches to the Library tab and pushes [r] there.
|
/// Switches to the Library tab and pushes [r] there.
|
||||||
|
|||||||
@@ -238,16 +238,18 @@ private struct DuoRail: View {
|
|||||||
@Environment(\.palette) private var p
|
@Environment(\.palette) private var p
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
// Sits at the bottom of the strip: the top is where the panes' back buttons and the
|
||||||
|
// status bar live, and the tabs used to land right on them.
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Spacer(minLength: 48)
|
Spacer(minLength: 160)
|
||||||
item("Playing", "waveform", on: ui.deck) { ui.showDeck() }
|
item("Playing", "waveform", on: ui.deck) { ui.showDeck() }
|
||||||
ForEach(ui.tabs, id: \.self) { t in
|
ForEach(ui.tabs, id: \.self) { t in
|
||||||
item(t.rawValue, t.icon, on: !ui.deck && ui.tab == t) { ui.selectTab(t) }
|
item(t.rawValue, t.icon, on: !ui.deck && ui.tab == t) { ui.selectTab(t) }
|
||||||
}
|
}
|
||||||
Capsule().fill(p.label3.opacity(0.4)).frame(width: 24, height: 2).padding(.vertical, 8)
|
Capsule().fill(p.label3.opacity(0.4)).frame(width: 24, height: 2).padding(.vertical, 8)
|
||||||
item("Swap", "arrow.left.arrow.right", on: false) { swap.toggle() }
|
item("Swap", "arrow.left.arrow.right", on: false) { swap.toggle() }
|
||||||
Spacer(minLength: 48)
|
|
||||||
}
|
}
|
||||||
|
.padding(.bottom, 20)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ struct NowPlayingOptionsScreen: View {
|
|||||||
.themedRow()
|
.themedRow()
|
||||||
}
|
}
|
||||||
} footer: { Text("Choose which buttons show under the playback controls.").foregroundStyle(p.label2) }
|
} footer: { Text("Choose which buttons show under the playback controls.").foregroundStyle(p.label2) }
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: $s.swipeUpQueue) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
SettingsIcon(symbol: "hand.draw", bg: 0x5856D6)
|
||||||
|
Text("Swipe Up for Playing Next").foregroundStyle(p.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(Color(hex: 0x34C759))
|
||||||
|
.themedRow()
|
||||||
|
} header: { Text("Gestures").foregroundStyle(p.label2) } footer: {
|
||||||
|
Text("Off, swiping up on the art or the stems does nothing. Tap Playing Next under the controls instead.").foregroundStyle(p.label2)
|
||||||
|
}
|
||||||
Section {
|
Section {
|
||||||
Toggle(isOn: $s.lockScreenMix) {
|
Toggle(isOn: $s.lockScreenMix) {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ struct NowPlayingScreen: View {
|
|||||||
@State private var tint: Color?
|
@State private var tint: Color?
|
||||||
/// How far the screen is being dragged down to close it.
|
/// How far the screen is being dragged down to close it.
|
||||||
@State private var drop: CGFloat = 0
|
@State private var drop: CGFloat = 0
|
||||||
|
/// Set once the phone has unfolded, so it's an iPhone Duo: its screens are shorter for their
|
||||||
|
/// width than a regular iPhone's, so the controls pack tighter and the art gets the room.
|
||||||
|
@AppStorage("duoSeen") private var duoSeen = false
|
||||||
|
private var tight: Bool { docked || duoSeen }
|
||||||
private var g: AppGraph { .shared }
|
private var g: AppGraph { .shared }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -133,7 +137,7 @@ struct NowPlayingScreen: View {
|
|||||||
panelView(track, header: true)
|
panelView(track, header: true)
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
||||||
controls(track, wide: false).frame(maxWidth: 560)
|
controls(track, wide: false).frame(maxWidth: tight ? 520 : 560)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
@@ -142,8 +146,8 @@ struct NowPlayingScreen: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 20)
|
.padding(.top, docked ? 4 : tight ? 14 : 20)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, tight ? 4 : 8)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(c.bg.ignoresSafeArea())
|
.background(c.bg.ignoresSafeArea())
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
@@ -172,7 +176,7 @@ struct NowPlayingScreen: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func panelView(_ track: Track, header: Bool) -> some View {
|
private func panelView(_ track: Track, header: Bool) -> some View {
|
||||||
switch panel {
|
switch panel {
|
||||||
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady).contentShape(Rectangle())
|
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady, inset: tight ? 18 : 28).contentShape(Rectangle())
|
||||||
case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() }
|
case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() }
|
||||||
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; QueuePanel(close: { show(.Queue) }) }
|
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; QueuePanel(close: { show(.Queue) }) }
|
||||||
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; LyricsPanel(track: track) }
|
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; LyricsPanel(track: track) }
|
||||||
@@ -195,11 +199,11 @@ struct NowPlayingScreen: View {
|
|||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
EllipsisButton(items: ui.menu.track(track))
|
EllipsisButton(items: ui.menu.track(track))
|
||||||
}
|
}
|
||||||
.padding(.top, 14)
|
.padding(.top, tight ? 8 : 14)
|
||||||
.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, wide ? 24 : panel == .Art ? 34 : 30)
|
.padding(.top, tight ? (wide ? 14 : 18) : wide ? 24 : panel == .Art ? 34 : 30)
|
||||||
HStack {
|
HStack {
|
||||||
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -220,8 +224,8 @@ struct NowPlayingScreen: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
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: tight ? 76 : 84)
|
||||||
.padding(.top, 14)
|
.padding(.top, tight ? 4 : 14)
|
||||||
HStack {
|
HStack {
|
||||||
let hidden = NowPlayingButton.hidden(g.settings.hiddenNowPlaying)
|
let hidden = NowPlayingButton.hidden(g.settings.hiddenNowPlaying)
|
||||||
if !hidden.contains(.Mixer) { PanelToggle(symbol: "slider.vertical.3", on: docked ? ui.deck : panel == .Mixer) { show(.Mixer) } }
|
if !hidden.contains(.Mixer) { PanelToggle(symbol: "slider.vertical.3", on: docked ? ui.deck : panel == .Mixer) { show(.Mixer) } }
|
||||||
@@ -235,14 +239,14 @@ struct NowPlayingScreen: View {
|
|||||||
if !docked { Button { show(.Queue) } label: {
|
if !docked { Button { show(.Queue) } label: {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Image(systemName: panel == .Queue ? "chevron.compact.down" : "chevron.compact.up").font(.system(size: 22, weight: .medium))
|
Image(systemName: panel == .Queue ? "chevron.compact.down" : "chevron.compact.up").font(.system(size: 22, weight: .medium))
|
||||||
if panel != .Queue { Text("Playing Next").font(.caption2.weight(.medium)) }
|
if panel != .Queue && !tight { Text("Playing Next").font(.caption2.weight(.medium)) }
|
||||||
}
|
}
|
||||||
.foregroundStyle(c.label3).frame(maxWidth: .infinity, minHeight: 30).contentShape(Rectangle())
|
.foregroundStyle(c.label3).frame(maxWidth: .infinity, minHeight: tight ? 24 : 30).contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(PressFade())
|
.buttonStyle(PressFade())
|
||||||
.gesture(swipe(up: panel != .Queue)) }
|
.gesture(swipe(up: panel != .Queue)) }
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 28)
|
.padding(.horizontal, tight ? 20 : 28)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens a panel, or goes back to the art if it's already open.
|
/// Opens a panel, or goes back to the art if it's already open.
|
||||||
@@ -267,6 +271,7 @@ struct NowPlayingScreen: View {
|
|||||||
DragGesture(minimumDistance: 20).onEnded { v in
|
DragGesture(minimumDistance: 20).onEnded { v in
|
||||||
let dy = v.translation.height
|
let dy = v.translation.height
|
||||||
guard up ? dy < -50 : dy > 50, abs(dy) > abs(v.translation.width) else { return }
|
guard up ? dy < -50 : dy > 50, abs(dy) > abs(v.translation.width) else { return }
|
||||||
|
if up && !g.settings.swipeUpQueue { return }
|
||||||
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = up ? .Queue : .Art }
|
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = up ? .Queue : .Art }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,7 +287,10 @@ struct NowPlayingScreen: View {
|
|||||||
let vertical = abs(dy) > abs(v.translation.width)
|
let vertical = abs(dy) > abs(v.translation.width)
|
||||||
if !docked && vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return }
|
if !docked && vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return }
|
||||||
withAnimation(.spring(duration: 0.3, bounce: 0.1)) { drop = 0 }
|
withAnimation(.spring(duration: 0.3, bounce: 0.1)) { drop = 0 }
|
||||||
if vertical && dy < -50 { withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = .Queue } }
|
// Never from the stems: a slightly upward fader drag or scroll there kept opening the queue.
|
||||||
|
if vertical && dy < -50 && panel != .Mixer && g.settings.swipeUpQueue {
|
||||||
|
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = .Queue }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +442,7 @@ private struct EllipsisButton: View {
|
|||||||
private struct ArtPanel: View {
|
private struct ArtPanel: View {
|
||||||
let thumb: String?
|
let thumb: String?
|
||||||
let playing: Bool
|
let playing: Bool
|
||||||
|
var inset: CGFloat = 28
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let side = max(min(geo.size.width, geo.size.height), 1)
|
let side = max(min(geo.size.width, geo.size.height), 1)
|
||||||
@@ -443,7 +452,7 @@ private struct ArtPanel: View {
|
|||||||
.animation(.spring(response: 0.45, dampingFraction: 0.62), value: playing)
|
.animation(.spring(response: 0.45, dampingFraction: 0.62), value: playing)
|
||||||
.frame(width: geo.size.width, height: geo.size.height)
|
.frame(width: geo.size.width, height: geo.size.height)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 28).padding(.vertical, 8)
|
.padding(.horizontal, inset).padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,6 +668,9 @@ private final class Meters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct MixerPanel: View {
|
private struct MixerPanel: View {
|
||||||
|
/// The iPhone Duo deck: each stem's name sits over its fader so the fader gets the row's full
|
||||||
|
/// width, with a bigger thumb for fine moves.
|
||||||
|
var roomy = false
|
||||||
@Environment(Ui.self) private var ui
|
@Environment(Ui.self) private var ui
|
||||||
@Environment(\.palette) private var p
|
@Environment(\.palette) private var p
|
||||||
@State private var meters = Meters()
|
@State private var meters = Meters()
|
||||||
@@ -670,7 +682,7 @@ private struct MixerPanel: View {
|
|||||||
let stems = pm.source == .Stems
|
let stems = pm.source == .Stems
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
// Spread spare height across the rows (stems + master) instead of leaving it all at the bottom.
|
// Spread spare height across the rows (stems + master) instead of leaving it all at the bottom.
|
||||||
let rowH = min(62, max(46, (geo.size.height - 80) / CGFloat(names.count + 1)))
|
let rowH = roomy ? Self.roomyRow : min(62, max(46, (geo.size.height - 80) / CGFloat(names.count + 1)))
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
if pm.source == .Original {
|
if pm.source == .Original {
|
||||||
@@ -734,13 +746,13 @@ private struct MixerPanel: View {
|
|||||||
let levels = stems ? meters.step(pm.engine, names.count) : []
|
let levels = stems ? meters.step(pm.engine, names.count) : []
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(Array(names.enumerated()), id: \.element) { i, name in
|
ForEach(Array(names.enumerated()), id: \.element) { i, name in
|
||||||
StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems, height: rowH)
|
StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems, height: rowH, roomy: roomy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.separator.frame(height: 0.5).padding(.vertical, 6)
|
p.separator.frame(height: 0.5).padding(.vertical, 6)
|
||||||
FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false, height: rowH) {
|
FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false, height: rowH, stacked: roomy) {
|
||||||
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
|
FaderSlider(value: pm.master, fill: p.label, thumb: roomy ? 30 : 24, onChange: pm.setMaster)
|
||||||
} trailing: { EmptyView() }
|
} trailing: { EmptyView() }
|
||||||
Spacer(minLength: 16)
|
Spacer(minLength: 16)
|
||||||
}
|
}
|
||||||
@@ -753,6 +765,12 @@ private struct MixerPanel: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension MixerPanel {
|
||||||
|
static let roomyRow: CGFloat = 60
|
||||||
|
/// Height that shows the presets, every stem and the master without scrolling, roomy rows.
|
||||||
|
static func roomyHeight(_ stems: Int) -> CGFloat { 48 + CGFloat(stems + 1) * roomyRow + 13 + 16 }
|
||||||
|
}
|
||||||
|
|
||||||
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
|
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
|
||||||
private struct PresetChip: View {
|
private struct PresetChip: View {
|
||||||
let text: String
|
let text: String
|
||||||
@@ -781,14 +799,15 @@ private struct StemFader: View {
|
|||||||
let level: Float
|
let level: Float
|
||||||
let enabled: Bool
|
let enabled: Bool
|
||||||
var height: CGFloat = 46
|
var height: CGFloat = 46
|
||||||
|
var roomy = false
|
||||||
@Environment(\.palette) private var p
|
@Environment(\.palette) private var p
|
||||||
private var pm: PlaybackManager { AppGraph.shared.playback }
|
private var pm: PlaybackManager { AppGraph.shared.playback }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let color = Color(hex: Stems.color(name))
|
let color = Color(hex: Stems.color(name))
|
||||||
FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted, height: height) {
|
FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted, height: height, stacked: roomy) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
|
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled, thumb: roomy ? 30 : 24) {
|
||||||
pm.setStemLevel(name, $0)
|
pm.setStemLevel(name, $0)
|
||||||
}
|
}
|
||||||
// Live level under the fader, -48..0 dBFS.
|
// Live level under the fader, -48..0 dBFS.
|
||||||
@@ -804,8 +823,8 @@ private struct StemFader: View {
|
|||||||
}
|
}
|
||||||
} trailing: {
|
} trailing: {
|
||||||
if enabled {
|
if enabled {
|
||||||
ToggleBox(text: "M", on: setting.muted, onColor: p.red) { pm.toggleMute(name) }
|
ToggleBox(text: "M", on: setting.muted, onColor: p.red, big: roomy) { pm.toggleMute(name) }
|
||||||
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00)) { pm.toggleSolo(name) }
|
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00), big: roomy) { pm.toggleSolo(name) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -819,10 +838,30 @@ struct FaderRow<Fader: View, Trailing: View>: View {
|
|||||||
let percent: Int
|
let percent: Int
|
||||||
let dim: Bool
|
let dim: Bool
|
||||||
var height: CGFloat = 46
|
var height: CGFloat = 46
|
||||||
|
/// Name and level on a line of their own over the fader, so the fader runs the full width.
|
||||||
|
var stacked = false
|
||||||
@ViewBuilder let fader: Fader
|
@ViewBuilder let fader: Fader
|
||||||
@ViewBuilder let trailing: Trailing
|
@ViewBuilder let trailing: Trailing
|
||||||
@Environment(\.palette) private var p
|
@Environment(\.palette) private var p
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if stacked {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
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)
|
||||||
|
Text("\(percent)%").font(.caption.monospacedDigit()).foregroundStyle(p.label2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(height: 18)
|
||||||
|
HStack(spacing: 8) { fader; trailing }
|
||||||
|
}
|
||||||
|
.frame(height: height)
|
||||||
|
} else {
|
||||||
|
row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var row: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
if let dot { Circle().fill(dot).frame(width: 8, height: 8) }
|
if let dot { Circle().fill(dot).frame(width: 8, height: 8) }
|
||||||
@@ -843,12 +882,13 @@ struct ToggleBox: View {
|
|||||||
let text: String
|
let text: String
|
||||||
let on: Bool
|
let on: Bool
|
||||||
let onColor: Color
|
let onColor: Color
|
||||||
|
var big = false
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
@Environment(\.palette) private var p
|
@Environment(\.palette) private var p
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(text).font(.footnote.bold()).foregroundStyle(on ? .black : p.label2)
|
Text(text).font(.footnote.bold()).foregroundStyle(on ? .black : p.label2)
|
||||||
.frame(width: 30, height: 26)
|
.frame(width: big ? 38 : 30, height: big ? 32 : 26)
|
||||||
.background(on ? onColor : p.fill, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
.background(on ? onColor : p.fill, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -873,13 +913,15 @@ struct FaderSlider: View {
|
|||||||
self.value = value; self.fill = fill; self.detent = detent; self.enabled = enabled; self.thumb = thumb; self.onChange = onChange
|
self.value = value; self.fill = fill; self.detent = detent; self.enabled = enabled; self.thumb = thumb; self.onChange = onChange
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var bar: CGFloat { thumb >= 28 ? 6 : 4 }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let track = max(geo.size.width - thumb, 1)
|
let track = max(geo.size.width - thumb, 1)
|
||||||
let x = CGFloat(min(max(value, 0), 1)) * track
|
let x = CGFloat(min(max(value, 0), 1)) * track
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
Capsule().fill(p.fillStrong).frame(height: 4).padding(.horizontal, thumb / 2)
|
Capsule().fill(p.fillStrong).frame(height: bar).padding(.horizontal, thumb / 2)
|
||||||
Capsule().fill(fill).frame(width: x + thumb / 2, height: 4).padding(.leading, thumb / 2 - 0.01)
|
Capsule().fill(fill).frame(width: x + thumb / 2, height: bar).padding(.leading, thumb / 2 - 0.01)
|
||||||
if let detent {
|
if let detent {
|
||||||
p.label3.frame(width: 2, height: 10).offset(x: CGFloat(detent) * track + thumb / 2 - 1)
|
p.label3.frame(width: 2, height: 10).offset(x: CGFloat(detent) * track + thumb / 2 - 1)
|
||||||
}
|
}
|
||||||
@@ -901,7 +943,7 @@ struct FaderSlider: View {
|
|||||||
onChange(v)
|
onChange(v)
|
||||||
}.onEnded { _ in start = nil })
|
}.onEnded { _ in start = nil })
|
||||||
}
|
}
|
||||||
.frame(height: 36)
|
.frame(height: max(36, thumb + 8))
|
||||||
.opacity(enabled ? 1 : 0.4)
|
.opacity(enabled ? 1 : 0.4)
|
||||||
.sensoryFeedback(.selection, trigger: caught) { _, now in now }
|
.sensoryFeedback(.selection, trigger: caught) { _, now in now }
|
||||||
}
|
}
|
||||||
@@ -923,7 +965,8 @@ struct NowPlayingDeck: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text("Stems").font(.headline).foregroundStyle(c.label)
|
Text("Stems").font(.headline).foregroundStyle(c.label)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 24)
|
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 24)
|
||||||
MixerPanel().frame(height: geo.size.height * 0.52)
|
// As much as the stems need to show without scrolling, leaving the queue a few rows.
|
||||||
|
MixerPanel(roomy: true).frame(height: min(MixerPanel.roomyHeight(max(g.playback.stemNames.count, 4)), geo.size.height * 0.68))
|
||||||
Rectangle().fill(c.label3.opacity(0.35)).frame(height: 0.5).padding(.horizontal, 24).padding(.bottom, 8)
|
Rectangle().fill(c.label3.opacity(0.35)).frame(height: 0.5).padding(.horizontal, 24).padding(.bottom, 8)
|
||||||
QueuePanel()
|
QueuePanel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ struct BackupCells: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
do { doc = ExportDocument(data: try StemifyExport.capture(g.stemPlaylists, g.settings).encoded()); exporting = true }
|
do { doc = ExportDocument(data: try StemifyExport.capture(g.stemPlaylists, g.playback, g.settings).encoded()); exporting = true }
|
||||||
catch { message = error.localizedDescription }
|
catch { message = error.localizedDescription }
|
||||||
} label: { ValueCell(title: "Export", value: "\(g.stemPlaylists.lists.count) playlists", icon: "square.and.arrow.up", iconBg: 0x007AFF) }
|
} label: { ValueCell(title: "Export", value: "\(g.stemPlaylists.lists.count) playlists", icon: "square.and.arrow.up", iconBg: 0x007AFF) }
|
||||||
.themedRow()
|
.themedRow()
|
||||||
@@ -412,9 +412,8 @@ struct BackupCells: View {
|
|||||||
let scoped = url.startAccessingSecurityScopedResource()
|
let scoped = url.startAccessingSecurityScopedResource()
|
||||||
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||||
let x = try StemifyExport.decode(Data(contentsOf: url))
|
let x = try StemifyExport.decode(Data(contentsOf: url))
|
||||||
let n = g.stemPlaylists.merge(x.playlists)
|
let r = x.restore(g.stemPlaylists, g.playback, g.settings)
|
||||||
x.apply(to: g.settings)
|
message = "Restored \(r.playlists) playlist\(r.playlists == 1 ? "" : "s"), \(r.mixes) song mix\(r.mixes == 1 ? "" : "es"), your presets and sound settings."
|
||||||
message = "Imported \(n) playlist\(n == 1 ? "" : "s") and your sound settings."
|
|
||||||
} catch { message = error.localizedDescription }
|
} catch { message = error.localizedDescription }
|
||||||
}
|
}
|
||||||
.alert("Backup", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) {
|
.alert("Backup", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) {
|
||||||
|
|||||||
Reference in new issue
Block a user