blastoise/ios/design.md

23 KiB

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.

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:

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:

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:

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

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:

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:

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

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:

{ "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:

{serverBaseURL}/api/tracks/{percent-encoded track.id}

For cookie-protected audio requests, create an AVURLAsset with HTTP header options:

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:

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:

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:

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

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

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.