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
+163 -56

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.
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 }
+7 -2
View File
@@ -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 }
+35 -6
View File
@@ -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) }
}
}
}
+90 -46
View File
@@ -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())
}
}
}
@@ -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
View File
@@ -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