diff --git a/README.md b/README.md index f60126c..1c2ed19 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,8 @@ adb forward tcp:5277 tcp:5277 `ios/` is a SwiftUI port of the Android app with the same features, plus CarPlay (browse tabs, Now Playing with Vocals and Mix buttons) and Siri ("play X in Stemify"). iOS 17 or newer. +On top of the Android features: the Save chip in the mixer remembers a mix for that song and puts it back every time it plays, and the mic button turns on karaoke mode (vocals muted on every song, synced lyrics from [LRCLIB](https://lrclib.net) on Now Playing; tap a line to jump to it). + Build an unsigned IPA on a Mac with Xcode and xcodegen (`brew install xcodegen`): ``` diff --git a/ios/Stemify/Playback/Lyrics.swift b/ios/Stemify/Playback/Lyrics.swift new file mode 100644 index 0000000..1b554c9 --- /dev/null +++ b/ios/Stemify/Playback/Lyrics.swift @@ -0,0 +1,86 @@ +import Foundation + +struct LyricLine: Hashable { + let ms: Int64 + let text: String +} + +struct Lyrics { + var lines: [LyricLine] = [] + /// False for plain lyrics, which have no timings (every [ms] is 0). + var synced = false + var instrumental = false +} + +/// Lyrics from LRCLIB (lrclib.net), synced when it has them. Hits are cached on disk by track, +/// misses only until the app restarts. +actor LyricsSource { + static let shared = LyricsSource() + + private struct Record: Codable { + var syncedLyrics: String? + var plainLyrics: String? + var instrumental: Bool? + var duration: Double? + } + + private let dir: URL + private var misses: Set = [] + + private init() { + dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("lyrics", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } + + /// Nil when LRCLIB has nothing for this song or couldn't be reached. + func lyrics(_ t: Track) async -> Lyrics? { + let file = dir.appendingPathComponent(t.ratingKey.replacingOccurrences(of: "/", with: "_") + ".json") + if let data = try? Data(contentsOf: file), let r = try? JSONDecoder().decode(Record.self, from: data) { return Self.parse(r) } + if misses.contains(t.ratingKey) { return nil } + let secs = Double(t.durationMs) / 1000 + var rec = await get("get", ["track_name": t.title, "artist_name": t.artist, "album_name": t.album, "duration": String(Int(secs.rounded()))]) + .flatMap { try? JSONDecoder().decode(Record.self, from: $0) } + if rec == nil, let data = await get("search", ["track_name": t.title, "artist_name": t.artist]), + let found = try? JSONDecoder().decode([Record].self, from: data) { + // Closest length first, so a live or extended version doesn't win. + let close = found.filter { abs(($0.duration ?? 0) - secs) < 8 || secs <= 0 } + rec = close.first { $0.syncedLyrics?.isEmpty == false } ?? close.first { $0.plainLyrics?.isEmpty == false } ?? close.first + } + guard let rec else { + misses.insert(t.ratingKey) + return nil + } + if let data = try? JSONEncoder().encode(rec) { try? data.write(to: file, options: .atomic) } + return Self.parse(rec) + } + + private func get(_ path: String, _ query: [String: String]) async -> Data? { + var c = URLComponents(string: "https://lrclib.net/api/\(path)")! + c.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) } + var req = URLRequest(url: c.url!, timeoutInterval: 10) + req.setValue("Stemify iOS (https://git.iamchrisama.com/cdnutter/stemify)", forHTTPHeaderField: "User-Agent") + guard let (data, resp) = try? await URLSession.shared.data(for: req), (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil } + return data + } + + private static let stamp = #/\[(\d+):(\d+(?:\.\d+)?)\]/# + + /// LRC: each line starts with one or more [mm:ss.xx] stamps. + private static func parse(_ r: Record) -> Lyrics { + if let lrc = r.syncedLyrics, !lrc.isEmpty { + var lines: [LyricLine] = [] + for raw in lrc.split(separator: "\n", omittingEmptySubsequences: false) { + let stamps = raw.matches(of: stamp) + guard let last = stamps.last else { continue } + let text = raw[last.range.upperBound...].trimmingCharacters(in: .whitespaces) + for m in stamps { + let ms = (Int64(m.output.1) ?? 0) * 60_000 + Int64((Double(m.output.2) ?? 0) * 1000) + lines.append(LyricLine(ms: ms, text: text)) + } + } + return Lyrics(lines: lines.sorted { $0.ms < $1.ms }, synced: true) + } + let plain = (r.plainLyrics ?? "").split(separator: "\n", omittingEmptySubsequences: false).map { LyricLine(ms: 0, text: String($0)) } + return Lyrics(lines: plain.count > 1 || plain.first?.text.isEmpty == false ? plain : [], instrumental: r.instrumental == true) + } +} diff --git a/ios/Stemify/Playback/PlaybackManager.swift b/ios/Stemify/Playback/PlaybackManager.swift index de970e2..80b2bcb 100644 --- a/ios/Stemify/Playback/PlaybackManager.swift +++ b/ios/Stemify/Playback/PlaybackManager.swift @@ -106,6 +106,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { @ObservationIgnored private let eq: EqController @ObservationIgnored private(set) var engine: StemEngine! @ObservationIgnored private let queueFile: URL + @ObservationIgnored private let songMixFile: URL private(set) var queue: [QueueItem] = [] private(set) var index = -1 @@ -121,6 +122,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { private(set) var waveform: Waveform? private(set) var durationMs: Int64 = 0 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. + private(set) var karaoke = false + @ObservationIgnored private var preKaraokeMix: [String: StemSetting]? @ObservationIgnored private var listeners: [UUID: () -> Void] = [:] @ObservationIgnored private var messageListeners: [UUID: (String) -> Void] = [:] @@ -148,6 +154,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { self.cache = cache self.eq = eq queueFile = dir.appendingPathComponent("queue.json") + songMixFile = dir.appendingPathComponent("song-mixes.json") + songMixes = (try? JSONDecoder().decode([String: [String: StemSetting]].self, from: Data(contentsOf: songMixFile))) ?? [:] master = settings.masterVolume engine = StemEngine(listener: self) eq.onParams = { [weak self] p in self?.engine.eqParams = p } @@ -405,6 +413,42 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { pushMix() } + /// 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 } + return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:]) + } + + private static let karaokeMix = ["vocals": StemSetting(muted: true)] + + /// 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 } + karaoke = on + if on { + preKaraokeMix = mix + applyMix(Self.karaokeMix) + } else { + if MixPresets.normalized(mix) == Self.karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) } + preKaraokeMix = nil + changed() + } + } + + var songMix: [String: StemSetting]? { current.flatMap { songMixes[$0.track.ratingKey] } } + + func saveSongMix() { setSongMix(MixPresets.normalized(mix)) } + func forgetSongMix() { setSongMix(nil) } + + private func setSongMix(_ m: [String: StemSetting]?) { + guard let key = current?.track.ratingKey else { return } + songMixes[key] = m + let all = songMixes, file = songMixFile + DispatchQueue.global(qos: .utility).async { + if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) } + } + } + private func editMix(_ name: String, _ f: (inout StemSetting) -> Void) { var s = mix[name] ?? StemSetting() f(&s) @@ -462,7 +506,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { if play && !activateSession() { return } playWhenReady = play buffering = true - if !settings.keepStemMix { mix = [:] } + mix = startMix(item.track) changed() save() loadTask = Task { @MainActor [weak self] in @@ -635,7 +679,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { guard let item = current else { return } durationMs = item.track.durationMs waveform = nil - if !settings.keepStemMix { mix = [:] } + mix = startMix(item.track) source = currentEntry != nil ? .Stems : .Original stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"]) pushMix() diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift index e75aff3..65a240a 100644 --- a/ios/Stemify/UI/Screens/NowPlayingScreen.swift +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -1,7 +1,7 @@ import SwiftUI import UIKit -enum NpPanel { case Art, Mixer, Queue } +enum NpPanel { case Art, Mixer, Queue, Lyrics } func deviceIcon(_ d: OutputDevice) -> (String, UInt32) { switch d.kind { @@ -105,6 +105,7 @@ struct NowPlayingScreen: View { case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady) case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() } case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() } + case .Lyrics: VStack(spacing: 0) { MiniHeader(track: track); LyricsPanel(track: track) } } } .frame(maxHeight: .infinity) @@ -123,6 +124,7 @@ struct NowPlayingScreen: View { .tint(c.label) .animation(.easeInOut(duration: 0.3), value: tint) .presentationDragIndicator(.visible) + .onAppear { if g.playback.karaoke { panel = .Lyrics } } .task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") { guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return } tint = await albumColor(thumb, dark: base.dark) @@ -172,6 +174,7 @@ struct NowPlayingScreen: View { HStack { PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) } Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity) + PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() } PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) } } .frame(height: 48) @@ -180,6 +183,16 @@ 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. + private func karaoke() { + let pm = g.playback + withAnimation(.spring(duration: 0.38, bounce: 0.12)) { + if pm.karaoke && panel != .Lyrics { panel = .Lyrics; return } + pm.setKaraoke(!pm.karaoke) + panel = pm.karaoke ? .Lyrics : .Art + } + } + private func show(_ to: NpPanel) { withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to } } @@ -490,6 +503,7 @@ private final class Meters { } private struct MixerPanel: View { + @Environment(Ui.self) private var ui @Environment(\.palette) private var p @State private var meters = Meters() private var g: AppGraph { .shared } @@ -510,6 +524,20 @@ private struct MixerPanel: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { let current = MixPresets.normalized(pm.mix) + let saved = pm.songMix.map(MixPresets.normalized) + let isSaved = saved == current + PresetChip(text: isSaved ? "Saved" : "Save", icon: isSaved ? "bookmark.fill" : "bookmark", selected: isSaved) { + if isSaved { + pm.forgetSongMix() + ui.overlays.hud("Removed this song's mix", "bookmark.slash") + } else { + pm.saveSongMix() + ui.overlays.hud("Mix saved for this song", "bookmark.fill") + } + } + if let saved, !isSaved { + PresetChip(text: "This Song", selected: false) { pm.applyMix(saved) } + } ForEach(MixPresets.all) { preset in if let target = preset.build(names) { PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) } @@ -549,12 +577,17 @@ private struct MixerPanel: View { /// [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 + var icon: String? = nil let selected: Bool let action: () -> Void @Environment(\.palette) private var p var body: some View { Button(action: action) { - Text(text).font(.footnote.weight(.semibold)).foregroundStyle(selected ? p.bg : p.label) + HStack(spacing: 5) { + if let icon { Image(systemName: icon) } + Text(text) + } + .font(.footnote.weight(.semibold)).foregroundStyle(selected ? p.bg : p.label) .padding(.horizontal, 14).frame(height: 32) .background(selected ? p.label : p.fill, in: Capsule()) } @@ -814,3 +847,66 @@ struct MiniPlayer: View { } } } + +/// Synced lyrics follow the song (tap a line to jump there); plain ones just scroll. +private struct LyricsPanel: View { + let track: Track + @Environment(\.palette) private var p + @State private var lyrics: Lyrics? + @State private var loading = true + @State private var at = -1 + private var g: AppGraph { .shared } + + var body: some View { + Group { + if loading { + ProgressView() + } else if let l = lyrics, !l.lines.isEmpty { + lines(l) + } else { + Text(lyrics?.instrumental == true ? "Instrumental" : "No lyrics found") + .font(.title3.weight(.semibold)).foregroundStyle(p.label2) + } + } + .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 lines(_ l: Lyrics) -> some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: l.synced ? 18 : 6) { + ForEach(Array(l.lines.enumerated()), id: \.offset) { i, line in + Text(line.text.isEmpty ? "\u{266A}" : line.text) + .font(l.synced ? .title2.weight(.bold) : .body) + .foregroundStyle(!l.synced || i == at ? p.label : p.label3) + .scaleEffect(i == at ? 1.03 : 1, anchor: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { if l.synced { g.playback.seekTo(line.ms) } } + .id(i) + } + } + .padding(.horizontal, 28) + .padding(.vertical, l.synced ? 120 : 16) + } + .scrollIndicators(.hidden) + .onChange(of: at) { _, i in + if i >= 0 { withAnimation(.easeInOut(duration: 0.4)) { proxy.scrollTo(i, anchor: .center) } } + } + } + } +} diff --git a/ios/project.yml b/ios/project.yml index 9b188dd..fc052ac 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -6,8 +6,8 @@ options: settings: base: SWIFT_VERSION: "5.0" - MARKETING_VERSION: "1.0.3" - CURRENT_PROJECT_VERSION: "4" + MARKETING_VERSION: "1.0.4" + CURRENT_PROJECT_VERSION: "5" targets: Stemify: type: application