From 2bdac607b4b1c68291a6e66d613fe39929cafb40 Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Fri, 10 Apr 2026 16:55:09 -0700 Subject: [PATCH] bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit. --- Shared/Audio/AudioPlayer.swift | 120 +-- iOS/Views/Common/AsyncCoverArt.swift | 21 + .../Companion/BatchAlbumEditorSheet.swift | 5 +- iOS/Views/Companion/TrackEditorView.swift | 11 +- iOS/Views/Library/DownloadsSettingsView.swift | 4 +- iOS/Views/Library/MyMusicView.swift | 1 + iOS/Views/Library/PlaylistsView.swift | 1 + iOS/Views/Library/RadioView.swift | 2 + iOS/Views/Library/SearchView.swift | 573 +++++++---- iOS/Views/Login/LoginView.swift | 4 + iOS/Views/NowPlaying/NowPlayingView.swift | 390 +++++--- .../Visualizer/MitsuhaVisualizerView.swift | 929 ++++++++++-------- 12 files changed, 1282 insertions(+), 779 deletions(-) diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 1b8d831..fa9a5b7 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -53,6 +53,8 @@ class AudioPlayer: NSObject, ObservableObject { // MARK: - AVPlayer (for streaming) private var player: AVPlayer? private var playerItem: AVPlayerItem? + /// Exposed for ShazamRecognizer tap installation + var currentPlayerItem: AVPlayerItem? { playerItem } private var timeObserver: Any? // MARK: - AVAudioEngine (for local files with FFT) @@ -84,7 +86,7 @@ class AudioPlayer: NSObject, ObservableObject { // MARK: - Offline Visualizer #if os(iOS) - private var offlineVisFrames: [[Float]] = [] + private var offlineVisBuffer = VisFrameBuffer.empty private var offlineVisTimer: Timer? private var offlineVisFPS: Double = 30.0 #endif @@ -776,11 +778,12 @@ class AudioPlayer: NSObject, ObservableObject { // (zeroed by pause) and the wave starts flat then slowly ramps up. if isUsingOfflineVis { // Offline vis: prime with the last-rendered frame at current position - if !offlineVisFrames.isEmpty { + if !offlineVisBuffer.isEmpty { let dur = duration let pct = dur > 0 ? min(currentTime / dur, 1.0) : 0 - let idx = min(Int(pct * Double(offlineVisFrames.count)), offlineVisFrames.count - 1) - setLevels(offlineVisFrames[idx]) + let idx = min(Int(pct * Double(offlineVisBuffer.frameCount)), offlineVisBuffer.frameCount - 1) + offlineVisBuffer.copyFrame(at: idx, into: &_audioLevels) + levelTick &+= 1 } startOfflineVisSync() } else if isUsingRealFFT, !isUsingOfflineVis { @@ -1179,10 +1182,10 @@ class AudioPlayer: NSObject, ObservableObject { if hasCache { alog("Offline vis: loading cache for \(songId)") - if let frames = try? await storage.loadCache(for: songId) { - let normalized = Self.normalizeVisFrames(frames) + if let buffer = try? await storage.loadCache(for: songId) { + let normalized = Self.normalizeVisBuffer(buffer) await MainActor.run { - self.offlineVisFrames = normalized + self.offlineVisBuffer = normalized self.isUsingOfflineVis = true self.isUsingRealFFT = true self.offlineVisProgress = 1.0 @@ -1195,11 +1198,17 @@ class AudioPlayer: NSObject, ObservableObject { if CompanionSettings.shared.isEnabled, let path = self.currentSong?.path { do { if let serverFrames = try await CompanionAPIService().fetchVisualizerFrames(relativePath: path) { - let normalized = Self.normalizeVisFrames(serverFrames) try? await storage.saveCache(frames: serverFrames, for: songId) + // Convert [[Float]] to flat buffer then normalize + let ppf = serverFrames.first?.count ?? 0 + var flat = ContiguousArray() + flat.reserveCapacity(serverFrames.count * ppf) + for frame in serverFrames { flat.append(contentsOf: frame) } + let rawBuf = VisFrameBuffer(data: flat, frameCount: serverFrames.count, pointsPerFrame: ppf) + let normalized = Self.normalizeVisBuffer(rawBuf) alog("Offline vis: fetched \(serverFrames.count) frames from server") await MainActor.run { - self.offlineVisFrames = normalized + self.offlineVisBuffer = normalized self.isUsingOfflineVis = true self.isUsingRealFFT = true self.offlineVisProgress = 1.0 @@ -1251,11 +1260,17 @@ class AudioPlayer: NSObject, ObservableObject { let frames = result.visFrames try? await storage.saveCache(frames: frames, for: songId) - let normalized = Self.normalizeVisFrames(frames) + // Convert [[Float]] to flat VisFrameBuffer then normalize + let ppf = frames.first?.count ?? 0 + var flat = ContiguousArray() + flat.reserveCapacity(frames.count * ppf) + for frame in frames { flat.append(contentsOf: frame) } + let rawBuf = VisFrameBuffer(data: flat, frameCount: frames.count, pointsPerFrame: ppf) + let normalized = Self.normalizeVisBuffer(rawBuf) alog("Offline vis: cached \(frames.count) frames for \(songId)") await MainActor.run { - self.offlineVisFrames = normalized + self.offlineVisBuffer = normalized self.isUsingOfflineVis = true self.isUsingRealFFT = true self.offlineVisProgress = 1.0 @@ -1272,32 +1287,28 @@ class AudioPlayer: NSObject, ObservableObject { /// Normalize vis frames so the wave is always visible regardless of song loudness. /// Maps the 95th percentile peak to 0.8, preserving dynamics without clipping. - private static func normalizeVisFrames(_ frames: [[Float]]) -> [[Float]] { - guard !frames.isEmpty else { return frames } - - // Collect all non-zero values to find the peak - var allValues: [Float] = [] - allValues.reserveCapacity(frames.count * (frames.first?.count ?? 20)) - for frame in frames { - for v in frame where v > 0.001 { - allValues.append(v) - } - } - - guard !allValues.isEmpty else { return frames } - - // Use 95th percentile instead of absolute max to avoid outlier spikes - allValues.sort() - let p95Index = min(Int(Float(allValues.count) * 0.95), allValues.count - 1) - let p95 = allValues[p95Index] - - guard p95 > 0.001 else { return frames } - - let scale = 0.8 / p95 - - return frames.map { frame in - frame.map { min(1.0, $0 * scale) } + /// Normalize a VisFrameBuffer to p95 amplitude in-place. + /// One sort + one pass — no [[Float]] allocation. + private static func normalizeVisBuffer(_ buffer: VisFrameBuffer) -> VisFrameBuffer { + guard buffer.frameCount > 0 else { return buffer } + + // Collect non-zero values to find p95 + var vals = buffer.data.filter { $0 > 0.001 } + guard !vals.isEmpty else { return buffer } + vals.sort() + let p95 = vals[min(Int(Float(vals.count) * 0.95), vals.count - 1)] + guard p95 > 0.001 else { return buffer } + + let scale = Float(0.8) / p95 + var normalized = buffer.data // single ContiguousArray copy + for i in 0.. 0.005 }) { - self.setLevels(Array(repeating: 0, count: 30)) + for i in 0.. 0 else { return } + + let dur = self.duration let time = self.currentTime - - // Map playback position to frame index proportionally + let frameIndex: Int if dur > 0 { let pct = min(time / dur, 1.0) - frameIndex = min(Int(pct * Double(frames.count)), frames.count - 1) + frameIndex = min(Int(pct * Double(buf.frameCount)), buf.frameCount - 1) } else { - // Duration still unknown — estimate at 30fps - frameIndex = min(Int(time * 30.0), frames.count - 1) - } - - if frameIndex >= 0, frameIndex < frames.count { - self.setLevels(frames[frameIndex]) + frameIndex = min(Int(time * 30.0), buf.frameCount - 1) } + + // Zero-allocation frame dispatch: copy directly from flat buffer into _audioLevels + buf.copyFrame(at: frameIndex, into: &self._audioLevels) + self.levelTick &+= 1 } } @@ -1458,7 +1468,7 @@ class AudioPlayer: NSObject, ObservableObject { isUsingRealFFT = false isUsingOfflineVis = false #if os(iOS) - offlineVisFrames = [] + offlineVisBuffer = VisFrameBuffer.empty #endif if isRadioStream { isRadioStream = false diff --git a/iOS/Views/Common/AsyncCoverArt.swift b/iOS/Views/Common/AsyncCoverArt.swift index 156a224..d593ec5 100644 --- a/iOS/Views/Common/AsyncCoverArt.swift +++ b/iOS/Views/Common/AsyncCoverArt.swift @@ -391,3 +391,24 @@ struct AsyncCoverArt: View { } } } + +// MARK: - Keyboard Done Button +/// Adds a "Done" button above the keyboard on any TextField or SecureField. +/// Usage: TextField(...).keyboardDoneButton() +extension View { + func keyboardDoneButton() -> some View { + self.toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, from: nil, for: nil + ) + } + .foregroundColor(Color(red: 1.0, green: 0.176, blue: 0.333)) + .fontWeight(.medium) + } + } + } +} diff --git a/iOS/Views/Companion/BatchAlbumEditorSheet.swift b/iOS/Views/Companion/BatchAlbumEditorSheet.swift index eb789d4..6c4f1ec 100644 --- a/iOS/Views/Companion/BatchAlbumEditorSheet.swift +++ b/iOS/Views/Companion/BatchAlbumEditorSheet.swift @@ -249,7 +249,4 @@ struct BatchAlbumEditorSheet: View { TextField(label, text: text) .keyboardType(keyboard) .multilineTextAlignment(.trailing) - } - .font(.system(size: 14)) - } -} + .keyboardDoneButton() diff --git a/iOS/Views/Companion/TrackEditorView.swift b/iOS/Views/Companion/TrackEditorView.swift index 6a74c46..a27bd84 100644 --- a/iOS/Views/Companion/TrackEditorView.swift +++ b/iOS/Views/Companion/TrackEditorView.swift @@ -288,16 +288,7 @@ struct TrackEditorView: View { .multilineTextAlignment(.trailing) .foregroundColor(isEnabled.wrappedValue ? .white : .gray.opacity(0.5)) .disabled(!isEnabled.wrappedValue) - } - .font(.system(size: 14)) - } - - private func infoRow(_ label: String, _ value: String) -> some View { - HStack { - Text(label).foregroundColor(.gray) - Spacer() - Text(value).foregroundColor(.white.opacity(0.7)).lineLimit(1) - } + .keyboardDoneButton() .font(.system(size: 13)) } } diff --git a/iOS/Views/Library/DownloadsSettingsView.swift b/iOS/Views/Library/DownloadsSettingsView.swift index c78a7a9..2db5f93 100644 --- a/iOS/Views/Library/DownloadsSettingsView.swift +++ b/iOS/Views/Library/DownloadsSettingsView.swift @@ -647,7 +647,7 @@ struct SettingsView: View { Text("Visualizer Appearance") .foregroundColor(.white) Spacer() - Text(VisualizerSettings.shared.style.rawValue) + Text(VisualizerSettings.shared.nowPlaying.style.rawValue) .foregroundColor(.gray) Image(systemName: "chevron.right") .font(.system(size: 12)) @@ -856,7 +856,7 @@ struct SmartDJVisualizerSettingsView: View { analysisProgress = "Scanning..." let downloaded = OfflineManager.shared.downloadedSongs - let points = VisualizerSettings.shared.numberOfPoints + let points = VisualizerSettings.shared.nowPlaying.numberOfPoints let fps = VisualizerSettings.shared.effectiveFPS let cutoff = VisualizerSettings.shared.frequencyCutoff diff --git a/iOS/Views/Library/MyMusicView.swift b/iOS/Views/Library/MyMusicView.swift index 4af15d2..692873e 100644 --- a/iOS/Views/Library/MyMusicView.swift +++ b/iOS/Views/Library/MyMusicView.swift @@ -235,6 +235,7 @@ struct MyMusicView: View { .font(.system(size: 15)) .foregroundColor(.white) .autocorrectionDisabled() + .keyboardDoneButton() if !searchText.isEmpty { Button(action: { searchText = "" }) { Image(systemName: "xmark.circle.fill") diff --git a/iOS/Views/Library/PlaylistsView.swift b/iOS/Views/Library/PlaylistsView.swift index e93934b..32dbe0d 100644 --- a/iOS/Views/Library/PlaylistsView.swift +++ b/iOS/Views/Library/PlaylistsView.swift @@ -104,6 +104,7 @@ struct CreatePlaylistAlert: View { var body: some View { TextField("Playlist name", text: $name) + .keyboardDoneButton() Button("Create") { Task { try? await serverManager.client.createPlaylist(name: name) } } diff --git a/iOS/Views/Library/RadioView.swift b/iOS/Views/Library/RadioView.swift index 0227e99..a73164c 100644 --- a/iOS/Views/Library/RadioView.swift +++ b/iOS/Views/Library/RadioView.swift @@ -162,7 +162,9 @@ struct RadioView: View { } .alert("Add Radio Station", isPresented: $showAddStation) { TextField("Station Name", text: $newStationName) + .keyboardDoneButton() TextField("Stream URL", text: $newStationURL) + .keyboardDoneButton() .textInputAutocapitalization(.never) .autocorrectionDisabled() Button("Add") { diff --git a/iOS/Views/Library/SearchView.swift b/iOS/Views/Library/SearchView.swift index d91d4fc..851af79 100644 --- a/iOS/Views/Library/SearchView.swift +++ b/iOS/Views/Library/SearchView.swift @@ -5,236 +5,351 @@ struct SearchView: View { @EnvironmentObject var audioPlayer: AudioPlayer @EnvironmentObject var offlineManager: OfflineManager @ObservedObject private var libraryCache = LibraryCache.shared - - @State private var searchText = "" - @State private var artists: [Artist] = [] - @State private var albums: [Album] = [] - @State private var songs: [Song] = [] - @State private var isSearching = false - + @ObservedObject private var watchManager = WatchConnectivityManager.shared + + @State private var searchText = "" + @State private var searchArtists: [Artist] = [] + @State private var searchAlbums: [Album] = [] + @State private var searchSongs: [Song] = [] + @State private var isSearching = false + + // All-songs browse state + @State private var allSongs: [Song] = [] + @State private var isLoadingSongs = false + @State private var songsLoaded = false + + // Long-press menu + @State private var menuSong: Song? = nil + @State private var showMenu = false + @State private var playlistPickerSongId: String? = nil + @State private var availablePlaylists: [Playlist] = [] + @State private var showPlaylistPicker = false + + @FocusState private var searchFocused: Bool + private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) - + private var isSearchActive: Bool { !searchText.isEmpty } + + // MARK: - Body + var body: some View { NavigationStack { - ScrollView { - VStack(spacing: 0) { - // Search bar - HStack(spacing: 10) { - Image(systemName: "magnifyingglass") - .foregroundColor(.gray) - - TextField("Artists, Songs, Albums", text: $searchText) - .foregroundColor(.white) - .autocapitalization(.none) - .disableAutocorrection(true) - .onSubmit { performSearch() } - .onChange(of: searchText) { oldValue, newValue in - if newValue.count >= 2 { - performSearch() - } - } - - if !searchText.isEmpty { - Button(action: { - searchText = "" - artists = []; albums = []; songs = [] - }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.gray) + VStack(spacing: 0) { + // Search bar + HStack(spacing: 10) { + Image(systemName: "magnifyingglass").foregroundColor(.gray) + TextField("Artists, Songs, Albums", text: $searchText) + .foregroundColor(.white) + .autocapitalization(.none) + .disableAutocorrection(true) + .focused($searchFocused) + .onSubmit { performSearch() } + .onChange(of: searchText) { _, new in + if new.count >= 2 { performSearch() } + else if new.isEmpty { clearSearch() } + } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { searchFocused = false } + .foregroundColor(accentPink) } } + if !searchText.isEmpty { + Button(action: { searchText = ""; clearSearch() }) { + Image(systemName: "xmark.circle.fill").foregroundColor(.gray) + } } - .padding(10) - .background(Color.white.opacity(0.08)) - .cornerRadius(10) - .padding(.horizontal, 16) - .padding(.top, 10) - - if isSearching { - ProgressView().tint(accentPink).padding(.top, 40) - } else if !searchText.isEmpty { - searchResults - } else { - emptyState - } - - Color.clear.frame(height: 120) + } + .padding(10) + .background(Color.white.opacity(0.08)) + .cornerRadius(10) + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 8) + + if isSearchActive { + searchResultsView + } else { + allSongsView } } .background(Color(white: 0.06)) - .navigationTitle("Search") + .navigationTitle("Songs") + .sheet(isPresented: $showPlaylistPicker) { + AddToPlaylistSheet(songId: playlistPickerSongId, playlists: availablePlaylists) + } + .task { await loadAllSongs() } } } - - private var emptyState: some View { - VStack(spacing: 12) { - Image(systemName: "magnifyingglass") - .font(.system(size: 40)) - .foregroundColor(.gray) - Text("Search your library") - .font(.system(size: 16)) - .foregroundColor(.gray) - } - .padding(.top, 80) - } - - private var searchResults: some View { - LazyVStack(alignment: .leading, spacing: 0) { - // Artists - if !artists.isEmpty { - sectionHeader("Artists") - ForEach(artists) { artist in - NavigationLink(destination: ArtistDetailView(artistId: artist.id)) { - HStack(spacing: 12) { - AsyncCoverArt(coverArtId: artist.coverArt, size: 44) - .frame(width: 44, height: 44) - .clipShape(Circle()) - - Text(artist.name) - .font(.system(size: 15)) - .foregroundColor(.white) - - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 12)) - .foregroundColor(.gray) + + // MARK: - All Songs Browse + + private var allSongsView: some View { + Group { + if isLoadingSongs { + Spacer() + ProgressView().tint(accentPink) + Spacer() + } else if allSongs.isEmpty { + Spacer() + VStack(spacing: 12) { + Image(systemName: "music.note.list") + .font(.system(size: 40)).foregroundColor(.gray) + Text("No songs found") + .font(.system(size: 16)).foregroundColor(.gray) + } + Spacer() + } else { + ScrollView { + LazyVStack(spacing: 0) { + // Download All banner (offline only — explicit user action) + downloadAllBanner + ForEach(allSongs) { song in + songRow(song, queue: allSongs) + Divider() + .background(Color.white.opacity(0.06)) + .padding(.leading, 72) } - .padding(.horizontal, 16) - .padding(.vertical, 8) + Color.clear.frame(height: 120) } } } - - // Albums - if !albums.isEmpty { - sectionHeader("Albums") - ForEach(albums) { album in - NavigationLink(destination: AlbumDetailView(albumId: album.id)) { - HStack(spacing: 12) { - AsyncCoverArt(coverArtId: album.coverArt, size: 48) - .frame(width: 48, height: 48) - .cornerRadius(4) - - VStack(alignment: .leading, spacing: 2) { - Text(album.name) - .font(.system(size: 15)) - .foregroundColor(.white) - .lineLimit(1) - Text(album.artist ?? "") - .font(.system(size: 12)) - .foregroundColor(.gray) - } - - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 12)) - .foregroundColor(.gray) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } + } + } + + private var downloadAllBanner: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("\(allSongs.count) Songs") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white) + let downloaded = allSongs.filter { offlineManager.isSongDownloaded($0.id) }.count + if downloaded > 0 { + Text("\(downloaded) downloaded") + .font(.system(size: 11)).foregroundColor(.gray) } } - - // Songs - if !songs.isEmpty { - sectionHeader("Songs") - ForEach(songs) { song in - let available = offlineManager.isSongDownloaded(song.id) || libraryCache.isServerAvailable - let isDownloaded = offlineManager.isSongDownloaded(song.id) - let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) - let dlState = offlineManager.downloads[song.id] - Button(action: { - if available { - audioPlayer.play(song: song, fromQueue: songs) - } - }) { - HStack(spacing: 12) { - ZStack { - AsyncCoverArt(coverArtId: song.coverArt, size: 44) - .frame(width: 44, height: 44) - .cornerRadius(3) - .opacity(available ? 1.0 : 0.4) - - if case .downloading(let progress) = dlState { - RoundedRectangle(cornerRadius: 3) - .fill(Color.black.opacity(0.5)) - .frame(width: 44, height: 44) - Circle() - .trim(from: 0, to: progress) - .stroke(accentPink, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) - .frame(width: 24, height: 24) - .rotationEffect(.degrees(-90)) - } else if case .queued = dlState { - RoundedRectangle(cornerRadius: 3) - .fill(Color.black.opacity(0.4)) - .frame(width: 44, height: 44) - ProgressView().tint(accentPink).scaleEffect(0.6) - } - } - - VStack(alignment: .leading, spacing: 2) { - Text(song.title) - .font(.system(size: 15)) - .foregroundColor( - !available ? .gray.opacity(0.35) : - audioPlayer.currentSong?.id == song.id ? accentPink : .white - ) - .lineLimit(1) - - Text("\(song.artist ?? "") — \(song.album ?? "")") - .font(.system(size: 12)) - .foregroundColor(available ? .gray : .gray.opacity(0.3)) - .lineLimit(1) - - if case .downloading(let progress) = dlState { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(Color.white.opacity(0.08)).frame(height: 2) - Capsule().fill(accentPink) - .frame(width: geo.size.width * progress, height: 2) - } + Spacer() + Button(action: downloadAll) { + HStack(spacing: 5) { + Image(systemName: "arrow.down.circle") + .font(.system(size: 14)) + Text("Download All") + .font(.system(size: 13, weight: .medium)) + } + .foregroundColor(accentPink) + .padding(.horizontal, 12).padding(.vertical, 6) + .background(accentPink.opacity(0.12)) + .cornerRadius(16) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.white.opacity(0.03)) + } + + // MARK: - Search Results + + private var searchResultsView: some View { + Group { + if isSearching { + Spacer() + ProgressView().tint(accentPink) + Spacer() + } else if searchArtists.isEmpty && searchAlbums.isEmpty && searchSongs.isEmpty { + Spacer() + VStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .font(.system(size: 36)).foregroundColor(.gray) + Text("No results for \"\(searchText)\"") + .font(.system(size: 15)).foregroundColor(.gray) + } + Spacer() + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if !searchArtists.isEmpty { + sectionHeader("Artists") + ForEach(searchArtists) { artist in + NavigationLink(destination: ArtistDetailView(artistId: artist.id)) { + HStack(spacing: 12) { + AsyncCoverArt(coverArtId: artist.coverArt, size: 44) + .frame(width: 44, height: 44).clipShape(Circle()) + Text(artist.name) + .font(.system(size: 15)).foregroundColor(.white) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12)).foregroundColor(.gray) } - .frame(height: 2) + .padding(.horizontal, 16).padding(.vertical, 8) } } - - Spacer() - - HStack(spacing: 4) { - if isOnWatch { - Image(systemName: "applewatch") - .font(.system(size: 9)) - .foregroundColor(.blue.opacity(0.7)) - } - if isDownloaded { - Image(systemName: "arrow.down.circle.fill") - .font(.system(size: 11)) - .foregroundColor(.green.opacity(0.6)) - } - } - - Text(song.durationFormatted) - .font(.system(size: 13)) - .foregroundColor(available ? .gray : .gray.opacity(0.3)) } - .padding(.horizontal, 16) - .padding(.vertical, 8) + if !searchAlbums.isEmpty { + sectionHeader("Albums") + ForEach(searchAlbums) { album in + NavigationLink(destination: AlbumDetailView(albumId: album.id)) { + HStack(spacing: 12) { + AsyncCoverArt(coverArtId: album.coverArt, size: 48) + .frame(width: 48, height: 48).cornerRadius(4) + VStack(alignment: .leading, spacing: 2) { + Text(album.name) + .font(.system(size: 15)).foregroundColor(.white).lineLimit(1) + Text(album.artist ?? "") + .font(.system(size: 12)).foregroundColor(.gray) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12)).foregroundColor(.gray) + } + .padding(.horizontal, 16).padding(.vertical, 8) + } + } + } + if !searchSongs.isEmpty { + sectionHeader("Songs") + ForEach(searchSongs) { song in + songRow(song, queue: searchSongs) + Divider() + .background(Color.white.opacity(0.06)) + .padding(.leading, 72) + } + } + Color.clear.frame(height: 120) } } } } } - + + // MARK: - Song Row (shared, with long-press menu) + + private func songRow(_ song: Song, queue: [Song]) -> some View { + let available = offlineManager.isSongDownloaded(song.id) || libraryCache.isServerAvailable + let isDownloaded = offlineManager.isSongDownloaded(song.id) + let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) + let dlState = offlineManager.downloads[song.id] + let isCurrent = audioPlayer.currentSong?.id == song.id + + return Button(action: { + if available { audioPlayer.play(song: song, fromQueue: queue) } + }) { + HStack(spacing: 12) { + // Artwork + download progress overlay + ZStack { + AsyncCoverArt(coverArtId: song.coverArt, size: 44) + .frame(width: 44, height: 44).cornerRadius(3) + .opacity(available ? 1.0 : 0.4) + if case .downloading(let progress) = dlState { + RoundedRectangle(cornerRadius: 3) + .fill(Color.black.opacity(0.5)).frame(width: 44, height: 44) + Circle().trim(from: 0, to: progress) + .stroke(accentPink, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .frame(width: 24, height: 24).rotationEffect(.degrees(-90)) + } else if case .queued = dlState { + RoundedRectangle(cornerRadius: 3) + .fill(Color.black.opacity(0.4)).frame(width: 44, height: 44) + ProgressView().tint(accentPink).scaleEffect(0.6) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text(song.title) + .font(.system(size: 15)) + .foregroundColor(!available ? .gray.opacity(0.35) : isCurrent ? accentPink : .white) + .lineLimit(1) + Text("\(song.artist ?? "") • \(song.album ?? "")") + .font(.system(size: 12)) + .foregroundColor(available ? .gray : .gray.opacity(0.3)) + .lineLimit(1) + if case .downloading(let progress) = dlState { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.white.opacity(0.08)).frame(height: 2) + Capsule().fill(accentPink) + .frame(width: geo.size.width * progress, height: 2) + } + }.frame(height: 2) + } + } + + Spacer() + + // Status badges + HStack(spacing: 4) { + if isOnWatch { + Image(systemName: "applewatch") + .font(.system(size: 9)).foregroundColor(.blue.opacity(0.7)) + } + if isDownloaded { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 11)).foregroundColor(.green.opacity(0.6)) + } + } + + Text(song.durationFormatted) + .font(.system(size: 13)) + .foregroundColor(available ? .gray : .gray.opacity(0.3)) + } + .padding(.horizontal, 16).padding(.vertical, 9) + } + .contextMenu { + Button(action: { audioPlayer.playNow(song) }) { + Label("Play Now", systemImage: "play.fill") + } + Button(action: { audioPlayer.playNext(song) }) { + Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") + } + Button(action: { audioPlayer.playLater(song) }) { + Label("Add to Queue", systemImage: "text.line.last.and.arrowtriangle.forward") + } + Divider() + if isDownloaded { + Button(role: .destructive, action: { offlineManager.removeSong(song.id) }) { + Label("Remove Download", systemImage: "trash") + } + } else { + Button(action: { + if let server = serverManager.activeServer { + offlineManager.downloadSong(song, server: server) + } + }) { + Label("Download", systemImage: "arrow.down.circle") + } + } + Button(action: { + if !isOnWatch { _ = WatchConnectivityManager.shared.sendSongToWatch(song) } + }) { + Label(isOnWatch ? "On Watch ✓" : "Send to Watch", + systemImage: isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward") + } + .disabled(isOnWatch) + Divider() + Button(action: { + playlistPickerSongId = song.id + Task { + availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? [] + showPlaylistPicker = true + } + }) { + Label("Add to Playlist...", systemImage: "text.badge.plus") + } + } + } + + // MARK: - Helpers + private func sectionHeader(_ title: String) -> some View { Text(title) - .font(.system(size: 20, weight: .bold)) - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.top, 20) - .padding(.bottom, 8) + .font(.system(size: 20, weight: .bold)).foregroundColor(.white) + .padding(.horizontal, 16).padding(.top, 20).padding(.bottom, 8) } - + + private func clearSearch() { + searchArtists = []; searchAlbums = []; searchSongs = [] + } + private func performSearch() { guard searchText.count >= 2 else { return } isSearching = true @@ -242,14 +357,62 @@ struct SearchView: View { do { let result = try await serverManager.client.search3(query: searchText) await MainActor.run { - artists = result?.artist ?? [] - albums = result?.album ?? [] - songs = result?.song ?? [] - isSearching = false + searchArtists = result?.artist ?? [] + searchAlbums = result?.album ?? [] + searchSongs = result?.song ?? [] + isSearching = false } } catch { await MainActor.run { isSearching = false } } } } + + private func loadAllSongs() async { + guard !songsLoaded else { return } + isLoadingSongs = true + + // Try loading from cache first for instant display + if let cached = libraryCache.load([Song].self, key: "all_songs_sorted") { + await MainActor.run { + allSongs = cached + isLoadingSongs = false + songsLoaded = true + } + return + } + + // Fetch all albums, then their songs, sorted alphabetically by title + do { + var offset = 0 + var collected: [Song] = [] + while true { + let albums = try await serverManager.client.getAlbumList2( + type: "alphabeticalByName", size: 500, offset: offset) + for album in albums { + if let detail = try? await serverManager.client.getAlbum(id: album.id) { + collected.append(contentsOf: detail.song ?? []) + } + } + if albums.count < 500 { break } + offset += 500 + } + let sorted = collected.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + libraryCache.save(sorted, key: "all_songs_sorted") + await MainActor.run { + allSongs = sorted + isLoadingSongs = false + songsLoaded = true + } + } catch { + await MainActor.run { isLoadingSongs = false } + } + } + + private func downloadAll() { + guard let server = serverManager.activeServer else { return } + for song in allSongs where !offlineManager.isSongDownloaded(song.id) { + offlineManager.downloadSong(song, server: server) + } + } } diff --git a/iOS/Views/Login/LoginView.swift b/iOS/Views/Login/LoginView.swift index e929c3f..ce8e5b5 100644 --- a/iOS/Views/Login/LoginView.swift +++ b/iOS/Views/Login/LoginView.swift @@ -221,6 +221,7 @@ struct AddServerSheet: View { TextField("My Server", text: $name) .textFieldStyle(DarkFieldStyle()) + .keyboardDoneButton() } // URL @@ -234,6 +235,7 @@ struct AddServerSheet: View { .keyboardType(.URL) .autocapitalization(.none) .disableAutocorrection(true) + .keyboardDoneButton() } // Username @@ -246,6 +248,7 @@ struct AddServerSheet: View { .textFieldStyle(DarkFieldStyle()) .autocapitalization(.none) .disableAutocorrection(true) + .keyboardDoneButton() } // Password @@ -256,6 +259,7 @@ struct AddServerSheet: View { SecureField("password", text: $password) .textFieldStyle(DarkFieldStyle()) + .keyboardDoneButton() } // Test Connection diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index bdb515a..03518d3 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -1547,170 +1547,330 @@ struct ShazamMatch { // MARK: - Shazam Recognizer /// Uses ShazamKit + AVAudioEngine mic tap for audio recognition. +// MARK: - ShazamRecognizer (AVPlayer buffer tap — no microphone required) +/// +/// Architecture: +/// AVPlayerItem → MTAudioProcessingTap (C callback) → analysisQueue +/// analysisQueue → AVAudioConverter (→ 16kHz mono PCM) → SHSession.matchStreamingBuffer +/// +/// The tap is installed as an AVMutableAudioMix on the current player item once the +/// player is playing. For non-AVPlayer paths (local engine) we fall back to the +/// legacy microphone approach. class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate { static let shared = ShazamRecognizer() - + @Published var isRecognizing = false - - private var session: SHSession? - private var audioEngine: AVAudioEngine? - private var completion: ((ShazamMatch?) -> Void)? - private var timeoutTask: Task? + + private var session: SHSession? + private var completion: ((ShazamMatch?) -> Void)? + private var timeoutTask: Task? private var hasDeliveredResult = false - + + // Tap state + private var tap: Unmanaged? + private var converter: AVAudioConverter? + private var sourceFormat: AVAudioFormat? + private let targetFormat = AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1)! + private let analysisQueue = DispatchQueue(label: "com.navidrome.shazam", qos: .userInitiated) + + // Legacy mic fallback + private var audioEngine: AVAudioEngine? + private override init() { super.init() } - - /// Start recognition via microphone using matchStreamingBuffer + + // MARK: - Public entry point + func recognize(completion: @escaping (ShazamMatch?) -> Void) { guard !isRecognizing else { return } - stopListening() - - isRecognizing = true + stopAll() + + isRecognizing = true hasDeliveredResult = false - self.completion = completion - - DebugLogger.shared.log("Shazam: starting recognition", category: "Audio") - - // Configure audio session for simultaneous playback + mic - do { - let audioSession = AVAudioSession.sharedInstance() - try audioSession.setCategory(.playAndRecord, mode: .default, options: [ - .defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers - ]) - try audioSession.setActive(true) - } catch { - DebugLogger.shared.log("Shazam: audio session failed: \(error.localizedDescription)", category: "Audio") - deliverResult(nil) - return - } - - // Create SHSession for Shazam catalog matching - let shSession = SHSession() + self.completion = completion + + let shSession = SHSession() shSession.delegate = self - session = shSession - - // Dedicated audio engine for mic capture - let engine = AVAudioEngine() - audioEngine = engine - - let inputNode = engine.inputNode - let bus: AVAudioNodeBus = 0 - let nativeFormat = inputNode.outputFormat(forBus: bus) - - guard nativeFormat.sampleRate > 0, nativeFormat.channelCount > 0 else { - DebugLogger.shared.log("Shazam: invalid mic format (sr=\(nativeFormat.sampleRate))", category: "Audio") - deliverResult(nil) - return - } - - DebugLogger.shared.log("Shazam: mic \(Int(nativeFormat.sampleRate))Hz, \(nativeFormat.channelCount)ch", category: "Audio") - - // Install tap — feed buffers directly to matchStreamingBuffer - // Per Apple docs: "Use matchStreamingBuffer when you are generating - // the audio buffers and passing them into the framework." - // This is simpler and more reliable than manual SHSignatureGenerator. - inputNode.installTap(onBus: bus, bufferSize: 2048, format: nativeFormat) { [weak shSession] buffer, time in - shSession?.matchStreamingBuffer(buffer, at: time) - } - - do { - engine.prepare() - try engine.start() - DebugLogger.shared.log("Shazam: engine started, listening...", category: "Audio") - } catch { - DebugLogger.shared.log("Shazam: engine start failed: \(error.localizedDescription)", category: "Audio") - deliverResult(nil) - return - } - - // Timeout after 12 seconds if no match - timeoutTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(12)) - if !self.hasDeliveredResult { - DebugLogger.shared.log("Shazam: timeout — no match after 12s", category: "Audio") - self.deliverResult(nil) + session = shSession + + DebugLogger.shared.log("Shazam: starting recognition", category: "Audio") + + // Prefer tap on AVPlayer (radio / stream / local AVPlayer path) + if let playerItem = AudioPlayer.shared.currentPlayerItem { + if installTap(on: playerItem) { + startTimeout(seconds: 15) + DebugLogger.shared.log("Shazam: tap installed on AVPlayerItem", category: "Audio") + return } } + + // Fallback: microphone for local engine path + startMicFallback() } - + + // MARK: - MTAudioProcessingTap + + private func installTap(on playerItem: AVPlayerItem) -> Bool { + guard let audioTrack = playerItem.asset.tracks(withMediaType: .audio).first else { + DebugLogger.shared.log("Shazam: no audio track on playerItem", category: "Audio") + return false + } + + // Store self as opaque pointer in tap storage + var callbacks = MTAudioProcessingTapCallbacks( + version: kMTAudioProcessingTapCallbacksVersion_0, + clientInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()), + init: { tap, clientInfo, tapStorageOut in + tapStorageOut.pointee = clientInfo + }, + finalize: nil, + prepare: { tap, maxFrames, processingFormat in + // Capture the source format so we can build the converter on first buffer + let storage = Unmanaged + .fromOpaque(MTAudioProcessingTapGetStorage(tap)) + .takeUnretainedValue() + let format = AVAudioFormat(streamDescription: processingFormat) + storage.analysisQueue.async { + storage.sourceFormat = format + if let src = format, let dst = storage.targetFormat as AVAudioFormat? { + storage.converter = try? AVAudioConverter(from: src, to: dst) + } + } + }, + unprepare: nil, + process: shazamTapProcess + ) + + var newTap: Unmanaged? + let status = MTAudioProcessingTapCreate( + kCFAllocatorDefault, &callbacks, + kMTAudioProcessingTapCreationFlag_PostEffects, &newTap + ) + guard status == noErr, let newTap else { + DebugLogger.shared.log("Shazam: MTAudioProcessingTapCreate failed \(status)", category: "Audio") + return false + } + tap = newTap + + let inputParams = AVMutableAudioMixInputParameters(track: audioTrack) + inputParams.audioTapProcessor = newTap.takeRetainedValue() + + let mix = AVMutableAudioMix() + mix.inputParameters = [inputParams] + playerItem.audioMix = mix + return true + } + + /// Called from the C tap callback — dispatches to analysis queue to avoid blocking render thread. + func processTapBuffer(_ bufferList: UnsafeMutablePointer, + frameCount: CMItemCount) { + guard let session, let conv = converter, + let srcFmt = sourceFormat else { return } + + analysisQueue.async { [weak self] in + guard let self else { return } + self.convertAndMatch(bufferList: bufferList, + frameCount: frameCount, + converter: conv, + sourceFormat: srcFmt, + session: session) + } + } + + private func convertAndMatch( + bufferList: UnsafeMutablePointer, + frameCount: CMItemCount, + converter: AVAudioConverter, + sourceFormat: AVAudioFormat, + session: SHSession + ) { + let capacity = AVAudioFrameCount(frameCount) + guard capacity > 0 else { return } + + // Wrap the raw AudioBufferList in an AVAudioPCMBuffer + guard let srcBuffer = AVAudioPCMBuffer(pcmFormat: sourceFormat, + frameCapacity: capacity) else { return } + srcBuffer.frameLength = capacity + + let abl = UnsafeMutableAudioBufferListPointer(bufferList) + for (i, buf) in abl.enumerated() { + guard i < Int(sourceFormat.channelCount) else { break } + let dst = srcBuffer.floatChannelData?[i] + let src = buf.mData?.assumingMemoryBound(to: Float.self) + let n = Int(buf.mDataByteSize) / MemoryLayout.size + if let dst, let src { dst.initialize(from: src, count: n) } + } + + // Output buffer for the converted audio + let outputCapacity = AVAudioFrameCount(Double(capacity) * + targetFormat.sampleRate / sourceFormat.sampleRate) + 16 + guard let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, + frameCapacity: outputCapacity) else { return } + + var error: NSError? + var inputConsumed = false + converter.convert(to: outBuffer, error: &error) { _, inputStatus in + if inputConsumed { + inputStatus.pointee = .noDataNow + return nil + } + inputConsumed = true + inputStatus.pointee = .haveData + return srcBuffer + } + + if let error { + DebugLogger.shared.log("Shazam: converter error \(error)", category: "Audio") + return + } + + if outBuffer.frameLength > 0 { + session.matchStreamingBuffer(outBuffer, at: nil) + } + } + + private func removeTap() { + // Removing the audioMix disconnects the tap cleanly + if let item = AudioPlayer.shared.currentPlayerItem { + item.audioMix = nil + } + tap = nil + converter = nil + sourceFormat = nil + } + + // MARK: - Microphone fallback (local engine / engine path) + + private func startMicFallback() { + DebugLogger.shared.log("Shazam: falling back to microphone", category: "Audio") + do { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playAndRecord, mode: .default, + options: [.defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers]) + try audioSession.setActive(true) + } catch { + DebugLogger.shared.log("Shazam: audio session failed: \(error)", category: "Audio") + deliverResult(nil); return + } + + let engine = AVAudioEngine() + audioEngine = engine + let inputNode = engine.inputNode + let bus: AVAudioNodeBus = 0 + let nativeFmt = inputNode.outputFormat(forBus: bus) + guard nativeFmt.sampleRate > 0, nativeFmt.channelCount > 0 else { + deliverResult(nil); return + } + guard let shSession = session else { deliverResult(nil); return } + + inputNode.installTap(onBus: bus, bufferSize: 2048, format: nativeFmt) { [weak shSession] buf, time in + shSession?.matchStreamingBuffer(buf, at: time) + } + do { + engine.prepare(); try engine.start() + } catch { + deliverResult(nil); return + } + startTimeout(seconds: 12) + } + + // MARK: - Timeout + + private func startTimeout(seconds: Int) { + timeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(seconds)) + guard let self, !self.hasDeliveredResult else { return } + DebugLogger.shared.log("Shazam: timeout after \(seconds)s", category: "Audio") + self.deliverResult(nil) + } + } + // MARK: - SHSessionDelegate - + func session(_ session: SHSession, didFind match: SHMatch) { guard let item = match.mediaItems.first else { return } - let title = item.title ?? "Unknown" + let title = item.title ?? "Unknown" let artist = item.artist ?? "" - let artworkURL = item.artworkURL - let appleMusicURL = item.appleMusicURL - DebugLogger.shared.log("Shazam: matched '\(title)' by '\(artist)'", category: "Audio") - - // Extract preview URL from SHMediaItem.songs MusicKit bridge. - // SHMediaItem.songs returns MusicKit Song objects directly. - // Song.previewAssets contains the 30s DRM-free preview URL. - var previewURL: URL? = nil + + var previewURL: URL? if let firstSong = item.songs.first, let preview = firstSong.previewAssets?.first { previewURL = preview.url - DebugLogger.shared.log("Shazam: preview URL from songs bridge", category: "Audio") } - DispatchQueue.main.async { - let result = ShazamMatch( - title: title, - artist: artist, - artworkURL: artworkURL, + self.deliverResult(ShazamMatch( + title: title, artist: artist, + artworkURL: item.artworkURL, previewURL: previewURL, - appleMusicURL: appleMusicURL - ) - self.deliverResult(result) + appleMusicURL: item.appleMusicURL + )) } } - + func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: (any Error)?) { - // matchStreamingBuffer keeps trying automatically — don't stop here. - // The timeout task handles giving up. - if let error = error { - DebugLogger.shared.log("Shazam: segment no match — \(error.localizedDescription)", category: "Audio") + if let error { + DebugLogger.shared.log("Shazam: segment no match — \(error)", category: "Audio") } } - + // MARK: - Cleanup - + private func deliverResult(_ result: ShazamMatch?) { guard !hasDeliveredResult else { return } hasDeliveredResult = true - stopListening() + stopAll() isRecognizing = false completion?(result) completion = nil } - - private func stopListening() { - timeoutTask?.cancel() - timeoutTask = nil - + + private func stopAll() { + timeoutTask?.cancel(); timeoutTask = nil + removeTap() if let engine = audioEngine { - engine.inputNode.removeTap(onBus: 0) + if engine.inputNode.numberOfInputs > 0 { + engine.inputNode.removeTap(onBus: 0) + } engine.stop() audioEngine = nil } session = nil - - // Restore playback audio session - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) - try AVAudioSession.sharedInstance().setActive(true) - } catch { - DebugLogger.shared.log("Shazam: restore session failed: \(error.localizedDescription)", category: "Audio") + + // Restore audio session only if we used the mic fallback + if audioEngine != nil { + try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) + try? AVAudioSession.sharedInstance().setActive(true) } } - + func cancel() { DebugLogger.shared.log("Shazam: cancelled", category: "Audio") deliverResult(nil) } } +// MARK: - MTAudioProcessingTap C callback (must be a free function) +/// Must be a C-style free function — cannot be a method or closure. +private func shazamTapProcess( + tap: MTAudioProcessingTap, + numberFrames: CMItemCount, + flags: MTAudioProcessingTapFlags, + bufferListInOut: UnsafeMutablePointer, + numberFramesOut: UnsafeMutablePointer, + flagsOut: UnsafeMutablePointer +) { + // Fetch audio from source — passes samples through to the player unchanged + let status = MTAudioProcessingTapGetSourceAudio( + tap, numberFrames, bufferListInOut, flagsOut, nil, numberFramesOut) + guard status == noErr else { return } + + // Forward to the recognizer on the analysis queue — never block this render thread + let recognizer = Unmanaged + .fromOpaque(MTAudioProcessingTapGetStorage(tap)) + .takeUnretainedValue() + recognizer.processTapBuffer(bufferListInOut, frameCount: numberFramesOut.pointee) +} + // MARK: - Shazam Result Sheet diff --git a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift index 36648cb..c9c3afa 100644 --- a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift +++ b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift @@ -1,132 +1,235 @@ import SwiftUI -// MARK: - Visualizer Settings (Mitsuha Infinity parity + advanced physics) -class VisualizerSettings: ObservableObject { - static let shared = VisualizerSettings() - - // General - @Published var enabled: Bool { didSet { save("vis_enabled", enabled) } } - @Published var nowPlayingEnabled: Bool { didSet { save("vis_nowplaying", nowPlayingEnabled) } } - @Published var miniPlayerEnabled: Bool { didSet { save("vis_miniplayer", miniPlayerEnabled) } } - @Published var style: Style { didSet { save("vis_style", style.rawValue) } } - @Published var numberOfPoints: Int { didSet { save("vis_points", numberOfPoints) } } - @Published var sensitivity: Double { didSet { save("vis_sensitivity", sensitivity) } } - @Published var fps: Double { didSet { save("vis_fps", fps) } } - @Published var realAudioAnalysis: Bool { didSet { save("vis_real_fft", realAudioAnalysis) } } - @Published var dynamicGainEnabled: Bool { didSet { save("vis_dynamic_gain", dynamicGainEnabled) } } - - // Wave - @Published var waveOffsetTop: Double { didSet { save("vis_wave_offset", waveOffsetTop) } } - - // Bar - @Published var barSpacing: Double { didSet { save("vis_bar_spacing", barSpacing) } } - @Published var barCornerRadius: Double { didSet { save("vis_bar_radius", barCornerRadius) } } - - // Line - @Published var lineThickness: Double { didSet { save("vis_line_thick", lineThickness) } } - - // Color - @Published var colorMode: ColorMode { didSet { save("vis_color", colorMode.rawValue) } } - @Published var alpha: Double { didSet { save("vis_alpha", alpha) } } - @Published var customColor: Color = .pink - - // Advanced Physics - @Published var viscosity: Double { didSet { save("vis_viscosity", viscosity) } } - @Published var frequencyCutoff: Int { didSet { save("vis_freq_cutoff", frequencyCutoff) } } - @Published var baseMultiplier: Double { didSet { save("vis_base_mult", baseMultiplier) } } - @Published var depthOffset: Double { didSet { save("vis_depth_offset", depthOffset) } } - @Published var depthOpacity: Double { didSet { save("vis_depth_opacity", depthOpacity) } } - @Published var idleAmplitude: Double { didSet { save("vis_idle_amp", idleAmplitude) } } - @Published var waveStrokeThickness: Double { didSet { save("vis_wave_stroke", waveStrokeThickness) } } - - // Layout - @Published var nowPlayingHeightPct: Double { didSet { save("vis_np_height", nowPlayingHeightPct) } } - @Published var miniPlayerHeight: Double { didSet { save("vis_mini_height", miniPlayerHeight) } } - - // Mini Player overrides - @Published var miniOpacity: Double { didSet { save("vis_mini_opacity", miniOpacity) } } - @Published var miniAmplitude: Double { didSet { save("vis_mini_amplitude", miniAmplitude) } } - @Published var miniIdleAmplitude: Double { didSet { save("vis_mini_idle", miniIdleAmplitude) } } - @Published var miniDepthOffset: Double { didSet { save("vis_mini_depth", miniDepthOffset) } } - @Published var miniDepthOpacity: Double { didSet { save("vis_mini_depth_opacity", miniDepthOpacity) } } - - // Now Playing overrides - @Published var npAmplitude: Double { didSet { save("vis_np_amplitude", npAmplitude) } } - @Published var npBaseLift: Double { didSet { save("vis_np_baselift", npBaseLift) } } - - enum Style: String, CaseIterable { - case wave = "Wave" - case bar = "Bar" - case line = "Line" - case siriWave = "Siri" - } - - enum ColorMode: String, CaseIterable { - case dynamic = "Dynamic" - case albumArt = "Album Art" - case vibrant = "Vibrant" - case custom = "Custom" - } - - private init() { - let d = UserDefaults.standard - enabled = d.object(forKey: "vis_enabled") as? Bool ?? true - nowPlayingEnabled = d.object(forKey: "vis_nowplaying") as? Bool ?? true - miniPlayerEnabled = d.object(forKey: "vis_miniplayer") as? Bool ?? true - style = Style(rawValue: d.string(forKey: "vis_style") ?? "") ?? .wave - numberOfPoints = { let v = d.integer(forKey: "vis_points"); return v > 0 ? v : 10 }() - sensitivity = { let v = d.double(forKey: "vis_sensitivity"); return v > 0 ? v : 1.5 }() - fps = { let v = d.double(forKey: "vis_fps"); return v > 0 ? v : 60.0 }() - realAudioAnalysis = d.object(forKey: "vis_real_fft") as? Bool ?? true - dynamicGainEnabled = d.object(forKey: "vis_dynamic_gain") as? Bool ?? true - waveOffsetTop = d.double(forKey: "vis_wave_offset") - barSpacing = { let v = d.double(forKey: "vis_bar_spacing"); return v > 0 ? v : 5.0 }() - barCornerRadius = d.double(forKey: "vis_bar_radius") - lineThickness = { let v = d.double(forKey: "vis_line_thick"); return v > 0 ? v : 5.0 }() - colorMode = ColorMode(rawValue: d.string(forKey: "vis_color") ?? "") ?? .dynamic - alpha = { let v = d.double(forKey: "vis_alpha"); return v > 0 ? v : 0.6 }() - // Advanced - viscosity = { let v = d.double(forKey: "vis_viscosity"); return v > 0 ? v : 0.25 }() - frequencyCutoff = { let v = d.integer(forKey: "vis_freq_cutoff"); return v > 0 ? v : 80 }() - baseMultiplier = { let v = d.double(forKey: "vis_base_mult"); return v > 0 ? v : 25.0 }() - depthOffset = { let v = d.double(forKey: "vis_depth_offset"); return v > 0 ? v : 15.0 }() - depthOpacity = { let v = d.double(forKey: "vis_depth_opacity"); return v > 0 ? v : 0.2 }() - idleAmplitude = { let v = d.double(forKey: "vis_idle_amp"); return v > 0 ? v : 0.03 }() - waveStrokeThickness = { let v = d.double(forKey: "vis_wave_stroke"); return v > 0 ? v : 1.5 }() - // Layout - nowPlayingHeightPct = { let v = d.double(forKey: "vis_np_height"); return v > 0 ? v : 0.50 }() - miniPlayerHeight = { let v = d.double(forKey: "vis_mini_height"); return v > 0 ? v : 48.0 }() - // Mini Player - miniOpacity = d.object(forKey: "vis_mini_opacity") as? Double ?? 0.5 - miniAmplitude = { let v = d.double(forKey: "vis_mini_amplitude"); return v > 0 ? v : 0.7 }() - miniIdleAmplitude = { let v = d.double(forKey: "vis_mini_idle"); return v > 0 ? v : 0.03 }() - miniDepthOffset = d.object(forKey: "vis_mini_depth") as? Double ?? 8.0 - miniDepthOpacity = d.object(forKey: "vis_mini_depth_opacity") as? Double ?? 0.2 - // Now Playing - npAmplitude = { let v = d.double(forKey: "vis_np_amplitude"); return v > 0 ? v : 0.45 }() - npBaseLift = d.object(forKey: "vis_np_baselift") as? Double ?? 130.0 - } - - var effectiveFPS: Double { - ProcessInfo.processInfo.isLowPowerModeEnabled ? min(fps, 24) : fps - } - - private func save(_ key: String, _ value: Any) { - // Debounce: update local var instantly for UI, delay disk write - pendingSaves[key] = value - saveTask?.cancel() - saveTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 500_000_000) - guard !Task.isCancelled else { return } - if let pending = self?.pendingSaves { - for (k, v) in pending { - UserDefaults.standard.set(v, forKey: k) - } - self?.pendingSaves.removeAll() +// MARK: - Per-View Independent Configuration +/// Holds the full visual configuration for one visualizer context (Now Playing or Mini Player). +/// Each style stores its own point count and sensitivity so switching styles +/// never bleeds parameters from a different style. +struct ViewVisualizerConfig: Codable { + // MARK: Style & Color + var style: VisualizerSettings.Style = .wave + var colorMode: VisualizerSettings.ColorMode = .dynamic + var alpha: Double = 0.6 + // Custom color stored as components — Color is not Codable + var customColorR: Double = 1.0 + var customColorG: Double = 0.176 + var customColorB: Double = 0.333 + + // MARK: Per-style point counts (independent — switching styles uses its own last value) + var wavePoints: Int = 9 + var barPoints: Int = 12 + var linePoints: Int = 10 + var siriPoints: Int = 16 + + // MARK: Per-style sensitivity + var waveSensitivity: Double = 0.9 + var barSensitivity: Double = 1.0 + var lineSensitivity: Double = 0.9 + var siriSensitivity: Double = 1.0 + + // MARK: Computed from active style + var numberOfPoints: Int { + get { + switch style { + case .wave: return wavePoints + case .bar: return barPoints + case .line: return linePoints + case .siriWave: return siriPoints + } + } + set { + switch style { + case .wave: wavePoints = newValue + case .bar: barPoints = newValue + case .line: linePoints = newValue + case .siriWave: siriPoints = newValue } } } - + + var sensitivity: Double { + get { + switch style { + case .wave: return waveSensitivity + case .bar: return barSensitivity + case .line: return lineSensitivity + case .siriWave: return siriSensitivity + } + } + set { + switch style { + case .wave: waveSensitivity = newValue + case .bar: barSensitivity = newValue + case .line: lineSensitivity = newValue + case .siriWave: siriSensitivity = newValue + } + } + } + + var customColor: Color { + Color(red: customColorR, green: customColorG, blue: customColorB) + } + + // MARK: Defaults + static var nowPlayingDefault: ViewVisualizerConfig { + var c = ViewVisualizerConfig() + c.wavePoints = 9; c.barPoints = 12; c.linePoints = 10; c.siriPoints = 16 + return c + } + static var miniPlayerDefault: ViewVisualizerConfig { + var c = ViewVisualizerConfig() + c.style = .wave; c.alpha = 0.5 + c.wavePoints = 8; c.barPoints = 10; c.linePoints = 8; c.siriPoints = 12 + return c + } +} + +// MARK: - Visualizer Settings (global + per-view configs) +class VisualizerSettings: ObservableObject { + static let shared = VisualizerSettings() + + // MARK: Global toggles + @Published var enabled: Bool { didSet { save("vis_enabled", enabled) } } + @Published var nowPlayingEnabled: Bool { didSet { save("vis_nowplaying", nowPlayingEnabled) } } + @Published var miniPlayerEnabled: Bool { didSet { save("vis_miniplayer", miniPlayerEnabled) } } + + // MARK: Per-view independent configs + @Published var nowPlaying: ViewVisualizerConfig { didSet { saveConfig("vis_np_config", nowPlaying) } } + @Published var miniPlayer: ViewVisualizerConfig { didSet { saveConfig("vis_mini_config", miniPlayer) } } + + // MARK: Global physics (shared between views) + @Published var fps: Double { didSet { save("vis_fps", fps) } } + @Published var realAudioAnalysis:Bool { didSet { save("vis_real_fft", realAudioAnalysis) } } + @Published var dynamicGainEnabled:Bool { didSet { save("vis_dynamic_gain", dynamicGainEnabled) } } + @Published var viscosity: Double { didSet { save("vis_viscosity", viscosity) } } + @Published var frequencyCutoff: Int { didSet { save("vis_freq_cutoff", frequencyCutoff) } } + @Published var baseMultiplier: Double { didSet { save("vis_base_mult", baseMultiplier) } } + + // MARK: Wave-specific global + @Published var waveStrokeThickness: Double { didSet { save("vis_wave_stroke", waveStrokeThickness) } } + + // MARK: Bar-specific global + @Published var barSpacing: Double { didSet { save("vis_bar_spacing", barSpacing) } } + @Published var barCornerRadius: Double { didSet { save("vis_bar_radius", barCornerRadius) } } + + // MARK: Line-specific global + @Published var lineThickness: Double { didSet { save("vis_line_thick", lineThickness) } } + + // MARK: Now Playing layout / depth + @Published var nowPlayingHeightPct: Double { didSet { save("vis_np_height", nowPlayingHeightPct) } } + @Published var waveOffsetTop: Double { didSet { save("vis_wave_offset", waveOffsetTop) } } + @Published var npAmplitude: Double { didSet { save("vis_np_amplitude", npAmplitude) } } + @Published var npBaseLift: Double { didSet { save("vis_np_baselift", npBaseLift) } } + @Published var depthOffset: Double { didSet { save("vis_depth_offset", depthOffset) } } + @Published var depthOpacity: Double { didSet { save("vis_depth_opacity",depthOpacity) } } + @Published var idleAmplitude: Double { didSet { save("vis_idle_amp", idleAmplitude) } } + + // MARK: Mini Player layout / depth + @Published var miniPlayerHeight: Double { didSet { save("vis_mini_height", miniPlayerHeight) } } + @Published var miniOpacity: Double { didSet { save("vis_mini_opacity", miniOpacity) } } + @Published var miniAmplitude: Double { didSet { save("vis_mini_amplitude", miniAmplitude) } } + @Published var miniIdleAmplitude: Double { didSet { save("vis_mini_idle", miniIdleAmplitude) } } + @Published var miniDepthOffset: Double { didSet { save("vis_mini_depth", miniDepthOffset) } } + @Published var miniDepthOpacity: Double { didSet { save("vis_mini_depth_opacity", miniDepthOpacity) } } + + enum Style: String, CaseIterable, Codable { + case wave = "Wave" + case bar = "Bar" + case line = "Line" + case siriWave = "Siri" + } + + enum ColorMode: String, CaseIterable, Codable { + case dynamic = "Dynamic" + case albumArt = "Album Art" + case vibrant = "Vibrant" + case custom = "Custom" + } + + private init() { + let d = UserDefaults.standard + + enabled = d.object(forKey: "vis_enabled") as? Bool ?? true + nowPlayingEnabled = d.object(forKey: "vis_nowplaying") as? Bool ?? true + miniPlayerEnabled = d.object(forKey: "vis_miniplayer") as? Bool ?? true + + // Per-view configs — load from JSON or use defaults + let dec = JSONDecoder() + if let data = d.data(forKey: "vis_np_config"), + let cfg = try? dec.decode(ViewVisualizerConfig.self, from: data) { + nowPlaying = cfg + } else { + nowPlaying = .nowPlayingDefault + } + if let data = d.data(forKey: "vis_mini_config"), + let cfg = try? dec.decode(ViewVisualizerConfig.self, from: data) { + miniPlayer = cfg + } else { + miniPlayer = .miniPlayerDefault + } + + // Global physics + fps = { let v = d.double(forKey: "vis_fps"); return v > 0 ? v : 60.0 }() + realAudioAnalysis = d.object(forKey: "vis_real_fft") as? Bool ?? true + dynamicGainEnabled = d.object(forKey: "vis_dynamic_gain") as? Bool ?? true + viscosity = { let v = d.double(forKey: "vis_viscosity"); return v > 0 ? v : 0.25 }() + frequencyCutoff = { let v = d.integer(forKey: "vis_freq_cutoff"); return v > 0 ? v : 80 }() + baseMultiplier = { let v = d.double(forKey: "vis_base_mult"); return v > 0 ? v : 25.0 }() + + // Style-specific globals + waveStrokeThickness = { let v = d.double(forKey: "vis_wave_stroke"); return v > 0 ? v : 1.5 }() + barSpacing = { let v = d.double(forKey: "vis_bar_spacing"); return v > 0 ? v : 5.0 }() + barCornerRadius = d.object(forKey: "vis_bar_radius") as? Double ?? 0.0 + lineThickness = { let v = d.double(forKey: "vis_line_thick"); return v > 0 ? v : 5.0 }() + + // Now Playing layout + nowPlayingHeightPct = { let v = d.double(forKey: "vis_np_height"); return v > 0 ? v : 0.50 }() + waveOffsetTop = d.object(forKey: "vis_wave_offset") as? Double ?? 0.0 + npAmplitude = { let v = d.double(forKey: "vis_np_amplitude"); return v > 0 ? v : 0.45 }() + npBaseLift = d.object(forKey: "vis_np_baselift") as? Double ?? 130.0 + // Fix: use object(forKey:) ?? default so setting value to 0 is persisted correctly + depthOffset = d.object(forKey: "vis_depth_offset") as? Double ?? 15.0 + depthOpacity = d.object(forKey: "vis_depth_opacity") as? Double ?? 0.2 + idleAmplitude = { let v = d.double(forKey: "vis_idle_amp"); return v > 0 ? v : 0.03 }() + + // Mini Player layout + miniPlayerHeight = { let v = d.double(forKey: "vis_mini_height"); return v > 0 ? v : 48.0 }() + miniOpacity = d.object(forKey: "vis_mini_opacity") as? Double ?? 0.5 + miniAmplitude = { let v = d.double(forKey: "vis_mini_amplitude"); return v > 0 ? v : 0.7 }() + miniIdleAmplitude = { let v = d.double(forKey: "vis_mini_idle"); return v > 0 ? v : 0.03 }() + miniDepthOffset = d.object(forKey: "vis_mini_depth") as? Double ?? 8.0 + miniDepthOpacity = d.object(forKey: "vis_mini_depth_opacity") as? Double ?? 0.2 + } + + var effectiveFPS: Double { + ProcessInfo.processInfo.isLowPowerModeEnabled ? min(fps, 24) : fps + } + + // MARK: - Persistence + + private func save(_ key: String, _ value: Any) { + pendingSaves[key] = value + scheduleFlush() + } + + private func saveConfig(_ key: String, _ config: ViewVisualizerConfig) { + if let data = try? JSONEncoder().encode(config) { + pendingSaves[key] = data + scheduleFlush() + } + } + + private func scheduleFlush() { + saveTask?.cancel() + saveTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 500_000_000) + guard !Task.isCancelled, let self else { return } + for (k, v) in self.pendingSaves { + UserDefaults.standard.set(v, forKey: k) + } + self.pendingSaves.removeAll() + } + } + private var pendingSaves: [String: Any] = [:] private var saveTask: Task? } @@ -180,100 +283,94 @@ struct MitsuhaVisualizerView: View { var isSongLoaded: Bool = true let accentColor: Color var compact: Bool = false - /// Set to false when this view is covered by another screen (e.g. NowPlaying is dismissed). - /// Stops FFT work and Canvas execution entirely, cutting CPU/GPU load to near zero. var isVisible: Bool = true @ObservedObject var settings = VisualizerSettings.shared @ObservedObject var albumColors = AlbumColorExtractor.shared - @StateObject private var box = VisualizerLevelBox() - - /// Whether the app is currently in the foreground. @State private var isAppActive = true static let historySize = 16 - /// True only when it's worth doing FFT + Canvas work. - /// Any one of these being false → draw idle state instead. + /// Active view config — mini player uses its own independent config + private var config: ViewVisualizerConfig { + compact ? settings.miniPlayer : settings.nowPlaying + } + private var isRenderingActive: Bool { settings.enabled && isPlaying && isAppActive && isVisible } var body: some View { - // .periodic fires on a fixed wall-clock interval independent of SwiftUI animation state. - // Interval is static — changing it would tear down @StateObject box and reset wave state. - // CACurrentMediaTime() is used for actual dt computation (single consistent clock). - TimelineView(.periodic(from: .now, by: 1.0 / 60.0)) { timeline in + Group { if settings.enabled { - let tickDate = timeline.date // captured by Canvas → forces re-execution - Canvas { context, size in - _ = tickDate - - // Resize scratch buffers if point count changed (rare — settings slider). - // This is the only allocation allowed in this path, and only when count changes. - box.resizeIfNeeded(count: settings.numberOfPoints, - idleAmplitude: Float(settings.idleAmplitude)) - - guard isRenderingActive else { - drawIdleState(ctx: context, size: size) - return - } - - let t = CACurrentMediaTime() - let rawLevels: [Float] = previewLevels ?? AudioPlayer.shared.currentLevels() - updateDisplayLevels(newRawLevels: rawLevels, t: t) - updateWobblePhase(t: t) - - guard box.displayLevels.count >= 2 else { return } - switch settings.style { - case .wave: drawWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset) - case .bar: drawBars(ctx: context, size: size, levels: box.displayLevels) - case .line: drawLine(ctx: context, size: size, levels: box.displayLevels) - case .siriWave: drawSiriWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset) - } - } - .opacity(isPlaying ? 1.0 : (isSongLoaded ? 0.35 : 1.0)) - .animation(isSongLoaded ? .easeInOut(duration: 0.6) : nil, value: isPlaying) - .onAppear { - box.resizeIfNeeded(count: settings.numberOfPoints, - idleAmplitude: Float(settings.idleAmplitude)) - // Seed compact vis from shared cache for morph continuity - if compact, !WaveStateCache.shared.compactLevels.isEmpty { - let cached = WaveStateCache.shared.compactLevels - let count = box.displayLevels.count - for i in 0..= 2 else { return } + switch config.style { + case .wave: drawWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset) + case .bar: drawBars(ctx: context, size: size, levels: box.displayLevels) + case .line: drawLine(ctx: context, size: size, levels: box.displayLevels) + case .siriWave: drawSiriWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset) + } } } - } - .onChange(of: isPlaying) { _, playing in - if !playing { - box.levelHistoryBuf.removeAll(keepingCapacity: true) - box.historyWriteIdx = 0 - box.peakFollower = 0.01 + } else { + // Idle/paused: static Canvas drawn once — zero animation cost, zero CPU + Canvas { context, size in + box.resizeIfNeeded(count: config.numberOfPoints, + idleAmplitude: Float(settings.idleAmplitude)) + drawIdleState(ctx: context, size: size) } } - // App foregrounded — reset lastTickTime so the first dt isn't huge - .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - isAppActive = true - box.lastTickTime = 0 - } - // App backgrounded — stop rendering and purge history - .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in - isAppActive = false - box.levelHistoryBuf.removeAll(keepingCapacity: true) - box.historyWriteIdx = 0 + } + } + .opacity(isPlaying ? 1.0 : (isSongLoaded ? 0.35 : 1.0)) + .animation(isSongLoaded ? .easeInOut(duration: 0.6) : nil, value: isPlaying) + .onAppear { + box.resizeIfNeeded(count: config.numberOfPoints, + idleAmplitude: Float(settings.idleAmplitude)) + if compact, !WaveStateCache.shared.compactLevels.isEmpty { + let cached = WaveStateCache.shared.compactLevels + let count = box.displayLevels.count + for i in 0..= 2 else { return } - switch settings.style { + switch config.style { case .wave: drawWave(ctx: ctx, size: size, levels: box.idleLevels, continuousTime: 0) case .bar: drawBars(ctx: ctx, size: size, levels: box.idleLevels) case .line: drawLine(ctx: ctx, size: size, levels: box.idleLevels) @@ -295,12 +392,12 @@ struct MitsuhaVisualizerView: View { @discardableResult private func updateDisplayLevels(newRawLevels: [Float], t: Double) -> Bool { - let count = settings.numberOfPoints + let count = config.numberOfPoints guard count > 0, !newRawLevels.isEmpty, box.targetLevels.count == count else { return false } let dt = Float(box.lastTickTime > 0 ? min(t - box.lastTickTime, 0.1) : 1.0/60.0) - let sens = Float(settings.sensitivity) + let sens = Float(config.sensitivity) let isPreProcessed = AudioPlayer.shared.isUsingOfflineVis // ── Write directly into pre-allocated box.targetLevels ─────────────── @@ -387,21 +484,22 @@ struct MitsuhaVisualizerView: View { // MARK: - Colors private var fillColors: [Color] { - let a = settings.alpha - switch settings.colorMode { - case .dynamic: return [accentColor.opacity(a), accentColor.opacity(a * 0.6)] + let a = config.alpha + switch config.colorMode { + case .dynamic: return [accentColor.opacity(a), accentColor.opacity(a * 0.6)] case .albumArt: return [albumColors.primaryColor.opacity(a), albumColors.secondaryColor.opacity(a * 0.7)] - case .vibrant: return [.pink.opacity(a), .green.opacity(a), .cyan.opacity(a), .purple.opacity(a), .white.opacity(a * 0.5), .pink.opacity(a)] - case .custom: return [settings.customColor.opacity(a), settings.customColor.opacity(a * 0.6)] + case .vibrant: return [.pink.opacity(a), .green.opacity(a), .cyan.opacity(a), .purple.opacity(a), .white.opacity(a * 0.5), .pink.opacity(a)] + case .custom: return [config.customColor.opacity(a), config.customColor.opacity(a * 0.6)] } } - + private var strokeColor: Color { - switch settings.colorMode { - case .dynamic: return accentColor.opacity(min(1, settings.alpha + 0.3)) - case .albumArt: return albumColors.primaryColor.opacity(min(1, settings.alpha + 0.3)) - case .vibrant: return Color.cyan.opacity(min(1, settings.alpha + 0.3)) - case .custom: return settings.customColor.opacity(min(1, settings.alpha + 0.3)) + let a = min(1, config.alpha + 0.3) + switch config.colorMode { + case .dynamic: return accentColor.opacity(a) + case .albumArt: return albumColors.primaryColor.opacity(a) + case .vibrant: return Color.cyan.opacity(a) + case .custom: return config.customColor.opacity(a) } } @@ -536,7 +634,7 @@ struct MitsuhaVisualizerView: View { fill.addLine(to: CGPoint(x: w, y: h)) fill.closeSubpath() - let isSiri = settings.colorMode == .vibrant + let isSiri = config.colorMode == .vibrant if isSiri { ctx.fill(fill, with: .linearGradient( Gradient(colors: fillColors), @@ -572,7 +670,7 @@ struct MitsuhaVisualizerView: View { let cr = CGFloat(settings.barCornerRadius) let totalGapSpace = gap * CGFloat(count - 1) let barW = max(1, (w - totalGapSpace) / CGFloat(count)) - let isSiri = settings.colorMode == .vibrant + let isSiri = config.colorMode == .vibrant // Hoist fillColors outside loop — was allocating [Color] count times per frame let colors = fillColors @@ -601,7 +699,7 @@ struct MitsuhaVisualizerView: View { guard levels.count >= 2 else { return } let spacing = w / CGFloat(levels.count - 1) let thick = CGFloat(settings.lineThickness) - let isSiri = settings.colorMode == .vibrant + let isSiri = config.colorMode == .vibrant let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift) let offset = compact ? 0 : CGFloat(settings.waveOffsetTop) @@ -671,8 +769,8 @@ struct MitsuhaVisualizerView: View { // Build siri colour gradient once let siriColors: [Color] = [.pink, .purple, .cyan, .green, .pink] - let useVibrant = settings.colorMode == .vibrant - let a = settings.alpha + let useVibrant = config.colorMode == .vibrant + let a = config.alpha for layer in layers { var points: [CGPoint] = [] @@ -716,9 +814,9 @@ struct MitsuhaVisualizerView: View { ), style: style) } else { let c: Color - switch settings.colorMode { + switch config.colorMode { case .albumArt: c = AlbumColorExtractor.shared.primaryColor - case .custom: c = settings.customColor + case .custom: c = config.customColor default: c = accentColor } layerCtx.stroke(curve, with: .color(c.opacity(layer.opacity * a)), style: style) @@ -750,105 +848,94 @@ struct CompactVisualizerView: View { struct VisualizerSettingsView: View { @ObservedObject var settings = VisualizerSettings.shared @Environment(\.dismiss) private var dismiss - + private let pink = Color(red: 1.0, green: 0.176, blue: 0.333) - + var body: some View { NavigationStack { Form { + // ── Master toggles ─────────────────────────────────────────── Section { Toggle("Enabled", isOn: $settings.enabled).tint(pink) Toggle("Now Playing Screen", isOn: $settings.nowPlayingEnabled).tint(pink).disabled(!settings.enabled) - Toggle("Mini Player", isOn: $settings.miniPlayerEnabled).tint(pink).disabled(!settings.enabled) + Toggle("Mini Player", isOn: $settings.miniPlayerEnabled).tint(pink).disabled(!settings.enabled) } header: { Text("VISUALIZER") } footer: { Text("Master toggle disables all visualizers. Individual toggles control each location.") } - - Section { - Picker("Style", selection: $settings.style) { - ForEach(VisualizerSettings.Style.allCases, id: \.self) { Text($0.rawValue).tag($0) } - }.pickerStyle(.segmented) - sliderRow("Number of points", value: $settings.numberOfPoints, range: 4...24, step: 1) - } header: { Text("STYLE") } footer: { - Text("Points control how many data points the wave is built from. Fewer points = bigger, rounder ocean swells. More points = detailed, ripply surface. 8–12 is a good starting point.") - } - - if settings.style == .wave { - Section { - sliderRowDouble("Stroke thickness", value: $settings.waveStrokeThickness, range: 0.5...5.0, step: 0.5, format: "%.1f") - } header: { Text("WAVE") } footer: { - Text("The white line on top of the wave fill. At 0.5, barely visible. At 3+, a bold outline.") - } - } - if settings.style == .bar { - Section { - sliderRowDouble("Bar spacing", value: $settings.barSpacing, range: 0...20, step: 1, format: "%.0f") - sliderRowDouble("Corner radius", value: $settings.barCornerRadius, range: 0...15, step: 1, format: "%.0f") - } header: { Text("BAR") } footer: { - Text("Spacing controls the gap between bars. Corner radius rounds the bar tops — at 0 they're sharp rectangles, at 15 they're rounded pills.") - } - } - if settings.style == .line { - Section { - sliderRowDouble("Line thickness", value: $settings.lineThickness, range: 1...12, step: 0.5, format: "%.1f") - } header: { Text("LINE") } footer: { - Text("How thick the oscillating line is. Includes an automatic glow effect that scales with thickness.") - } - } - - Section { - Picker("Color", selection: $settings.colorMode) { - ForEach(VisualizerSettings.ColorMode.allCases, id: \.self) { Text($0.rawValue).tag($0) } - }.pickerStyle(.segmented) - sliderRowDouble("Color alpha", value: $settings.alpha, range: 0.1...1.0, step: 0.05, format: "%.2f") - if settings.colorMode == .custom { ColorPicker("Wave Color", selection: $settings.customColor) } - } header: { Text("COLOR") } footer: { - switch settings.colorMode { - case .dynamic: Text("Uses the app's accent color.") - case .albumArt: Text("Extracts dominant and secondary colors from the current album cover. The wave changes color with every song.") - case .vibrant: Text("Rainbow gradient (pink → purple → cyan → green) applied horizontally. Also used for the Siri wave style's multi-layer colours.") - case .custom: Text("Pick any color with the color picker above.") - } - } - + + // ── Per-view independent configs ───────────────────────────── Section { NavigationLink { - NowPlayingVisSettingsView(settings: settings) + ViewConfigSettingsView( + title: "Now Playing", + config: $settings.nowPlaying, + amplitude: $settings.npAmplitude, + baseLift: $settings.npBaseLift, + waveOffset: $settings.waveOffsetTop, + depthOffset: $settings.depthOffset, + depthOpacity: $settings.depthOpacity, + idleAmplitude: $settings.idleAmplitude, + heightPct: $settings.nowPlayingHeightPct, + isCompact: false + ) } label: { HStack { Image(systemName: "play.rectangle.fill").foregroundColor(pink).frame(width: 28) Text("Now Playing") Spacer() - Text("\(Int(settings.npAmplitude * 100))% amp").font(.caption).foregroundColor(.gray) + Text(settings.nowPlaying.style.rawValue) + .font(.caption).foregroundColor(.gray) } } NavigationLink { - MiniPlayerVisSettingsView(settings: settings) + ViewConfigSettingsView( + title: "Mini Player", + config: $settings.miniPlayer, + amplitude: $settings.miniAmplitude, + baseLift: .constant(0), + waveOffset: .constant(0), + depthOffset: $settings.miniDepthOffset, + depthOpacity: $settings.miniDepthOpacity, + idleAmplitude: $settings.miniIdleAmplitude, + heightPct: .constant(0), + isCompact: true + ) } label: { HStack { Image(systemName: "rectangle.bottomhalf.filled").foregroundColor(pink).frame(width: 28) Text("Mini Player") Spacer() - Text("\(Int(settings.miniAmplitude * 100))% amp").font(.caption).foregroundColor(.gray) + Text(settings.miniPlayer.style.rawValue) + .font(.caption).foregroundColor(.gray) } } } header: { Text("PER-VIEW SETTINGS") } footer: { - Text("Each view has its own amplitude, depth, idle, and layout controls. Tap to configure individually.") + Text("Each view has its own style, color, point count, sensitivity, amplitude, and depth. Changes in one view never affect the other.") } - + + // ── Style-specific global params ───────────────────────────── Section { - sliderRowDouble("Viscosity", value: $settings.viscosity, range: 0.05...1.0, step: 0.01, format: "%.2f") - sliderRow("Frequency cutoff", value: $settings.frequencyCutoff, range: 40...150, step: 5) - sliderRowDouble("Base multiplier", value: $settings.baseMultiplier, range: 5.0...50.0, step: 1.0, format: "%.0f") - sliderRowDouble("Sensitivity", value: $settings.sensitivity, range: 0.1...2.0, step: 0.1, format: "%.1f") - sliderRowDouble("FPS", value: $settings.fps, range: 15...60, step: 1, format: "%.0f") - Toggle("Real Audio Analysis", isOn: $settings.realAudioAnalysis).tint(pink) - Toggle("Dynamic Gain", isOn: $settings.dynamicGainEnabled).tint(pink) - } header: { Text("SHARED / ADVANCED") } footer: { - Text("Viscosity: 0.10–0.20 = heavy, slow liquid (Mitsuha). 0.4+ = snappy EQ. This is the most important slider.\n\nSensitivity: multiplies incoming audio. 0.8–1.0 is the sweet spot. Above 1.5 causes the wave to clip at the top.\n\nBase Multiplier: overall gain for FFT data. 15–25 works well. Higher values are needed for quieter recordings.\n\nFrequency Cutoff: limits how many FFT bins are used. 60–80 gives mostly bass and mids. 100+ adds treble detail.\n\nDynamic Gain: recommended ON — normalises amplitude across loud and quiet tracks.") + sliderRowDouble("Wave stroke", value: $settings.waveStrokeThickness, range: 0.5...5.0, step: 0.5, format: "%.1f") + sliderRowDouble("Bar spacing", value: $settings.barSpacing, range: 0...20, step: 1, format: "%.0f") + sliderRowDouble("Bar radius", value: $settings.barCornerRadius, range: 0...15, step: 1, format: "%.0f") + sliderRowDouble("Line thickness",value: $settings.lineThickness, range: 1...12, step: 0.5, format: "%.1f") + } header: { Text("STYLE GLOBALS") } footer: { + Text("These shape parameters apply globally to their respective styles across both views.") } - - // Presets + + // ── Shared physics ──────────────────────────────────────────── + Section { + sliderRowDouble("Viscosity", value: $settings.viscosity, range: 0.05...1.0, step: 0.01, format: "%.2f") + sliderRow( "Frequency cutoff",value: $settings.frequencyCutoff, range: 40...150, step: 5) + sliderRowDouble("Base multiplier", value: $settings.baseMultiplier, range: 5.0...50.0, step: 1.0, format: "%.0f") + sliderRowDouble("FPS", value: $settings.fps, range: 15...60, step: 1, format: "%.0f") + Toggle("Real Audio Analysis", isOn: $settings.realAudioAnalysis).tint(pink) + Toggle("Dynamic Gain", isOn: $settings.dynamicGainEnabled).tint(pink) + } header: { Text("SHARED PHYSICS") } footer: { + Text("Viscosity: 0.10–0.20 = heavy ocean swell. 0.4+ = snappy EQ. Base Multiplier: 15–25 for most music. Dynamic Gain normalises amplitude across loud and quiet tracks.") + } + + // ── Presets ─────────────────────────────────────────────────── Section { Button(action: applyDeepOcean) { HStack { @@ -887,9 +974,9 @@ struct VisualizerSettingsView: View { } } } header: { Text("PRESETS") } footer: { - Text("Apply a preset to set multiple sliders at once. You can fine-tune afterwards.") + Text("Presets apply to the Now Playing view. Fine-tune in Per-View Settings afterwards.") } - + Section { Button("Reset to Defaults", role: .destructive) { resetDefaults() } } } .navigationTitle("Visualizer") @@ -901,28 +988,36 @@ struct VisualizerSettingsView: View { } } } - + // MARK: - Presets - + private func applyDeepOcean() { - settings.numberOfPoints = 6; settings.viscosity = 0.12; settings.sensitivity = 0.8 - settings.npAmplitude = 0.75; settings.depthOffset = 30; settings.frequencyCutoff = 50; settings.baseMultiplier = 22 + settings.nowPlaying.wavePoints = 6 + settings.viscosity = 0.12; settings.baseMultiplier = 22; settings.frequencyCutoff = 50 + settings.npAmplitude = 0.75; settings.depthOffset = 30 + settings.nowPlaying.waveSensitivity = 0.8 } private func applyReactiveEQ() { - settings.numberOfPoints = 18; settings.viscosity = 0.6; settings.sensitivity = 1.4 - settings.npAmplitude = 0.5; settings.depthOffset = 5; settings.frequencyCutoff = 120; settings.baseMultiplier = 20 + settings.nowPlaying.barPoints = 18; settings.nowPlaying.style = .bar + settings.viscosity = 0.6; settings.baseMultiplier = 20; settings.frequencyCutoff = 120 + settings.npAmplitude = 0.5; settings.depthOffset = 5 + settings.nowPlaying.barSensitivity = 1.4 } private func applySubtleAmbient() { - settings.numberOfPoints = 8; settings.viscosity = 0.18; settings.sensitivity = 0.7 - settings.npAmplitude = 0.25; settings.alpha = 0.3; settings.idleAmplitude = 0.04; settings.baseMultiplier = 15 + settings.nowPlaying.wavePoints = 8; settings.nowPlaying.style = .wave + settings.viscosity = 0.18; settings.baseMultiplier = 15; settings.idleAmplitude = 0.04 + settings.npAmplitude = 0.25; settings.nowPlaying.alpha = 0.3 + settings.nowPlaying.waveSensitivity = 0.7 } private func applyHeavyMercury() { - settings.numberOfPoints = 10; settings.viscosity = 0.12; settings.sensitivity = 2.5 - settings.baseMultiplier = 60; settings.npAmplitude = 0.6; settings.depthOffset = 20 + settings.nowPlaying.wavePoints = 10; settings.nowPlaying.style = .wave + settings.viscosity = 0.12; settings.baseMultiplier = 60 + settings.npAmplitude = 0.6; settings.depthOffset = 20 + settings.nowPlaying.waveSensitivity = 2.5 } - - // MARK: - Slider Helpers - + + // MARK: - Slider helpers + func sliderRow(_ label: String, value: Binding, range: ClosedRange, step: Int) -> some View { VStack(alignment: .leading, spacing: 4) { Text(label).font(.body) @@ -933,7 +1028,7 @@ struct VisualizerSettingsView: View { } } } - + func sliderRowDouble(_ label: String, value: Binding, range: ClosedRange, step: Double, format: String) -> some View { VStack(alignment: .leading, spacing: 4) { Text(label).font(.body) @@ -943,68 +1038,146 @@ struct VisualizerSettingsView: View { } } } - - var previewLevels: [Float] { (0..<30).map { Float(0.2 + 0.5 * sin(Float($0) * 0.4)) } } - + private func resetDefaults() { + settings.nowPlaying = .nowPlayingDefault + settings.miniPlayer = .miniPlayerDefault settings.enabled = true; settings.nowPlayingEnabled = true; settings.miniPlayerEnabled = true - settings.style = .wave; settings.numberOfPoints = 9 settings.fps = 60; settings.realAudioAnalysis = true; settings.dynamicGainEnabled = true - settings.waveOffsetTop = 0 - settings.barSpacing = 5; settings.barCornerRadius = 0; settings.lineThickness = 5 - settings.colorMode = .dynamic; settings.alpha = 0.6 - // Tuned for Mitsuha feel: slow liquid viscosity, low sensitivity, moderate gain - settings.viscosity = 0.17; settings.sensitivity = 0.9 - settings.frequencyCutoff = 75; settings.baseMultiplier = 18.0 - settings.depthOffset = 14.0; settings.depthOpacity = 0.18; settings.idleAmplitude = 0.03 - settings.waveStrokeThickness = 1.5; settings.nowPlayingHeightPct = 0.50; settings.miniPlayerHeight = 48.0 - settings.miniOpacity = 0.5; settings.miniAmplitude = 0.7; settings.miniIdleAmplitude = 0.03 - settings.miniDepthOffset = 8.0; settings.miniDepthOpacity = 0.2 + settings.waveStrokeThickness = 1.5; settings.barSpacing = 5; settings.barCornerRadius = 0; settings.lineThickness = 5 + settings.viscosity = 0.17; settings.frequencyCutoff = 75; settings.baseMultiplier = 18.0 + settings.waveOffsetTop = 0; settings.nowPlayingHeightPct = 0.50 settings.npAmplitude = 0.50; settings.npBaseLift = 130.0 + settings.depthOffset = 14.0; settings.depthOpacity = 0.18; settings.idleAmplitude = 0.03 + settings.miniPlayerHeight = 48.0; settings.miniOpacity = 0.5 + settings.miniAmplitude = 0.7; settings.miniIdleAmplitude = 0.03 + settings.miniDepthOffset = 8.0; settings.miniDepthOpacity = 0.2 } } -// MARK: - Now Playing Sub-Settings -struct NowPlayingVisSettingsView: View { - @ObservedObject var settings: VisualizerSettings +// MARK: - Per-View Config Settings (Now Playing & Mini Player share this) +struct ViewConfigSettingsView: View { + let title: String + @Binding var config: ViewVisualizerConfig + @Binding var amplitude: Double + @Binding var baseLift: Double + @Binding var waveOffset: Double + @Binding var depthOffset: Double + @Binding var depthOpacity: Double + @Binding var idleAmplitude:Double + @Binding var heightPct: Double + var isCompact: Bool + + @ObservedObject private var settings = VisualizerSettings.shared private let pink = Color(red: 1.0, green: 0.176, blue: 0.333) - + var body: some View { Form { + // ── Style ───────────────────────────────────────────────────────── Section { - VStack(alignment: .leading, spacing: 4) { - Text("Screen height %").font(.body) - HStack { - Slider(value: $settings.nowPlayingHeightPct, in: 0.2...0.8, step: 0.05).tint(pink) - Text("\(Int(settings.nowPlayingHeightPct * 100))%").foregroundColor(.gray).frame(width: 52, alignment: .trailing) + Picker("Style", selection: $config.style) { + ForEach(VisualizerSettings.Style.allCases, id: \.self) { Text($0.rawValue).tag($0) } + }.pickerStyle(.segmented) + + // Per-style point count — independent, not shared with other style + sd("Points (\(config.style.rawValue))", + value: Binding( + get: { Double(config.numberOfPoints) }, + set: { config.numberOfPoints = Int($0) } + ), + range: 4...24, step: 1, format: "%.0f") + + // Per-style sensitivity + sd("Sensitivity (\(config.style.rawValue))", + value: Binding( + get: { config.sensitivity }, + set: { config.sensitivity = $0 } + ), + range: 0.1...2.0, step: 0.1, format: "%.1f") + + } header: { Text("STYLE") } footer: { + Text("Each style remembers its own point count and sensitivity independently. Switching styles never overwrites another style's values.") + } + + // ── Color ───────────────────────────────────────────────────────── + Section { + Picker("Color", selection: $config.colorMode) { + ForEach(VisualizerSettings.ColorMode.allCases, id: \.self) { Text($0.rawValue).tag($0) } + }.pickerStyle(.segmented) + sd("Alpha", value: $config.alpha, range: 0.1...1.0, step: 0.05, format: "%.2f") + if config.colorMode == .custom { + ColorPicker("Wave Color", selection: Binding( + get: { config.customColor }, + set: { c in + if let components = UIColor(c).cgColor.components, components.count >= 3 { + config.customColorR = Double(components[0]) + config.customColorG = Double(components[1]) + config.customColorB = Double(components[2]) + } + } + )) + } + } header: { Text("COLOR") } + + // ── Amplitude & Layout ──────────────────────────────────────────── + Section { + if !isCompact { + VStack(alignment: .leading, spacing: 4) { + Text("Screen height %").font(.body) + HStack { + Slider(value: $heightPct, in: 0.2...0.8, step: 0.05).tint(pink) + Text("\(Int(heightPct * 100))%").foregroundColor(.gray).frame(width: 52, alignment: .trailing) + } } } - sd("Amplitude", value: $settings.npAmplitude, range: 0.1...1.0, step: 0.05, format: "%.2f") - sd("Base lift (from bottom)", value: $settings.npBaseLift, range: 0...300, step: 5, format: "%.0f pt") - sd("Wave offset (top)", value: $settings.waveOffsetTop, range: -100...300, step: 5, format: "%.0f") - } header: { Text("LAYOUT & AMPLITUDE") } footer: { - Text("Screen height controls how much of the Now Playing screen the visualizer fills. At 50%, it covers the bottom half. At 70%, it reaches behind the album art.\n\nAmplitude controls how tall wave peaks are. At 0.45, peaks reach about halfway. Push to 0.8+ for dramatic waves. Drop to 0.2 for a subtle accent.\n\nBase lift moves the wave baseline up from the very bottom of the screen. At 130 (default), the wave sits above the transport controls. Set to 0 for the absolute bottom edge.\n\nWave offset adds additional vertical shift on top of base lift. Use negative values to push down, positive to push up.") - } + sd("Amplitude", value: $amplitude, range: 0.1...1.0, step: 0.05, format: "%.2f") + if !isCompact { + sd("Base lift (from bottom)", value: $baseLift, range: 0...300, step: 5, format: "%.0f pt") + sd("Wave offset (top)", value: $waveOffset, range: -100...300, step: 5, format: "%.0f") + } + } header: { Text("LAYOUT & AMPLITUDE") } + + // ── Depth & Idle ────────────────────────────────────────────────── Section { - sd("Depth offset", value: $settings.depthOffset, range: 0...50, step: 2, format: "%.0f") - sd("Depth opacity", value: $settings.depthOpacity, range: 0.0...0.5, step: 0.05, format: "%.2f") - sd("Idle amplitude", value: $settings.idleAmplitude, range: 0.0...0.10, step: 0.005, format: "%.3f") - } header: { Text("DEPTH & IDLE") } footer: { - Text("Depth offset controls how far below the main wave the shadow layer is drawn. At 15, there's visible parallax. At 0, they overlap. At 40+, the shadow looks like a distant reflection.\n\nDepth opacity sets how visible the shadow wave is. At 0.2, it's subtle. At 0, invisible.\n\nIdle amplitude is the minimum wave height during quiet moments. Prevents the wave from going completely flat. At 0.03, there's always gentle surface tension.") - } + sd("Depth offset", value: $depthOffset, range: 0...50, step: 2, format: "%.0f") + sd("Depth opacity", value: $depthOpacity, range: 0.0...0.5, step: 0.05, format: "%.2f") + sd("Idle amplitude",value: $idleAmplitude, range: 0.0...0.15, step: 0.005, format: "%.3f") + } header: { Text("DEPTH & IDLE") } + + // ── Preview ─────────────────────────────────────────────────────── Section { ZStack { - Color.black - MitsuhaVisualizerView(previewLevels: previewLevels, isPlaying: true, accentColor: pink) + Color(white: isCompact ? 0.12 : 0.0) + MitsuhaVisualizerView(previewLevels: previewLevels, isPlaying: true, + accentColor: pink, compact: isCompact) + .frame(height: isCompact ? settings.miniPlayerHeight : 180) + .opacity(isCompact ? settings.miniOpacity : 1.0) + if isCompact { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 4).fill(Color.gray.opacity(0.3)).frame(width: 40, height: 40) + VStack(alignment: .leading, spacing: 2) { + Text("Song Title").font(.system(size: 14, weight: .medium)).foregroundColor(.white) + Text("Artist").font(.system(size: 12)).foregroundColor(.gray) + } + Spacer() + Image(systemName: "pause.fill").foregroundColor(.white) + Image(systemName: "forward.fill").foregroundColor(.white) + }.padding(.horizontal, 12) + } } - .frame(height: 180).listRowInsets(EdgeInsets()).listRowBackground(Color.black) + .frame(height: isCompact ? 64 : 180) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color(white: isCompact ? 0.12 : 0.0)) } header: { Text("PREVIEW") } } - .navigationTitle("Now Playing").navigationBarTitleDisplayMode(.inline) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) } - - private var previewLevels: [Float] { (0..<30).map { Float(0.2 + 0.5 * sin(Float($0) * 0.4)) } } - + + private var previewLevels: [Float] { + (0..<30).map { Float(0.2 + 0.5 * sin(Float($0) * 0.4)) } + } + private func sd(_ label: String, value: Binding, range: ClosedRange, step: Double, format: String) -> some View { VStack(alignment: .leading, spacing: 4) { Text(label).font(.body) @@ -1016,59 +1189,39 @@ struct NowPlayingVisSettingsView: View { } } -// MARK: - Mini Player Sub-Settings -struct MiniPlayerVisSettingsView: View { +// MARK: - Legacy sub-settings views (kept for backward compat, now redirect to unified view) +struct NowPlayingVisSettingsView: View { @ObservedObject var settings: VisualizerSettings - private let pink = Color(red: 1.0, green: 0.176, blue: 0.333) - var body: some View { - Form { - Section { - sd("Height (points)", value: $settings.miniPlayerHeight, range: 24...80, step: 4, format: "%.0f pt") - sd("Amplitude", value: $settings.miniAmplitude, range: 0.1...2.0, step: 0.05, format: "%.2f") - sd("Opacity", value: $settings.miniOpacity, range: 0.1...1.0, step: 0.05, format: "%.2f") - } header: { Text("LAYOUT & AMPLITUDE") } footer: { - Text("Height is the actual size of the mini player visualizer frame in points. Larger values give the wave more room.\n\nAmplitude is higher than Now Playing by default (0.7) because the mini player is much smaller. Push to 1.5+ if you want the wave to fill the entire height.\n\nOpacity controls transparency behind the mini player controls. At 0.5, the wave is visible but song title and buttons are readable. At 1.0, fully opaque. At 0.2, a subtle shimmer.") - } - Section { - sd("Depth offset", value: $settings.miniDepthOffset, range: 0...30, step: 1, format: "%.0f") - sd("Depth opacity", value: $settings.miniDepthOpacity, range: 0.0...0.5, step: 0.05, format: "%.2f") - sd("Idle amplitude", value: $settings.miniIdleAmplitude, range: 0.0...0.15, step: 0.005, format: "%.3f") - } header: { Text("DEPTH & IDLE") } footer: { - Text("Depth offset controls the shadow wave distance. Keep lower than Now Playing since the mini player is smaller — 8 is a good default.\n\nIdle amplitude prevents the wave from flatlining during quiet moments.") - } - Section { - ZStack { - Color(white: 0.12) - MitsuhaVisualizerView(previewLevels: previewLevels, isPlaying: true, accentColor: pink, compact: true) - .frame(height: settings.miniPlayerHeight) - .opacity(settings.miniOpacity) - HStack(spacing: 12) { - RoundedRectangle(cornerRadius: 4).fill(Color.gray.opacity(0.3)).frame(width: 40, height: 40) - VStack(alignment: .leading, spacing: 2) { - Text("Song Title").font(.system(size: 14, weight: .medium)).foregroundColor(.white) - Text("Artist").font(.system(size: 12)).foregroundColor(.gray) - } - Spacer() - Image(systemName: "pause.fill").foregroundColor(.white) - Image(systemName: "forward.fill").foregroundColor(.white) - }.padding(.horizontal, 12) - } - .frame(height: 64).listRowInsets(EdgeInsets()).listRowBackground(Color(white: 0.12)) - } header: { Text("PREVIEW") } - } - .navigationTitle("Mini Player").navigationBarTitleDisplayMode(.inline) - } - - private var previewLevels: [Float] { (0..<30).map { Float(0.2 + 0.5 * sin(Float($0) * 0.4)) } } - - private func sd(_ label: String, value: Binding, range: ClosedRange, step: Double, format: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(label).font(.body) - HStack { - Slider(value: value, in: range, step: step).tint(pink) - Text(String(format: format, value.wrappedValue)).foregroundColor(.gray).frame(width: 52, alignment: .trailing) - } - } + ViewConfigSettingsView( + title: "Now Playing", + config: $settings.nowPlaying, + amplitude: $settings.npAmplitude, + baseLift: $settings.npBaseLift, + waveOffset: $settings.waveOffsetTop, + depthOffset: $settings.depthOffset, + depthOpacity: $settings.depthOpacity, + idleAmplitude: $settings.idleAmplitude, + heightPct: $settings.nowPlayingHeightPct, + isCompact: false + ) + } +} + +struct MiniPlayerVisSettingsView: View { + @ObservedObject var settings: VisualizerSettings + var body: some View { + ViewConfigSettingsView( + title: "Mini Player", + config: $settings.miniPlayer, + amplitude: $settings.miniAmplitude, + baseLift: .constant(0), + waveOffset: .constant(0), + depthOffset: $settings.miniDepthOffset, + depthOpacity: $settings.miniDepthOpacity, + idleAmplitude: $settings.miniIdleAmplitude, + heightPct: .constant(0), + isCompact: true + ) } }