ios: Liquid Glass mini player, adjustable karaoke softness (1.0.7)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-20 09:39:02 -07:00
1 parent e98d595365
commit d52a3e257f
6 files changed
+131 -24

No files matched your search

+2
View File
@@ -186,6 +186,8 @@ final class Settings: @unchecked Sendable {
/// Downloads page order: 0 download order, 1 artist, 2 release date. /// 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 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) } } 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 keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
var masterVolume: Float { var masterVolume: Float {
get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 } get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 }
+7 -2
View File
@@ -421,11 +421,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:]) 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] { private var karaokeMix: [String: StemSetting] {
switch karaokeVocals { switch karaokeVocals {
case .On: return [:] 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)] case .Off: return ["vocals": StemSetting(muted: true)]
} }
} }
@@ -437,6 +437,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
if karaoke { applyMix(karaokeMix) } 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. /// 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) { func setKaraoke(_ on: Bool) {
guard on != karaoke else { return } guard on != karaoke else { return }
+35 -6
View File
@@ -14,15 +14,12 @@ struct RootView: View {
root(t).miniPlayerRoom() root(t).miniPlayerRoom()
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() } .navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
} }
// Floats over the screens; each screen leaves room for it so nothing ends up underneath. .modifier(FloatingMiniPlayer())
.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)
.tabItem { Label(t.rawValue, systemImage: t.icon) } .tabItem { Label(t.rawValue, systemImage: t.icon) }
.tag(t) .tag(t)
} }
} }
.modifier(GlassMiniPlayerHost())
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) { .sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
NowPlayingScreen() NowPlayingScreen()
.overlayHost(ui.overlays, level: 1) .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 struct MiniPlayerRoom: ViewModifier {
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
func body(content: Content) -> some View { func body(content: Content) -> some View {
content.safeAreaInset(edge: .bottom, spacing: 0) { 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) }
} }
} }
} }
+58 -14
View File
@@ -794,8 +794,14 @@ struct MiniPlayer: View {
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
var body: some View { 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 let pm = g.playback
if let t = pm.current?.track {
HStack(spacing: 0) { HStack(spacing: 0) {
Artwork(thumb: t.thumb, size: 44, corner: 8).padding(.trailing, 12) Artwork(thumb: t.thumb, size: 44, corner: 8).padding(.trailing, 12)
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
@@ -803,19 +809,7 @@ struct MiniPlayer: View {
Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1) Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
} }
Spacer(minLength: 6) Spacer(minLength: 6)
Button(action: pm.togglePlay) { MiniTransport(color: p.label, next: true)
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())
} }
.padding(.leading, 8).padding(.trailing, 6) .padding(.leading, 8).padding(.trailing, 6)
.frame(height: 60) .frame(height: 60)
@@ -845,6 +839,56 @@ struct MiniPlayer: View {
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } }) .gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } })
.actionsMenu(ui.menu.track(t)) .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())
}
} }
} }
@@ -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. /// A tappable row in the accent (or red), optionally centred.
private struct ActionCell: View { private struct ActionCell: View {
let title: String 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: "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) 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) ToggleCell(title: "Keep Stem Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
SoftVocalsCell()
} header: { Header(text: "Sound") } } header: { Header(text: "Sound") }
Section { Section {
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage) NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
+2 -2
View File
@@ -6,8 +6,8 @@ options:
settings: settings:
base: base:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.0.6" MARKETING_VERSION: "1.0.7"
CURRENT_PROJECT_VERSION: "7" CURRENT_PROJECT_VERSION: "8"
targets: targets:
Stemify: Stemify:
type: application type: application