diff --git a/ios/Stemify/Core/Backup.swift b/ios/Stemify/Core/Backup.swift index a82e056..129b580 100644 --- a/ios/Stemify/Core/Backup.swift +++ b/ios/Stemify/Core/Backup.swift @@ -2,11 +2,13 @@ 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. +/// What an export file holds: stem playlists (with each song's mix and any cover picked from +/// 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 { 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 { var stemQuality: String? @@ -15,6 +17,10 @@ struct StemifyExport: Codable { var karaokeSoftDb: Int? var playbackSpeed: Double? var downloadsWifiOnly: Bool? + var presets: String? + var presetOrder: String? + var hiddenPresets: String? + var lockedPreset: String? } var format = StemifyExport.format @@ -22,12 +28,23 @@ struct StemifyExport: Codable { var exported = Date() var playlists: [StemPlaylist] var prefs: Prefs + /// Saved per-song mixes, by rating key. + var songMixes: [String: [String: StemSetting]]? + /// Covers picked from Photos ("local:"), base64 by file name. + var playlistArt: [String: String]? @MainActor - static func capture(_ lists: StemPlaylists, _ s: Settings) -> StemifyExport { - StemifyExport(playlists: lists.lists, prefs: Prefs( + static func capture(_ lists: StemPlaylists, _ pm: PlaybackManager, _ s: Settings) -> StemifyExport { + 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, - 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 { @@ -46,14 +63,42 @@ struct StemifyExport: Codable { 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 - 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.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 } + 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 } } } diff --git a/ios/Stemify/Core/Settings.swift b/ios/Stemify/Core/Settings.swift index 02a229a..6f4df01 100644 --- a/ios/Stemify/Core/Settings.swift +++ b/ios/Stemify/Core/Settings.swift @@ -109,7 +109,7 @@ final class Settings: @unchecked Sendable { "servers", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle", "stemUrl", "stemToken", "plexAccountToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate", "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(_ kp: KeyPath, _ 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) } } /// 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) } } + /// 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 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) } } diff --git a/ios/Stemify/Playback/PlaybackManager.swift b/ios/Stemify/Playback/PlaybackManager.swift index e292b4f..a74121a 100644 --- a/ios/Stemify/Playback/PlaybackManager.swift +++ b/ios/Stemify/Playback/PlaybackManager.swift @@ -592,9 +592,22 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { func saveSongMix() { setSongMix(MixPresets.normalized(mix)) } 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]?) { guard let key = current?.track.ratingKey else { return } songMixes[key] = m + saveSongMixes() + } + + private func saveSongMixes() { let all = songMixes, file = songMixFile DispatchQueue.global(qos: .utility).async { if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) } diff --git a/ios/Stemify/Playback/StemPlaylists.swift b/ios/Stemify/Playback/StemPlaylists.swift index bea6c78..f634191 100644 --- a/ios/Stemify/Playback/StemPlaylists.swift +++ b/ios/Stemify/Playback/StemPlaylists.swift @@ -163,22 +163,14 @@ final class StemPlaylists { 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. + /// Import from a backup: the backup's copy of each list wins, lists only on this phone stay. @discardableResult - func merge(_ incoming: [StemPlaylist]) -> Int { - var n = 0 + func restore(_ incoming: [StemPlaylist]) -> Int { 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 let i = lists.firstIndex(where: { $0.id == pl.id }) { lists[i] = pl } else { lists.append(pl) } } - if n > 0 { save() } - return n + if !incoming.isEmpty { save() } + return incoming.count } private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) { diff --git a/ios/Stemify/UI/Nav.swift b/ios/Stemify/UI/Nav.swift index 57c5258..3c0d13d 100644 --- a/ios/Stemify/UI/Nav.swift +++ b/ios/Stemify/UI/Nav.swift @@ -98,7 +98,11 @@ final class Ui { func setSplit(_ on: Bool) { guard on != split else { return } 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. diff --git a/ios/Stemify/UI/RootView.swift b/ios/Stemify/UI/RootView.swift index 00ba748..66be60d 100644 --- a/ios/Stemify/UI/RootView.swift +++ b/ios/Stemify/UI/RootView.swift @@ -238,16 +238,18 @@ private struct DuoRail: View { @Environment(\.palette) private var p 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) { - Spacer(minLength: 48) + Spacer(minLength: 160) item("Playing", "waveform", on: ui.deck) { ui.showDeck() } ForEach(ui.tabs, id: \.self) { t in 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) item("Swap", "arrow.left.arrow.right", on: false) { swap.toggle() } - Spacer(minLength: 48) } + .padding(.bottom, 20) .frame(maxWidth: .infinity) } diff --git a/ios/Stemify/UI/Screens/NowPlayingOptions.swift b/ios/Stemify/UI/Screens/NowPlayingOptions.swift index f1a826e..d738c78 100644 --- a/ios/Stemify/UI/Screens/NowPlayingOptions.swift +++ b/ios/Stemify/UI/Screens/NowPlayingOptions.swift @@ -50,6 +50,18 @@ struct NowPlayingOptionsScreen: View { .themedRow() } } 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 { Toggle(isOn: $s.lockScreenMix) { HStack(spacing: 14) { diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift index 75ed7c9..2870ae4 100644 --- a/ios/Stemify/UI/Screens/NowPlayingScreen.swift +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -108,6 +108,10 @@ struct NowPlayingScreen: View { @State private var tint: Color? /// How far the screen is being dragged down to close it. @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 } var body: some View { @@ -133,7 +137,7 @@ struct NowPlayingScreen: View { panelView(track, header: true) .frame(maxHeight: .infinity) .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) } @@ -142,8 +146,8 @@ struct NowPlayingScreen: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } - .padding(.top, 20) - .padding(.bottom, 8) + .padding(.top, docked ? 4 : tight ? 14 : 20) + .padding(.bottom, tight ? 4 : 8) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(c.bg.ignoresSafeArea()) .overlay(alignment: .top) { @@ -172,7 +176,7 @@ struct NowPlayingScreen: View { @ViewBuilder private func panelView(_ track: Track, header: Bool) -> some View { 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 .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) } @@ -195,11 +199,11 @@ struct NowPlayingScreen: View { Spacer(minLength: 8) EllipsisButton(items: ui.menu.track(track)) } - .padding(.top, 14) + .padding(.top, tight ? 8 : 14) .transition(.opacity.combined(with: .move(edge: .top))) } 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 { SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle) Spacer() @@ -220,8 +224,8 @@ struct NowPlayingScreen: View { Spacer() SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat) } - .frame(height: 84) - .padding(.top, 14) + .frame(height: tight ? 76 : 84) + .padding(.top, tight ? 4 : 14) HStack { let hidden = NowPlayingButton.hidden(g.settings.hiddenNowPlaying) 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: { VStack(spacing: 0) { 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()) .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. @@ -267,6 +271,7 @@ struct NowPlayingScreen: View { DragGesture(minimumDistance: 20).onEnded { v in let dy = v.translation.height 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 } } } @@ -282,7 +287,10 @@ struct NowPlayingScreen: View { let vertical = abs(dy) > abs(v.translation.width) if !docked && vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return } 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 { let thumb: String? let playing: Bool + var inset: CGFloat = 28 var body: some View { GeometryReader { geo in 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) .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 { + /// 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(\.palette) private var p @State private var meters = Meters() @@ -670,7 +682,7 @@ private struct MixerPanel: View { let stems = pm.source == .Stems GeometryReader { geo in // Spread spare height across the rows (stems + master) instead of leaving it all at the bottom. - let rowH = min(62, max(46, (geo.size.height - 80) / CGFloat(names.count + 1))) + let rowH = roomy ? Self.roomyRow : min(62, max(46, (geo.size.height - 80) / CGFloat(names.count + 1))) ScrollView { VStack(alignment: .leading, spacing: 0) { if pm.source == .Original { @@ -734,13 +746,13 @@ private struct MixerPanel: View { let levels = stems ? meters.step(pm.engine, names.count) : [] VStack(spacing: 0) { 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) - FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false, height: rowH) { - FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster) + FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false, height: rowH, stacked: roomy) { + FaderSlider(value: pm.master, fill: p.label, thumb: roomy ? 30 : 24, onChange: pm.setMaster) } trailing: { EmptyView() } 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. private struct PresetChip: View { let text: String @@ -781,14 +799,15 @@ private struct StemFader: View { let level: Float let enabled: Bool var height: CGFloat = 46 + var roomy = false @Environment(\.palette) private var p private var pm: PlaybackManager { AppGraph.shared.playback } var body: some View { 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) { - 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) } // Live level under the fader, -48..0 dBFS. @@ -804,8 +823,8 @@ private struct StemFader: View { } } 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) } + ToggleBox(text: "M", on: setting.muted, onColor: p.red, big: roomy) { pm.toggleMute(name) } + ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00), big: roomy) { pm.toggleSolo(name) } } } } @@ -819,10 +838,30 @@ struct FaderRow: View { let percent: Int let dim: Bool 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 trailing: Trailing @Environment(\.palette) private var p 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: 6) { if let dot { Circle().fill(dot).frame(width: 8, height: 8) } @@ -843,12 +882,13 @@ struct ToggleBox: View { let text: String let on: Bool let onColor: Color + var big = false let action: () -> Void @Environment(\.palette) private var p var body: some View { Button(action: action) { 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)) } .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 } + private var bar: CGFloat { thumb >= 28 ? 6 : 4 } + var body: some View { GeometryReader { geo in let track = max(geo.size.width - thumb, 1) let x = CGFloat(min(max(value, 0), 1)) * track ZStack(alignment: .leading) { - Capsule().fill(p.fillStrong).frame(height: 4).padding(.horizontal, thumb / 2) - Capsule().fill(fill).frame(width: x + thumb / 2, height: 4).padding(.leading, thumb / 2 - 0.01) + Capsule().fill(p.fillStrong).frame(height: bar).padding(.horizontal, thumb / 2) + Capsule().fill(fill).frame(width: x + thumb / 2, height: bar).padding(.leading, thumb / 2 - 0.01) if let detent { p.label3.frame(width: 2, height: 10).offset(x: CGFloat(detent) * track + thumb / 2 - 1) } @@ -901,7 +943,7 @@ struct FaderSlider: View { onChange(v) }.onEnded { _ in start = nil }) } - .frame(height: 36) + .frame(height: max(36, thumb + 8)) .opacity(enabled ? 1 : 0.4) .sensoryFeedback(.selection, trigger: caught) { _, now in now } } @@ -923,7 +965,8 @@ struct NowPlayingDeck: View { VStack(spacing: 0) { Text("Stems").font(.headline).foregroundStyle(c.label) .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) QueuePanel() } diff --git a/ios/Stemify/UI/Screens/SettingsScreens.swift b/ios/Stemify/UI/Screens/SettingsScreens.swift index 7799824..6140b22 100644 --- a/ios/Stemify/UI/Screens/SettingsScreens.swift +++ b/ios/Stemify/UI/Screens/SettingsScreens.swift @@ -397,7 +397,7 @@ struct BackupCells: View { var body: some View { 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 } } label: { ValueCell(title: "Export", value: "\(g.stemPlaylists.lists.count) playlists", icon: "square.and.arrow.up", iconBg: 0x007AFF) } .themedRow() @@ -412,9 +412,8 @@ struct BackupCells: View { 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." + let r = x.restore(g.stemPlaylists, g.playback, g.settings) + message = "Restored \(r.playlists) playlist\(r.playlists == 1 ? "" : "s"), \(r.mixes) song mix\(r.mixes == 1 ? "" : "es"), your presets and sound settings." } catch { message = error.localizedDescription } } .alert("Backup", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) {