diff --git a/ios/Stemify/Core/Settings.swift b/ios/Stemify/Core/Settings.swift index 7149713..ed52554 100644 --- a/ios/Stemify/Core/Settings.swift +++ b/ios/Stemify/Core/Settings.swift @@ -185,6 +185,7 @@ final class Settings: @unchecked Sendable { var downloadsWifiOnly: Bool { get { read(\.downloadsWifiOnly, "downloadsWifiOnly", true) } set { write(\.downloadsWifiOnly, "downloadsWifiOnly", newValue, true) } } /// 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) } } 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 80b2bcb..ba91fbe 100644 --- a/ios/Stemify/Playback/PlaybackManager.swift +++ b/ios/Stemify/Playback/PlaybackManager.swift @@ -19,6 +19,8 @@ enum Stems { } } +enum KaraokeVocals: Int, CaseIterable { case On, Soft, Off } + /// One stem's fader. [level] is the slider position; 0.8 is unity gain. struct StemSetting: Codable, Hashable { static let unity: Float = 0.8 @@ -124,7 +126,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { private(set) var message: AppMessage? /// Mixes saved for single songs (by rating key), put back whenever that song starts. private(set) var songMixes: [String: [String: StemSetting]] = [:] - /// Karaoke mode: vocals out on every song, lyrics on Now Playing. + /// Karaoke mode: vocals on, soft or out (per settings) on every song, lyrics on Now Playing. private(set) var karaoke = false @ObservationIgnored private var preKaraokeMix: [String: StemSetting]? @@ -415,11 +417,25 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { /// The mix a song starts with: karaoke if that's on, else the song's saved mix, else cleared (or kept, per settings). private func startMix(_ t: Track) -> [String: StemSetting] { - if karaoke { return Self.karaokeMix } + if karaoke { return karaokeMix } return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:]) } - private static let karaokeMix = ["vocals": StemSetting(muted: true)] + /// How much of the singer is left in karaoke mode. Soft is about -12 dB, a guide under your own voice. + private var karaokeMix: [String: StemSetting] { + switch karaokeVocals { + case .On: return [:] + case .Soft: return ["vocals": StemSetting(level: 0.4)] + case .Off: return ["vocals": StemSetting(muted: true)] + } + } + + var karaokeVocals: KaraokeVocals { KaraokeVocals(rawValue: settings.karaokeVocals) ?? .Off } + + func setKaraokeVocals(_ v: KaraokeVocals) { + settings.karaokeVocals = v.rawValue + if karaoke { 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) { @@ -427,9 +443,9 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { karaoke = on if on { preKaraokeMix = mix - applyMix(Self.karaokeMix) + applyMix(karaokeMix) } else { - if MixPresets.normalized(mix) == Self.karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) } + if MixPresets.normalized(mix) == karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) } preKaraokeMix = nil changed() } diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift index 65a240a..e8cef2d 100644 --- a/ios/Stemify/UI/Screens/NowPlayingScreen.swift +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -183,7 +183,7 @@ struct NowPlayingScreen: View { } /// Opens a panel, or goes back to the art if it's already open. - /// Karaoke: vocals out and lyrics up. If it's on but another panel is showing, brings the lyrics back first. + /// Karaoke: vocals down and lyrics up. If it's on but another panel is showing, brings the lyrics back first. private func karaoke() { let pm = g.playback withAnimation(.spring(duration: 0.38, bounce: 0.12)) { @@ -858,6 +858,22 @@ private struct LyricsPanel: View { private var g: AppGraph { .shared } var body: some View { + VStack(spacing: 0) { + // Soft leaves the singer in quietly as a guide. + HStack(spacing: 12) { + Text("Vocals").font(.subheadline.weight(.semibold)).foregroundStyle(p.label2) + Picker("Vocals", selection: Binding(get: { g.playback.karaokeVocals }, set: { g.playback.setKaraokeVocals($0) })) { + ForEach(KaraokeVocals.allCases, id: \.self) { Text(String(describing: $0)).tag($0) } + } + .pickerStyle(.segmented) + } + .padding(.horizontal, 28).padding(.top, 8) + content + } + .task(id: track.ratingKey) { await follow() } + } + + @ViewBuilder private var content: some View { Group { if loading { ProgressView() @@ -869,19 +885,20 @@ private struct LyricsPanel: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .task(id: track.ratingKey) { - loading = true - at = -1 - lyrics = await LyricsSource.shared.lyrics(track) - loading = false - guard let l = lyrics, l.synced else { return } - while !Task.isCancelled { - // A little early, so the line lights up as it's sung rather than after. - let pos = g.playback.positionMs() + 250 - let i = (l.lines.lastIndex { $0.ms <= pos }) ?? -1 - if i != at { withAnimation(.easeOut(duration: 0.25)) { at = i } } - try? await Task.sleep(nanoseconds: 150_000_000) - } + } + + private func follow() async { + loading = true + at = -1 + lyrics = await LyricsSource.shared.lyrics(track) + loading = false + guard let l = lyrics, l.synced else { return } + while !Task.isCancelled { + // A little early, so the line lights up as it's sung rather than after. + let pos = g.playback.positionMs() + 250 + let i = (l.lines.lastIndex { $0.ms <= pos }) ?? -1 + if i != at { withAnimation(.easeOut(duration: 0.25)) { at = i } } + try? await Task.sleep(nanoseconds: 150_000_000) } } diff --git a/ios/project.yml b/ios/project.yml index cc0b25b..eda5db9 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -6,8 +6,8 @@ options: settings: base: SWIFT_VERSION: "5.0" - MARKETING_VERSION: "1.0.5" - CURRENT_PROJECT_VERSION: "6" + MARKETING_VERSION: "1.0.6" + CURRENT_PROJECT_VERSION: "7" targets: Stemify: type: application