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:
1 parent
e0184f540b
commit
bea3f8f5a2
5 files changed
+234
-6
No files matched your search
@@ -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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user