Add iOS port design and explicit library search

This commit is contained in:
peterino2 2026-06-06 22:42:22 -07:00
parent 9874ea3cdb
commit ec194c3c9a
2 changed files with 773 additions and 16 deletions

View File

@ -18,14 +18,13 @@ import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.Editable
import android.text.InputType
import android.text.TextUtils
import android.text.TextWatcher
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageView
@ -530,10 +529,6 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
}
}
private val renderLibraryRunnable = Runnable {
renderLibrary()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
SessionStore.load(this)
@ -549,7 +544,6 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
override fun onDestroy() {
mainHandler.removeCallbacks(ticker)
mainHandler.removeCallbacks(renderLibraryRunnable)
PlaybackBridge.unregister(this)
controllerFuture?.let { MediaController.releaseFuture(it) }
controllerFuture = null
@ -1234,6 +1228,12 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
?: Track(trackId, trackId, trackId.take(24), 0.0)
}
private fun submitLibrarySearch(value: String) {
libraryQuery = value.trim()
renderLibrary()
updateDeck()
}
private fun renderStations() {
if (!::radioContent.isInitialized) return
radioContent.removeAllViews()
@ -1329,8 +1329,12 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
MUTED,
), matchWrapWithTop(dp(2)))
val searchRow = row().apply {
gravity = Gravity.CENTER_VERTICAL
}
val searchInput = EditText(this).apply {
setSingleLine(true)
imeOptions = EditorInfo.IME_ACTION_SEARCH
textSize = 15f
typeface = bodyFont
setTextColor(TEXT)
@ -1340,17 +1344,22 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
setSelection(text.length)
setPadding(dp(12), 0, dp(12), 0)
background = box(PANEL2, STROKE)
addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
libraryQuery = s?.toString().orEmpty()
mainHandler.removeCallbacks(renderLibraryRunnable)
mainHandler.postDelayed(renderLibraryRunnable, 120)
setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
submitLibrarySearch(text.toString())
true
} else {
false
}
override fun afterTextChanged(s: Editable?) = Unit
}
}
searchRow.addView(searchInput, LinearLayout.LayoutParams(0, dp(48), 1f))
searchRow.addView(button("SEARCH", ACCENT, BG, Typeface.BOLD, R.drawable.ic_search, 17).apply {
setOnClickListener { submitLibrarySearch(searchInput.text.toString()) }
}, LinearLayout.LayoutParams(dp(124), dp(48)).apply {
leftMargin = dp(8)
})
}
libraryContent.addView(searchInput, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48)).apply {
libraryContent.addView(searchRow, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48)).apply {
topMargin = dp(10)
})

748
ios/design.md Normal file
View File

@ -0,0 +1,748 @@
# Blastoise iOS Port Design
This document describes how to build a native iOS version of the current native Android Blastoise app. It is not a marketing spec or a prompt. It is an implementation design for a Swift/iOS port that preserves the same product model, features, playback behavior, and visual direction.
## Goal
Create a native iOS app that behaves like the Android app:
- Defaults to `http://mhsgroove.peterino.com:3001`.
- Requires sign in/sign up before room, queue, library, and playlist operations.
- Lets a user join a room and hear that room's server-owned queue.
- Keeps audio playing when the phone is locked, like a normal music app.
- Exposes lock-screen / Control Center playback controls.
- Shows room, queue, people, library, and playlist views.
- Lets users search the library, play local library tracks, add tracks/playlists to the current room queue, play next, jump in the queue, remove queue items, and cycle playback modes.
- Preserves the current stylized themes: Pixel, Aura/angel, and Black Cat/Game Boy.
- Shows debug/status info for server, auth, socket, playback drift, and loaded data.
## Recommended Technology
Use a native Swift app, not a web wrapper.
- UI: SwiftUI.
- Audio playback: AVFoundation `AVPlayer`.
- Background audio: `AVAudioSession` with `.playback` plus the Xcode Background Modes capability for Audio, AirPlay, and Picture in Picture.
- Lock-screen controls and metadata: MediaPlayer `MPRemoteCommandCenter` and `MPNowPlayingInfoCenter`.
- HTTP API: `URLSession`.
- WebSocket: `URLSessionWebSocketTask`.
- Credential/session storage: Keychain for session cookie and username; `UserDefaults` only for non-secret settings like server URL and selected theme.
- State model: one observable app/playback model that owns networking, player state, socket state, and UI snapshots.
Useful Apple references:
- Background modes: https://developer.apple.com/documentation/xcode/configuring-background-execution-modes
- AVAudioSession: https://developer.apple.com/documentation/avfaudio/avaudiosession
- AVPlayer: https://developer.apple.com/documentation/avfoundation/avplayer
- Now Playing / remote controls: https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/MediaPlaybackGuide/Contents/Resources/en.lproj/RefiningTheUserExperience/RefiningTheUserExperience.html
- URLSessionWebSocketTask: https://developer.apple.com/documentation/foundation/urlsessionwebsockettask
- URLSession and ATS: https://developer.apple.com/documentation/foundation/urlsession
- App Transport Security: https://developer.apple.com/documentation/security/preventing-insecure-network-connections
- Keychain-backed credential persistence: https://developer.apple.com/documentation/foundation/urlcredential/persistence-swift.enum
## Current Android Behavior To Preserve
The Android native app is structured around:
- `PlaybackService`: long-lived playback and networking owner.
- `MusicRoomClient`: HTTP and WebSocket API client.
- `PlaybackSnapshot`: immutable UI state published from service to activity.
- `MainActivity`: programmatic themed UI.
- `SessionStore`: server URL, auth cookie, selected theme.
iOS should use the same separation even though it will not have an Android-style foreground service.
Android feature set to mirror:
- Auth:
- `POST /api/auth/login`
- `POST /api/auth/signup`
- `POST /api/auth/logout`
- `GET /api/auth/me`
- cache session cookie
- block room/library/playlist/queue actions unless signed in
- Room/radio playback:
- `GET /api/channels`
- auto-join default room after sign in
- `WS /api/channels/:id/ws`
- receive channel state: track, timestamp, paused, queue, current index, listeners, playback mode
- play `/api/tracks/:id`
- seek/sync to server timestamp
- pause/unpause/seek/jump via WebSocket messages
- reconnect WebSocket with backoff
- Library/local playback:
- `GET /api/library`
- search tracks by title/filename
- tap a track to play locally
- queue track into current room
- play track next by inserting after current queue index
- Playlists:
- `GET /api/playlists`
- `GET /api/playlists/:id`
- display mine and shared playlists
- select playlist and view tracks
- add playlist to current room queue
- play playlist next
- add individual playlist tracks to room queue
- Queue:
- render current room queue
- highlight now-playing item
- tap track to jump
- remove queue item
- add current track to queue or play next
- People:
- show listeners in current room
- mark current user
- Playback controls:
- previous/jump back in radio queue or local library
- seek back 15 seconds
- play/pause
- seek forward 15 seconds
- next/jump forward
- cycle playback mode: once, repeat-all, repeat-one, shuffle
- queue current
- play current next
- stop and exit
- Debug/status:
- connection status
- server URL
- auth state and username
- room count and current room ID
- library count and search query
- playlist counts
- current track ID/title
- expected/player timestamps and drift
- recent event log
## iOS Architecture
Use this module layout:
```text
ios/
Blastoise/
BlastoiseApp.swift
AppModel.swift
Models/
Track.swift
Channel.swift
Playlist.swift
UserSession.swift
PlaybackSnapshot.swift
Services/
SessionStore.swift
KeychainStore.swift
MusicRoomAPI.swift
RoomWebSocket.swift
PlaybackEngine.swift
NowPlayingController.swift
Views/
RootView.swift
AuthView.swift
PlayerDeckView.swift
RoomsView.swift
QueueView.swift
PeopleView.swift
LibraryView.swift
PlaylistsView.swift
DebugView.swift
Theme/
ThemeSpec.swift
PixelFrame.swift
SeraphFrame.swift
PixelCatView.swift
```
### State Ownership
`AppModel` should be the single source of truth for UI state. It replaces `PlaybackBridge` and most of `PlaybackService`'s public surface.
Suggested shape:
```swift
@MainActor
final class AppModel: ObservableObject {
@Published private(set) var snapshot = PlaybackSnapshot()
private let sessionStore: SessionStore
private let api: MusicRoomAPI
private let socket: RoomWebSocket
private let playback: PlaybackEngine
private let nowPlaying: NowPlayingController
}
```
Do not scatter `AVPlayer`, WebSocket, auth cookie, and queue state across views. SwiftUI views should call intent methods such as:
- `signIn(username:password:)`
- `signUp(username:password:)`
- `connectToServer(_:)`
- `joinRoom(_:)`
- `enterLibraryMode()`
- `playLibraryTrack(index:)`
- `queueTrack(_:playNext:)`
- `addPlaylistToQueue(_:playNext:)`
- `jumpToQueueIndex(_:)`
- `removeQueueIndex(_:)`
- `togglePlay()`
- `seek(to:)`
- `seekBy(seconds:)`
- `cyclePlaybackMode()`
- `stopAndExit()`
- `cycleTheme()`
### PlaybackSnapshot
Mirror the Android `PlaybackSnapshot` so views stay dumb:
```swift
struct PlaybackSnapshot: Equatable {
var sourceMode: PlaybackSourceMode = .radio
var authState: AuthState = .checking
var currentUser: UserSession?
var status = "Starting"
var channels: [ChannelInfo] = []
var libraryTracks: [Track] = []
var libraryLoaded = false
var myPlaylists: [Playlist] = []
var sharedPlaylists: [Playlist] = []
var playlistsLoaded = false
var selectedPlaylistId: String?
var selectedPlaylist: Playlist?
var currentChannelId: String?
var currentTrackId: String?
var localLibraryIndex = -1
var currentRoomListeners: [String] = []
var paused = true
var queue: [Track] = []
var queueLoaded = false
var currentIndex = 0
var playbackMode = "repeat-all"
var channelName = "No channel"
var trackTitle = "No track"
var trackDuration: TimeInterval = 0
var serverTimestampMs: Int64 = 0
var stateMonotonicTime: TimeInterval = 0
var expectedPositionMs: Int64 = 0
var playerPositionMs: Int64 = 0
var driftMs: Int64 = 0
var playbackState = "none"
var isPlaying = false
var debugEvents: [String] = []
}
```
Use `CACurrentMediaTime()` or `ProcessInfo.processInfo.systemUptime` for monotonic timing, not `Date()`, when calculating drift.
## Networking Design
### SessionStore
Defaults:
- `serverBaseURL = "http://mhsgroove.peterino.com:3001"`
- `userAgent = "BlastoiseiOS/0.1"`
- `themeKey = "seraph"`
Store:
- server URL in `UserDefaults`
- theme key in `UserDefaults`
- session cookie in Keychain
The Android app stores a literal cookie header string like `blastoise_session=...`. iOS can do the same to stay compatible. Every request and WebSocket handshake should send:
```text
User-Agent: BlastoiseiOS/0.1
Cookie: blastoise_session=...
```
### App Transport Security
The current test server is plain HTTP. iOS blocks insecure HTTP by default for modern apps unless configured otherwise. For development, add an ATS exception for `mhsgroove.peterino.com` in `Info.plist`. For any public release, use HTTPS and remove the exception.
Development-only `Info.plist` direction:
```xml
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>mhsgroove.peterino.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
```
### HTTP API Client
`MusicRoomAPI` should wrap `URLSession` and expose typed async methods:
```swift
struct MusicRoomAPI {
func login(username: String, password: String) async throws -> UserSession
func signup(username: String, password: String) async throws -> UserSession
func logout() async
func me() async throws -> UserSession?
func channels() async throws -> [ChannelInfo]
func library() async throws -> [Track]
func playlists() async throws -> PlaylistBundle
func playlist(id: String) async throws -> Playlist
func setPlaybackMode(channelId: String, mode: String) async throws
func addTracksToQueue(channelId: String, trackIds: [String], insertAt: Int?) async throws
func removeTracksFromQueue(channelId: String, indices: [Int]) async throws
func channelState(channelId: String) async throws -> ChannelState
}
```
Decode with `Codable`, but be tolerant of server naming differences already handled in Android:
- `resource_id` and `resourceId`
- `is_admin` and `isAdmin`
- `is_guest` and `isGuest`
- nullable `shareToken`
### WebSocket
Use `URLSessionWebSocketTask`.
Connection URL:
- `http://...` becomes `ws://.../api/channels/:id/ws`
- `https://...` becomes `wss://.../api/channels/:id/ws`
Client messages:
```json
{ "action": "pause" }
{ "action": "unpause" }
{ "action": "seek", "timestamp": 45.5 }
{ "action": "jump", "index": 3 }
```
Server messages:
- untyped channel state object
- `{ "type": "channel_list", "channels": [...] }`
- `{ "type": "switched", "channelId": "..." }`
- `{ "type": "kick", "reason": "..." }`
- `{ "type": "error", "message": "..." }`
Reconnect policy:
- close socket intentionally when entering local library mode or signing out
- reconnect only in radio mode
- backoff: `min(3s * (attempt + 1), 30s)`
- after reconnect, load channel state via `GET /api/channels/:id`
## Playback Design
### Audio Engine
Use `AVPlayer` with one current `AVPlayerItem`.
Build track URLs as:
```text
{serverBaseURL}/api/tracks/{percent-encoded track.id}
```
For cookie-protected audio requests, create an `AVURLAsset` with HTTP header options:
```swift
let headers = [
"User-Agent": SessionStore.userAgent,
"Cookie": sessionCookie
]
let asset = AVURLAsset(
url: trackURL,
options: ["AVURLAssetHTTPHeaderFieldsKey": headers]
)
let item = AVPlayerItem(asset: asset)
player.replaceCurrentItem(with: item)
```
Verify this with the current server because custom headers on AVFoundation assets are more brittle than `URLSession` requests. If this fails, use an authenticated streaming proxy inside the app only as a fallback, or move the server to signed short-lived track URLs.
### Background Audio
Set up audio once at app startup or before first playback:
```swift
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default)
try session.setActive(true)
```
In Xcode:
- Add Signing & Capabilities -> Background Modes.
- Enable Audio, AirPlay, and Picture in Picture.
- Confirm `Info.plist` includes `UIBackgroundModes` with `audio`.
iOS does not provide an Android-equivalent foreground media service. If audio is playing, the app may continue playback in the background. If audio is paused and the app backgrounds, assume the process can be suspended. Therefore:
- Keep `AVPlayer` as the durable playback object.
- Keep Now Playing metadata accurate.
- On foreground, app activation, socket reconnect, and remote command events, resync room state.
- Do not rely on an idle WebSocket staying alive forever while paused in the background.
### Radio Sync Logic
Use the same algorithm as Android:
1. Receive channel state from WebSocket or HTTP.
2. Record `serverTimestampMs = currentTimestamp * 1000`.
3. Record `stateMonotonicTime = ProcessInfo.processInfo.systemUptime`.
4. Expected position:
- if paused: `serverTimestampMs`
- if playing: `serverTimestampMs + elapsedMonotonicMs`
5. New track:
- replace AVPlayer item
- seek to expected position
- play unless server says paused
6. Same track:
- calculate drift = `player.currentTime - expected`
- if absolute drift >= 2000ms, seek to expected
7. Keep a 500ms ticker to update UI and perform drift correction while in radio mode.
### Local Library Mode
Local mode is not room-synced:
- close WebSocket intentionally
- play selected library track through `AVPlayer`
- previous/next walks local library list
- shuffle chooses a random index not equal to current
- repeat-one seeks to zero and plays again on end
- repeat-all advances to next local library track on end
- once stops at end
The app still allows queueing the local track into the current/default room.
### Lock Screen / Control Center
Use `MPNowPlayingInfoCenter`:
- title: current track title
- artist:
- radio mode: current room name
- local mode: `Blastoise Library`
- elapsed playback time
- duration
- playback rate: `0` or `1`
Use `MPRemoteCommandCenter`:
- play
- pause
- toggle play/pause
- next track
- previous track
- seek forward 15 seconds
- seek backward 15 seconds
- change playback position
Remote commands should call the same `AppModel` intent methods used by SwiftUI.
## UI Design
Keep the current room-first shape:
```text
Header: BLASTOISE | status | theme | stop/exit
Tabs: Rooms | Queue | People | Lib | Lists
Player deck:
room badge / mode / room name / meta
theme-specific art
track title
elapsed/duration
progress + black-cat sprite on cat theme
transport controls
queue/play-next controls
status meters
Selected tab content:
rooms, queue, people, library, playlists
Diagnostics panel
```
SwiftUI view mapping:
- `RootView`: owns header, tabs, player deck, selected panel, debug panel.
- `AuthView`: server URL, username, password, sign in, sign up.
- `PlayerDeckView`: now-playing and controls.
- `RoomsView`: room cards and up-next preview.
- `QueueView`: current room queue and per-track jump/remove controls.
- `PeopleView`: listener list.
- `LibraryView`: search field, local play, queue, play-next.
- `PlaylistsView`: playlist list, selected playlist details, queue/play-next actions.
- `DebugView`: compact diagnostic text.
### Touch Targets
Minimum touch target should be 44pt. Keep per-track action buttons compact but tappable.
### Main Controls
Use icon buttons for:
- previous
- seek back 15
- play/pause
- seek forward 15
- next
- shuffle/repeat mode
- queue
- play next
- stop/exit
- theme switcher
Avoid text-only controls where an icon is clearer.
### Search and Lists
- Library search should update as the user types.
- Show first 80 matching tracks initially, matching Android.
- Playlist list can show first 40 initially.
- Queue and selected playlist track views can show first 80 initially.
## Themes
Implement `ThemeSpec` in Swift:
```swift
struct ThemeSpec: Equatable {
var key: String
var label: String
var background: Color
var panel: Color
var panel2: Color
var stroke: Color
var text: Color
var muted: Color
var accent: Color
var green: Color
var amber: Color
var red: Color
var purple: Color
var frameStyle: FrameStyle
var lightSystemBars: Bool
}
```
Initial themes:
- `arcade` / `PIXEL`
- dark warm background
- orange/green arcade accents
- pixel frame language
- Pixelify Sans or bundled pixel font
- `seraph` / `AURA`
- pale lavender body
- gold/sky/mint accents
- angel banner background
- small angel room badges
- softer angular frame
- `cat` / `BLACK CAT`
- near-black background
- lime Game Boy accents
- pixel frame language
- animated pixel cat above the progress line
### Pixel/Seraph Frames
Recreate Android's custom drawables as SwiftUI `Shape`s:
- `PixelFrame`: hard 8-bit stepped corners and square chips.
- `SeraphFrame`: angled beveled corners and fine horizontal ornaments.
Use these as backgrounds/strokes for panels, buttons, and cards. Keep cards angular; do not drift back to rounded default iOS cards.
### Angel Art
Use the same source assets as Android if licensing is acceptable for this private app:
- `android/BlastoiseNative/app/src/main/res/drawable-nodpi/anime_angel_banner.png`
- `android/BlastoiseNative/app/src/main/res/drawable-nodpi/anime_angel_room_badge.png`
Copy them into the iOS asset catalog:
```text
Assets.xcassets/
anime_angel_banner.imageset/
anime_angel_room_badge.imageset/
```
In AURA, the banner should be both:
- a large deck image
- a subtle body background with a readability wash
### Pixel Cat
Implement as a SwiftUI `Canvas` or custom `View`, not as a raster PNG, so it can animate crisply.
Behavior:
- visible only in `BLACK CAT`
- fixed perch above the progress line, not tied to playhead progress
- leave a clear gap so the seek line remains visible
- idle animation only: small tail flick, tiny breathing bounce, optional sparkle
- reference style: chunky square head, two bright square eyes, upright ears, white chest/paws, curled tail
## iOS Project Setup
Create a new Xcode project:
- Product name: `Blastoise`
- Bundle identifier: `com.peterino.blastoise`
- Interface: SwiftUI
- Language: Swift
- Minimum iOS: iOS 17 is a pragmatic target if using modern SwiftUI observation. Use iOS 16 if broader device support matters.
Capabilities:
- Background Modes -> Audio, AirPlay, and Picture in Picture
Info.plist:
- `UIBackgroundModes` includes `audio`
- development ATS exception for `mhsgroove.peterino.com`
Assets:
- app icon
- angel banner
- angel room badge
- icon vectors if not drawn directly in SwiftUI
- bundled fonts if license permits:
- Pixelify Sans for pixel/cat themes
- Rajdhani for AURA
- JetBrains Mono for diagnostics
## Implementation Plan
### Phase 1: Skeleton and Auth
- Create SwiftUI app shell.
- Add `SessionStore` and `KeychainStore`.
- Add models and JSON decoding.
- Add login, signup, logout, me.
- Show auth panel and signed-in/signed-out state.
- Default server URL to `http://mhsgroove.peterino.com:3001`.
### Phase 2: Rooms and Radio Playback
- Add channels API.
- Auto-join default room after sign in.
- Add WebSocket room state.
- Add `AVPlayer` playback for `/api/tracks/:id`.
- Implement server timestamp sync.
- Add play/pause/seek/jump WebSocket actions.
- Add reconnect backoff.
### Phase 3: Background Audio
- Configure `AVAudioSession`.
- Enable Background Modes audio.
- Add Now Playing metadata.
- Add remote command center handlers.
- Test:
- screen off
- app backgrounded
- lock-screen pause/play
- Control Center seek
- headphone route changes
### Phase 4: Library and Local Mode
- Add library loading/search.
- Implement local playback mode.
- Implement local previous/next/shuffle/repeat.
- Add queue/play-next actions from library.
### Phase 5: Queue, People, Playlists
- Add room queue screen.
- Add jump/remove queue item.
- Add people/listeners screen.
- Add playlist list and selected playlist details.
- Add playlist queue/play-next actions.
### Phase 6: Themes and Polish
- Implement Pixel, AURA, and BLACK CAT theme specs.
- Port pixel/seraph frame shapes.
- Add angel assets and AURA body background.
- Add animated pixel cat.
- Add compact diagnostics panel.
- Tune dynamic type and small-screen layout.
### Phase 7: Packaging
- Build a debug `.ipa` for side loading if needed.
- For TestFlight/App Store:
- switch server to HTTPS
- remove or narrow ATS exception
- add app icons/launch assets
- verify background audio declaration is justified by actual music playback
- review privacy text for server auth and audio playback
## Testing Checklist
- Fresh install opens to auth screen.
- Existing session cookie restores signed-in state.
- Invalid session clears cookie and blocks app actions.
- Sign in loads rooms, library, and playlists.
- Default room auto-joins.
- WebSocket state starts audio at server timestamp.
- Drift under 2s is ignored.
- Drift over 2s seeks to expected timestamp.
- Pause/unpause controls affect room playback.
- Seek sends room seek in radio mode.
- Seek changes local player position in library mode.
- Previous/next jump room queue in radio mode.
- Previous/next navigate library in local mode.
- Queue/play-next track actions patch room queue.
- Playlist queue/play-next actions patch room queue.
- Queue item jump sends WebSocket jump.
- Queue item remove patches room queue.
- People tab shows listeners and current user.
- Stop/exit stops playback, clears player, closes socket.
- Audio keeps playing with screen locked.
- Lock-screen controls work.
- Reopening app resyncs room state.
- Theme switch persists.
- AURA shows angel background/art.
- BLACK CAT shows fixed pixel cat above the progress line.
- Debug panel shows server/auth/socket/drift data.
## Known Risks
- Cleartext HTTP is development-only on iOS. Production should use HTTPS.
- AVFoundation custom HTTP headers for protected audio URLs must be tested early. If cookies do not reliably reach `/api/tracks/:id`, the server should add short-lived signed track URLs.
- iOS background execution is narrower than Android foreground services. Playback can continue, but idle sockets while paused/backgrounded should not be treated as durable.
- The existing Android app streams tracks but does not yet implement full offline caching like the web client. iOS can add content-hash disk caching later, but it should not block the first port.
- SwiftUI custom pixel frames and cat animation should be performance-light: draw simple shapes, avoid image-heavy recomposition on every 500ms tick.
## Open Decisions
- Minimum iOS version: iOS 17 for modern SwiftUI observation, or iOS 16 for broader compatibility.
- Whether to ship the private/friend build outside TestFlight.
- Whether to add room creation/rename/delete in the first iOS version. The server supports it, but the current Android UI focuses on joining and queueing.
- Whether to implement full offline track caching in v1 or defer it.
- Whether to move the server behind HTTPS before iOS testing on physical devices.