From d52a3e257f449ec0c186f0f3fcfd30f169f3c0f3 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 20 Oct 2026 09:39:02 -0700 Subject: [PATCH] ios: Liquid Glass mini player, adjustable karaoke softness (1.0.7) Co-Authored-By: Claude Opus 5.5 --- ios/Stemify/Core/Settings.swift | 2 + ios/Stemify/Playback/PlaybackManager.swift | 9 +- ios/Stemify/UI/RootView.swift | 41 +++++- ios/Stemify/UI/Screens/NowPlayingScreen.swift | 136 ++++++++++++------ ios/Stemify/UI/Screens/SettingsScreens.swift | 27 ++++ ios/project.yml | 4 +- 6 files changed, 163 insertions(+), 56 deletions(-) diff --git a/ios/Stemify/Core/Settings.swift b/ios/Stemify/Core/Settings.swift index ed52554..a77585c 100644 --- a/ios/Stemify/Core/Settings.swift +++ b/ios/Stemify/Core/Settings.swift @@ -186,6 +186,8 @@ final class Settings: @unchecked Sendable { /// 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 } diff --git a/ios/Stemify/Playback/PlaybackManager.swift b/ios/Stemify/Playback/PlaybackManager.swift index ba91fbe..408b15e 100644 --- a/ios/Stemify/Playback/PlaybackManager.swift +++ b/ios/Stemify/Playback/PlaybackManager.swift @@ -421,11 +421,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:]) } - /// How much of the singer is left in karaoke mode. Soft is about -12 dB, a guide under your own voice. + /// 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: 0.4)] + case .Soft: return ["vocals": StemSetting(level: stemUntaper(Float(pow(10, Double(settings.karaokeSoftDb) / 20))))] case .Off: return ["vocals": StemSetting(muted: true)] } } @@ -437,6 +437,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { 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) { guard on != karaoke else { return } diff --git a/ios/Stemify/UI/RootView.swift b/ios/Stemify/UI/RootView.swift index 8fb6524..568205f 100644 --- a/ios/Stemify/UI/RootView.swift +++ b/ios/Stemify/UI/RootView.swift @@ -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) @@ -72,11 +69,43 @@ struct RootView: View { } } +/// 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) + } + } +} + 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) } } } } diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift index e8cef2d..f278f14 100644 --- a/ios/Stemify/UI/Screens/NowPlayingScreen.swift +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -794,56 +794,100 @@ 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) { - Text(t.title).font(.subheadline.weight(.semibold)).foregroundStyle(p.label).lineLimit(1) - 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()) + HStack(spacing: 0) { + Artwork(thumb: t.thumb, size: 44, corner: 8).padding(.trailing, 12) + VStack(alignment: .leading, spacing: 1) { + Text(t.title).font(.subheadline.weight(.semibold)).foregroundStyle(p.label).lineLimit(1) + Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1) } - .padding(.leading, 8).padding(.trailing, 6) - .frame(height: 60) - .background { - // A solid card in the app's own colours instead of system glass. - let shape = RoundedRectangle(cornerRadius: 14, style: .continuous) - ZStack { - shape.fill(p.cell) - shape.fill(p.fill.opacity(p.dark ? 0.5 : 0)) - shape.strokeBorder(p.separator, lineWidth: 1 / scale) - } - .shadow(color: .black.opacity(p.dark ? 0.5 : 0.12), radius: 12, y: 4) + Spacer(minLength: 6) + MiniTransport(color: p.label, next: true) + } + .padding(.leading, 8).padding(.trailing, 6) + .frame(height: 60) + .background { + // A solid card in the app's own colours instead of system glass. + let shape = RoundedRectangle(cornerRadius: 14, style: .continuous) + ZStack { + shape.fill(p.cell) + shape.fill(p.fill.opacity(p.dark ? 0.5 : 0)) + shape.strokeBorder(p.separator, lineWidth: 1 / scale) } - .overlay(alignment: .bottom) { - // Thin progress line along the bottom of the card. - TimelineView(.animation(minimumInterval: 0.25, paused: !pm.playWhenReady)) { _ in - let d = pm.durationMs - let f = d > 0 ? min(max(Double(pm.positionMs()) / Double(d), 0), 1) : 0 - GeometryReader { geo in p.accent.frame(width: geo.size.width * f, height: 2) }.frame(height: 2) - } - .padding(.horizontal, 14) + .shadow(color: .black.opacity(p.dark ? 0.5 : 0.12), radius: 12, y: 4) + } + .overlay(alignment: .bottom) { + // Thin progress line along the bottom of the card. + TimelineView(.animation(minimumInterval: 0.25, paused: !pm.playWhenReady)) { _ in + let d = pm.durationMs + let f = d > 0 ? min(max(Double(pm.positionMs()) / Double(d), 0), 1) : 0 + GeometryReader { geo in p.accent.frame(width: geo.size.width * f, height: 2) }.frame(height: 2) } - .padding(.horizontal, 8) - .padding(.bottom, 6) - .contentShape(Rectangle()) - .onTapGesture { ui.openNowPlaying() } - .gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } }) - .actionsMenu(ui.menu.track(t)) + .padding(.horizontal, 14) + } + .padding(.horizontal, 8) + .padding(.bottom, 6) + .contentShape(Rectangle()) + .onTapGesture { ui.openNowPlaying() } + .gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } }) + .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()) } } } diff --git a/ios/Stemify/UI/Screens/SettingsScreens.swift b/ios/Stemify/UI/Screens/SettingsScreens.swift index 459bc9d..425154d 100644 --- a/ios/Stemify/UI/Screens/SettingsScreens.swift +++ b/ios/Stemify/UI/Screens/SettingsScreens.swift @@ -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) diff --git a/ios/project.yml b/ios/project.yml index eda5db9..2b3ebb2 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -6,8 +6,8 @@ options: settings: base: SWIFT_VERSION: "5.0" - MARKETING_VERSION: "1.0.6" - CURRENT_PROJECT_VERSION: "7" + MARKETING_VERSION: "1.0.7" + CURRENT_PROJECT_VERSION: "8" targets: Stemify: type: application