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.
Recommended Technology
Use a native Swift app, not a web wrapper.
- UI: SwiftUI.
- Audio playback: AVFoundation
AVPlayer. - Background audio:
AVAudioSessionwith.playbackplus the Xcode Background Modes capability for Audio, AirPlay, and Picture in Picture. - Lock-screen controls and metadata: MediaPlayer
MPRemoteCommandCenterandMPNowPlayingInfoCenter. - HTTP API:
URLSession. - WebSocket:
URLSessionWebSocketTask. - Credential/session storage: Keychain for session cookie and username;
UserDefaultsonly 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/loginPOST /api/auth/signupPOST /api/auth/logoutGET /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/playlistsGET /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_idandresourceIdis_adminandisAdminis_guestandisGuest- nullable
shareToken
WebSocket
Use URLSessionWebSocketTask.
Connection URL:
http://...becomesws://.../api/channels/:id/wshttps://...becomeswss://.../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.plistincludesUIBackgroundModeswithaudio.
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
AVPlayeras 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:
- Receive channel state from WebSocket or HTTP.
- Record
serverTimestampMs = currentTimestamp * 1000. - Record
stateMonotonicTime = ProcessInfo.processInfo.systemUptime. - Expected position:
- if paused:
serverTimestampMs - if playing:
serverTimestampMs + elapsedMonotonicMs
- if paused:
- New track:
- replace AVPlayer item
- seek to expected position
- play unless server says paused
- Same track:
- calculate drift =
player.currentTime - expected - if absolute drift >= 2000ms, seek to expected
- calculate drift =
- 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:
0or1
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.pngandroid/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:
UIBackgroundModesincludesaudio- 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
SessionStoreandKeychainStore. - 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
AVPlayerplayback 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
.ipafor 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.