NavidromeApp/iOS/Views/Companion/EditHistoryView.swift
Dallas Groot 3385b88270 Audio Tap Infrastructure:
- AudioTapProcessor: shared MTAudioProcessingTap with lock-free PCM ring buffer
- Pre-allocated vDSP FFT (1024-sample, Hann window, log-frequency 30-band output)
- Zero per-frame heap allocation in FFT path
- Shared tap serves both FFT visualizer and Shazam simultaneously

Fixes (blockers for tap to work):
- radioGoLive/radioSeekBack now update self.playerItem (was orphaned)
- Tap reinstalled on every AVPlayerItem swap (seek, live, station change)
- Tap removed on background, reinstalled on foreground
- Tap removed on radio→music transition

Shazam rework:
- Uses shared AudioTapProcessor instead of creating its own tap
- Fixes tap conflict where Shazam overwrote FFT audioMix
- 500ms wait for tapPrepare callback (sourceFormat timing race)
- Fixed pre-existing bug: stopAll() audio session never restored after mic fallback

Debug capture:
- Capture Audio Tap button in Visualizer Settings
- Records 5s of raw tap PCM as playable WAV file
- Uses actual stream sample rate (not hardcoded 44100)
- Share sheet via Notification pattern (survives view dismiss)
- Spinner auto-resets on appear if capture interrupted by background

Also includes from main branch:
- Edit History UI, batch undo, companion API 7-bug fix
- Recently Played tab, Discover section, Play Queue sync, Share links"
2026-04-14 17:15:34 -07:00

431 lines
16 KiB
Swift

import SwiftUI
// MARK: - Edit History View
struct EditHistoryView: View {
@State private var entries: [EditHistoryEntry] = []
@State private var isLoading = true
@State private var errorMessage: String?
@State private var revertingId: String?
@State private var confirmRevert: EditHistoryEntry?
@State private var undoResult: (restored: Int, failed: Int)?
@State private var undoError: String?
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private let api = CompanionAPIService.shared
var body: some View {
List {
if isLoading {
Section {
HStack {
ProgressView().tint(accentPink)
Text("Loading edit history…")
.font(.system(size: 14))
.foregroundColor(.gray)
.padding(.leading, 8)
}
.padding(.vertical, 4)
}
} else if let error = errorMessage {
Section {
Text(error)
.font(.system(size: 13))
.foregroundColor(.red)
}
} else if entries.isEmpty {
Section {
HStack(spacing: 12) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 28))
.foregroundColor(.gray)
VStack(alignment: .leading, spacing: 2) {
Text("No edit history")
.font(.system(size: 15, weight: .medium))
Text("Tag edits will appear here with the option to revert.")
.font(.system(size: 13))
.foregroundColor(.gray)
}
}
.padding(.vertical, 8)
}
} else {
// Group entries by section date
ForEach(sectionDates, id: \.self) { section in
Section(header: Text(section).font(.system(size: 13, weight: .semibold))) {
ForEach(entriesForSection(section)) { entry in
EditHistoryCard(
entry: entry,
isReverting: revertingId == entry.batch_id,
onRevert: { confirmRevert = entry }
)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
}
}
}
}
.listStyle(.insetGrouped)
.navigationTitle("Edit History")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(action: { Task { await loadHistory() } }) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(accentPink)
}
}
}
.task { await loadHistory() }
.confirmationDialog(
"Revert this edit?",
isPresented: Binding(
get: { confirmRevert != nil },
set: { if !$0 { confirmRevert = nil } }
),
titleVisibility: .visible
) {
if let entry = confirmRevert {
Button("Revert \(entry.file_count) track\(entry.file_count == 1 ? "" : "s")", role: .destructive) {
Task { await performRevert(entry) }
}
Button("Cancel", role: .cancel) { confirmRevert = nil }
}
} message: {
if let entry = confirmRevert {
Text("This will restore all \(entry.file_count) track\(entry.file_count == 1 ? "" : "s") to their previous tags. The files will be updated on disk and Navidrome will rescan.")
}
}
.overlay(alignment: .bottom) {
// Undo result toast
if let result = undoResult {
UndoResultToast(
restored: result.restored,
failed: result.failed,
onDismiss: { withAnimation { undoResult = nil } }
)
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 16)
}
if let error = undoError {
UndoErrorToast(
message: error,
onDismiss: { withAnimation { undoError = nil } }
)
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 16)
}
}
}
// MARK: - Section grouping
private var sectionDates: [String] {
var seen = Set<String>()
var result: [String] = []
for e in entries {
let s = e.sectionDate
if seen.insert(s).inserted {
result.append(s)
}
}
return result
}
private func entriesForSection(_ section: String) -> [EditHistoryEntry] {
entries.filter { $0.sectionDate == section }
}
// MARK: - API
private func loadHistory() async {
isLoading = true
errorMessage = nil
do {
entries = try await api.fetchEditHistory()
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
private func performRevert(_ entry: EditHistoryEntry) async {
revertingId = entry.batch_id
do {
let result = try await api.undoBatchEdit(batchId: entry.batch_id)
let restored = result.restored?.count ?? 0
let failed = result.failed?.count ?? 0
withAnimation { undoResult = (restored, failed) }
// Refresh the list so the entry shows as reverted
await loadHistory()
// Auto-dismiss after 5s
Task {
try? await Task.sleep(for: .seconds(5))
withAnimation { undoResult = nil }
}
} catch {
withAnimation { undoError = error.localizedDescription }
Task {
try? await Task.sleep(for: .seconds(5))
withAnimation { undoError = nil }
}
}
revertingId = nil
}
}
// MARK: - Edit History Card
private struct EditHistoryCard: View {
let entry: EditHistoryEntry
let isReverting: Bool
let onRevert: () -> Void
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private var isReverted: Bool { entry.is_reverted ?? false }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Top row: icon + title + time
HStack(alignment: .top, spacing: 10) {
editIcon
.frame(width: 34, height: 34)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(entry.editTitle)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(isReverted ? .gray : .white)
if entry.edit_type == "single" {
Text("single")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.gray)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Color.white.opacity(0.06))
.clipShape(Capsule())
}
}
Text("\(entry.file_count) track\(entry.file_count == 1 ? "" : "s") · \(entry.contextString)")
.font(.system(size: 13))
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer(minLength: 0)
Text(entry.timeAgo)
.font(.system(size: 12))
.foregroundColor(Color(white: 0.4))
}
// Change pills
if let changes = entry.tags_changed, !changes.isEmpty {
changePills(changes)
}
// Action row
HStack {
// Affected context
if let albums = entry.affected_albums, !albums.isEmpty {
Text(albums.prefix(2).joined(separator: ", "))
.font(.system(size: 12))
.foregroundColor(Color(white: 0.35))
.lineLimit(1)
}
Spacer()
if isReverting {
ProgressView()
.tint(accentPink)
.scaleEffect(0.8)
} else if isReverted {
HStack(spacing: 4) {
Image(systemName: "arrow.uturn.backward")
.font(.system(size: 11))
Text("Reverted")
.font(.system(size: 13, weight: .semibold))
}
.foregroundColor(.green.opacity(0.7))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.green.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
Button(action: onRevert) {
HStack(spacing: 4) {
Image(systemName: "arrow.uturn.backward")
.font(.system(size: 11))
Text("Revert")
.font(.system(size: 13, weight: .semibold))
}
.foregroundColor(accentPink)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(accentPink.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(accentPink.opacity(0.25), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
}
.padding(.vertical, 4)
.opacity(isReverted ? 0.5 : 1.0)
}
// MARK: - Icon
@ViewBuilder
private var editIcon: some View {
let changes = entry.tags_changed ?? [:]
let keys = Set(changes.keys)
if keys.count == 1, keys.contains("genre") {
iconBox(symbol: "music.note", bgColor: .purple)
} else if keys.count == 1, keys.contains("album") {
iconBox(symbol: "square.stack", bgColor: .blue)
} else if keys.count == 1, (keys.contains("artist") || keys.contains("album_artist")) {
iconBox(symbol: "person.fill", bgColor: .orange)
} else if keys.count > 1 {
iconBox(symbol: "pencil", bgColor: accentPink)
} else {
iconBox(symbol: "tag.fill", bgColor: .gray)
}
}
private func iconBox(symbol: String, bgColor: Color) -> some View {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(bgColor.opacity(0.15))
.overlay(
Image(systemName: symbol)
.font(.system(size: 15, weight: .medium))
.foregroundColor(bgColor)
)
}
// MARK: - Change pills
private func changePills(_ changes: [String: String]) -> some View {
let sorted = changes.sorted { $0.key < $1.key }
return FlowLayout(spacing: 6) {
ForEach(sorted, id: \.key) { key, value in
HStack(spacing: 3) {
Text(key.capitalized)
.font(.system(size: 11, weight: .medium))
Text("")
.font(.system(size: 10))
.opacity(0.5)
Text(value)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
}
.foregroundColor(pillColor(for: key))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(pillColor(for: key).opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(pillColor(for: key).opacity(0.2), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
private func pillColor(for key: String) -> Color {
switch key.lowercased() {
case "genre": return .purple
case "album": return .blue
case "artist", "album_artist": return .orange
case "year", "date": return .green
case "title": return .cyan
default: return .gray
}
}
}
// FlowLayout is defined in LyricsOverlayView.swift reused here for change pills.
// MARK: - Undo Result Toast
private struct UndoResultToast: View {
let restored: Int
let failed: Int
let onDismiss: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: failed == 0 ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 20))
.foregroundColor(failed == 0 ? .green : .yellow)
VStack(alignment: .leading, spacing: 1) {
Text(failed == 0
? "Reverted \(restored) track\(restored == 1 ? "" : "s")"
: "\(restored) reverted, \(failed) failed")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
Text("Tags restored to previous state")
.font(.system(size: 12))
.foregroundColor(.gray)
}
Spacer()
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.gray)
}
}
.padding(14)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
)
.padding(.horizontal, 16)
}
}
// MARK: - Undo Error Toast
private struct UndoErrorToast: View {
let message: String
let onDismiss: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.red)
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
.lineLimit(2)
Spacer()
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.gray)
}
}
.padding(14)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color.red.opacity(0.2), lineWidth: 1)
)
.padding(.horizontal, 16)
}
}