iOS 1.0.4: per-song saved mixes, karaoke mode with synced lyrics

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-20 09:18:31 -07:00
1 parent e0184f540b
commit bea3f8f5a2
5 files changed
+234 -6

No files matched your search

+98 -2
View File
@@ -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) } }
}
}
}
}