revert fixed project info
This commit is contained in:
dallasgroot 2026-04-10 00:31:29 -07:00
parent 63114f02fb
commit d0cfb4053a
5 changed files with 276 additions and 195 deletions

207
README.md
View file

@ -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 SwiftUIs `preferredColorScheme`. Architecture:
- `NavidromeSceneDelegate` (in `iOS/App/NavidromeSceneDelegate.swift`) creates the window manually with `NavidromeHostingController` as root
- `NavidromeHostingController: UIHostingController<AnyView>` 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:
Navidromes `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 Navidromes 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 servers 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 youd expect.
- **LUFS normalization** — volume scaled as `pow(10, gainDB/20)` clamped to 0.051.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 songs offline vis frames so theyre 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 (120500pt)
- **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 youd 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.

View file

@ -2,91 +2,70 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Navidrome</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>ca.dallasgroot.navidromeplayer.smartdj.refresh</string>
<string>ca.dallasgroot.navidromeplayer.library.sync</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Navidrome</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>Identify songs playing on the radio using Shazam</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose cover images for your radio stations</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<!-- Background execution: audio keeps playback alive, fetch/processing for library sync -->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>fetch</string>
<string>processing</string>
</array>
<!-- Background task identifiers registered in AppDelegate -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.navidromeplayer.smartdj.refresh</string>
<string>com.navidromeplayer.library.sync</string>
</array>
<!-- Scene configuration (single-window SwiftUI app) -->
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).NavidromeSceneDelegate</string>
</dict>
</array>
</dict>
</dict>
<!-- Orientation support -->
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<!-- Allow HTTP connections to local Navidrome/Subsonic servers -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<!-- App Store encryption compliance — no custom crypto used -->
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- Permission usage descriptions -->
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose a cover image for your radio stations.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Identify songs playing nearby using Shazam.</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View file

@ -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..<count {
let normalizedIndex = Float(i + 1) / Float(count)
let logIndex = log10(normalizedIndex * 9.0 + 1.0)
let centerBin = logIndex * Float(maxUsefulBin)
// Bass bins are wider average more FFT bins for heavy, smooth low-end response
// matching original Mitsuha behaviour where bass rolls and treble reacts sharply
let binWidth = max(1.0, Float(maxUsefulBin) / Float(count))
let startBin = max(1, Int(centerBin - binWidth / 2))
let endBin = min(maxUsefulBin, Int(centerBin + binWidth / 2))
var sum: Float = 0
var countInBand = 0
for j in startBin...endBin where j < newRawLevels.count {
sum += newRawLevels[j]
countInBand += 1
}
let averageInBand = countInBand > 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.100.20 = heavy, slow liquid (Mitsuha). 0.4+ = snappy EQ. This is the most important slider.\n\nSensitivity: multiplies incoming audio. 0.81.0 is the sweet spot. Above 1.5 causes the wave to clip at the top.\n\nBase Multiplier: overall gain for FFT data. 1525 works well. Higher values are needed for quieter recordings.\n\nFrequency Cutoff: limits how many FFT bins are used. 6080 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.150.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.")
}

View file

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

View file

@ -20,34 +20,28 @@
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<!-- Required: marks this as a watchOS app -->
<key>WKApplication</key>
<true/>
<!-- Must exactly match the iOS companion app bundle ID -->
<key>WKCompanionAppBundleIdentifier</key>
<string>ca.dallasgroot.navidromeplayer.app</string>
<!-- Allow the Watch app to function without the iPhone nearby -->
<key>WKRunsIndependentlyOfCompanionApp</key>
<true/>
<!-- Background audio for playback continuation -->
<key>WKBackgroundModes</key>
<string>ca.dallasgroot.navidromeplayer.app</string> <key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<!-- Allow HTTP connections to local Navidrome servers -->
<key>WKBackgroundModes</key>
<array>
<string>self-care</string>
<string>workout-processing</string>
</array>
<key>WKRunsIndependentlyOfCompanionApp</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<!-- App Store encryption compliance -->
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSHealthShareUsageDescription</key>
<string>Used to maintain background audio playback through the speaker.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Used to maintain background audio playback through the speaker.</string>
</dict>
</plist>