From fba2da4700f708c11d6ab6fb89f52958d96e3afe Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Fri, 17 Apr 2026 09:58:05 -0700 Subject: [PATCH] UI scaling on scrollabile windows --- Shared/API/SubsonicClient.swift | 28 + Shared/Audio/AudioPlayer.swift | 120 +++- Shared/Models/Models.swift | 31 + companion-api/main.py | 592 +++++++++++++++++- iOS/App/NavidromePlayerApp.swift | 7 + iOS/Data/AudioTapProcessor.swift | 422 +++++++++++++ iOS/Views/Common/MainTabView.swift | 58 +- .../Companion/BatchAlbumEditorSheet.swift | 35 ++ iOS/Views/Companion/CompanionAPIService.swift | 157 ++++- iOS/Views/Companion/EditHistoryView.swift | 10 +- .../Companion/MultiAlbumEditorSheet.swift | 34 + iOS/Views/Library/AlbumDetailView.swift | 2 +- iOS/Views/Library/ArtistDetailView.swift | 4 +- iOS/Views/Library/DownloadsSettingsView.swift | 26 +- iOS/Views/Library/LicensesView.swift | 2 + iOS/Views/Library/MyMusicView.swift | 260 +++++++- iOS/Views/Library/PlaylistsView.swift | 2 +- iOS/Views/Library/RadioView.swift | 1 + iOS/Views/Library/SearchView.swift | 4 +- iOS/Views/Lyrics/LyricsManager.swift | 23 +- iOS/Views/Lyrics/LyricsOverlayView.swift | 140 ++++- iOS/Views/NowPlaying/NowPlayingView.swift | 232 ++++--- .../Visualizer/MitsuhaVisualizerView.swift | 86 +++ project.yml | 2 +- 24 files changed, 2123 insertions(+), 155 deletions(-) create mode 100644 iOS/Data/AudioTapProcessor.swift diff --git a/Shared/API/SubsonicClient.swift b/Shared/API/SubsonicClient.swift index 185f8de..a2f68a3 100644 --- a/Shared/API/SubsonicClient.swift +++ b/Shared/API/SubsonicClient.swift @@ -329,6 +329,34 @@ class SubsonicClient: ObservableObject { return body.internetRadioStations?.internetRadioStation ?? [] } + // MARK: - Play Queue (cross-device sync) + + /// Save the current play queue to the server for cross-device resume. + func savePlayQueue(songIds: [String], current: String? = nil, position: Int64 = 0) async throws { + var params: [URLQueryItem] = songIds.map { URLQueryItem(name: "id", value: $0) } + if let c = current { params.append(URLQueryItem(name: "current", value: c)) } + params.append(URLQueryItem(name: "position", value: "\(position)")) + _ = try await requestBody(endpoint: "savePlayQueue", params: params) + } + + /// Get the saved play queue from the server (saved by any device). + func getPlayQueue() async throws -> PlayQueue? { + let body = try await requestBody(endpoint: "getPlayQueue") + return body.playQueue + } + + // MARK: - Shares (public links) + + /// Create a sharing link for a song, album, or playlist. + /// Returns the created share, or nil if the server doesn't support sharing. + func createShare(id: String, description: String? = nil, expires: Int64? = nil) async throws -> ShareItem? { + var params = [URLQueryItem(name: "id", value: id)] + if let d = description { params.append(URLQueryItem(name: "description", value: d)) } + if let e = expires { params.append(URLQueryItem(name: "expires", value: "\(e)")) } + let body = try await requestBody(endpoint: "createShare", params: params) + return body.shares?.share?.first + } + } // MARK: - API Errors diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 1049c31..739f8d0 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -152,11 +152,13 @@ class AudioPlayer: NSObject, ObservableObject { // Stop all visualizer timers when backgrounded — they serve no purpose without // a visible Canvas and burn CPU (causing XPC_EXIT_REASON_FAULT at ~169% CPU). NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) + .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.suspendVisTimers() } .store(in: &cancellables) // Restart the correct timer when returning to foreground NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.resumeVisTimers() } .store(in: &cancellables) @@ -165,12 +167,14 @@ class AudioPlayer: NSObject, ObservableObject { // other apps taking the audio session. Without this, playback stops but // isPlaying stays true — timers keep firing and music never auto-resumes. NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification) + .receive(on: DispatchQueue.main) .sink { [weak self] notification in self?.handleAudioInterruption(notification) } .store(in: &cancellables) // ── Audio route change (headphones unplugged etc.) ──────────────────── // Apple HIG: pause on oldDeviceUnavailable (headphones pulled out). NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) + .receive(on: DispatchQueue.main) .sink { [weak self] notification in self?.handleRouteChange(notification) } .store(in: &cancellables) #endif @@ -238,6 +242,12 @@ class AudioPlayer: NSObject, ObservableObject { analysisTask = nil AudioPreFetcher.shared.cancelAll() removeTimeObserver() + #if os(iOS) + // Remove audio tap on background — no point running FFT when no one can see it + if isRadioStream { + AudioTapProcessor.shared.removeTap(from: playerItem) + } + #endif // SmartCrossfadeManager has its own 10Hz time observer that writes // @Published currentTime/duration — same SwiftUI churn as AudioPlayer's if isUsingCrossfade { @@ -296,6 +306,11 @@ class AudioPlayer: NSObject, ObservableObject { if isUsingOfflineVis { startOfflineVisSync() + } else if isRadioStream { + #if os(iOS) + // Reinstall the audio tap that was removed on background + installAudioTapIfNeeded(on: playerItem) + #endif } else if !isUsingCrossfade && !VisualizerSettings.shared.realAudioAnalysis { startLevelSimulation() } @@ -405,6 +420,7 @@ class AudioPlayer: NSObject, ObservableObject { radioStreamURL = nil #if os(iOS) RadioStreamBuffer.shared.stopBuffering() + AudioTapProcessor.shared.removeTap(from: playerItem) isPlayingFromBuffer = false #endif } @@ -669,6 +685,7 @@ class AudioPlayer: NSObject, ObservableObject { let asset = AVURLAsset(url: snapURL) let item = AVPlayerItem(asset: asset) + playerItem = item // Keep self.playerItem in sync — taps and Shazam depend on this // Auto-return to live when snapshot playback reaches the end snapshotEndObserver = NotificationCenter.default.addObserver( @@ -679,6 +696,9 @@ class AudioPlayer: NSObject, ObservableObject { } player?.replaceCurrentItem(with: item) + #if os(iOS) + installAudioTapIfNeeded(on: item) + #endif // KVO on status — more reliable than polling; fires immediately on failure too snapshotStatusObservation = item.observe(\.status, options: [.new, .initial]) { [weak self] observedItem, _ in @@ -727,8 +747,24 @@ class AudioPlayer: NSObject, ObservableObject { let asset = AVURLAsset(url: liveURL) let item = AVPlayerItem(asset: asset) + playerItem = item // Keep self.playerItem in sync — taps and Shazam depend on this player?.replaceCurrentItem(with: item) player?.play() + #if os(iOS) + // Start simulation immediately — tap installs when stream reaches readyToPlay + startRadioSimulation() + let liveItem = item + Task { @MainActor [weak self] in + for await status in liveItem.publisher(for: \.status).values { + guard let self, self.playerItem === liveItem else { break } + if status == .readyToPlay { + self.installAudioTapIfNeeded(on: liveItem) + break + } + if status == .failed { break } + } + } + #endif alog("Radio: ▶ live") } @@ -802,6 +838,7 @@ class AudioPlayer: NSObject, ObservableObject { // errors that AVPlayer swallows silently otherwise #if os(iOS) let itemToObserve = playerItem! + let isRadio = isRadioStream Task { @MainActor [weak self] in for await status in itemToObserve.publisher(for: \.status).values { guard let self = self, self.playerItem === itemToObserve else { break } @@ -813,6 +850,12 @@ class AudioPlayer: NSObject, ObservableObject { category: "Audio", level: .error) case .readyToPlay: DebugLogger.shared.log("✓ Ready: \(url.lastPathComponent)", category: "Audio") + // Install audio tap AFTER the stream has connected and the asset + // has parsed its container. Before readyToPlay, loadTracks returns + // empty for live streams → tap silently fails. + if isRadio { + self.installAudioTapIfNeeded(on: itemToObserve) + } default: break } @@ -871,9 +914,9 @@ class AudioPlayer: NSObject, ObservableObject { updateNowPlayingInfo() fetchAndSetArtwork(coverArtId: currentSong?.coverArt) - // Radio streams use a smooth sinusoidal simulation — random phase - // simulation reacts to currentTime jumps (buffer seeks) causing - // the peakFollower to spike and "raise" the wave unexpectedly. + // Radio streams: start simulation immediately for visual feedback while + // the stream connects. The readyToPlay status observer (above) will install + // the real FFT audio tap once the asset has loaded its tracks. #if os(iOS) if isRadioStream { startRadioSimulation() @@ -1062,6 +1105,21 @@ class AudioPlayer: NSObject, ObservableObject { } func resume() { + // Cold restore: app was killed, queue restored from PlaybackStateStore, + // but AVPlayer was never created. player?.play() would be a nil no-op. + // Detect this and go through the full play(song:) path, then seek to + // the saved position so playback resumes where the user left off. + if player == nil, let song = currentSong { + let savedPosition = currentTime + alog("Cold resume: loading \(song.title) at \(Int(savedPosition))s") + play(song: song) + // AVPlayer queues seeks — this executes once the item is ready + if savedPosition > 1 { + seek(to: savedPosition) + } + return + } + #if os(iOS) if isUsingCrossfade { SmartCrossfadeManager.shared.resume() @@ -1733,6 +1791,57 @@ class AudioPlayer: NSObject, ObservableObject { } #endif + // MARK: - Real-time FFT via Audio Tap (radio streams) + + #if os(iOS) + /// Install the shared audio tap on a player item for real-time FFT visualization. + /// Async because `loadTracks(withMediaType:)` is the modern non-deprecated API. + /// Falls back to sinusoidal simulation if tap installation fails. + func installAudioTapIfNeeded(on item: AVPlayerItem?) { + guard let item else { + startRadioSimulation() + return + } + Task { @MainActor in + let success = await AudioTapProcessor.shared.installTap(on: item) + if success { + startRadioFFT() + } else { + alog("Audio tap failed — falling back to simulation") + startRadioSimulation() + } + } + } + + /// Timer that reads real PCM samples from the audio tap ring buffer, + /// runs vDSP FFT, and feeds 30 frequency bands to the visualizer. + /// Replaces startRadioSimulation() when the tap is active. + private func startRadioFFT() { + stopLevelTimer() + alog("Radio FFT: ▶ real-time analysis active") + levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in + guard let self else { return } + guard VisualizerSettings.shared.enabled else { return } + guard self.isPlaying else { + if self.internalLevels.contains(where: { $0 > 0.01 }) { + for i in 0.. str: + return hashlib.md5(full_path.encode()).hexdigest() + +def _backup_key_rel(relative_path: str) -> str: + """Secondary key based on relative path — survives full_path changes from restructure.""" + return "rel_" + hashlib.md5(relative_path.encode()).hexdigest() + +def backup_tags(full_path: str) -> Optional[str]: + """Snapshot all current tags to a JSON file before any write. + + Creates TWO backup files with different keys: + 1. Keyed by full_path (fast lookup when path hasn't changed) + 2. Keyed by relative_path (fallback after restructure moves the file) + + Both files contain identical data. This ensures undo works even after + /bulk-fix restructures files to new directories. + + Handles AIFF files specially (easy=True returns None for AIFF). + Returns the primary backup file path, or None on failure. + """ + try: + ext = Path(full_path).suffix.lower() + + if ext in ('.aiff', '.aif'): + tags = _read_aiff_tags_for_backup(full_path) + if tags is None: + return None + else: + audio = MutagenFile(full_path, easy=True) + if audio is None: + return None + tags = {k: v for k, v in audio.items()} + + relative = os.path.relpath(full_path, MUSIC_DIR) + backup = { + "path": full_path, + "relative_path": relative, + "filename": os.path.basename(full_path), + "timestamp": datetime.utcnow().isoformat(), + "tags": tags, + } + + data = json.dumps(backup, indent=2) + + # Primary key: full absolute path + primary_path = os.path.join(TAG_BACKUP_DIR, f"{_backup_key(full_path)}.json") + with open(primary_path, 'w') as f: + f.write(data) + + # Secondary key: relative path (survives restructure) + secondary_path = os.path.join(TAG_BACKUP_DIR, f"{_backup_key_rel(relative)}.json") + with open(secondary_path, 'w') as f: + f.write(data) + + return primary_path + except Exception as e: + print(f" backup_tags FAILED for {os.path.basename(full_path)}: {e}", flush=True) + return None + + +def _read_aiff_tags_for_backup(full_path: str) -> Optional[dict]: + """Read AIFF tags via raw ID3 frames and return as easy-mode-style dict. + Returns None if the file can't be read.""" + try: + from mutagen.aiff import AIFF + audio = AIFF(full_path) + if audio.tags is None: + return {} + + # Map raw ID3 frames to easy-mode key names + frame_to_easy = { + 'TIT2': 'title', + 'TPE1': 'artist', + 'TALB': 'album', + 'TPE2': 'albumartist', + 'TRCK': 'tracknumber', + 'TPOS': 'discnumber', + 'TDRC': 'date', + 'TYER': 'date', + 'TCON': 'genre', + 'TCOM': 'composer', + } + + tags = {} + for frame_id, easy_key in frame_to_easy.items(): + frame = audio.tags.get(frame_id) + if frame: + # ID3 text frames store values as lists + val = [str(t) for t in frame.text] if hasattr(frame, 'text') else [str(frame)] + if val and val[0]: + tags[easy_key] = val + return tags + except Exception as e: + print(f" _read_aiff_tags FAILED for {os.path.basename(full_path)}: {e}", flush=True) + return None + + +def _find_backup(full_path: str, original_path: str = None) -> Optional[str]: + """Find the backup JSON for a file, trying multiple key strategies. + + Lookup order: + 1. Current full_path key (fast — no restructure happened) + 2. Current relative_path key (file moved but relative_path recalculated) + 3. Original full_path key (manifest stored the old path, backup keyed to it) + 4. Original relative_path key (old relative path from manifest) + + Returns the backup file path, or None if not found. + """ + # 1. Current full_path + bp = os.path.join(TAG_BACKUP_DIR, f"{_backup_key(full_path)}.json") + if os.path.exists(bp): + return bp + + # 2. Current relative_path + try: + rel = os.path.relpath(full_path, MUSIC_DIR) + bp = os.path.join(TAG_BACKUP_DIR, f"{_backup_key_rel(rel)}.json") + if os.path.exists(bp): + return bp + except ValueError: + pass + + if original_path and original_path != full_path: + # 3. Original full_path + bp = os.path.join(TAG_BACKUP_DIR, f"{_backup_key(original_path)}.json") + if os.path.exists(bp): + return bp + + # 4. Original relative_path + try: + old_rel = os.path.relpath(original_path, MUSIC_DIR) + bp = os.path.join(TAG_BACKUP_DIR, f"{_backup_key_rel(old_rel)}.json") + if os.path.exists(bp): + return bp + except ValueError: + pass + + return None + +def restore_tags_from_backup(full_path: str, original_path: str = None) -> bool: + """Restore tags from the most recent backup for this file. + + Uses _find_backup() to locate the backup JSON, trying multiple key strategies + (current path, relative path, original path). This ensures undo works even + after restructure has moved the file to a different directory. + + IMPORTANT: Only overwrites text tags (title, artist, album, etc.) that + easy=True exposes. Does NOT touch binary frames like APIC (album art), + USLT (lyrics), or COMM (comments). Previous version used audio.delete() + which nuked everything including embedded art — that was a data-loss bug. + """ + backup_path = _find_backup(full_path, original_path=original_path) + if not backup_path: + print(f" restore: no backup found for {os.path.basename(full_path)}" + f" (original: {os.path.basename(original_path) if original_path else 'none'})", flush=True) + return False + try: + with open(backup_path) as f: + backup = json.load(f) + + ext = Path(full_path).suffix.lower() + + if ext in ('.aiff', '.aif'): + return _restore_aiff_from_backup(full_path, backup) + + audio = MutagenFile(full_path, easy=True) + if audio is None: + return False + + backup_tags_dict = backup.get("tags", {}) + + # 1. Remove any easy-mode tags currently on file that AREN'T in the backup. + # This undoes tags that were ADDED by the edit. + # Only touches easy-namespace keys — APIC, USLT, COMM are untouched. + current_keys = list(audio.keys()) + for k in current_keys: + if k not in backup_tags_dict: + try: + del audio[k] + except Exception: + pass + + # 2. Set every tag from the backup (overwrites edited values, restores deleted ones). + for k, v in backup_tags_dict.items(): + try: + audio[k] = v + except Exception: + pass + + audio.save() + return True + except Exception as e: + print(f" restore_tags FAILED for {os.path.basename(full_path)}: {e}", flush=True) + return False + + +def _restore_aiff_from_backup(full_path: str, backup: dict) -> bool: + """Restore AIFF tags from backup using raw ID3 frames.""" + from mutagen.aiff import AIFF + from mutagen.id3 import TIT2, TPE1, TALB, TPE2, TRCK, TPOS, TDRC, TCON + + try: + audio = AIFF(full_path) + if audio.tags is None: + audio.add_tags() + tags = audio.tags + + # Map easy-mode names back to ID3 frames + frame_map = { + 'title': lambda v: TIT2(encoding=3, text=v), + 'artist': lambda v: TPE1(encoding=3, text=v), + 'album': lambda v: TALB(encoding=3, text=v), + 'albumartist': lambda v: TPE2(encoding=3, text=v), + 'tracknumber': lambda v: TRCK(encoding=3, text=v), + 'discnumber': lambda v: TPOS(encoding=3, text=v), + 'date': lambda v: TDRC(encoding=3, text=v), + 'genre': lambda v: TCON(encoding=3, text=v), + } + + for k, v in backup.get("tags", {}).items(): + k_lower = k.lower() + if k_lower in frame_map: + val = v if isinstance(v, list) else [v] + tags.add(frame_map[k_lower](val)) + + audio.save() + return True + except Exception as e: + print(f" restore_aiff FAILED for {os.path.basename(full_path)}: {e}", flush=True) + return False + +def save_batch_manifest(batch_id: str, paths: list, tags_changed: dict = None, + affected_albums: list = None, affected_artists: list = None, + edit_type: str = "batch"): + """Save a manifest of a batch (or single) edit for undo + history UI. + + Args: + batch_id: unique identifier for this edit + paths: list of absolute file paths that were modified + tags_changed: dict of field→value that was applied (e.g. {"genre": "Rock"}) + affected_albums: list of album names touched (for UI display) + affected_artists: list of artist names touched (for UI display) + edit_type: "batch", "single", or "restructure" + """ + manifest = { + "batch_id": batch_id, + "timestamp": datetime.utcnow().isoformat(), + "file_count": len(paths), + "paths": paths, + "tags_changed": tags_changed or {}, + "affected_albums": list(set(affected_albums or [])), + "affected_artists": list(set(affected_artists or [])), + "edit_type": edit_type, + "is_reverted": False, + } + manifest_path = os.path.join(TAG_BACKUP_DIR, f"batch_{batch_id}.json") + with open(manifest_path, 'w') as f: + json.dump(manifest, f, indent=2) + return manifest_path + +def validate_essential_tags(full_path: str, had_artist: bool, had_album: bool, had_title: bool) -> list: + """Check that essential tags weren't destroyed. Returns list of problems.""" + problems = [] + try: + audio = MutagenFile(full_path, easy=True) + if audio is None: + return ["unsupported format"] + now_artist = bool(audio.get('artist', [''])[0].strip()) + now_album = bool(audio.get('album', [''])[0].strip()) + now_title = bool(audio.get('title', [''])[0].strip()) + if had_artist and not now_artist: + problems.append("artist lost") + if had_album and not now_album: + problems.append("album lost") + if had_title and not now_title: + problems.append("title lost") + except: + pass + return problems + + NAVIDROME_TAGS = { 'TITLE', 'ARTIST', 'ALBUM', 'ALBUMARTIST', 'TRACKNUMBER', 'DISCNUMBER', 'DATE', @@ -1188,6 +1480,29 @@ NAVIDROME_TAGS = { 'REPLAYGAIN_ALBUM_GAIN', 'REPLAYGAIN_ALBUM_PEAK', } +# ID3v2 frame IDs that correspond to the Vorbis Comment names above. +# enforce_tag_whitelist uses NAVIDROME_TAGS (Vorbis names) for FLAC, +# but MP3 files use raw ID3 frame IDs — without this mapping, EVERY +# MP3 tag gets deleted because "TPE1" != "ARTIST". +ID3_FRAME_WHITELIST = { + 'TIT2', # TITLE + 'TPE1', # ARTIST + 'TALB', # ALBUM + 'TPE2', # ALBUMARTIST + 'TRCK', # TRACKNUMBER + 'TPOS', # DISCNUMBER + 'TDRC', # DATE (ID3v2.4) + 'TYER', # DATE (ID3v2.3 legacy) + 'TCON', # GENRE + 'TCOM', # COMPOSER + 'COMM', # COMMENT + 'TSRC', # ISRC + 'APIC', # Album art — always keep + 'USLT', # LYRICS (unsynced) + 'SYLT', # LYRICS (synced) + 'TXXX', # User-defined (checked by sub-key below) +} + # Keep the blacklist for reference / legacy clean-tags endpoint PICARD_TAGS_TO_REMOVE = { 'MUSICBRAINZ_TRACKID', 'MUSICBRAINZ_ALBUMID', 'MUSICBRAINZ_RELEASETRACKID', @@ -1255,10 +1570,27 @@ def enforce_tag_whitelist( if audio is None: result["error"] = "Unsupported format" return result + # For MP3/ID3 files, check raw frame IDs against ID3_FRAME_WHITELIST + # (not NAVIDROME_TAGS, which uses Vorbis Comment names like "ARTIST" + # that don't match ID3 frame IDs like "TPE1"). + allowed_rg = { + 'REPLAYGAIN_TRACK_GAIN', 'REPLAYGAIN_TRACK_PEAK', + 'REPLAYGAIN_ALBUM_GAIN', 'REPLAYGAIN_ALBUM_PEAK', + } to_remove = [] for k in list(audio.keys()): - ku = k.upper().split(':')[0].strip() - if ku not in allowed: + # ID3 keys look like "TPE1", "TXXX:replaygain_track_gain", + # "APIC:", "COMM::eng", "USLT::eng" + frame_id = k.split(':')[0].strip().upper() + if frame_id in ID3_FRAME_WHITELIST: + # Frame type is allowed — but for TXXX, also check the + # sub-key (description) against known ReplayGain names + if frame_id == 'TXXX': + desc = k.split(':', 1)[1].upper() if ':' in k else '' + if desc not in allowed_rg: + to_remove.append(k) + # Otherwise keep it + else: to_remove.append(k) to_remove = list(set(to_remove)) result["removed"] = to_remove @@ -1459,6 +1791,12 @@ def restructure_all() -> dict: if not os.path.isfile(full_path): skipped += 1 continue + # Safety net: snapshot tags BEFORE enforce_tag_whitelist touches them. + # If whitelist enforcement damages tags (as it did for MP3s before the + # ID3_FRAME_WHITELIST fix), the backup enables recovery. + # Non-fatal if backup fails — log and continue (restructure is bulk). + if not backup_tags(full_path): + print(f" restructure: backup failed for {os.path.basename(full_path)}, proceeding cautiously", flush=True) # Enforce whitelist before restructuring — clean tags first so # build_target_path reads clean data and generates the correct path enforce_tag_whitelist(full_path, preserve_composer=True, preserve_lyrics=True) @@ -1472,10 +1810,24 @@ def restructure_all() -> dict: def apply_tags(path: str, u, preserve_composer: bool = True, preserve_lyrics: bool = True): - """Write tags then enforce whitelist — only Navidrome tags survive.""" + """Write tags then enforce whitelist — only Navidrome tags survive. + Backs up all tags BEFORE writing so undo is always possible. + REFUSES to write if backup fails — never modifies tags without a safety net.""" + # Snapshot current state for undo — MUST succeed before we touch anything + if not backup_tags(path): + raise RuntimeError( + f"Cannot edit {os.path.basename(path)}: backup failed. " + "Tags untouched. Check disk space and file permissions in TAG_BACKUP_DIR." + ) + audio = MutagenFile(path, easy=True) if audio is None: raise ValueError(f"Unsupported format: {path}") + # Record what was present before write + had_artist = bool(audio.get('artist', [''])[0].strip()) + had_album = bool(audio.get('album', [''])[0].strip()) + had_title = bool(audio.get('title', [''])[0].strip()) + if u.title: audio['title'] = u.title if u.artist: audio['artist'] = u.artist if u.album: audio['album'] = u.album @@ -1484,15 +1836,36 @@ def apply_tags(path: str, u, preserve_composer: bool = True, preserve_lyrics: bo if u.year: audio['date'] = str(u.year) if u.track_number: audio['tracknumber'] = str(u.track_number) audio.save() - # Enforce whitelist after writing — nukes everything not in NAVIDROME_TAGS + # Enforce whitelist after writing enforce_tag_whitelist(path, preserve_composer=preserve_composer, preserve_lyrics=preserve_lyrics) + # Validate — if essential tags were destroyed, auto-restore + problems = validate_essential_tags(path, had_artist, had_album, had_title) + if problems: + print(f" ⚠ TAG DAMAGE DETECTED in {os.path.basename(path)}: {problems} — auto-restoring", flush=True) + restore_tags_from_backup(path) + raise RuntimeError(f"Tag write damaged {os.path.basename(path)}: {problems}. Restored from backup.") + def apply_tags_dict(path: str, tags: dict, preserve_composer: bool = True, preserve_lyrics: bool = True): - """Write tags dict then enforce whitelist — only Navidrome tags survive.""" + """Write tags dict then enforce whitelist — only Navidrome tags survive. + Backs up all tags BEFORE writing so undo is always possible. + REFUSES to write if backup fails — never modifies tags without a safety net.""" + # Snapshot current state for undo — MUST succeed before we touch anything + if not backup_tags(path): + raise RuntimeError( + f"Cannot edit {os.path.basename(path)}: backup failed. " + "Tags untouched. Check disk space and file permissions in TAG_BACKUP_DIR." + ) + audio = MutagenFile(path, easy=True) if audio is None: raise ValueError(f"Unsupported format: {path}") + # Record what was present before write + had_artist = bool(audio.get('artist', [''])[0].strip()) + had_album = bool(audio.get('album', [''])[0].strip()) + had_title = bool(audio.get('title', [''])[0].strip()) + mapping = {'title': 'title', 'artist': 'artist', 'album': 'album', 'album_artist': 'albumartist', 'genre': 'genre', 'year': 'date', 'track_number': 'tracknumber'} @@ -1504,6 +1877,13 @@ def apply_tags_dict(path: str, tags: dict, preserve_composer: bool = True, prese # Enforce whitelist after writing enforce_tag_whitelist(path, preserve_composer=preserve_composer, preserve_lyrics=preserve_lyrics) + # Validate — if essential tags were destroyed, auto-restore + problems = validate_essential_tags(path, had_artist, had_album, had_title) + if problems: + print(f" ⚠ TAG DAMAGE DETECTED in {os.path.basename(path)}: {problems} — auto-restoring", flush=True) + restore_tags_from_backup(path) + raise RuntimeError(f"Tag write damaged {os.path.basename(path)}: {problems}. Restored from backup.") + # ── Analysis ───────────────────────────────────────────────────────────────── @@ -1700,19 +2080,55 @@ async def edit_metadata(update: MetadataUpdate): fp = resolve_path(update.relative_path) if not fp: raise HTTPException(404, f"File not found. raw='{update.relative_path}' MUSIC_DIR='{MUSIC_DIR}'") + + # Generate a batch ID so single-track edits appear in edit history too + batch_id = hashlib.md5(f"{time.time()}_{update.relative_path}".encode()).hexdigest()[:12] + try: + # Collect context before the edit (for history UI) + album_name = "" + artist_name = "" + try: + pre = MutagenFile(fp, easy=True) + if pre: + album_name = pre.get('album', [''])[0] + artist_name = pre.get('albumartist', pre.get('artist', ['']))[0] + except Exception: + pass + # Offload blocking Mutagen I/O to a thread — audio.save() on FLAC can # take 100-500ms and must not block the event loop (AUDIT-009) await asyncio.to_thread(apply_tags, fp, update) await asyncio.to_thread(update_song_in_db, fp) new_relative = os.path.relpath(fp, MUSIC_DIR) + + # Build tags_changed from update fields + tags_changed = {} + if update.title: tags_changed["title"] = update.title + if update.artist: tags_changed["artist"] = update.artist + if update.album: tags_changed["album"] = update.album + if update.album_artist: tags_changed["album_artist"] = update.album_artist + if update.genre: tags_changed["genre"] = update.genre + if update.year: tags_changed["year"] = str(update.year) + if update.track_number: tags_changed["track_number"] = str(update.track_number) + + # Save manifest so single edits appear in edit history + save_batch_manifest( + batch_id, [fp], + tags_changed=tags_changed, + affected_albums=[album_name] if album_name else [], + affected_artists=[artist_name] if artist_name else [], + edit_type="single", + ) + await trigger_scan() await push.broadcast("metadata_updated", { "path": new_relative, "title": update.title or "", "artist": update.artist or "", - "album": update.album or "" + "album": update.album or "", + "batch_id": batch_id, }) - return {"status": "success", "file": new_relative, "resolved": fp} + return {"status": "success", "file": new_relative, "resolved": fp, "batch_id": batch_id} except Exception as e: import traceback traceback.print_exc() @@ -1721,7 +2137,9 @@ async def edit_metadata(update: MetadataUpdate): @app.patch("/batch-edit-metadata") async def batch_edit_metadata(update: BatchMetadataUpdate): - results = {"succeeded": [], "failed": []} + # Generate a batch ID for undo support + batch_id = hashlib.md5(f"{time.time()}_{len(update.relative_paths)}".encode()).hexdigest()[:12] + results = {"succeeded": [], "failed": [], "batch_id": batch_id} tags = {} if update.title: tags["title"] = update.title if update.artist: tags["artist"] = update.artist @@ -1730,18 +2148,43 @@ async def batch_edit_metadata(update: BatchMetadataUpdate): if update.genre: tags["genre"] = update.genre if update.year: tags["year"] = str(update.year) def _apply_batch(): - """Blocking tag writes run in a thread so the event loop stays free.""" + """Blocking tag writes run in a thread so the event loop stays free. + Each file is backed up BEFORE any write — undo is always possible.""" + resolved_paths = [] + albums_seen = [] + artists_seen = [] for rp in update.relative_paths: fp = resolve_path(rp) if not fp: results["failed"].append({"path": rp, "error": "File not found"}) continue try: + # Collect album/artist context BEFORE the edit (for history UI) + try: + pre = MutagenFile(fp, easy=True) + if pre: + a = pre.get('album', [''])[0] + ar = pre.get('albumartist', pre.get('artist', ['']))[0] + if a: albums_seen.append(a) + if ar: artists_seen.append(ar) + except Exception: + pass + apply_tags_dict(fp, tags) update_song_in_db(fp) results["succeeded"].append(os.path.relpath(fp, MUSIC_DIR)) + resolved_paths.append(fp) except Exception as e: results["failed"].append({"path": rp, "error": str(e)}) + # Save batch manifest for bulk undo — includes what changed and what was affected + if resolved_paths: + save_batch_manifest( + batch_id, resolved_paths, + tags_changed=tags, + affected_albums=albums_seen, + affected_artists=artists_seen, + edit_type="batch", + ) await asyncio.to_thread(_apply_batch) await trigger_scan() @@ -1750,12 +2193,143 @@ async def batch_edit_metadata(update: BatchMetadataUpdate): await push.broadcast("batch_metadata_updated", {"count": len(results["succeeded"]), "album": update.album or "", + "batch_id": batch_id, "paths_changed": "true"}) # Run conflict check in background after every batch edit _create_task(_run_conflict_check_and_broadcast()) return results +@app.post("/undo-batch-edit/{batch_id}") +async def undo_batch_edit(batch_id: str): + """Restore all files in a batch edit to their pre-edit tags. + The batch_id is returned by PATCH /batch-edit-metadata. + + Handles files that moved after restructure: if the manifest path + no longer exists, resolves the current location and uses the + original path to find the backup. + """ + manifest_path = os.path.join(TAG_BACKUP_DIR, f"batch_{batch_id}.json") + if not os.path.exists(manifest_path): + raise HTTPException(404, f"Batch {batch_id} not found. Backups may have been cleaned up.") + + with open(manifest_path) as f: + manifest = json.load(f) + + # Prevent double-undo: if already reverted, reject + if manifest.get("is_reverted"): + reverted_at = manifest.get("reverted_at", "unknown time") + raise HTTPException(409, f"Batch {batch_id} was already reverted at {reverted_at}.") + + results = {"restored": [], "failed": []} + def _restore(): + for original_fp in manifest["paths"]: + # Find the file — it may have moved since the edit + if os.path.isfile(original_fp): + current_fp = original_fp + else: + # File moved (restructure happened). Try resolve_path. + old_rel = os.path.relpath(original_fp, MUSIC_DIR) if original_fp.startswith(MUSIC_DIR) else original_fp + current_fp = resolve_path(old_rel) + if not current_fp: + # Last resort: search by filename anywhere in MUSIC_DIR + fname = os.path.basename(original_fp) + for root, dirs, files in os.walk(MUSIC_DIR): + if fname in files: + current_fp = os.path.join(root, fname) + break + if not current_fp: + results["failed"].append({ + "path": os.path.relpath(original_fp, MUSIC_DIR), + "error": "File not found (may have been deleted)" + }) + continue + + try: + ok = restore_tags_from_backup(current_fp, original_path=original_fp) + if ok: + results["restored"].append(os.path.relpath(current_fp, MUSIC_DIR)) + else: + results["failed"].append({ + "path": os.path.relpath(current_fp, MUSIC_DIR), + "error": "No backup found for this file" + }) + except Exception as e: + results["failed"].append({ + "path": os.path.relpath(original_fp, MUSIC_DIR), + "error": str(e) + }) + + await asyncio.to_thread(_restore) + + # Mark the manifest as reverted so it can't be undone again + manifest["is_reverted"] = True + manifest["reverted_at"] = datetime.utcnow().isoformat() + manifest["restore_results"] = { + "restored": len(results["restored"]), + "failed": len(results["failed"]), + } + with open(manifest_path, 'w') as f: + json.dump(manifest, f, indent=2) + + await trigger_scan() + await asyncio.sleep(4) + await push.broadcast("batch_undo_complete", { + "batch_id": batch_id, + "restored": len(results["restored"]), + "failed": len(results["failed"]), + }) + return results + + +@app.post("/restore-tags") +async def restore_single_file_tags(relative_path: str = Query(...)): + """Restore a single file's tags from its backup.""" + fp = resolve_path(relative_path) + if not fp: + raise HTTPException(404, "File not found") + ok = await asyncio.to_thread(restore_tags_from_backup, fp) + if not ok: + raise HTTPException(404, "No backup found for this file") + await asyncio.to_thread(update_song_in_db, fp) + await trigger_scan() + return {"status": "restored", "path": relative_path} + + +@app.get("/batch-edit-history") +async def batch_edit_history(): + """List recent edits (batch + single) that can be undone. + Returns enough data for the Edit History UI to display: + - what changed (tags_changed pills) + - what was affected (album/artist names) + - whether already reverted + - edit type (batch/single) + """ + manifests = [] + if os.path.isdir(TAG_BACKUP_DIR): + for f in sorted(os.listdir(TAG_BACKUP_DIR), reverse=True): + if f.startswith("batch_") and f.endswith(".json"): + try: + with open(os.path.join(TAG_BACKUP_DIR, f)) as fh: + m = json.load(fh) + manifests.append({ + "batch_id": m.get("batch_id", ""), + "timestamp": m.get("timestamp", ""), + "file_count": m.get("file_count", 0), + "tags_changed": m.get("tags_changed", {}), + "affected_albums": m.get("affected_albums", []), + "affected_artists": m.get("affected_artists", []), + "edit_type": m.get("edit_type", "batch"), + "is_reverted": m.get("is_reverted", False), + "reverted_at": m.get("reverted_at"), + }) + except: + pass + # Sort by timestamp descending (filenames aren't guaranteed to sort by time) + manifests.sort(key=lambda x: x["timestamp"], reverse=True) + return {"batches": manifests[:50]} # last 50 + + @app.post("/upload-track") async def upload_track( file: UploadFile = File(...), diff --git a/iOS/App/NavidromePlayerApp.swift b/iOS/App/NavidromePlayerApp.swift index a931dd6..ab5278e 100644 --- a/iOS/App/NavidromePlayerApp.swift +++ b/iOS/App/NavidromePlayerApp.swift @@ -196,6 +196,13 @@ struct RootView: View { category: "Audio", level: .info ) } + + // Play Queue Sync — start observing for saves, check server for cross-device resume + let queueSync = PlayQueueSyncManager.shared + queueSync.start() + if AudioPlayer.shared.currentSong == nil { + await queueSync.checkServerQueue() + } } // Connect Companion push client if enabled diff --git a/iOS/Data/AudioTapProcessor.swift b/iOS/Data/AudioTapProcessor.swift new file mode 100644 index 0000000..f3312a1 --- /dev/null +++ b/iOS/Data/AudioTapProcessor.swift @@ -0,0 +1,422 @@ +import Foundation +import AVFoundation +import MediaToolbox +import Accelerate + +// MARK: - Lock-free Ring Buffer for audio samples + +/// Single-producer (audio render thread), single-consumer (main thread) ring buffer. +/// No locks, no allocations on the audio thread. ARM64 naturally-atomic Int writes +/// ensure the single write index is safely visible across threads. +final class PCMRingBuffer { + let capacity: Int + private let buffer: UnsafeMutablePointer + // Atomic indices — render thread writes `writeIndex`, main thread writes `readIndex` + private var _writeIndex: Int = 0 + + init(capacity: Int) { + self.capacity = capacity + buffer = .allocate(capacity: capacity) + buffer.initialize(repeating: 0, count: capacity) + } + + deinit { + buffer.deallocate() + } + + /// Write samples from the audio render thread. Lock-free. + func write(_ samples: UnsafePointer, count: Int) { + let wi = _writeIndex + let space = capacity + for i in 0.., count: Int) -> Int { + let wi = _writeIndex + let start = (wi - count + capacity) % capacity + for i in 0.., CMItemCount) -> Void)? + + // Pre-allocated FFT resources — created once, reused every frame (30fps) + private let fftSize = 1024 + private let fftLog2n: vDSP_Length + private let fftSetup: FFTSetup + private var hannWindow: [Float] + private var fftTimeDomain: [Float] + private var fftRealp: [Float] + private var fftImagp: [Float] + private var fftMagnitudes: [Float] + + // Debug: save PCM to WAV file for verification — tap the share button in settings + var debugDumpEnabled = false + var debugDumpURL: URL? + private var debugFileHandle: FileHandle? + private var debugSamplesWritten: Int = 0 + private var debugMaxSamplesActual = 44100 * 5 // recalculated in startDebugDump from actual rate + + /// Posted on main thread when debug capture completes. userInfo contains "url": URL. + static let captureCompleteNotification = Notification.Name("AudioTapCaptureComplete") + + // Source format detected by the tap's prepare callback + var sourceFormat: AVAudioFormat? + + private init() { + let halfSize = fftSize / 2 + fftLog2n = vDSP_Length(log2(Float(fftSize))) + fftSetup = vDSP_create_fftsetup(fftLog2n, FFTRadix(kFFTRadix2))! + hannWindow = [Float](repeating: 0, count: fftSize) + vDSP_hann_window(&hannWindow, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM)) + fftTimeDomain = [Float](repeating: 0, count: fftSize) + fftRealp = [Float](repeating: 0, count: halfSize) + fftImagp = [Float](repeating: 0, count: halfSize) + fftMagnitudes = [Float](repeating: 0, count: halfSize) + } + + deinit { + vDSP_destroy_fftsetup(fftSetup) + } + + // MARK: - Install / Remove Tap + + /// Install the shared tap on a player item. Returns true if successful. + /// Safe to call multiple times — removes any existing tap first. + @MainActor + func installTap(on playerItem: AVPlayerItem) async -> Bool { + // Remove existing tap + removeTap(from: playerItem) + + // Load audio tracks — async API (non-deprecated) + guard let audioTrack = try? await playerItem.asset + .loadTracks(withMediaType: .audio).first else { + print("[AudioTap] No audio track found on playerItem") + return false + } + + // Create the MTAudioProcessingTap with C callbacks + var callbacks = MTAudioProcessingTapCallbacks( + version: kMTAudioProcessingTapCallbacksVersion_0, + clientInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()), + init: tapInit, + finalize: nil, + prepare: tapPrepare, + unprepare: nil, + process: tapProcess + ) + + var tapOut: MTAudioProcessingTap? + let status = MTAudioProcessingTapCreate( + kCFAllocatorDefault, &callbacks, + kMTAudioProcessingTapCreationFlag_PostEffects, &tapOut + ) + guard status == noErr, let tap = tapOut else { + print("[AudioTap] MTAudioProcessingTapCreate failed: \(status)") + return false + } + + let inputParams = AVMutableAudioMixInputParameters(track: audioTrack) + inputParams.audioTapProcessor = tap + + let mix = AVMutableAudioMix() + mix.inputParameters = [inputParams] + playerItem.audioMix = mix + + print("[AudioTap] Tap installed successfully") + + // Start debug dump if enabled + if debugDumpEnabled { + startDebugDump() + } + + return true + } + + /// Remove the tap from a player item. + func removeTap(from playerItem: AVPlayerItem?) { + playerItem?.audioMix = nil + sourceFormat = nil + ringBuffer.reset() // Prevent stale samples from previous stream bleeding into next FFT + stopDebugDump() + } + + // MARK: - FFT Processing (called from main thread timer) + + /// Perform FFT on the most recent samples and return frequency bands (0.0-1.0). + /// Uses pre-allocated buffers — minimal heap allocation per call. + /// Call this at 30fps from the visualizer timer. + func computeFFTBands(bandCount: Int = 30) -> [Float] { + let halfSize = fftSize / 2 + + // Read most recent 1024 samples from ring buffer into pre-allocated array + _ = fftTimeDomain.withUnsafeMutableBufferPointer { buf in + ringBuffer.readMostRecent(into: buf.baseAddress!, count: fftSize) + } + + // Apply Hann window (pre-computed) to reduce spectral leakage + vDSP_vmul(fftTimeDomain, 1, hannWindow, 1, &fftTimeDomain, 1, vDSP_Length(fftSize)) + + // Zero the split complex buffers + for i in 0..> 8) & 0xFF) + p[26] = UInt8((sr >> 16) & 0xFF); p[27] = UInt8((sr >> 24) & 0xFF) + // Byte rate: sampleRate * 1ch * 4 bytes + let br = sr * 4 + p[28] = UInt8(br & 0xFF); p[29] = UInt8((br >> 8) & 0xFF) + p[30] = UInt8((br >> 16) & 0xFF); p[31] = UInt8((br >> 24) & 0xFF) + // Block align: 4 + p[32] = 4; p[33] = 0 + // Bits per sample: 32 + p[34] = 32; p[35] = 0 + // "data" + p[36] = 0x64; p[37] = 0x61; p[38] = 0x74; p[39] = 0x61 + // Data size placeholder (patch later) + } + + FileManager.default.createFile(atPath: url.path, contents: header) + debugFileHandle = FileHandle(forWritingAtPath: url.path) + debugFileHandle?.seekToEndOfFile() + debugSamplesWritten = 0 + debugDumpEnabled = true + print("[AudioTap] Debug capture started: \(url.lastPathComponent) at \(sampleRate)Hz") + } + + /// Stop capturing and finalize the WAV header with correct sizes. + func stopDebugDump() { + guard let fh = debugFileHandle else { return } + debugDumpEnabled = false + + // Patch WAV header with correct sizes + let dataSize = UInt32(debugSamplesWritten * MemoryLayout.size) + let fileSize = dataSize + 36 // 44 - 8 = 36 + + fh.seek(toFileOffset: 4) + var fs = fileSize; fh.write(Data(bytes: &fs, count: 4)) + fh.seek(toFileOffset: 40) + var ds = dataSize; fh.write(Data(bytes: &ds, count: 4)) + fh.closeFile() + debugFileHandle = nil + + if debugSamplesWritten > 0 { + let rate = sourceFormat?.sampleRate ?? 44100 + let duration = Double(debugSamplesWritten) / rate + print("[AudioTap] Debug capture complete: \(String(format: "%.1f", duration))s, \(dataSize) bytes") + } + debugSamplesWritten = 0 + } + + /// Called from the tap process callback to write samples to WAV file. + func debugWriteSamples(_ samples: UnsafePointer, count: Int) { + guard debugDumpEnabled, debugSamplesWritten < debugMaxSamplesActual else { + if debugDumpEnabled && debugSamplesWritten >= debugMaxSamplesActual { + // Auto-stop after 5 seconds + debugDumpEnabled = false + DispatchQueue.main.async { [weak self] in + self?.stopDebugDump() + if let url = self?.debugDumpURL { + NotificationCenter.default.post( + name: AudioTapProcessor.captureCompleteNotification, + object: nil, + userInfo: ["url": url] + ) + } + } + } + return + } + let data = Data(bytes: samples, count: count * MemoryLayout.size) + debugFileHandle?.write(data) + debugSamplesWritten += count + } +} + +// MARK: - C Tap Callbacks (free functions, not methods) + +private func tapInit( + tap: MTAudioProcessingTap, + clientInfo: UnsafeMutableRawPointer?, + tapStorageOut: UnsafeMutablePointer +) { + tapStorageOut.pointee = clientInfo +} + +private func tapPrepare( + tap: MTAudioProcessingTap, + maxFrames: CMItemCount, + processingFormat: UnsafePointer +) { + let processor = Unmanaged + .fromOpaque(MTAudioProcessingTapGetStorage(tap)) + .takeUnretainedValue() + let format = AVAudioFormat(streamDescription: processingFormat) + processor.sourceFormat = format + print("[AudioTap] Prepared: \(processingFormat.pointee.mSampleRate)Hz, " + + "\(processingFormat.pointee.mChannelsPerFrame)ch, " + + "\(processingFormat.pointee.mBitsPerChannel)bit, " + + "float=\(processingFormat.pointee.mFormatFlags & kAudioFormatFlagIsFloat != 0)") +} + +private func tapProcess( + tap: MTAudioProcessingTap, + numberFrames: CMItemCount, + flags: MTAudioProcessingTapFlags, + bufferListInOut: UnsafeMutablePointer, + numberFramesOut: UnsafeMutablePointer, + flagsOut: UnsafeMutablePointer +) { + // Fetch audio from the source — passes through to player unchanged + let status = MTAudioProcessingTapGetSourceAudio( + tap, numberFrames, bufferListInOut, flagsOut, nil, numberFramesOut + ) + guard status == noErr else { return } + + let processor = Unmanaged + .fromOpaque(MTAudioProcessingTapGetStorage(tap)) + .takeUnretainedValue() + + // Extract mono float samples from the first channel + let abl = UnsafeMutableAudioBufferListPointer(bufferListInOut) + guard let firstBuffer = abl.first, + let data = firstBuffer.mData else { return } + + let floatPtr = data.assumingMemoryBound(to: Float.self) + let frameCount = Int(numberFramesOut.pointee) + + // Write to ring buffer (lock-free, no allocation) + processor.ringBuffer.write(floatPtr, count: frameCount) + + // Forward to Shazam handler if active + processor.shazamHandler?(bufferListInOut, numberFramesOut.pointee) + + // Debug dump if enabled + if processor.debugDumpEnabled { + processor.debugWriteSamples(floatPtr, count: frameCount) + } +} diff --git a/iOS/Views/Common/MainTabView.swift b/iOS/Views/Common/MainTabView.swift index e028201..301a825 100644 --- a/iOS/Views/Common/MainTabView.swift +++ b/iOS/Views/Common/MainTabView.swift @@ -5,6 +5,7 @@ import Combine struct MainTabView: View { @EnvironmentObject var audioPlayer: AudioPlayer @ObservedObject private var debugLogger = DebugLogger.shared + @ObservedObject private var queueSync = PlayQueueSyncManager.shared @State private var selectedTab = 0 @State private var navigateToPlaylistId: String? @State private var navigateToAlbumId: String? @@ -48,7 +49,8 @@ struct MainTabView: View { MyMusicView( navigateToPlaylistId: $navigateToPlaylistId, navigateToAlbumId: $navigateToAlbumId, - navigateToArtistId: $navigateToArtistId + navigateToArtistId: $navigateToArtistId, + isDynamicIsland: $isDynamicIsland ) .tabItem { Image(systemName: "music.note") @@ -86,9 +88,55 @@ struct MainTabView: View { } .tint(accentPink) .safeAreaInset(edge: .bottom) { - // Reserve space for MiniPlayerBar so content never gets obscured - if audioPlayer.currentSong != nil && !showNowPlaying { - Color.clear.frame(height: 80) + // Reserve space for MiniPlayerBar so content never gets obscured. + // Only when the mini player is at the bottom (not in Dynamic Island mode). + if audioPlayer.currentSong != nil && !showNowPlaying && !isDynamicIsland { + Color.clear.frame(height: 72) + } + } + .overlay(alignment: .top) { + // Cross-device queue resume banner + if queueSync.hasPendingRestore, let pq = queueSync.serverQueue { + let songCount = pq.entry?.count ?? 0 + let device = pq.changedBy ?? "another device" + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(accentPink) + + VStack(alignment: .leading, spacing: 2) { + Text("Resume from \(device)?") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + Text("\(songCount) song\(songCount == 1 ? "" : "s") in queue") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + + Spacer() + + Button(action: { queueSync.restoreFromServer() }) { + Text("Resume") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(accentPink) + .cornerRadius(8) + } + + Button(action: { queueSync.dismissRestore() }) { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.gray) + } + } + .padding(14) + .background(.ultraThinMaterial) + } + .transition(.move(edge: .top).combined(with: .opacity)) + .animation(.easeInOut, value: queueSync.hasPendingRestore) } } @@ -605,7 +653,7 @@ struct MiniPlayerBar: View { height: VisualizerSettings.shared.miniPlayerHeight ) .matchedGeometryEffect(id: "visWave", in: namespace) - .offset(y: 10) + .offset(y: VisualizerSettings.shared.miniVisYOffset) .opacity(VisualizerSettings.shared.miniOpacity) .allowsHitTesting(false) } diff --git a/iOS/Views/Companion/BatchAlbumEditorSheet.swift b/iOS/Views/Companion/BatchAlbumEditorSheet.swift index 12f19e0..11b28be 100644 --- a/iOS/Views/Companion/BatchAlbumEditorSheet.swift +++ b/iOS/Views/Companion/BatchAlbumEditorSheet.swift @@ -30,6 +30,8 @@ struct BatchAlbumEditorSheet: View { @State private var progress: Double = 0 @State private var resultMessage: String? @State private var failedTracks: [String] = [] + @State private var lastBatchId: String? + @State private var isUndoing = false @ObservedObject private var settings = CompanionSettings.shared @@ -235,6 +237,38 @@ struct BatchAlbumEditorSheet: View { .font(.system(size: 11)) .foregroundColor(.red) } + // Undo button — visible when a batch_id was returned + if let batchId = lastBatchId, failedTracks.isEmpty { + Button(action: { + Task { + isUndoing = true + do { + let r = try await api.undoBatchEdit(batchId: batchId) + let restored = r.restored?.count ?? 0 + resultMessage = "↩ Reverted \(restored) tracks to previous tags" + lastBatchId = nil + } catch { + resultMessage = "Undo failed: \(error.localizedDescription)" + } + isUndoing = false + } + }) { + HStack(spacing: 6) { + if isUndoing { + ProgressView().tint(accentPink).scaleEffect(0.7) + } else { + Image(systemName: "arrow.uturn.backward") + .font(.system(size: 12)) + } + Text("Undo") + .font(.system(size: 13, weight: .semibold)) + } + .foregroundColor(accentPink) + .padding(.top, 4) + } + .disabled(isUndoing) + .buttonStyle(.plain) + } } } } @@ -363,6 +397,7 @@ struct BatchAlbumEditorSheet: View { await MainActor.run { progress = 1.0 isSaving = false + lastBatchId = result.batch_id let succeeded = result.succeeded?.count ?? 0 let failed = result.failed ?? [] if failed.isEmpty { diff --git a/iOS/Views/Companion/CompanionAPIService.swift b/iOS/Views/Companion/CompanionAPIService.swift index 7334778..6496419 100644 --- a/iOS/Views/Companion/CompanionAPIService.swift +++ b/iOS/Views/Companion/CompanionAPIService.swift @@ -195,6 +195,116 @@ struct UploadMetadata { var preserveLyrics: Bool = false } +// MARK: - Batch Edit Result + +struct BatchEditResult: Codable { + let succeeded: [String]? + let failed: [BatchEditFailure]? + let batch_id: String? +} +struct BatchEditFailure: Codable { + let path: String + let error: String +} + +// MARK: - Edit History Models + +struct EditHistoryResponse: Codable { + let batches: [EditHistoryEntry] +} + +struct EditHistoryEntry: Codable, Identifiable { + let batch_id: String + let timestamp: String + let file_count: Int + let tags_changed: [String: String]? + let affected_albums: [String]? + let affected_artists: [String]? + let edit_type: String? + let is_reverted: Bool? + let reverted_at: String? + + var id: String { batch_id } + + /// Parse the Python UTC timestamp (which lacks timezone suffix). + private var parsedDate: Date? { + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = iso.date(from: timestamp) { return d } + iso.formatOptions = [.withInternetDateTime] + if let d = iso.date(from: timestamp) { return d } + if !timestamp.hasSuffix("Z") && !timestamp.contains("+") { + if let d = iso.date(from: timestamp + "Z") { return d } + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = iso.date(from: timestamp + "Z") { return d } + } + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POSIX") + df.timeZone = TimeZone(identifier: "UTC") + for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "yyyy-MM-dd'T'HH:mm:ss"] { + df.dateFormat = fmt + if let d = df.date(from: timestamp) { return d } + } + return nil + } + + var timeAgo: String { + guard let date = parsedDate else { return timestamp } + let interval = Date().timeIntervalSince(date) + if interval < 60 { return "just now" } + if interval < 3600 { return "\(Int(interval / 60))m ago" } + if interval < 86400 { return "\(Int(interval / 3600))h ago" } + if interval < 604800 { return "\(Int(interval / 86400))d ago" } + let df = DateFormatter() + df.dateStyle = .short + return df.string(from: date) + } + + var sectionDate: String { + guard let date = parsedDate else { return "Unknown" } + if Calendar.current.isDateInToday(date) { return "Today" } + if Calendar.current.isDateInYesterday(date) { return "Yesterday" } + let df = DateFormatter() + df.dateFormat = "EEEE, MMM d" + return df.string(from: date) + } + + var changeSummary: String { + guard let changes = tags_changed, !changes.isEmpty else { return "Tags edited" } + if changes.count == 1, let (key, val) = changes.first { + return "\(key.capitalized) → \(val)" + } + return changes.keys.map { $0.capitalized }.joined(separator: ", ") + " edited" + } + + var editTitle: String { + guard let changes = tags_changed, !changes.isEmpty else { return "Tag Edit" } + if changes.count == 1, let key = changes.keys.first { + return "\(key.capitalized) Edit" + } + return "Multi-field Edit" + } + + var contextString: String { + let artists = affected_artists ?? [] + let albums = affected_albums ?? [] + if !artists.isEmpty { + let display = artists.prefix(3).joined(separator: ", ") + return artists.count > 3 ? "\(display)…" : display + } + if !albums.isEmpty { + let display = albums.prefix(2).joined(separator: ", ") + return albums.count > 2 ? "\(display)…" : display + } + return "\(file_count) file\(file_count == 1 ? "" : "s")" + } +} + +struct UndoResult: Codable { + let restored: [String]? + let failed: [BatchEditFailure]? +} + // MARK: - Companion API Service actor CompanionAPIService { @@ -316,15 +426,6 @@ actor CompanionAPIService { // MARK: - Batch Edit Metadata (PATCH /batch-edit-metadata) - struct BatchEditResult: Codable { - let succeeded: [String]? - let failed: [BatchEditFailure]? - } - struct BatchEditFailure: Codable { - let path: String - let error: String - } - /// Edit the same tags on multiple files in a single request + single Navidrome scan. func batchEditMetadata(_ request: BatchMetadataEditRequest) async throws -> BatchEditResult { let base = try baseURL() @@ -348,6 +449,44 @@ actor CompanionAPIService { return try JSONDecoder().decode(BatchEditResult.self, from: data) } + // MARK: - Edit History & Undo + + /// Fetch list of recent batch/single edits with undo capability. + func fetchEditHistory() async throws -> [EditHistoryEntry] { + let base = try baseURL() + let url = base.appendingPathComponent("batch-edit-history") + let (data, response) = try await session.data(from: url) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + throw CompanionError.invalidResponse + } + let decoded = try JSONDecoder().decode(EditHistoryResponse.self, from: data) + return decoded.batches + } + + /// Undo a batch edit by restoring all files to their pre-edit tags. + /// Returns (restored count, failed count). + func undoBatchEdit(batchId: String) async throws -> UndoResult { + let base = try baseURL() + let url = base.appendingPathComponent("undo-batch-edit/\(batchId)") + var req = URLRequest(url: url) + req.httpMethod = "POST" + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { throw CompanionError.invalidResponse } + + if http.statusCode == 409 { + let detail = (try? JSONDecoder().decode([String:String].self, from: data))?["detail"] + ?? "Already reverted" + throw CompanionError.serverErrorDetail(409, detail) + } + if !(200...299).contains(http.statusCode) { + let detail = (try? JSONDecoder().decode([String:String].self, from: data))?["detail"] + ?? String(data: data, encoding: .utf8) + ?? "Unknown error" + throw CompanionError.serverErrorDetail(http.statusCode, detail) + } + return try JSONDecoder().decode(UndoResult.self, from: data) + } + // MARK: - Upload Track (POST /upload-track) func uploadTrack(fileURL: URL, metadata: UploadMetadata) async throws { diff --git a/iOS/Views/Companion/EditHistoryView.swift b/iOS/Views/Companion/EditHistoryView.swift index 3635a64..18863f0 100644 --- a/iOS/Views/Companion/EditHistoryView.swift +++ b/iOS/Views/Companion/EditHistoryView.swift @@ -3,11 +3,11 @@ import SwiftUI // MARK: - Edit History View struct EditHistoryView: View { - @State private var entries: [CompanionAPIService.EditHistoryEntry] = [] + @State private var entries: [EditHistoryEntry] = [] @State private var isLoading = true @State private var errorMessage: String? @State private var revertingId: String? - @State private var confirmRevert: CompanionAPIService.EditHistoryEntry? + @State private var confirmRevert: EditHistoryEntry? @State private var undoResult: (restored: Int, failed: Int)? @State private var undoError: String? @@ -132,7 +132,7 @@ struct EditHistoryView: View { return result } - private func entriesForSection(_ section: String) -> [CompanionAPIService.EditHistoryEntry] { + private func entriesForSection(_ section: String) -> [EditHistoryEntry] { entries.filter { $0.sectionDate == section } } @@ -149,7 +149,7 @@ struct EditHistoryView: View { isLoading = false } - private func performRevert(_ entry: CompanionAPIService.EditHistoryEntry) async { + private func performRevert(_ entry: EditHistoryEntry) async { revertingId = entry.batch_id do { let result = try await api.undoBatchEdit(batchId: entry.batch_id) @@ -177,7 +177,7 @@ struct EditHistoryView: View { // MARK: - Edit History Card private struct EditHistoryCard: View { - let entry: CompanionAPIService.EditHistoryEntry + let entry: EditHistoryEntry let isReverting: Bool let onRevert: () -> Void diff --git a/iOS/Views/Companion/MultiAlbumEditorSheet.swift b/iOS/Views/Companion/MultiAlbumEditorSheet.swift index 90d2cd9..3792870 100644 --- a/iOS/Views/Companion/MultiAlbumEditorSheet.swift +++ b/iOS/Views/Companion/MultiAlbumEditorSheet.swift @@ -39,6 +39,8 @@ struct MultiAlbumEditorSheet: View { @State private var progress: Double = 0 @State private var resultMessage: String? @State private var failedTracks: [String] = [] + @State private var lastBatchId: String? + @State private var isUndoing = false @ObservedObject private var settings = CompanionSettings.shared private let api = CompanionAPIService.shared @@ -313,6 +315,37 @@ struct MultiAlbumEditorSheet: View { .font(.system(size: 11)) .foregroundColor(.red) } + if let batchId = lastBatchId, failedTracks.isEmpty { + Button(action: { + Task { + isUndoing = true + do { + let r = try await CompanionAPIService.shared.undoBatchEdit(batchId: batchId) + let restored = r.restored?.count ?? 0 + resultMessage = "↩ Reverted \(restored) tracks to previous tags" + lastBatchId = nil + } catch { + resultMessage = "Undo failed: \(error.localizedDescription)" + } + isUndoing = false + } + }) { + HStack(spacing: 6) { + if isUndoing { + ProgressView().tint(Color(red: 1, green: 0.176, blue: 0.333)).scaleEffect(0.7) + } else { + Image(systemName: "arrow.uturn.backward") + .font(.system(size: 12)) + } + Text("Undo") + .font(.system(size: 13, weight: .semibold)) + } + .foregroundColor(Color(red: 1, green: 0.176, blue: 0.333)) + .padding(.top, 4) + } + .disabled(isUndoing) + .buttonStyle(.plain) + } } } } @@ -423,6 +456,7 @@ struct MultiAlbumEditorSheet: View { await MainActor.run { progress = 1.0 isSaving = false + lastBatchId = result.batch_id let succeeded = result.succeeded?.count ?? 0 let failed = result.failed ?? [] if failed.isEmpty { diff --git a/iOS/Views/Library/AlbumDetailView.swift b/iOS/Views/Library/AlbumDetailView.swift index af0c6f2..dac9da6 100644 --- a/iOS/Views/Library/AlbumDetailView.swift +++ b/iOS/Views/Library/AlbumDetailView.swift @@ -37,7 +37,7 @@ struct AlbumDetailView: View { songList(album) // Bottom spacing - Color.clear.frame(height: 120) + Color.clear.frame(height: 80) } } else if loadFailed { VStack(spacing: 16) { diff --git a/iOS/Views/Library/ArtistDetailView.swift b/iOS/Views/Library/ArtistDetailView.swift index eb5a668..46d2d39 100644 --- a/iOS/Views/Library/ArtistDetailView.swift +++ b/iOS/Views/Library/ArtistDetailView.swift @@ -86,7 +86,7 @@ struct ArtistDetailView: View { } } - Color.clear.frame(height: 120) + Color.clear.frame(height: 80) } } else if loadFailed { // Connection failed — show retry @@ -217,7 +217,7 @@ struct GenreDetailView: View { } .padding(16) - Color.clear.frame(height: 120) + Color.clear.frame(height: 80) } else if loadFailed { VStack(spacing: 16) { Image(systemName: "wifi.slash") diff --git a/iOS/Views/Library/DownloadsSettingsView.swift b/iOS/Views/Library/DownloadsSettingsView.swift index f0c5d25..b933efa 100644 --- a/iOS/Views/Library/DownloadsSettingsView.swift +++ b/iOS/Views/Library/DownloadsSettingsView.swift @@ -140,6 +140,8 @@ struct DownloadsView: View { } } } + + Section {} footer: { Spacer().frame(height: 80) } } } @@ -356,6 +358,8 @@ struct DownloadsView: View { } } } + + Section {} footer: { Spacer().frame(height: 80) } } } .onAppear { @@ -746,7 +750,7 @@ struct SettingsView: View { // Extra space so the last items aren't hidden behind the mini player Section {} footer: { - Spacer().frame(height: 60) + Spacer().frame(height: 80) } } .navigationTitle("Settings") @@ -1078,6 +1082,26 @@ struct LibraryConflictsView: View { var body: some View { List { + // Edit History link — always visible at top + Section { + NavigationLink { + EditHistoryView() + } label: { + HStack(spacing: 12) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(accentPink) + .frame(width: 28) + Text("Edit History") + .font(.system(size: 15, weight: .medium)) + Spacer() + Text("View & revert") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + } + } + // Tab picker Section { Picker("", selection: $selectedTab) { diff --git a/iOS/Views/Library/LicensesView.swift b/iOS/Views/Library/LicensesView.swift index b30d476..feaae38 100644 --- a/iOS/Views/Library/LicensesView.swift +++ b/iOS/Views/Library/LicensesView.swift @@ -123,6 +123,8 @@ struct LicensesView: View { } header: { Text("Acknowledgments") } + + Section {} footer: { Spacer().frame(height: 80) } } .navigationTitle("Licenses") } diff --git a/iOS/Views/Library/MyMusicView.swift b/iOS/Views/Library/MyMusicView.swift index 37baeaf..219caec 100644 --- a/iOS/Views/Library/MyMusicView.swift +++ b/iOS/Views/Library/MyMusicView.swift @@ -18,8 +18,11 @@ struct MyMusicView: View { @Binding var navigateToPlaylistId: String? @Binding var navigateToAlbumId: String? @Binding var navigateToArtistId: String? + @Binding var isDynamicIsland: Bool @State private var recentAlbums: [Album] = [] + @State private var recentlyPlayedAlbums: [Album] = [] + @State private var randomAlbums: [Album] = [] @State private var allAlbums: [Album] = [] @State private var allSongs: [Song] = [] @State private var songsLoaded = false @@ -62,6 +65,7 @@ struct MyMusicView: View { enum SortMode: String, CaseIterable { case recentlyAdded = "Recently Added" + case recentlyPlayed = "Recently Played" case favourites = "Favourites" case artists = "Artists" case albums = "Albums" @@ -84,6 +88,9 @@ struct MyMusicView: View { if mode == .favourites { Image(systemName: "heart.fill") .font(.system(size: 11)) + } else if mode == .recentlyPlayed { + Image(systemName: "clock.fill") + .font(.system(size: 11)) } Text(mode == .favourites ? "Favourites" : mode.rawValue) .font(.system(size: 13, weight: .medium)) @@ -100,8 +107,8 @@ struct MyMusicView: View { .padding(.vertical, 10) } - // Search bar (for all tabs except Recently Added) - if sortMode != .recentlyAdded { + // Search bar (for all tabs except Recently Added and Recently Played) + if sortMode != .recentlyAdded && sortMode != .recentlyPlayed { searchBar } @@ -110,13 +117,14 @@ struct MyMusicView: View { VStack(alignment: .leading, spacing: 0) { switch sortMode { case .recentlyAdded: recentlyAddedTab + case .recentlyPlayed: recentlyPlayedTab case .favourites: favouritesTab case .artists: artistsTab case .albums: albumsTab case .songs: songsTab case .genres: genresTab } - Color.clear.frame(height: 100) + Color.clear.frame(height: 80) } } } @@ -155,7 +163,7 @@ struct MyMusicView: View { .foregroundColor(selectedAlbumIds.isEmpty ? .gray : accentPink) } .disabled(selectedAlbumIds.isEmpty) - } else { + } else if !isDynamicIsland { Button(action: { showServerPicker = true }) { Image(systemName: "server.rack") .foregroundColor(accentPink) @@ -278,10 +286,249 @@ struct MyMusicView: View { private var recentlyAddedTab: some View { VStack(alignment: .leading, spacing: 0) { recentlyAddedSection + discoverSection playlistsSection } } + // MARK: - Recently Played Tab + + private var recentlyPlayedTab: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Recently Played") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.white) + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 16) + + if recentlyPlayedAlbums.isEmpty { + VStack(spacing: 8) { + Image(systemName: "clock") + .font(.system(size: 36)) + .foregroundColor(.gray) + Text("No recently played albums") + .font(.system(size: 14)) + .foregroundColor(.gray) + Text("Albums will appear here as you listen.") + .font(.system(size: 12)) + .foregroundColor(Color(white: 0.4)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else { + LazyVStack(spacing: 0) { + ForEach(recentlyPlayedAlbums) { album in + NavigationLink(destination: AlbumDetailView(albumId: album.id)) { + HStack(spacing: 12) { + AsyncCoverArt(coverArtId: album.coverArt, size: 60) + .frame(width: 56, height: 56) + .cornerRadius(4) + + VStack(alignment: .leading, spacing: 3) { + Text(album.name) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + Text(album.artist ?? "") + .font(.system(size: 13)) + .foregroundColor(.gray) + .lineLimit(1) + } + + Spacer() + + Button(action: { playAlbum(album) }) { + Image(systemName: "play.circle.fill") + .font(.system(size: 28)) + .foregroundColor(accentPink) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .contextMenu { + Button(action: { playAlbum(album) }) { + Label("Play", systemImage: "play.fill") + } + Button(action: { playAlbumNext(album) }) { + Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") + } + } + } + } + } + } + .task { + if recentlyPlayedAlbums.isEmpty { + await loadRecentlyPlayed() + } + } + } + + private func loadRecentlyPlayed() async { + do { + let albums = try await serverManager.client.getAlbumList2(type: "recent", size: 30) + await MainActor.run { recentlyPlayedAlbums = albums } + } catch { + print("[MyMusicView] Failed to load recently played: \(error)") + } + } + + // MARK: - Discover Section + + private var discoverSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Discover") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.white) + Spacer() + Button(action: { Task { await refreshRandom() } }) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(accentPink) + } + } + .padding(.horizontal, 16) + .padding(.top, 20) + + // Random Albums horizontal scroll + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 14) { + // Shuffle All button + Button(action: { Task { await playRandomMix() } }) { + VStack(spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 4) + .fill( + LinearGradient( + colors: [accentPink.opacity(0.3), accentPink.opacity(0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 140, height: 140) + VStack(spacing: 6) { + Image(systemName: "shuffle") + .font(.system(size: 28, weight: .medium)) + .foregroundColor(accentPink) + Text("Shuffle All") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(accentPink) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text("I'm Feeling Lucky") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + Text("Random mix") + .font(.system(size: 11)) + .foregroundColor(.gray) + .lineLimit(1) + } + } + .frame(width: 140) + } + .buttonStyle(.plain) + + // Random albums + ForEach(deduplicateAlbums(randomAlbums)) { album in + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .bottomTrailing) { + NavigationLink(destination: AlbumDetailView(albumId: album.id)) { + AsyncCoverArt(coverArtId: album.coverArt, size: 140) + .frame(width: 140, height: 140) + .cornerRadius(4) + .shadow(color: .black.opacity(0.3), radius: 4, y: 2) + } + + Button(action: { playAlbum(album) }) { + Image(systemName: "play.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + .shadow(radius: 4) + } + .padding(4) + } + + NavigationLink(destination: AlbumDetailView(albumId: album.id)) { + VStack(alignment: .leading, spacing: 2) { + Text(album.name) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + Text(album.artist ?? "") + .font(.system(size: 11)) + .foregroundColor(.gray) + .lineLimit(1) + } + } + } + .frame(width: 140) + } + } + .padding(.horizontal, 16) + } + + // Genre quick-play chips + if !genres.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(genres.prefix(12)) { genre in + Button(action: { Task { await playGenreMix(genre.value) } }) { + Text(genre.value) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.white.opacity(0.08)) + .cornerRadius(14) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 4) + } + } + } + } + + private func refreshRandom() async { + do { + let albums = try await serverManager.client.getAlbumList2(type: "random", size: 10) + await MainActor.run { randomAlbums = albums } + } catch { + print("[MyMusicView] Failed to load random albums: \(error)") + } + } + + private func playRandomMix() async { + do { + let songs = try await serverManager.client.getRandomSongs(size: 50) + if !songs.isEmpty { + AudioPlayer.shared.play(song: songs[0], fromQueue: songs, at: 0) + } + } catch { + print("[MyMusicView] Failed to play random mix: \(error)") + } + } + + private func playGenreMix(_ genre: String) async { + do { + let songs = try await serverManager.client.getRandomSongs(size: 50, genre: genre) + if !songs.isEmpty { + AudioPlayer.shared.play(song: songs[0], fromQueue: songs, at: 0) + } + } catch { + print("[MyMusicView] Failed to play genre mix \(genre): \(error)") + } + } + private var recentlyAddedSection: some View { VStack(alignment: .leading, spacing: 10) { HStack { @@ -1202,6 +1449,11 @@ struct MyMusicView: View { if favouriteSongs.isEmpty, let v = cache.load([Song].self, key: "starred_songs") { favouriteSongs = v } isLoading = false + + // Load random albums for Discover section (always fresh, not cached) + if randomAlbums.isEmpty { + Task { await refreshRandom() } + } // If the cache was empty (first launch or after a clear), kick off a sync // and wait for it to fill the cache, then reload. diff --git a/iOS/Views/Library/PlaylistsView.swift b/iOS/Views/Library/PlaylistsView.swift index 32dbe0d..cc53bff 100644 --- a/iOS/Views/Library/PlaylistsView.swift +++ b/iOS/Views/Library/PlaylistsView.swift @@ -144,7 +144,7 @@ struct PlaylistDetailView: View { playlistSongList(playlist) - Color.clear.frame(height: 120) + Color.clear.frame(height: 80) } } else if isLoading { ProgressView().tint(accentPink).padding(.top, 100) diff --git a/iOS/Views/Library/RadioView.swift b/iOS/Views/Library/RadioView.swift index 08dbc86..e0ad8d0 100644 --- a/iOS/Views/Library/RadioView.swift +++ b/iOS/Views/Library/RadioView.swift @@ -154,6 +154,7 @@ struct RadioView: View { ForEach(stations) { station in stationRow(station) } + Section {} footer: { Spacer().frame(height: 80) } } } } diff --git a/iOS/Views/Library/SearchView.swift b/iOS/Views/Library/SearchView.swift index 4a407e5..b1314da 100644 --- a/iOS/Views/Library/SearchView.swift +++ b/iOS/Views/Library/SearchView.swift @@ -111,7 +111,7 @@ struct SearchView: View { .background(Color.white.opacity(0.06)) .padding(.leading, 72) } - Color.clear.frame(height: 120) + Color.clear.frame(height: 80) } } } @@ -216,7 +216,7 @@ struct SearchView: View { .padding(.leading, 72) } } - Color.clear.frame(height: 120) + Color.clear.frame(height: 80) } } } diff --git a/iOS/Views/Lyrics/LyricsManager.swift b/iOS/Views/Lyrics/LyricsManager.swift index 51a44e9..cae9cc7 100644 --- a/iOS/Views/Lyrics/LyricsManager.swift +++ b/iOS/Views/Lyrics/LyricsManager.swift @@ -28,6 +28,10 @@ final class LyricsManager: ObservableObject { /// Whether the lyrics overlay is visible in Now Playing. @Published var isLyricsVisible = false + /// Global timing offset in seconds. Positive = lyrics appear later, negative = earlier. + /// Applied in syncToTime and wordProgress — instant feedback without editing timestamps. + @Published var globalOffset: Double = 0 + // MARK: - Non-Published State (polled per-frame by views) /// Current word index within the active line. Updated per-frame when visible. @@ -81,6 +85,7 @@ final class LyricsManager: ObservableObject { currentLineIndex = 0 currentWordIndex = 0 lastSyncedTime = 0 + globalOffset = 0 isLyricsVisible = false } @@ -92,7 +97,10 @@ final class LyricsManager: ObservableObject { lastSyncedTime = time guard let lyrics = currentLyrics, !lyrics.lines.isEmpty else { return } - let newIndex = findLineIndex(at: time, in: lyrics.lines) + // Apply global offset: positive = lyrics later, so we look up an earlier time + let adjusted = time - globalOffset + + let newIndex = findLineIndex(at: adjusted, in: lyrics.lines) if newIndex != currentLineIndex { currentLineIndex = newIndex } @@ -101,7 +109,7 @@ final class LyricsManager: ObservableObject { if isLyricsVisible, newIndex < lyrics.lines.count, let words = lyrics.lines[newIndex].words { - currentWordIndex = findWordIndex(at: time, in: words) + currentWordIndex = findWordIndex(at: adjusted, in: words) } } @@ -112,21 +120,24 @@ final class LyricsManager: ObservableObject { return (0, 0, 0) } - let lineIdx = findLineIndex(at: time, in: lyrics.lines) + // Apply global offset + let adjusted = time - globalOffset + + let lineIdx = findLineIndex(at: adjusted, in: lyrics.lines) let line = lyrics.lines[lineIdx] guard let words = line.words, !words.isEmpty else { return (lineIdx, 0, 0) } - let wordIdx = findWordIndex(at: time, in: words) + let wordIdx = findWordIndex(at: adjusted, in: words) let word = words[min(wordIdx, words.count - 1)] let wordDuration = word.endTime - word.startTime let fraction: Double if wordDuration > 0 { - fraction = min(max((time - word.startTime) / wordDuration, 0), 1) + fraction = min(max((adjusted - word.startTime) / wordDuration, 0), 1) } else { - fraction = time >= word.startTime ? 1 : 0 + fraction = adjusted >= word.startTime ? 1 : 0 } return (lineIdx, wordIdx, fraction) diff --git a/iOS/Views/Lyrics/LyricsOverlayView.swift b/iOS/Views/Lyrics/LyricsOverlayView.swift index f59211a..18bb14b 100644 --- a/iOS/Views/Lyrics/LyricsOverlayView.swift +++ b/iOS/Views/Lyrics/LyricsOverlayView.swift @@ -15,22 +15,28 @@ import SwiftUI struct LyricsOverlayView: View { @ObservedObject var lyricsManager = LyricsManager.shared @ObservedObject var audioPlayer = AudioPlayer.shared + @State private var showTimingAdjust = false private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) var body: some View { if let lyrics = lyricsManager.currentLyrics, !lyrics.lines.isEmpty { - TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !audioPlayer.isPlaying)) { timeline in - let now = audioPlayer.currentTime - let progress = lyricsManager.wordProgress(at: now) + ZStack(alignment: .bottom) { + TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !audioPlayer.isPlaying)) { timeline in + let now = audioPlayer.currentTime + let progress = lyricsManager.wordProgress(at: now) + + lyricsContent( + lyrics: lyrics, + currentTime: now, + lineIndex: progress.lineIndex, + wordIndex: progress.wordIndex, + wordFraction: progress.wordFraction + ) + } - lyricsContent( - lyrics: lyrics, - currentTime: now, - lineIndex: progress.lineIndex, - wordIndex: progress.wordIndex, - wordFraction: progress.wordFraction - ) + // Bottom toolbar — search, timing, edit + lyricsToolbar(hasSynced: lyrics.hasSynced) } } else if lyricsManager.isLoading { VStack { @@ -55,7 +61,6 @@ struct LyricsOverlayView: View { .padding(.top, 8) Button { - // Open search sheet NotificationCenter.default.post(name: .openLyricsSearch, object: nil) } label: { Text("Search Lyrics") @@ -73,6 +78,118 @@ struct LyricsOverlayView: View { } } + // MARK: - Lyrics Toolbar + + @ViewBuilder + private func lyricsToolbar(hasSynced: Bool) -> some View { + VStack(spacing: 0) { + // Timing adjustment (expandable) + if showTimingAdjust && hasSynced { + HStack(spacing: 12) { + Button { lyricsManager.globalOffset -= 0.5 } label: { + Text("-0.5s") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.white.opacity(0.1)) + .cornerRadius(8) + } + + Button { lyricsManager.globalOffset -= 0.1 } label: { + Text("-0.1s") + .font(.system(size: 13, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.white.opacity(0.06)) + .cornerRadius(8) + } + + Text(String(format: "%+.1fs", lyricsManager.globalOffset)) + .font(.system(size: 15, weight: .bold, design: .monospaced)) + .foregroundColor(lyricsManager.globalOffset == 0 ? .white.opacity(0.5) : accentPink) + .frame(width: 60) + .onTapGesture { lyricsManager.globalOffset = 0 } + + Button { lyricsManager.globalOffset += 0.1 } label: { + Text("+0.1s") + .font(.system(size: 13, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.white.opacity(0.06)) + .cornerRadius(8) + } + + Button { lyricsManager.globalOffset += 0.5 } label: { + Text("+0.5s") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.white.opacity(0.1)) + .cornerRadius(8) + } + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + // Main toolbar buttons + HStack(spacing: 0) { + // Re-search lyrics + Button { + NotificationCenter.default.post(name: .openLyricsSearch, object: nil) + } label: { + VStack(spacing: 3) { + Image(systemName: "magnifyingglass") + .font(.system(size: 16)) + Text("Search") + .font(.system(size: 10, weight: .medium)) + } + .foregroundColor(.white.opacity(0.6)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + // Timing offset toggle (only for synced lyrics) + if hasSynced { + Button { + withAnimation(.spring(response: 0.3)) { showTimingAdjust.toggle() } + } label: { + VStack(spacing: 3) { + Image(systemName: "timer") + .font(.system(size: 16)) + Text("Timing") + .font(.system(size: 10, weight: .medium)) + } + .foregroundColor(showTimingAdjust ? accentPink : .white.opacity(0.6)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + } + + // Open full editor + Button { + NotificationCenter.default.post(name: .openLyricsEditor, object: nil) + } label: { + VStack(spacing: 3) { + Image(systemName: "pencil.line") + .font(.system(size: 16)) + Text("Edit") + .font(.system(size: 10, weight: .medium)) + } + .foregroundColor(.white.opacity(0.6)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + } + .background(.ultraThinMaterial.opacity(0.8)) + } + } + // MARK: - Lyrics Content @ViewBuilder @@ -258,4 +375,5 @@ struct FlowLayout: Layout { extension Notification.Name { static let openLyricsSearch = Notification.Name("openLyricsSearch") + static let openLyricsEditor = Notification.Name("openLyricsEditor") } diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index 1dcee1e..51ce2bf 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -355,6 +355,9 @@ struct NowPlayingView: View { .onReceive(NotificationCenter.default.publisher(for: .openLyricsSearch)) { _ in showLyricsSearch = true } + .onReceive(NotificationCenter.default.publisher(for: .openLyricsEditor)) { _ in + showLyricsEditor = true + } .onAppear { dragOffset = 0 isStarred = audioPlayer.currentSong?.starred != nil @@ -1564,8 +1567,7 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate { private var timeoutTask: Task? private var hasDeliveredResult = false - // Tap state — MTAudioProcessingTap is a CF type, ARC manages it directly - private var tap: MTAudioProcessingTap? + // Tap state — uses shared AudioTapProcessor (no longer owns its own tap) private var converter: AVAudioConverter? private var sourceFormat: AVAudioFormat? private let targetFormat = AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1)! @@ -1592,14 +1594,40 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate { DebugLogger.shared.log("Shazam: starting recognition", category: "Audio") - // Prefer tap on AVPlayer — async track load, then install tap on main actor - if let playerItem = AudioPlayer.shared.currentPlayerItem { + // Use the shared AudioTapProcessor — piggyback on FFT tap if active, + // or install a new one. Fixes the old bug where Shazam's own tap + // would overwrite the FFT visualizer tap (or vice versa). + if AudioPlayer.shared.currentPlayerItem != nil { Task { @MainActor [weak self] in guard let self else { return } - if await self.installTap(on: playerItem) { + + let tapProc = AudioTapProcessor.shared + // Ensure the shared tap is installed (may already be active for FFT) + if tapProc.sourceFormat == nil, + let item = AudioPlayer.shared.currentPlayerItem { + let _ = await tapProc.installTap(on: item) + } + + // tapPrepare callback fires async on the audio thread after installTap. + // Wait briefly for sourceFormat to be populated — without this, + // sourceFormat is often nil and Shazam falls back to mic unnecessarily. + if tapProc.sourceFormat == nil { + try? await Task.sleep(for: .milliseconds(500)) + } + + if let srcFormat = tapProc.sourceFormat { + self.sourceFormat = srcFormat + self.converter = AVAudioConverter(from: srcFormat, to: self.targetFormat) + + // Subscribe to the shared tap — receives buffers from render thread + tapProc.shazamHandler = { [weak self] bufferList, frameCount in + guard let self else { return } + self.processTapBuffer(bufferList, frameCount: frameCount) + } self.startTimeout(seconds: 15) - DebugLogger.shared.log("Shazam: tap installed on AVPlayerItem", category: "Audio") + DebugLogger.shared.log("Shazam: subscribed to shared audio tap (\(Int(srcFormat.sampleRate))Hz)", category: "Audio") } else { + DebugLogger.shared.log("Shazam: shared tap has no format — mic fallback", category: "Audio") self.startMicFallback() } } @@ -1608,64 +1636,17 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate { } } - // MARK: - MTAudioProcessingTap + // MARK: - Shared Tap Consumer - /// Async because `loadTracks(withMediaType:)` is the non-deprecated API (iOS 16+). - @MainActor - private func installTap(on playerItem: AVPlayerItem) async -> Bool { - // loadTracks is the non-deprecated replacement for tracks(withMediaType:) - guard let audioTrack = try? await playerItem.asset - .loadTracks(withMediaType: .audio).first else { - DebugLogger.shared.log("Shazam: no audio track on playerItem", category: "Audio") - return false - } - - var callbacks = MTAudioProcessingTapCallbacks( - version: kMTAudioProcessingTapCallbacksVersion_0, - clientInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()), - init: { tap, clientInfo, tapStorageOut in - tapStorageOut.pointee = clientInfo - }, - finalize: nil, - prepare: { tap, _, processingFormat in - let storage = Unmanaged - .fromOpaque(MTAudioProcessingTapGetStorage(tap)) - .takeUnretainedValue() - let format = AVAudioFormat(streamDescription: processingFormat) - storage.analysisQueue.async { - storage.sourceFormat = format - if let src = format { - storage.converter = AVAudioConverter(from: src, to: storage.targetFormat) - } - } - }, - unprepare: nil, - process: shazamTapProcess - ) - - // MTAudioProcessingTap is a CF type — Swift bridges it as MTAudioProcessingTap? - var tapOut: MTAudioProcessingTap? - let status = MTAudioProcessingTapCreate( - kCFAllocatorDefault, &callbacks, - kMTAudioProcessingTapCreationFlag_PostEffects, &tapOut - ) - guard status == noErr, let tapValue = tapOut else { - DebugLogger.shared.log("Shazam: MTAudioProcessingTapCreate failed \(status)", category: "Audio") - return false - } - - tap = tapValue // ARC owns it - - let inputParams = AVMutableAudioMixInputParameters(track: audioTrack) - inputParams.audioTapProcessor = tapValue - - let mix = AVMutableAudioMix() - mix.inputParameters = [inputParams] - playerItem.audioMix = mix - return true + private func removeTap() { + // Unsubscribe from the shared tap — do NOT remove the audioMix + // because the FFT visualizer may still be using it. + AudioTapProcessor.shared.shazamHandler = nil + converter = nil + sourceFormat = nil } - /// Called from the C tap callback — dispatches to analysis queue to avoid blocking render thread. + /// Called from the shared tap callback — dispatches to analysis queue to avoid blocking render thread. func processTapBuffer(_ bufferList: UnsafeMutablePointer, frameCount: CMItemCount) { guard let session, let conv = converter, @@ -1733,16 +1714,6 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate { } } - 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() { @@ -1832,6 +1803,7 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate { private func stopAll() { timeoutTask?.cancel(); timeoutTask = nil removeTap() + let usedMic = audioEngine != nil if let engine = audioEngine { if engine.inputNode.numberOfInputs > 0 { engine.inputNode.removeTap(onBus: 0) @@ -1842,7 +1814,7 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate { session = nil // Restore audio session only if we used the mic fallback - if audioEngine != nil { + if usedMic { try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) try? AVAudioSession.sharedInstance().setActive(true) } @@ -1854,29 +1826,6 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate { } } -// 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 @@ -1972,6 +1921,10 @@ struct ShazamResultSheet: View { struct SongInfoSheet: View { let song: Song @Environment(\.dismiss) private var dismiss + @State private var shareURL: String? + @State private var isCreatingShare = false + @State private var shareError: String? + @State private var showCopiedToast = false private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) @@ -2011,6 +1964,73 @@ struct SongInfoSheet: View { let isDownloaded = OfflineManager.shared.isSongDownloaded(song.id) infoRow("Downloaded", isDownloaded ? "Yes" : "No") } + + // Share Link + Section("SHARE") { + if let url = shareURL { + HStack { + Text(url) + .font(.system(size: 12)) + .foregroundColor(accentPink) + .lineLimit(2) + Spacer() + Button(action: { + UIPasteboard.general.string = url + withAnimation { showCopiedToast = true } + Task { + try? await Task.sleep(for: .seconds(2)) + withAnimation { showCopiedToast = false } + } + }) { + Image(systemName: showCopiedToast ? "checkmark" : "doc.on.clipboard") + .font(.system(size: 14)) + .foregroundColor(showCopiedToast ? .green : accentPink) + } + } + + // System share sheet + if let shareLink = URL(string: url) { + ShareLink(item: shareLink) { + HStack { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 14)) + Text("Share via…") + .font(.system(size: 14)) + } + .foregroundColor(accentPink) + } + } + } else if isCreatingShare { + HStack { + ProgressView().tint(accentPink).scaleEffect(0.8) + Text("Creating share link…") + .font(.system(size: 13)) + .foregroundColor(.gray) + .padding(.leading, 8) + } + } else if let error = shareError { + VStack(alignment: .leading, spacing: 4) { + Text(error) + .font(.system(size: 13)) + .foregroundColor(.red) + Button("Try Again") { + Task { await createShareLink() } + } + .font(.system(size: 13, weight: .medium)) + .foregroundColor(accentPink) + } + } else { + Button(action: { Task { await createShareLink() } }) { + HStack { + Image(systemName: "link.badge.plus") + .font(.system(size: 14)) + Text("Create Share Link") + .font(.system(size: 14)) + } + .foregroundColor(accentPink) + } + } + } } .navigationTitle("Get Info") .navigationBarTitleDisplayMode(.inline) @@ -2022,6 +2042,28 @@ struct SongInfoSheet: View { } } + private func createShareLink() async { + isCreatingShare = true + shareError = nil + do { + // Expires in 7 days (ms since epoch) + let expires = Int64((Date().timeIntervalSince1970 + 7 * 86400) * 1000) + let share = try await ServerManager.shared.client.createShare( + id: song.id, + description: "\(song.title) — \(song.artist ?? "Unknown")", + expires: expires + ) + if let url = share?.url { + shareURL = url + } else { + shareError = "Server returned no share URL. Sharing may be disabled." + } + } catch { + shareError = error.localizedDescription + } + isCreatingShare = false + } + private func infoRow(_ label: String, _ value: String) -> some View { HStack { Text(label).foregroundColor(.gray) diff --git a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift index aeeefa3..005d9e2 100644 --- a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift +++ b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift @@ -130,6 +130,7 @@ class VisualizerSettings: ObservableObject { @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) } } + @Published var miniVisYOffset: Double { didSet { save("vis_mini_y_offset", miniVisYOffset) } } enum Style: String, CaseIterable, Codable { case wave = "Wave" @@ -198,6 +199,7 @@ class VisualizerSettings: ObservableObject { 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 + miniVisYOffset = d.object(forKey: "vis_mini_y_offset") as? Double ?? 0.0 } var effectiveFPS: Double { @@ -912,6 +914,9 @@ struct CompactVisualizerView: View { struct VisualizerSettingsView: View { @ObservedObject var settings = VisualizerSettings.shared @Environment(\.dismiss) private var dismiss + @State private var isCapturingTap = false + @State private var showShareSheet = false + @State private var capturedFileURL: URL? private let pink = Color(red: 1.0, green: 0.176, blue: 0.333) @@ -1042,6 +1047,46 @@ struct VisualizerSettingsView: View { } Section { Button("Reset to Defaults", role: .destructive) { resetDefaults() } } + + // ── Debug: Audio Tap Capture ───────────────────────────────── + Section { + if isCapturingTap { + HStack { + ProgressView().tint(pink) + Text("Capturing 5s of audio tap…") + .font(.system(size: 13)) + .foregroundColor(.gray) + .padding(.leading, 8) + } + } else { + Button(action: startTapCapture) { + HStack { + Image(systemName: "waveform.badge.mic") + .foregroundColor(.orange) + VStack(alignment: .leading) { + Text("Capture Audio Tap").foregroundColor(.white) + Text("Records 5s of raw tap output as WAV") + .font(.caption2).foregroundColor(.gray) + } + } + } + .disabled(!AudioPlayer.shared.isRadioStream) + } + + if let fmt = AudioTapProcessor.shared.sourceFormat { + HStack { + Text("Tap format") + .foregroundColor(.gray) + Spacer() + Text("\(Int(fmt.sampleRate))Hz \(fmt.channelCount)ch") + .font(.system(size: 12)) + .foregroundColor(.white) + } + .font(.system(size: 13)) + } + } header: { Text("DEBUG: AUDIO TAP") } footer: { + Text("Captures raw PCM from MTAudioProcessingTap as a playable WAV file. Only active during radio playback. Use to verify the tap is receiving real audio data.") + } } .navigationTitle("Visualizer") .navigationBarTitleDisplayMode(.inline) @@ -1050,8 +1095,34 @@ struct VisualizerSettingsView: View { Button("Done") { dismiss() }.foregroundColor(pink) } } + .sheet(isPresented: $showShareSheet) { + if let url = capturedFileURL { + ShareSheet(items: [url]) + } + } + .onAppear { + // Bug 12 fix: reset stuck spinner if capture was interrupted by background + if isCapturingTap && !AudioTapProcessor.shared.debugDumpEnabled { + isCapturingTap = false + } + } + .onReceive(NotificationCenter.default.publisher(for: AudioTapProcessor.captureCompleteNotification)) { note in + // Bug 11 fix: notification-based completion — works even if view was dismissed and re-opened + isCapturingTap = false + if let url = note.userInfo?["url"] as? URL { + capturedFileURL = url + showShareSheet = true + } + } } } + + // MARK: - Tap Capture + + private func startTapCapture() { + isCapturingTap = true + AudioTapProcessor.shared.startDebugDump() + } // MARK: - Presets @@ -1116,6 +1187,7 @@ struct VisualizerSettingsView: View { 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.miniVisYOffset = 0.0 } } @@ -1199,6 +1271,20 @@ struct ViewConfigSettingsView: View { 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") } + if isCompact { + sd("Height", value: Binding( + get: { settings.miniPlayerHeight }, + set: { settings.miniPlayerHeight = $0 } + ), range: 20...100, step: 2, format: "%.0f pt") + sd("Opacity", value: Binding( + get: { settings.miniOpacity }, + set: { settings.miniOpacity = $0 } + ), range: 0.1...1.0, step: 0.05, format: "%.2f") + sd("Vertical position", value: Binding( + get: { settings.miniVisYOffset }, + set: { settings.miniVisYOffset = $0 } + ), range: -20...20, step: 1, format: "%+.0f pt") + } } header: { Text("LAYOUT & AMPLITUDE") } // ── Depth & Idle ────────────────────────────────────────────────── diff --git a/project.yml b/project.yml index 2438875..bb2f38e 100644 --- a/project.yml +++ b/project.yml @@ -20,7 +20,7 @@ settings: base: SWIFT_VERSION: "5.9" MARKETING_VERSION: "1.0.0" - CURRENT_PROJECT_VERSION: "1" + CURRENT_PROJECT_VERSION: "10" DEAD_CODE_STRIPPING: true ENABLE_USER_SCRIPT_SANDBOXING: true DEVELOPMENT_TEAM: E9C9AGS9K6