From d0cfb4053aff7f7c93d5dbbfe209049cbaf2e58a Mon Sep 17 00:00:00 2001 From: dallasgroot Date: Fri, 10 Apr 2026 00:31:29 -0700 Subject: [PATCH] revert 63114f02fb1b060ec73d3c09ba268baa1aea382e revert fixed project info --- README.md | 207 +++++++++++++----- iOS/Resources/Info.plist | 149 ++++++------- .../Visualizer/MitsuhaVisualizerView.swift | 79 +++---- project.yml | 6 +- watchOS/Resources/Info.plist | 30 +-- 5 files changed, 276 insertions(+), 195 deletions(-) diff --git a/README.md b/README.md index d0a85c7..1c31a75 100644 --- a/README.md +++ b/README.md @@ -5,34 +5,44 @@ A native iOS + watchOS music player for [Navidrome](https://www.navidrome.org/) ## Features ### iOS App + - **Full Navidrome/Subsonic library** — browse by albums, artists, songs, genres, playlists -- **Offline playback** — download songs for offline listening with per-track and per-album downloads -- **Mitsuha visualizer** — real-time FFT waveform visualization with Catmull-Rom spline rendering, multiple styles (wave, bar, line), color modes (dynamic, album art, custom), and configurable presets +- **Offline playback** — download songs for offline listening with per-track and per-album downloads; all song rows grey out and disable when network or server is unavailable +- **Mitsuha visualizer** — real-time FFT waveform visualization with Catmull-Rom spline rendering, multiple styles (wave, bar, line), color modes (dynamic, album art, Siri, custom), and configurable presets; compact wave morphs between mini player and Dynamic Island using `matchedGeometryEffect` - **Now Playing** — full-screen player with album art color extraction, drag-to-dismiss, AirPlay, seek bar, queue management - **Mini player** — persistent bottom bar with scrubbable progress, visualizer overlay, transport controls -- **Radio streaming** — live radio with HLS/PLS/M3U playlist resolution, timeshift buffering, recording, and Shazam identification -- **Smart DJ** — crossfade engine with silence skipping, loudness normalization (LUFS), and predictive transitions (requires Companion API) +- **Dynamic Island** — pill morphs from mini player with spring animation; album art and visualizer fly between contexts using matched geometry +- **Radio streaming** — live radio with HLS/PLS/M3U playlist resolution, timeshift buffering, recording, and Shazam identification; stations grey out and show offline banner when server is unreachable +- **Smart DJ** — crossfade engine with silence skipping, loudness normalization (LUFS), predictive transitions, and full queue reactivity (requires Companion API) +- **Playlist reorder** — drag-and-drop song reorder within playlist detail, synced to server via `updatePlaylist` - **Batch metadata editing** — edit album/artist tags across multiple albums simultaneously (requires Companion API) +- **Zip import** — import audio files via ZIPFoundation; extraction and batch upload with background `URLSession` - **Custom album covers** — long-press album art to replace with photos from your library - **Multi-server support** — automatic failover between servers with the same credentials - **Background audio** — Lock Screen / Control Center controls, Dynamic Island support -- **Keyboard dismiss** — tap anywhere outside text fields or scroll to dismiss +- **Dynamic status bar** — status bar icon colour (light/dark) adapts to album art brightness via UIKit `preferredStatusBarStyle` override; decoupled from SwiftUI `preferredColorScheme` so the rest of the UI always stays dark +- **Download progress** — circular arc + thin progress bar on song cover art during downloads across all song-list views (Albums, Songs, Playlists, Search) ### watchOS App + - **Offline playback** — transfer songs from iPhone to Apple Watch for standalone listening - **Bluetooth + Speaker mode** — Apple Watch Ultra speaker support via HKWorkoutSession - **Crown control** — Digital Crown volume control with haptic feedback - **Compact visualizer** — waveform visualization on watch face ### Companion API (Optional) + A Python FastAPI server that runs alongside Navidrome on your server (e.g., Raspberry Pi) providing: + - **Smart DJ analysis** — BPM detection, silence boundary mapping, LUFS loudness measurement - **Mitsuha visualizer pre-computation** — generate FFT frames on the server instead of on-device - **Remote ID3 tag editing** — edit metadata on server files via mutagen -- **File uploads** — upload and auto-tag new music via the app -- **WebSocket push** — real-time notifications to the iOS app when metadata changes or uploads complete +- **File uploads** — upload and auto-tag new music via the app; ZIP archives extracted via ZIPFoundation +- **WebSocket push** — real-time notifications to the iOS app; metadata updates and track uploads trigger an immediate library delta-sync in the app - **Navidrome scan trigger** — automatically rescan after tag edits +----- + ## Architecture ``` @@ -42,15 +52,17 @@ NavidromePlayer/ │ ├── Audio/AudioPlayer.swift # AVPlayer + AVAudioEngine, FFT, visualizer levels │ ├── Models/Models.swift # Codable models for all API responses │ └── Storage/ -│ ├── LibraryCache.swift # Disk cache for instant offline browsing +│ ├── LibraryCache.swift # Disk cache + NWPathMonitor + isServerAvailable │ ├── OfflineManager.swift # Download manager with progress tracking │ ├── ServerManager.swift # Multi-server with auto-failover │ └── WatchConnectivityManager.swift # WCSession file transfers │ ├── iOS/ -│ ├── App/NavidromePlayerApp.swift # Entry point, background upload delegate +│ ├── App/ +│ │ ├── NavidromePlayerApp.swift # Entry point, BGTask registration +│ │ └── NavidromeSceneDelegate.swift # UIWindowSceneDelegate, NavidromeHostingController │ └── Views/ -│ ├── Common/ # MainTabView, MiniPlayer, AsyncCoverArt, DebugConsole +│ ├── Common/ # MainTabView, MiniPlayer, DynamicIslandView, AsyncCoverArt, DebugConsole │ ├── Companion/ # Companion API integration │ │ ├── CompanionAPIService.swift # API client + WebSocket push client │ │ ├── CompanionSettingsView.swift # Config, Smart DJ toggles, analysis triggers @@ -59,25 +71,27 @@ NavidromePlayer/ │ │ ├── BatchAlbumEditorSheet.swift # Edit tags for one album │ │ ├── MultiAlbumEditorSheet.swift # Batch edit across multiple albums │ │ ├── BatchUploadView.swift # Zip import + batch upload -│ │ └── ZipImportManager.swift # Background URLSession uploads +│ │ └── ZipImportManager.swift # ZIPFoundation extraction + background URLSession uploads │ ├── Library/ # MyMusic, Albums, Artists, Playlists, Search, Radio, Downloads │ ├── Login/ # Server configuration │ ├── NowPlaying/ # Full player, SiriSeekBar, RadioStreamBuffer, Shazam -│ └── Visualizer/ # MitsuhaVisualizerView, OfflineAudioAnalyzer, storage +│ └── Visualizer/ # MitsuhaVisualizerView, OfflineAudioAnalyzer, storage │ ├── watchOS/ │ ├── App/ # Watch app entry, offline store, session manager │ ├── Audio/WatchAudioPlayer.swift # Dual mode: Bluetooth + Ultra speaker -│ └── Views/ # Library, NowPlaying, Setup, Visualizer +│ └── Views/ # Library, NowPlaying, Setup │ -├── project.yml # XcodeGen project definition +├── project.yml # XcodeGen project definition (includes ZIPFoundation SPM) └── generate.sh # Regenerate .xcodeproj ``` +----- + ## Requirements -- **iOS 17.0+** / **watchOS 10.0+** -- **Xcode 15+** +- **iOS 26.0+** / **watchOS 26.0+** +- **Xcode 26+** - **XcodeGen** — `brew install xcodegen` - **Navidrome server** — any version with Subsonic API support @@ -90,7 +104,50 @@ NavidromePlayer/ Set your Apple Developer Team ID in `project.yml` under `DEVELOPMENT_TEAM`. ---- +> **ZIPFoundation** is declared as an SPM dependency in `project.yml`. Xcode resolves it automatically after running `./generate.sh`. + +----- + +## Key Architecture Notes + +### Offline Availability (`LibraryCache.isServerAvailable`) + +All song-list views use `LibraryCache.shared.isServerAvailable` to decide whether to grey out and disable songs. This property combines two signals: + +1. **NWPathMonitor** — detects network loss (`isOffline`) +1. **ServerManager.connectionState** — detects server unreachability even when the network is up + +`LibraryCache` subscribes to `ServerManager.$connectionState` via Combine and fires `objectWillChange`, so all observing views update reactively when the server connects or drops. A `hasEverConnected` guard prevents songs flashing grey on first launch while the initial ping is in-flight. + +### Status Bar Dynamic Coloring + +`preferredStatusBarStyle` is decoupled from SwiftUI’s `preferredColorScheme`. Architecture: + +- `NavidromeSceneDelegate` (in `iOS/App/NavidromeSceneDelegate.swift`) creates the window manually with `NavidromeHostingController` as root +- `NavidromeHostingController: UIHostingController` overrides `preferredStatusBarStyle` to read from `AlbumColorExtractor.shared.preferredStatusBarStyle` +- `AlbumColorExtractor` derives UIKit style from album art HSB brightness (threshold: 0.88) and publishes it — the scene delegate subscribes and calls `setNeedsStatusBarAppearanceUpdate()` on change +- `NowPlayingView` keeps `.preferredColorScheme(.dark)` so all SwiftUI views stay dark; only the status bar icons flip + +### Smart DJ / Crossfade Queue Reactivity + +`SmartCrossfadeManager` (dual-AVPlayer A/B engine) stays in sync with the queue through: + +- All queue mutations (`playNext`, `playLater`, `moveQueueItem`, `removeFromQueue`) call `notifyQueueChanged()` +- If not crossfading: immediately calls `prepareNextForCrossfade()` to load the new `queue[queueIndex+1]` into standby +- If mid-crossfade: sets `queueChangedDuringFade = true` to avoid interrupting audio; `finalizeCrossfade()` calls `needsNextTrack()` → `prepareNextForCrossfade()` which reads current queue state +- `needsNextTrack` syncs `queueIndex` by searching for `pendingNextSong.id` in the current queue (handles reorder and removal); falls back to sequential advancement if the song was removed +- Scrobble fires in `needsNextTrack` (not `playerDidFinish`) to avoid double-counting on the crossfade path +- Safety-net `AVPlayerItemDidPlayToEndTime` observer is re-registered on every crossfade slot swap + +### Companion Push → Library Sync + +`SyncEngine` subscribes to `.companionMetadataUpdated` and `.companionTrackUploaded` notifications (posted by `CompanionPushClient`) via Combine. Events are debounced 2 seconds and trigger a delta sync, so uploads and tag edits appear in the library immediately rather than waiting for the 15-minute background refresh. + +### WaveStateCache (Visualizer Morph Continuity) + +`WaveStateCache.shared` stores the most recent compact visualizer `displayLevels` array. When the wave flies between `MiniPlayerBar` and `DynamicIslandView` via `matchedGeometryEffect`, the incoming view seeds from the cache so the wave shape is continuous rather than resetting to flat idle. + +----- ## Companion API @@ -101,16 +158,16 @@ The Companion API is optional. Without it, the app works as a standard Navidrome ``` /home/pi/docker/navidrome/ ├── docker-compose.yml # Both Navidrome + Companion -├── navidrome_data/ # Navidrome database (auto-created) -├── companion_data/ # Persistent data (auto-created) -│ ├── smart_dj.db # BPM, silence, loudness profiles -│ └── vis_cache/ # Pre-computed Mitsuha FFT frames -└── companion_api/ # Companion API source code +├── navidrome_data/ # Navidrome database (auto-created) +├── companion_data/ # Persistent data (auto-created) +│ ├── smart_dj.db # BPM, silence, loudness profiles +│ └── vis_cache/ # Pre-computed Mitsuha FFT frames +└── companion_api/ # Companion API source code ├── Dockerfile ├── main.py └── pre_analyze.py -/home/pi/navidrome/music/ # Your music library +/home/pi/navidrome/music/ # Your music library ├── Artist/Album/song.flac └── ... ``` @@ -175,7 +232,7 @@ docker compose exec music-companion python pre_analyze.py --dj # DJ profile docker compose exec music-companion python pre_analyze.py --vis # Visualizer frames only docker compose exec music-companion python pre_analyze.py --force # Re-analyze everything -# ── Reset Analysis Data ───────────────────────────────── +# ── Reset Analysis Data ────────────────────────────────── rm companion_data/smart_dj.db # Delete DJ profiles rm -rf companion_data/vis_cache/* # Delete vis frame cache docker compose exec music-companion python pre_analyze.py # Regenerate from scratch @@ -188,47 +245,61 @@ docker compose down -v # Stop + remove volu ### API Endpoints -| Method | Endpoint | Description | -|--------|----------|-------------| -| `GET` | `/health` | Server status, profile count, vis cache count, connected clients | -| `PATCH` | `/edit-metadata` | Edit ID3 tags (JSON body: `relative_path`, `title`, `artist`, `album`, etc.) | -| `POST` | `/upload-track` | Upload audio file with metadata (multipart: `file`, `title`, `artist`, `album`) | -| `GET` | `/smart-dj/profile?relative_path=...` | BPM, silence start/end, LUFS for a track | -| `GET` | `/smart-dj/bulk-profiles?paths=...` | Batch fetch profiles (comma-separated paths) | -| `GET` | `/visualizer/frames?relative_path=...` | Pre-computed Mitsuha FFT frames (JSON array) | -| `POST` | `/visualizer/precompute` | Trigger background vis frame generation for all tracks | -| `POST` | `/bulk-fix` | Trigger Navidrome library rescan | -| `WS` | `/ws/push` | Real-time push events to iOS app | +|Method |Endpoint |Description | +|-------|--------------------------------------|-------------------------------------------------------------------------------| +|`GET` |`/health` |Server status, profile count, vis cache count, connected clients | +|`PATCH`|`/edit-metadata` |Edit ID3 tags (JSON body: `relative_path`, `title`, `artist`, `album`, etc.) | +|`POST` |`/upload-track` |Upload audio file with metadata (multipart: `file`, `title`, `artist`, `album`)| +|`GET` |`/smart-dj/profile?relative_path=...` |BPM, silence start/end, LUFS for a track | +|`GET` |`/smart-dj/bulk-profiles?paths=...` |Batch fetch profiles (comma-separated paths) | +|`GET` |`/visualizer/frames?relative_path=...`|Pre-computed Mitsuha FFT frames (JSON array) | +|`POST` |`/visualizer/precompute` |Trigger background vis frame generation for all tracks | +|`POST` |`/bulk-fix` |Trigger Navidrome library rescan | +|`WS` |`/ws/push` |Real-time push events to iOS app | ### Path Resolution -Navidrome's `song.path` field can differ from the actual filesystem path. The Companion API uses a three-strategy resolver: +Navidrome’s `song.path` field can differ from the actual filesystem path. The Companion API uses a four-strategy resolver: + 1. **Direct join** — `MUSIC_DIR + relative_path` -2. **Strip prefix** — removes leading components one at a time (handles Navidrome's library folder prefix) -3. **Filename search** — walks the music directory looking for an exact filename match +1. **Strip prefix** — removes leading components one at a time (handles Navidrome’s library folder prefix) +1. **Filename search** — walks the music directory for an exact filename match +1. **Fuzzy DB match** — searches `file_index` SQLite table with 50% word-match threshold If a 404 still occurs, the error response includes the exact paths that were tried for debugging. ### iOS App Configuration 1. **Settings** → **Companion API** -2. Enter your server's IP address and port (default: 8000) -3. Toggle **Enable Companion API** -4. Tap **Test Connection** to verify -5. Toggle **Smart DJ** for crossfade and analysis features +1. Enter your server’s IP address and port (default: 8000) +1. Toggle **Enable Companion API** +1. Tap **Test Connection** to verify +1. Toggle **Smart DJ** for crossfade and analysis features ---- +----- ## Feature Details ### Mitsuha Visualizer The visualizer renders smooth liquid waveforms using Catmull-Rom splines with configurable tension. FFT data comes from one of three sources: + - **Real-time engine FFT** — AVAudioEngine tap on local files - **Offline pre-analyzed frames** — cached FFT data synced to playback position - **Server-computed frames** — downloaded from Companion API (saves device battery) -Settings include per-view configuration for Now Playing and Mini Player independently, with four built-in presets. The mini player visualizer automatically pauses when the full-screen Now Playing view is open (battery optimization). +The compact visualizer (mini player, Dynamic Island) uses `WaveStateCache` to preserve wave shape across the `matchedGeometryEffect` flight animation, and `matchedGeometryEffect(id: "visWave")` to physically squish/morph the wave between positions rather than cutting. Settings include per-view configuration for Now Playing and Mini Player independently. The mini player visualizer pauses when the full-screen Now Playing view is open (battery optimisation). + +> **Do not refactor `WavePhysics` or the `Canvas` rendering.** It is heavily optimised with Catmull-Rom splines, touch ripples, temporal smoothing via viscosity, and a phase-shifted depth shadow layer for liquid parallax. Breaking any part of this will degrade visual quality significantly. + +### Smart DJ / Crossfade + +- **Silence detection** — `silence_start` is where *trailing* silence begins (crossfade trigger); `silence_end` is where *leading* silence ends (seek-to point). These fields are intentionally backwards from what you’d expect. +- **LUFS normalization** — volume scaled as `pow(10, gainDB/20)` clamped to 0.05–1.0 +- **Dual-AVPlayer A/B** — standby player pre-loaded and seeked past leading silence while active player fades out +- **Queue reactivity** — see *Smart DJ / Crossfade Queue Reactivity* above +- **Scrobble** — fires in `needsNextTrack` on the crossfade path; fires in `playerDidFinish` on the normal AVPlayer path (guarded against double-fire) +- **Visualizer handoff** — at 50% crossfade midpoint, `visualizerHandoff` callback triggers loading the incoming song’s offline vis frames so they’re ready by the time the fade completes ### Radio @@ -238,41 +309,73 @@ Settings include per-view configuration for Now Playing and Mini Player independ - **Recording** — capture radio segments to local files - **Shazam** — identify currently playing content via SHSession + dedicated AVAudioEngine mic tap - **Recorded playback** — recorded radio files play with ±5s skip buttons instead of prev/next -- **Live toggle** — start/stop buffering, snap to live edge +- **Offline** — stations dim and show an orange banner when server is unreachable; taps are blocked + +### Playlist Reorder + +Playlist detail view has a **Reorder** toolbar button that toggles a `List` with `.onMove` + `.environment(\.editMode, .constant(.active))`. Drag to reorder locally (optimistic update), synced to server via `SubsonicClient.reorderPlaylist()` which removes all indices then re-adds in new order atomically. Reverts to server state on error. ### Batch Tag Editing Long-press any album to: -- **Edit Album** — edit tags for all tracks in that album -- **Select Albums** — enter multi-select mode (nav bar transforms: Cancel / count / Edit button), tap albums to select, "Select All" toggle, then apply changes across all selected albums -The "Set same artist on all tracks" toggle fixes compilation albums that split into separate per-artist entries in Navidrome. +- **Edit Album** — edit tags for all tracks in that album +- **Select Albums** — enter multi-select mode (nav bar transforms: Cancel / count / Edit button), tap albums to select, “Select All” toggle, then apply changes across all selected albums + +The “Set same artist on all tracks” toggle fixes compilation albums that split into separate per-artist entries in Navidrome. + +### Zip Import + +`ZipImportManager` uses [ZIPFoundation](https://github.com/weichsel/ZIPFoundation) (SPM, iOS target only) to extract archives. The previous `NSFileCoordinator(.forUploading)` approach was incorrect — it re-compressed the source URL rather than extracting it. After extraction, audio files are staged and uploaded via background `URLSession` with per-file progress tracking. ### Downloads Split into two sub-tabs: + - **Offline** — storage usage, downloaded songs with playback, swipe to delete - **Watch** — Apple Watch connection status, pending transfers with progress, songs on watch, send all / delete all +Download progress shows as a circular arc overlay on cover art plus a thin capsule progress bar under the song title — consistent across Albums, Songs, Playlists, and Search views. + ### Debug Console Toggle in Settings → Developer. Two display modes: + - **Docked** — panel above the tab bar, drag handle to resize (120–500pt) - **PiP** — floating draggable/resizable window with collapse, dock-back, and close buttons +All iOS-path log calls route through `DebugLogger` via `alog()` / `wlog()` helpers. watchOS falls back to `print()` since `DebugLogger` is iOS-only. + ### Caching All views use cache-first loading to eliminate spinners on warm launches: -- **LibraryCache** — albums, artists, playlists, genres, album/artist details as JSON -- **ImageCache** — two-tier NSCache + disk JPEG with 200MB limit and LRU eviction + +- **LibraryCache** — albums, artists, playlists, genres, album/artist details as JSON; also owns `isServerAvailable` combining NWPathMonitor + ServerManager state +- **ImageCache** — two-tier NSCache + disk JPEG with 200 MB limit and LRU eviction - **SmartDJCache** — Smart DJ profiles cached locally per song path +- **WaveStateCache** — compact visualizer levels for morph continuity across DI transitions - **AlbumCoverStore** — user-set custom album covers in Documents - **VisualizerStorageManager** — pre-analyzed FFT frames per song Detail views load from cache instantly, refresh from server silently, and show a Retry button if both cache and server are unavailable. ---- +----- + +## Critical Rules for Future Developers + +1. **DO NOT** refactor `WavePhysics` or `Canvas` rendering in `MitsuhaVisualizerView` +1. **DO NOT** make `AudioPlayer.currentTime` / `duration` `@Published` — causes 10 Hz full-hierarchy rerenders; leaf views poll via local `Timer.publish` +1. **DO NOT** use `Menu` in `NowPlayingView` — causes `_UIReparentingView` errors; use `Button + .sheet` +1. **DO NOT** use `.contextMenu` on watchOS — deprecated; use `.swipeActions` +1. **DO NOT** use `UIScreen.main` — deprecated iOS 26; use `GeometryReader` +1. **DO NOT** use `sheet(isPresented:)` with `if let` — causes timing bugs; use `sheet(item:)` +1. **DO NOT** use `.preferredColorScheme` to control status bar style — it cascades to the entire view hierarchy and flips all system colors; use the `NavidromeHostingController` UIKit bridge instead +1. `silence_start` from the Companion API is **trailing** silence; `silence_end` is **leading** silence — this is backwards from what you’d expect +1. `song.path` from Navidrome is a virtual ID3-derived path — never treat it as a real filesystem path +1. `album_artist` is the field Navidrome groups albums by — not `artist` + +----- ## License -Personal project. Not affiliated with Navidrome. +Personal project. Not affiliated with Navidrome. \ No newline at end of file diff --git a/iOS/Resources/Info.plist b/iOS/Resources/Info.plist index 75dcd29..a0fd007 100644 --- a/iOS/Resources/Info.plist +++ b/iOS/Resources/Info.plist @@ -2,91 +2,70 @@ - CFBundleDevelopmentRegion - en - CFBundleDisplayName - Navidrome - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - + BGTaskSchedulerPermittedIdentifiers + + ca.dallasgroot.navidromeplayer.smartdj.refresh + ca.dallasgroot.navidromeplayer.library.sync + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Navidrome + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSMicrophoneUsageDescription + Identify songs playing on the radio using Shazam + NSPhotoLibraryUsageDescription + Choose cover images for your radio stations + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + audio + fetch + processing + + + UILaunchScreen + - - UIBackgroundModes - - audio - fetch - processing - - - - BGTaskSchedulerPermittedIdentifiers - - com.navidromeplayer.smartdj.refresh - com.navidromeplayer.library.sync - - - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).NavidromeSceneDelegate - - - - - - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - - - ITSAppUsesNonExemptEncryption - - - - NSPhotoLibraryUsageDescription - Choose a cover image for your radio stations. - NSMicrophoneUsageDescription - Identify songs playing nearby using Shazam. + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + diff --git a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift index 623cfdc..b7d9fb6 100644 --- a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift +++ b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift @@ -177,16 +177,12 @@ struct MitsuhaVisualizerView: View { TimelineView(.animation) { timeline in if settings.enabled { Canvas { context, size in - // Use CACurrentMediaTime for consistent delta-time across both functions. - // Previously updateDisplayLevels used CACurrentMediaTime() but - // updateWobblePhase stored lastTickTime from timeIntervalSinceReferenceDate — - // mixing two clocks made dt ≈ -753,000,000, clamped to 0.001, giving - // smoothFactor ≈ 0.01 and a ~2-second response lag. - let t = CACurrentMediaTime() + // timeline.date changes every frame — SwiftUI must re-execute this closure. + let t = timeline.date.timeIntervalSinceReferenceDate let rawLevels: [Float] = isPlaying ? (previewLevels ?? AudioPlayer.shared.currentLevels()) : Array(repeating: Float(0), count: max(settings.numberOfPoints, 1)) - updateDisplayLevels(newRawLevels: rawLevels, t: t) + updateDisplayLevels(newRawLevels: rawLevels) updateWobblePhase(t: t) let pts = box.displayLevels.isEmpty @@ -235,17 +231,13 @@ struct MitsuhaVisualizerView: View { // MARK: - Temporal Smoothing & Log Binning @discardableResult - private func updateDisplayLevels(newRawLevels: [Float], t: Double) -> Bool { + private func updateDisplayLevels(newRawLevels: [Float]) -> Bool { let count = settings.numberOfPoints guard count > 0, !newRawLevels.isEmpty else { return false } - - // Delta time from the same CACurrentMediaTime clock as updateWobblePhase. - // lastTickTime is 0 on first frame — fall back to 60fps assumption. - let dt = Float(box.lastTickTime > 0 ? min(t - box.lastTickTime, 0.1) : 1.0/60.0) - + let sens = Float(settings.sensitivity) let isPreProcessed = AudioPlayer.shared.isUsingOfflineVis - + var targetLevels: [Float] if isPreProcessed { @@ -265,29 +257,40 @@ struct MitsuhaVisualizerView: View { } } } else { - // Raw FFT bins or simulated levels — full log binning + // Raw FFT bins or simulated levels — full log binning + EQ boost targetLevels = [Float](repeating: 0, count: count) let maxUsefulBin = min(newRawLevels.count - 1, settings.frequencyCutoff) for i in 0.. 0 ? (sum / Float(countInBand)) : 0 targetLevels[i] = min(1.0, averageInBand * Float(settings.baseMultiplier) * sens) } } - // Temporal smoothing — viscosity 0.17 at 60fps → smoothFactor ≈ 0.17 per frame + // Temporal Smoothing — delta-time based so it's frame-rate independent. + // Use elapsed time since last tick to compute smoothFactor rather than + // assuming a fixed fps — this prevents lag when running at 60/120fps. + let dt = Float(max(box.lastTickTime > 0 ? min(CACurrentMediaTime() - box.lastTickTime, 0.1) : 1.0/60.0, 0.001)) + // viscosity 0.05 = very slow, 1.0 = instant. Scale by 60*dt so behaviour + // at 60fps matches the original fpsScale=1 viscosity settings. let smoothFactor = min(Float(settings.viscosity) * 60.0 * dt, 1.0) // ── Dynamic Gain / Peak Follower ───────────────────────────────────── @@ -784,15 +787,15 @@ struct VisualizerSettingsView: View { } Section { - sliderRowDouble("Viscosity", value: $settings.viscosity, range: 0.05...1.0, step: 0.01, format: "%.2f") - sliderRow("Frequency cutoff", value: $settings.frequencyCutoff, range: 40...150, step: 5) - sliderRowDouble("Base multiplier", value: $settings.baseMultiplier, range: 5.0...50.0, step: 1.0, format: "%.0f") - sliderRowDouble("Sensitivity", value: $settings.sensitivity, range: 0.1...2.0, step: 0.1, format: "%.1f") + sliderRowDouble("Viscosity", value: $settings.viscosity, range: 0.05...1.0, step: 0.05, format: "%.2f") + sliderRow("Frequency cutoff", value: $settings.frequencyCutoff, range: 40...200, step: 10) + sliderRowDouble("Base multiplier", value: $settings.baseMultiplier, range: 10.0...100.0, step: 5.0, format: "%.0f") + sliderRowDouble("Sensitivity", value: $settings.sensitivity, range: 0.1...4.0, step: 0.1, format: "%.1f") sliderRowDouble("FPS", value: $settings.fps, range: 15...60, step: 1, format: "%.0f") Toggle("Real Audio Analysis", isOn: $settings.realAudioAnalysis).tint(pink) Toggle("Dynamic Gain", isOn: $settings.dynamicGainEnabled).tint(pink) } header: { Text("SHARED / ADVANCED") } footer: { - Text("Viscosity: 0.10–0.20 = heavy, slow liquid (Mitsuha). 0.4+ = snappy EQ. This is the most important slider.\n\nSensitivity: multiplies incoming audio. 0.8–1.0 is the sweet spot. Above 1.5 causes the wave to clip at the top.\n\nBase Multiplier: overall gain for FFT data. 15–25 works well. Higher values are needed for quieter recordings.\n\nFrequency Cutoff: limits how many FFT bins are used. 60–80 gives mostly bass and mids. 100+ adds treble detail.\n\nDynamic Gain: recommended ON — normalises amplitude across loud and quiet tracks.") + Text("Viscosity controls how quickly the wave reacts. 0.15–0.25 = heavy liquid. 0.5 = responsive. 0.8+ = snappy EQ.\n\nSensitivity multiplies incoming audio. Base multiplier is a second gain stage for FFT.\n\nDynamic Gain automatically normalises the wave amplitude across loud and quiet tracks — keeps the wave full on soft passages without clipping on loud ones.\n\nFPS drops to 24 in Low Power Mode.") } // Presets @@ -852,16 +855,16 @@ struct VisualizerSettingsView: View { // MARK: - Presets private func applyDeepOcean() { - settings.numberOfPoints = 6; settings.viscosity = 0.12; settings.sensitivity = 0.8 - settings.npAmplitude = 0.75; settings.depthOffset = 30; settings.frequencyCutoff = 50; settings.baseMultiplier = 22 + settings.numberOfPoints = 6; settings.viscosity = 0.15; settings.sensitivity = 2.0 + settings.npAmplitude = 0.8; settings.depthOffset = 30; settings.frequencyCutoff = 50 } private func applyReactiveEQ() { - settings.numberOfPoints = 18; settings.viscosity = 0.6; settings.sensitivity = 1.4 - settings.npAmplitude = 0.5; settings.depthOffset = 5; settings.frequencyCutoff = 120; settings.baseMultiplier = 20 + settings.numberOfPoints = 20; settings.viscosity = 0.7; settings.sensitivity = 1.5 + settings.npAmplitude = 0.5; settings.depthOffset = 5; settings.frequencyCutoff = 150 } private func applySubtleAmbient() { - settings.numberOfPoints = 8; settings.viscosity = 0.18; settings.sensitivity = 0.7 - settings.npAmplitude = 0.25; settings.alpha = 0.3; settings.idleAmplitude = 0.04; settings.baseMultiplier = 15 + settings.numberOfPoints = 8; settings.viscosity = 0.20; settings.sensitivity = 1.0 + settings.npAmplitude = 0.25; settings.alpha = 0.3; settings.idleAmplitude = 0.05 } private func applyHeavyMercury() { settings.numberOfPoints = 10; settings.viscosity = 0.12; settings.sensitivity = 2.5 @@ -895,19 +898,19 @@ struct VisualizerSettingsView: View { private func resetDefaults() { settings.enabled = true; settings.nowPlayingEnabled = true; settings.miniPlayerEnabled = true - settings.style = .wave; settings.numberOfPoints = 9 - settings.fps = 60; settings.realAudioAnalysis = true; settings.dynamicGainEnabled = true + settings.style = .wave; settings.numberOfPoints = 10; settings.sensitivity = 1.5 + settings.fps = 60; settings.realAudioAnalysis = true; settings.dynamicGainEnabled = false settings.waveOffsetTop = 0 settings.barSpacing = 5; settings.barCornerRadius = 0; settings.lineThickness = 5 - settings.colorMode = .dynamic; settings.alpha = 0.6 - // Tuned for Mitsuha feel: slow liquid viscosity, low sensitivity, moderate gain - settings.viscosity = 0.17; settings.sensitivity = 0.9 - settings.frequencyCutoff = 75; settings.baseMultiplier = 18.0 - settings.depthOffset = 14.0; settings.depthOpacity = 0.18; settings.idleAmplitude = 0.03 + settings.colorMode = .dynamic; settings.alpha = 0.6; settings.viscosity = 0.25 + // baseMultiplier reduced from 40 → 25 after removing eqBoost (no longer need + // to compensate for the treble under-amplification the boost was masking) + settings.frequencyCutoff = 80; settings.baseMultiplier = 25.0 + settings.depthOffset = 15.0; settings.depthOpacity = 0.2; settings.idleAmplitude = 0.03 settings.waveStrokeThickness = 1.5; settings.nowPlayingHeightPct = 0.50; settings.miniPlayerHeight = 48.0 settings.miniOpacity = 0.5; settings.miniAmplitude = 0.7; settings.miniIdleAmplitude = 0.03 settings.miniDepthOffset = 8.0; settings.miniDepthOpacity = 0.2 - settings.npAmplitude = 0.50; settings.npBaseLift = 130.0 + settings.npAmplitude = 0.45; settings.npBaseLift = 130.0 } } @@ -926,16 +929,16 @@ struct NowPlayingVisSettingsView: View { Text("\(Int(settings.nowPlayingHeightPct * 100))%").foregroundColor(.gray).frame(width: 52, alignment: .trailing) } } - sd("Amplitude", value: $settings.npAmplitude, range: 0.1...1.0, step: 0.05, format: "%.2f") + sd("Amplitude", value: $settings.npAmplitude, range: 0.1...1.5, step: 0.05, format: "%.2f") sd("Base lift (from bottom)", value: $settings.npBaseLift, range: 0...300, step: 5, format: "%.0f pt") sd("Wave offset (top)", value: $settings.waveOffsetTop, range: -100...300, step: 5, format: "%.0f") } header: { Text("LAYOUT & AMPLITUDE") } footer: { Text("Screen height controls how much of the Now Playing screen the visualizer fills. At 50%, it covers the bottom half. At 70%, it reaches behind the album art.\n\nAmplitude controls how tall wave peaks are. At 0.45, peaks reach about halfway. Push to 0.8+ for dramatic waves. Drop to 0.2 for a subtle accent.\n\nBase lift moves the wave baseline up from the very bottom of the screen. At 130 (default), the wave sits above the transport controls. Set to 0 for the absolute bottom edge.\n\nWave offset adds additional vertical shift on top of base lift. Use negative values to push down, positive to push up.") } Section { - sd("Depth offset", value: $settings.depthOffset, range: 0...50, step: 2, format: "%.0f") + sd("Depth offset", value: $settings.depthOffset, range: 0...50, step: 1, format: "%.0f") sd("Depth opacity", value: $settings.depthOpacity, range: 0.0...0.5, step: 0.05, format: "%.2f") - sd("Idle amplitude", value: $settings.idleAmplitude, range: 0.0...0.10, step: 0.005, format: "%.3f") + sd("Idle amplitude", value: $settings.idleAmplitude, range: 0.0...0.15, step: 0.005, format: "%.3f") } header: { Text("DEPTH & IDLE") } footer: { Text("Depth offset controls how far below the main wave the shadow layer is drawn. At 15, there's visible parallax. At 0, they overlap. At 40+, the shadow looks like a distant reflection.\n\nDepth opacity sets how visible the shadow wave is. At 0.2, it's subtle. At 0, invisible.\n\nIdle amplitude is the minimum wave height during quiet moments. Prevents the wave from going completely flat. At 0.03, there's always gentle surface tension.") } diff --git a/project.yml b/project.yml index c7da516..c03f237 100644 --- a/project.yml +++ b/project.yml @@ -37,6 +37,7 @@ targets: - path: iOS/Resources settings: base: + # This matches your watchOS WKCompanionAppBundleIdentifier PRODUCT_BUNDLE_IDENTIFIER: ca.dallasgroot.navidromeplayer.app INFOPLIST_FILE: iOS/Resources/Info.plist CODE_SIGN_ENTITLEMENTS: iOS/Resources/NavidromePlayer.entitlements @@ -45,7 +46,7 @@ targets: SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: true dependencies: - target: NavidromeWatch - embed: true + embed: true # Embeds the Watch app so it doesn't create a generic archive - package: ZIPFoundation # ───────────────────────────────────── @@ -62,12 +63,13 @@ targets: - path: watchOS/Resources settings: base: + # This is strictly prefixed by the iOS bundle ID above PRODUCT_BUNDLE_IDENTIFIER: ca.dallasgroot.navidromeplayer.app.watchkitapp INFOPLIST_FILE: watchOS/Resources/Info.plist CODE_SIGN_ENTITLEMENTS: watchOS/Resources/NavidromeWatch.entitlements ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon WATCHOS_DEPLOYMENT_TARGET: "26.0" - SKIP_INSTALL: true + SKIP_INSTALL: true # Ensures only the iOS app installs to root, fixing the generic archive schemes: NavidromePlayer: diff --git a/watchOS/Resources/Info.plist b/watchOS/Resources/Info.plist index 53ef0c2..97b81df 100644 --- a/watchOS/Resources/Info.plist +++ b/watchOS/Resources/Info.plist @@ -20,34 +20,28 @@ 1.0 CFBundleVersion 1 - - WKApplication - - WKCompanionAppBundleIdentifier - ca.dallasgroot.navidromeplayer.app - - - WKRunsIndependentlyOfCompanionApp - - - - WKBackgroundModes + ca.dallasgroot.navidromeplayer.app UIBackgroundModes audio - - + WKBackgroundModes + + self-care + workout-processing + + WKRunsIndependentlyOfCompanionApp + NSAppTransportSecurity NSAllowsArbitraryLoads - - - ITSAppUsesNonExemptEncryption - + NSHealthShareUsageDescription + Used to maintain background audio playback through the speaker. + NSHealthUpdateUsageDescription + Used to maintain background audio playback through the speaker.