UI scaling on scrollabile windows

This commit is contained in:
Dallas Groot 2026-04-17 09:58:05 -07:00
parent 72c622ab44
commit fba2da4700
24 changed files with 2123 additions and 155 deletions

View file

@ -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

View file

@ -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..<self.internalLevels.count { self.internalLevels[i] = 0 }
self.setLevels(self.internalLevels)
}
return
}
let bands = AudioTapProcessor.shared.computeFFTBands(bandCount: self.internalLevels.count)
// Apply viscosity smoothing (same physics as offline vis path)
let viscosity = Float(VisualizerSettings.shared.viscosity)
for i in 0..<self.internalLevels.count {
self.internalLevels[i] = self.internalLevels[i] * viscosity + bands[i] * (1 - viscosity)
}
self.setLevels(self.internalLevels)
}
}
#endif
// MARK: - Simulated Level Animation (for streams)
/// Radio-specific simulation: smooth sinusoidal wave that never spikes.
@ -1955,6 +2064,11 @@ class AudioPlayer: NSObject, ObservableObject {
}
private func stopAVPlayer() {
#if os(iOS)
// Clean up audio tap before nilling the playerItem prevents stale
// sourceFormat from confusing Shazam into skipping tap installation
AudioTapProcessor.shared.removeTap(from: playerItem)
#endif
player?.pause()
player?.replaceCurrentItem(with: nil)
if let observer = timeObserver {

View file

@ -52,6 +52,8 @@ struct SubsonicResponseBody: Codable {
let lyrics: LyricsResult?
let internetRadioStations: InternetRadioContainer?
let similarSongs2: SimilarSongsContainer?
let playQueue: PlayQueue?
let shares: SharesContainer?
}
struct SubsonicError: Codable {
@ -320,6 +322,35 @@ struct SimilarSongsContainer: Codable {
let song: [Song]?
}
// MARK: - Play Queue (cross-device resume)
struct PlayQueueContainer: Codable {
let playQueue: PlayQueue?
}
struct PlayQueue: Codable {
let entry: [Song]?
let current: String? // song ID of current track
let position: Int64? // position in ms
let changed: String? // ISO timestamp of last save
let changedBy: String? // client that saved
let username: String?
}
// MARK: - Shares (public links)
struct SharesContainer: Codable {
let share: [ShareItem]?
}
struct ShareItem: Codable, Identifiable {
let id: String
let url: String?
let description: String?
let username: String?
let created: String?
let expires: String?
let lastVisited: String?
let visitCount: Int?
let entry: [Song]?
}
// MARK: - Companion Library Models
// These map the Companion API /library/* responses.
// They convert to standard Song/Album objects using navidrome_id for stream URLs.

View file

@ -6,6 +6,9 @@ Endpoints (existing - unchanged):
POST /reindex
PATCH /edit-metadata
PATCH /batch-edit-metadata
POST /undo-batch-edit/{batch_id}
POST /restore-tags?relative_path=...
GET /batch-edit-history
POST /upload-track
POST /upload-tracks
GET /smart-dj/profile
@ -58,6 +61,7 @@ MUSIC_DIR = os.getenv("MUSIC_DIR", "/music")
DB_PATH = os.getenv("DB_PATH", "/app/data/smart_dj.db")
VIS_CACHE_DIR = os.getenv("VIS_CACHE_DIR", "/app/data/vis_cache")
COVER_ART_DIR = os.getenv("COVER_ART_DIR", "/app/data/cover_art")
TAG_BACKUP_DIR = os.getenv("TAG_BACKUP_DIR", "/app/data/tag_backups")
ARTIST_PHOTO_DIR = os.getenv("ARTIST_PHOTO_DIR", "/app/data/artist_photos")
NAVIDROME_URL = os.getenv("NAVIDROME_URL", "http://navidrome:4533/navidrome")
SUBSONIC_USER = os.getenv("SUBSONIC_USER")
@ -1180,6 +1184,294 @@ async def sync_navidrome_ids_task():
# cause album splitting, wrong grouping, and other library issues.
# Using a whitelist (keep only these) is safer than a blacklist (remove known bad ones)
# because it handles any future Picard tags automatically.
# ── Tag Backup System ────────────────────────────────────────────────────────
# Snapshots all tags before any destructive write. Enables per-file and
# batch-level undo. Backups are JSON files keyed by MD5 of the file path.
# Batch edits also create a manifest linking all affected files for bulk undo.
os.makedirs(TAG_BACKUP_DIR, exist_ok=True)
def _backup_key(full_path: str) -> 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 fieldvalue 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(...),

View file

@ -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

View file

@ -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<Float>
// 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<Float>, count: Int) {
let wi = _writeIndex
let space = capacity
for i in 0..<count {
buffer[(wi + i) % space] = samples[i]
}
_writeIndex = (wi + count) % space
}
/// Read the most recent `count` samples from the main thread. Lock-free.
/// Returns the number of samples actually copied.
@discardableResult
func readMostRecent(into dest: UnsafeMutablePointer<Float>, count: Int) -> Int {
let wi = _writeIndex
let start = (wi - count + capacity) % capacity
for i in 0..<count {
dest[i] = buffer[(start + i) % capacity]
}
return count
}
func reset() {
_writeIndex = 0
buffer.initialize(repeating: 0, count: capacity)
}
}
// MARK: - Audio Tap Processor
/// Installs an MTAudioProcessingTap on an AVPlayerItem and makes raw PCM samples
/// available for FFT visualization and Shazam recognition.
///
/// Architecture:
/// AVPlayerItem MTAudioProcessingTap (C callback on render thread)
/// PCMRingBuffer (lock-free)
/// Timer at 30fps reads buffer, runs vDSP FFT 30 bands setLevels()
/// Optional: Shazam consumer subscribes via `shazamHandler`
///
/// Thread safety:
/// - Tap callback: CoreAudio real-time render thread (no locks, no ObjC, no heap alloc)
/// - FFT timer: main thread
/// - Ring buffer: lock-free single-producer/single-consumer
final class AudioTapProcessor {
static let shared = AudioTapProcessor()
// Ring buffer: 8192 samples 186ms at 44.1kHz plenty for 1024-sample FFT windows
let ringBuffer = PCMRingBuffer(capacity: 8192)
// Shazam consumer set by ShazamRecognizer, cleared when done
var shazamHandler: ((UnsafeMutablePointer<AudioBufferList>, 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..<halfSize { fftRealp[i] = 0; fftImagp[i] = 0 }
// Run FFT using withUnsafeMutableBufferPointer to get stable pointers
// that outlive the DSPSplitComplex init call.
fftRealp.withUnsafeMutableBufferPointer { realpBuf in
fftImagp.withUnsafeMutableBufferPointer { imagpBuf in
var splitComplex = DSPSplitComplex(
realp: realpBuf.baseAddress!,
imagp: imagpBuf.baseAddress!
)
fftTimeDomain.withUnsafeBufferPointer { ptr in
ptr.baseAddress!.withMemoryRebound(to: DSPComplex.self, capacity: halfSize) {
vDSP_ctoz($0, 2, &splitComplex, 1, vDSP_Length(halfSize))
}
}
vDSP_fft_zrip(fftSetup, &splitComplex, 1, fftLog2n, FFTDirection(kFFTDirection_Forward))
vDSP_zvmags(&splitComplex, 1, &fftMagnitudes, 1, vDSP_Length(halfSize))
}
}
// Convert to dB scale magnitude 0 -inf, clamped to 0 in normalization step
var one: Float = 1.0
vDSP_vdbcon(fftMagnitudes, 1, &one, &fftMagnitudes, 1, vDSP_Length(halfSize), 1)
// Map to frequency bands with logarithmic spacing (more bass/mid resolution)
var bands = [Float](repeating: 0, count: bandCount)
let maxBin = min(halfSize, 300) // Cap at ~13kHz (300/512 * 22050)
for i in 0..<bandCount {
let lowPct = Float(i) / Float(bandCount)
let highPct = Float(i + 1) / Float(bandCount)
let lowBin = Int(powf(lowPct, 2.0) * Float(maxBin))
let highBin = max(lowBin + 1, Int(powf(highPct, 2.0) * Float(maxBin)))
let clampedHigh = min(highBin, maxBin)
if lowBin < clampedHigh {
var sum: Float = 0
var count: Float = 0
for bin in lowBin..<clampedHigh {
sum += fftMagnitudes[bin]
count += 1
}
bands[i] = sum / count
}
}
// Normalize: map dB range to 0.0-1.0
let minDB: Float = -50
let maxDB: Float = 15
let range = maxDB - minDB
for i in 0..<bandCount {
bands[i] = max(0, min(1, (bands[i] - minDB) / range))
}
return bands
}
// MARK: - Debug Dump (WAV file)
/// Start capturing audio tap output to a WAV file. Captures 5 seconds then auto-stops.
func startDebugDump() {
stopDebugDump()
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("audio_tap_capture_\(Int(Date().timeIntervalSince1970)).wav")
debugDumpURL = url
// Use the actual stream sample rate, default 44100 if unknown
let sampleRate = UInt32(sourceFormat?.sampleRate ?? 44100)
debugMaxSamplesActual = Int(sampleRate) * 5 // 5 seconds at actual rate
// Write WAV header placeholder (44 bytes) we'll patch the size fields when done
var header = Data(count: 44)
header.withUnsafeMutableBytes { ptr in
let p = ptr.baseAddress!.assumingMemoryBound(to: UInt8.self)
// "RIFF"
p[0] = 0x52; p[1] = 0x49; p[2] = 0x46; p[3] = 0x46
// File size placeholder (patch later)
// "WAVE"
p[8] = 0x57; p[9] = 0x41; p[10] = 0x56; p[11] = 0x45
// "fmt "
p[12] = 0x66; p[13] = 0x6D; p[14] = 0x74; p[15] = 0x20
// Chunk size: 16
p[16] = 16; p[17] = 0; p[18] = 0; p[19] = 0
// Format: IEEE float (3)
p[20] = 3; p[21] = 0
// Channels: 1
p[22] = 1; p[23] = 0
// Sample rate (from actual stream format)
let sr = sampleRate
p[24] = UInt8(sr & 0xFF); p[25] = UInt8((sr >> 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<Float>.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<Float>, 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<Float>.size)
debugFileHandle?.write(data)
debugSamplesWritten += count
}
}
// MARK: - C Tap Callbacks (free functions, not methods)
private func tapInit(
tap: MTAudioProcessingTap,
clientInfo: UnsafeMutableRawPointer?,
tapStorageOut: UnsafeMutablePointer<UnsafeMutableRawPointer?>
) {
tapStorageOut.pointee = clientInfo
}
private func tapPrepare(
tap: MTAudioProcessingTap,
maxFrames: CMItemCount,
processingFormat: UnsafePointer<AudioStreamBasicDescription>
) {
let processor = Unmanaged<AudioTapProcessor>
.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<AudioBufferList>,
numberFramesOut: UnsafeMutablePointer<CMItemCount>,
flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
) {
// 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<AudioTapProcessor>
.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)
}
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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

View file

@ -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 {

View file

@ -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) {

View file

@ -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")

View file

@ -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) {

View file

@ -123,6 +123,8 @@ struct LicensesView: View {
} header: {
Text("Acknowledgments")
}
Section {} footer: { Spacer().frame(height: 80) }
}
.navigationTitle("Licenses")
}

View file

@ -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.

View file

@ -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)

View file

@ -154,6 +154,7 @@ struct RadioView: View {
ForEach(stations) { station in
stationRow(station)
}
Section {} footer: { Spacer().frame(height: 80) }
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)

View file

@ -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")
}

View file

@ -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<Void, Never>?
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<ShazamRecognizer>
.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<AudioBufferList>,
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<AudioBufferList>,
numberFramesOut: UnsafeMutablePointer<CMItemCount>,
flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
) {
// 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<ShazamRecognizer>
.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)

View file

@ -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

View file

@ -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