Add iOS port design and explicit library search
This commit is contained in:
parent
9874ea3cdb
commit
ec194c3c9a
|
|
@ -18,14 +18,13 @@ import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.text.Editable
|
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.text.TextWatcher
|
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
|
|
@ -530,10 +529,6 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val renderLibraryRunnable = Runnable {
|
|
||||||
renderLibrary()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
SessionStore.load(this)
|
SessionStore.load(this)
|
||||||
|
|
@ -549,7 +544,6 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
mainHandler.removeCallbacks(ticker)
|
mainHandler.removeCallbacks(ticker)
|
||||||
mainHandler.removeCallbacks(renderLibraryRunnable)
|
|
||||||
PlaybackBridge.unregister(this)
|
PlaybackBridge.unregister(this)
|
||||||
controllerFuture?.let { MediaController.releaseFuture(it) }
|
controllerFuture?.let { MediaController.releaseFuture(it) }
|
||||||
controllerFuture = null
|
controllerFuture = null
|
||||||
|
|
@ -1234,6 +1228,12 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
|
||||||
?: Track(trackId, trackId, trackId.take(24), 0.0)
|
?: Track(trackId, trackId, trackId.take(24), 0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun submitLibrarySearch(value: String) {
|
||||||
|
libraryQuery = value.trim()
|
||||||
|
renderLibrary()
|
||||||
|
updateDeck()
|
||||||
|
}
|
||||||
|
|
||||||
private fun renderStations() {
|
private fun renderStations() {
|
||||||
if (!::radioContent.isInitialized) return
|
if (!::radioContent.isInitialized) return
|
||||||
radioContent.removeAllViews()
|
radioContent.removeAllViews()
|
||||||
|
|
@ -1329,8 +1329,12 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
|
||||||
MUTED,
|
MUTED,
|
||||||
), matchWrapWithTop(dp(2)))
|
), matchWrapWithTop(dp(2)))
|
||||||
|
|
||||||
|
val searchRow = row().apply {
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
}
|
||||||
val searchInput = EditText(this).apply {
|
val searchInput = EditText(this).apply {
|
||||||
setSingleLine(true)
|
setSingleLine(true)
|
||||||
|
imeOptions = EditorInfo.IME_ACTION_SEARCH
|
||||||
textSize = 15f
|
textSize = 15f
|
||||||
typeface = bodyFont
|
typeface = bodyFont
|
||||||
setTextColor(TEXT)
|
setTextColor(TEXT)
|
||||||
|
|
@ -1340,17 +1344,22 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
|
||||||
setSelection(text.length)
|
setSelection(text.length)
|
||||||
setPadding(dp(12), 0, dp(12), 0)
|
setPadding(dp(12), 0, dp(12), 0)
|
||||||
background = box(PANEL2, STROKE)
|
background = box(PANEL2, STROKE)
|
||||||
addTextChangedListener(object : TextWatcher {
|
setOnEditorActionListener { _, actionId, _ ->
|
||||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
|
||||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
submitLibrarySearch(text.toString())
|
||||||
libraryQuery = s?.toString().orEmpty()
|
true
|
||||||
mainHandler.removeCallbacks(renderLibraryRunnable)
|
} else {
|
||||||
mainHandler.postDelayed(renderLibraryRunnable, 120)
|
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(searchRow, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48)).apply {
|
||||||
libraryContent.addView(searchInput, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48)).apply {
|
|
||||||
topMargin = dp(10)
|
topMargin = dp(10)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
Loading…
Reference in New Issue