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