Compare commits
11 Commits
dev/playli
...
integratio
| Author | SHA1 | Date |
|---|---|---|
|
|
cd8c1814ca | |
|
|
a3334cb2a7 | |
|
|
adc450f14f | |
|
|
d184c6a663 | |
|
|
091e54c599 | |
|
|
910b25e7c7 | |
|
|
ec194c3c9a | |
|
|
9874ea3cdb | |
|
|
25d37ec9dd | |
|
|
bc09479dfb | |
|
|
c58e30b30d |
|
|
@ -33,6 +33,26 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
# Xcode
|
||||
DerivedData/
|
||||
ios/**/build/
|
||||
*.xcuserstate
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
xcuserdata/
|
||||
*.xcresult
|
||||
*.xcarchive
|
||||
*.app
|
||||
*.appex
|
||||
*.dSYM
|
||||
*.dSYM.zip
|
||||
*.ipa
|
||||
|
||||
# Swift Package Manager / Xcode package scratch
|
||||
.build/
|
||||
.swiftpm/
|
||||
|
||||
tmp/
|
||||
library_cache.db
|
||||
musicroom.db
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
local.properties
|
||||
*.iml
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# Blastoise Native Android Design Brief
|
||||
|
||||
Design direction: broadcast-console music app, not generic AI dashboard.
|
||||
|
||||
## Principles
|
||||
|
||||
- Put the listening task first: current channel, current track, transport, progress, channel switcher, and queue preview must stay visible on the main screen.
|
||||
- Use explicit states: connected, loading, disconnected, guest session, drift, buffering, and playback state are visible without opening logs.
|
||||
- Avoid generic AI decoration: no random blobs, ornamental gradients, nested cards, oversized empty hero areas, or decorative metrics.
|
||||
- Keep controls direct and tactile: large touch targets, clear labels, predictable placement, no hidden primary actions.
|
||||
- Keep diagnostics subordinate: useful for development, but visually quieter than playback.
|
||||
|
||||
## Theme
|
||||
|
||||
- Personality: late-night radio console, compact and technical.
|
||||
- Display/body font: Rajdhani.
|
||||
- Diagnostics font: JetBrains Mono.
|
||||
- Background: near-black.
|
||||
- Surfaces: layered blue-black panels.
|
||||
- Accents: safety orange for primary actions, level-meter green for live sync, brass for warnings/session state, restrained red for failures.
|
||||
- Avoid blue, cyan, and purple as dominant interface colors.
|
||||
- Corners: squared receiver hardware. Use small 2-4dp radii, never bubbly cards.
|
||||
|
||||
## Layout
|
||||
|
||||
- Header: app identity plus connection status.
|
||||
- Server row: compact editable endpoint and reconnect action.
|
||||
- Mode switch: RADIO and LIBRARY are first-class modes.
|
||||
- Main deck: station/library identity, track title, progress, transport, session/sync health.
|
||||
- Transport controls: use a real player strip with previous, seek back, large play/pause, seek forward, and next. Avoid generic equal-width text buttons for playback.
|
||||
- Radio channels: station cards that feel like playlists/radio rooms the user can join.
|
||||
- Library: dense track rows for direct local-style MP3 playback from the server library.
|
||||
- Queue: next few tracks for Radio mode, not the whole library.
|
||||
- Diagnostics: compact, monospaced, last few events.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# Blastoise Native Android
|
||||
|
||||
Native Kotlin listener app for the Blastoise/MusicRoom server.
|
||||
|
||||
## Build
|
||||
|
||||
From this directory:
|
||||
|
||||
```bat
|
||||
gradlew.bat :app:assembleDebug
|
||||
```
|
||||
|
||||
The debug APK is written to:
|
||||
|
||||
```text
|
||||
app\build\outputs\apk\debug\app-debug.apk
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
Use the root Android helper:
|
||||
|
||||
```bat
|
||||
..\run.bat
|
||||
```
|
||||
|
||||
Or install manually:
|
||||
|
||||
```bat
|
||||
gradlew.bat :app:installDebug
|
||||
adb shell am start -n com.peterino.blastoise/.MainActivity
|
||||
```
|
||||
|
||||
## MVP scope
|
||||
|
||||
- Defaults to `https://tunes.peterino.com/` and auto-connects on launch.
|
||||
- Saves a server URL locally.
|
||||
- Uses `/api/channels` to establish an authenticated or guest session.
|
||||
- Supports `/api/auth/login` for named users.
|
||||
- Connects to `/api/channels/:id/ws`.
|
||||
- Plays `/api/tracks/:id` through AndroidX Media3.
|
||||
- Corrects playback drift when the server timestamp differs by 2 seconds or more.
|
||||
- Shows channel switching, queue preview, sync health, drift, session state, and recent WebSocket events on the main screen.
|
||||
|
||||
Offline track caching is intentionally not included yet.
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.peterino.blastoise"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.peterino.blastoise"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val media3Version = "1.10.1"
|
||||
|
||||
implementation("androidx.media3:media3-exoplayer:$media3Version")
|
||||
implementation("androidx.media3:media3-session:$media3Version")
|
||||
implementation("androidx.media3:media3-common:$media3Version")
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.0")
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".PlaybackService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,171 @@
|
|||
package com.peterino.blastoise
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
data class Track(
|
||||
val id: String,
|
||||
val filename: String,
|
||||
val title: String,
|
||||
val duration: Double,
|
||||
)
|
||||
|
||||
data class ChannelInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val listenerCount: Int,
|
||||
val isDefault: Boolean,
|
||||
val trackCount: Int = 0,
|
||||
val listeners: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class ChannelState(
|
||||
val track: Track?,
|
||||
val currentTimestamp: Double,
|
||||
val channelName: String,
|
||||
val channelId: String,
|
||||
val paused: Boolean,
|
||||
val queue: List<Track>?,
|
||||
val currentIndex: Int,
|
||||
val playbackMode: String,
|
||||
val listenerCount: Int,
|
||||
val listeners: List<String>,
|
||||
)
|
||||
|
||||
data class Playlist(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val ownerId: Int,
|
||||
val ownerName: String,
|
||||
val isPublic: Boolean,
|
||||
val shareToken: String?,
|
||||
val trackIds: List<String>,
|
||||
)
|
||||
|
||||
data class PlaylistBundle(
|
||||
val mine: List<Playlist>,
|
||||
val shared: List<Playlist>,
|
||||
)
|
||||
|
||||
data class Permission(
|
||||
val resourceType: String,
|
||||
val resourceId: String?,
|
||||
val permission: String,
|
||||
)
|
||||
|
||||
data class UserSession(
|
||||
val id: Int,
|
||||
val username: String,
|
||||
val isAdmin: Boolean,
|
||||
val isGuest: Boolean,
|
||||
val permissions: List<Permission> = emptyList(),
|
||||
) {
|
||||
val isSignedIn: Boolean
|
||||
get() = !isGuest
|
||||
}
|
||||
|
||||
fun JSONObject.toTrack(): Track {
|
||||
return Track(
|
||||
id = optString("id", optString("filename")),
|
||||
filename = optString("filename"),
|
||||
title = optString("title", optString("filename", "Unknown")),
|
||||
duration = optDouble("duration", 0.0),
|
||||
)
|
||||
}
|
||||
|
||||
fun JSONObject.toChannelInfo(): ChannelInfo {
|
||||
return ChannelInfo(
|
||||
id = getString("id"),
|
||||
name = optString("name", "Channel"),
|
||||
description = optString("description", ""),
|
||||
listenerCount = optInt("listenerCount", 0),
|
||||
isDefault = optBoolean("isDefault", false),
|
||||
trackCount = optInt("trackCount", 0),
|
||||
listeners = optJSONArray("listeners").toStringList(),
|
||||
)
|
||||
}
|
||||
|
||||
fun JSONObject.toChannelState(): ChannelState {
|
||||
val trackJson = optJSONObject("track")
|
||||
val queueJson = optJSONArray("queue")
|
||||
val queue = if (queueJson == null) {
|
||||
null
|
||||
} else {
|
||||
(0 until queueJson.length()).map { queueJson.getJSONObject(it).toTrack() }
|
||||
}
|
||||
|
||||
return ChannelState(
|
||||
track = trackJson?.toTrack(),
|
||||
currentTimestamp = optDouble("currentTimestamp", 0.0),
|
||||
channelName = optString("channelName", ""),
|
||||
channelId = optString("channelId", ""),
|
||||
paused = optBoolean("paused", true),
|
||||
queue = queue,
|
||||
currentIndex = optInt("currentIndex", 0),
|
||||
playbackMode = optString("playbackMode", "repeat-all"),
|
||||
listenerCount = optInt("listenerCount", 0),
|
||||
listeners = optJSONArray("listeners").toStringList(),
|
||||
)
|
||||
}
|
||||
|
||||
fun JSONObject.toPermission(): Permission {
|
||||
return Permission(
|
||||
resourceType = optString("resource_type", optString("resourceType", "")),
|
||||
resourceId = when {
|
||||
isNull("resource_id") -> null
|
||||
has("resource_id") -> optString("resource_id")
|
||||
isNull("resourceId") -> null
|
||||
else -> optString("resourceId")
|
||||
},
|
||||
permission = optString("permission", ""),
|
||||
)
|
||||
}
|
||||
|
||||
fun JSONObject.toUserSession(): UserSession {
|
||||
val user = optJSONObject("user") ?: this
|
||||
return UserSession(
|
||||
id = user.optInt("id", 0),
|
||||
username = user.optString("username", "guest"),
|
||||
isAdmin = user.optBoolean("isAdmin", user.optBoolean("is_admin", false)),
|
||||
isGuest = user.optBoolean("isGuest", user.optBoolean("is_guest", false)),
|
||||
permissions = optJSONArray("permissions").toPermissions(),
|
||||
)
|
||||
}
|
||||
|
||||
fun JSONObject.toPlaylist(): Playlist {
|
||||
return Playlist(
|
||||
id = getString("id"),
|
||||
name = optString("name", "Playlist"),
|
||||
description = optString("description", ""),
|
||||
ownerId = optInt("ownerId", 0),
|
||||
ownerName = optString("ownerName", ""),
|
||||
isPublic = optBoolean("isPublic", false),
|
||||
shareToken = if (isNull("shareToken")) null else getString("shareToken"),
|
||||
trackIds = optJSONArray("trackIds").toStringList(),
|
||||
)
|
||||
}
|
||||
|
||||
fun JSONArray.toChannels(): List<ChannelInfo> {
|
||||
return (0 until length()).map { getJSONObject(it).toChannelInfo() }
|
||||
}
|
||||
|
||||
fun JSONArray.toTracks(): List<Track> {
|
||||
return (0 until length()).map { getJSONObject(it).toTrack() }
|
||||
}
|
||||
|
||||
fun JSONArray?.toPlaylists(): List<Playlist> {
|
||||
if (this == null) return emptyList()
|
||||
return (0 until length()).map { getJSONObject(it).toPlaylist() }
|
||||
}
|
||||
|
||||
fun JSONArray?.toStringList(): List<String> {
|
||||
if (this == null) return emptyList()
|
||||
return (0 until length()).mapNotNull { optString(it).takeIf(String::isNotBlank) }
|
||||
}
|
||||
|
||||
fun JSONArray?.toPermissions(): List<Permission> {
|
||||
if (this == null) return emptyList()
|
||||
return (0 until length()).map { getJSONObject(it).toPermission() }
|
||||
}
|
||||
|
|
@ -0,0 +1,509 @@
|
|||
package com.peterino.blastoise
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MusicRoomClient(
|
||||
private val context: Context,
|
||||
private val listener: Listener,
|
||||
) {
|
||||
interface Listener {
|
||||
fun onStatus(message: String)
|
||||
fun onDebug(message: String)
|
||||
fun onChannels(channels: List<ChannelInfo>)
|
||||
fun onLibrary(tracks: List<Track>)
|
||||
fun onPlaylists(playlists: PlaylistBundle)
|
||||
fun onPlaylistDetail(playlist: Playlist)
|
||||
fun onChannelState(state: ChannelState)
|
||||
fun onSwitched(channelId: String)
|
||||
fun onAuth(user: UserSession?)
|
||||
fun onError(message: String)
|
||||
}
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private var socket: WebSocket? = null
|
||||
|
||||
fun close() {
|
||||
socket?.close(1000, "closing")
|
||||
socket = null
|
||||
}
|
||||
|
||||
fun login(username: String, password: String) {
|
||||
listener.onDebug("POST /api/auth/login")
|
||||
val body = JSONObject()
|
||||
.put("username", username)
|
||||
.put("password", password)
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = request("/api/auth/login")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
listener.onDebug("login failure: ${e.message ?: "network error"}")
|
||||
listener.onError("Login failed: ${e.message ?: "network error"}")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
captureCookie(it)
|
||||
listener.onDebug("login HTTP ${it.code}; cookie=${SessionStore.cookieHeader.isNotBlank()}")
|
||||
if (!it.isSuccessful) {
|
||||
listener.onError("Login failed: HTTP ${it.code}")
|
||||
return
|
||||
}
|
||||
val session = JSONObject(it.body.string()).toUserSession()
|
||||
listener.onAuth(session)
|
||||
SessionStore.save(context)
|
||||
listener.onStatus("Signed in")
|
||||
loadMe()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun signup(username: String, password: String) {
|
||||
listener.onDebug("POST /api/auth/signup")
|
||||
val body = JSONObject()
|
||||
.put("username", username)
|
||||
.put("password", password)
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = request("/api/auth/signup")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
listener.onDebug("signup failure: ${e.message ?: "network error"}")
|
||||
listener.onError("Signup failed: ${e.message ?: "network error"}")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
captureCookie(it)
|
||||
listener.onDebug("signup HTTP ${it.code}; cookie=${SessionStore.cookieHeader.isNotBlank()}")
|
||||
if (!it.isSuccessful) {
|
||||
listener.onError("Signup failed: HTTP ${it.code}")
|
||||
return
|
||||
}
|
||||
val session = JSONObject(it.body.string()).toUserSession()
|
||||
listener.onAuth(session)
|
||||
SessionStore.save(context)
|
||||
listener.onStatus("Signed up")
|
||||
loadMe()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
listener.onDebug("POST /api/auth/logout")
|
||||
val request = request("/api/auth/logout")
|
||||
.post("{}".toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
listener.onDebug("logout failure: ${e.message ?: "network error"}")
|
||||
SessionStore.cookieHeader = ""
|
||||
SessionStore.save(context)
|
||||
listener.onAuth(null)
|
||||
listener.onStatus("Signed out")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
captureCookie(it)
|
||||
SessionStore.cookieHeader = ""
|
||||
SessionStore.save(context)
|
||||
listener.onDebug("logout HTTP ${it.code}")
|
||||
listener.onAuth(null)
|
||||
listener.onStatus("Signed out")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun loadMe() {
|
||||
listener.onDebug("GET /api/auth/me")
|
||||
val request = request("/api/auth/me").build()
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
listener.onDebug("me failure: ${e.message ?: "network error"}")
|
||||
listener.onError("Auth check failed: ${e.message ?: "network error"}")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
captureCookie(it)
|
||||
listener.onDebug("me HTTP ${it.code}; cookie=${SessionStore.cookieHeader.isNotBlank()}")
|
||||
if (!it.isSuccessful) {
|
||||
SessionStore.cookieHeader = ""
|
||||
SessionStore.save(context)
|
||||
listener.onAuth(null)
|
||||
listener.onStatus("Sign in required")
|
||||
return
|
||||
}
|
||||
val body = JSONObject(it.body.string())
|
||||
val user = body.optJSONObject("user")
|
||||
if (user == null) {
|
||||
SessionStore.cookieHeader = ""
|
||||
listener.onAuth(null)
|
||||
listener.onStatus("Sign in required")
|
||||
} else {
|
||||
listener.onAuth(body.toUserSession())
|
||||
}
|
||||
SessionStore.save(context)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun loadChannels() {
|
||||
listener.onDebug("GET /api/channels ${SessionStore.serverBaseUrl}")
|
||||
val request = request("/api/channels").build()
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
listener.onDebug("channels failure: ${e.message ?: "network error"}")
|
||||
listener.onError("Could not load channels: ${e.message ?: "network error"}")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
captureCookie(it)
|
||||
SessionStore.save(context)
|
||||
listener.onDebug("channels HTTP ${it.code}; cookie=${SessionStore.cookieHeader.isNotBlank()}")
|
||||
if (!it.isSuccessful) {
|
||||
listener.onError("Could not load channels: HTTP ${it.code}")
|
||||
return
|
||||
}
|
||||
val json = it.body.string()
|
||||
val channels = JSONArray(json).toChannels()
|
||||
listener.onDebug("channels parsed: ${channels.size}")
|
||||
listener.onChannels(channels)
|
||||
listener.onStatus("Loaded ${channels.size} channel(s)")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun loadLibrary() {
|
||||
listener.onDebug("GET /api/library")
|
||||
val request = request("/api/library").build()
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
listener.onDebug("library failure: ${e.message ?: "network error"}")
|
||||
listener.onError("Could not load library: ${e.message ?: "network error"}")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
captureCookie(it)
|
||||
SessionStore.save(context)
|
||||
listener.onDebug("library HTTP ${it.code}; cookie=${SessionStore.cookieHeader.isNotBlank()}")
|
||||
if (!it.isSuccessful) {
|
||||
listener.onError("Could not load library: HTTP ${it.code}")
|
||||
return
|
||||
}
|
||||
val tracks = JSONArray(it.body.string()).toTracks()
|
||||
listener.onDebug("library parsed: ${tracks.size}")
|
||||
listener.onLibrary(tracks)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun loadPlaylists() {
|
||||
listener.onDebug("GET /api/playlists")
|
||||
val request = request("/api/playlists").build()
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
listener.onDebug("playlists failure: ${e.message ?: "network error"}")
|
||||
listener.onError("Could not load playlists: ${e.message ?: "network error"}")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
captureCookie(it)
|
||||
SessionStore.save(context)
|
||||
listener.onDebug("playlists HTTP ${it.code}; cookie=${SessionStore.cookieHeader.isNotBlank()}")
|
||||
if (!it.isSuccessful) {
|
||||
listener.onError("Could not load playlists: HTTP ${it.code}")
|
||||
return
|
||||
}
|
||||
val json = JSONObject(it.body.string())
|
||||
val bundle = PlaylistBundle(
|
||||
mine = json.optJSONArray("mine").toPlaylists(),
|
||||
shared = json.optJSONArray("shared").toPlaylists(),
|
||||
)
|
||||
listener.onDebug("playlists parsed: mine=${bundle.mine.size} shared=${bundle.shared.size}")
|
||||
listener.onPlaylists(bundle)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun loadPlaylist(playlistId: String) {
|
||||
listener.onDebug("GET /api/playlists/$playlistId")
|
||||
val request = request("/api/playlists/$playlistId").build()
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
listener.onDebug("playlist detail failure: ${e.message ?: "network error"}")
|
||||
listener.onError("Could not load playlist: ${e.message ?: "network error"}")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
captureCookie(it)
|
||||
SessionStore.save(context)
|
||||
listener.onDebug("playlist detail HTTP ${it.code}")
|
||||
if (!it.isSuccessful) {
|
||||
listener.onError("Could not load playlist: HTTP ${it.code}")
|
||||
return
|
||||
}
|
||||
listener.onPlaylistDetail(JSONObject(it.body.string()).toPlaylist())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun connectChannel(channelId: String) {
|
||||
socket?.close(1000, "switching")
|
||||
listener.onDebug("WS connecting channel=$channelId")
|
||||
val builder = Request.Builder()
|
||||
.url(SessionStore.wsUrl(channelId))
|
||||
.header("User-Agent", SessionStore.userAgent)
|
||||
if (SessionStore.cookieHeader.isNotBlank()) {
|
||||
builder.header("Cookie", SessionStore.cookieHeader)
|
||||
}
|
||||
|
||||
socket = client.newWebSocket(builder.build(), object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
listener.onDebug("WS open HTTP ${response.code}")
|
||||
listener.onStatus("Connected")
|
||||
loadChannelState(channelId)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handleSocketMessage(text)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
listener.onDebug("WS failure ${response?.code ?: "-"}: ${t.message ?: "websocket error"}")
|
||||
listener.onError("Disconnected: ${t.message ?: "websocket error"}")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
listener.onDebug("WS closed $code $reason")
|
||||
listener.onStatus("Disconnected")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun sendAction(action: String) {
|
||||
listener.onDebug("WS send action=$action")
|
||||
socket?.send(JSONObject().put("action", action).toString())
|
||||
}
|
||||
|
||||
fun sendSeek(timestamp: Double) {
|
||||
listener.onDebug("WS send seek=${"%.1f".format(timestamp)}")
|
||||
socket?.send(
|
||||
JSONObject()
|
||||
.put("action", "seek")
|
||||
.put("timestamp", timestamp)
|
||||
.toString()
|
||||
)
|
||||
}
|
||||
|
||||
fun sendJump(index: Int) {
|
||||
listener.onDebug("WS send jump=$index")
|
||||
socket?.send(
|
||||
JSONObject()
|
||||
.put("action", "jump")
|
||||
.put("index", index)
|
||||
.toString()
|
||||
)
|
||||
}
|
||||
|
||||
fun setPlaybackMode(channelId: String, mode: String) {
|
||||
listener.onDebug("POST /api/channels/$channelId/mode $mode")
|
||||
val body = JSONObject()
|
||||
.put("mode", mode)
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val request = request("/api/channels/$channelId/mode")
|
||||
.post(body)
|
||||
.build()
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
listener.onDebug("mode failure: ${e.message ?: "network error"}")
|
||||
listener.onError("Mode failed: ${e.message ?: "network error"}")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
listener.onDebug("mode HTTP ${it.code}")
|
||||
if (it.code == 403) {
|
||||
listener.onError("Mode denied")
|
||||
} else if (!it.isSuccessful) {
|
||||
listener.onError("Mode failed: HTTP ${it.code}")
|
||||
} else {
|
||||
listener.onStatus("Mode set")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun addTracksToQueue(channelId: String, trackIds: List<String>, insertAt: Int? = null) {
|
||||
if (trackIds.isEmpty()) return
|
||||
val insertLabel = insertAt?.let { " insertAt=$it" } ?: ""
|
||||
listener.onDebug("PATCH /api/channels/$channelId/queue add=${trackIds.size}$insertLabel")
|
||||
val bodyJson = JSONObject().put("add", JSONArray(trackIds))
|
||||
if (insertAt != null) bodyJson.put("insertAt", insertAt)
|
||||
val request = request("/api/channels/$channelId/queue")
|
||||
.patch(bodyJson.toString().toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
listener.onDebug("queue add failure: ${e.message ?: "network error"}")
|
||||
listener.onError("Queue failed: ${e.message ?: "network error"}")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
listener.onDebug("queue add HTTP ${it.code}")
|
||||
if (it.code == 403) {
|
||||
listener.onError("Queue denied")
|
||||
} else if (!it.isSuccessful) {
|
||||
listener.onError("Queue failed: HTTP ${it.code}")
|
||||
} else {
|
||||
listener.onStatus(if (insertAt == null) "Queued ${trackIds.size}" else "Play next queued")
|
||||
currentChannelFromQueuePatch(channelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun removeTracksFromQueue(channelId: String, indices: List<Int>) {
|
||||
if (indices.isEmpty()) return
|
||||
listener.onDebug("PATCH /api/channels/$channelId/queue remove=${indices.size}")
|
||||
val request = request("/api/channels/$channelId/queue")
|
||||
.patch(
|
||||
JSONObject()
|
||||
.put("remove", JSONArray(indices))
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
)
|
||||
.build()
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
listener.onDebug("queue remove failure: ${e.message ?: "network error"}")
|
||||
listener.onError("Queue remove failed: ${e.message ?: "network error"}")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
listener.onDebug("queue remove HTTP ${it.code}")
|
||||
if (it.code == 403) {
|
||||
listener.onError("Queue denied")
|
||||
} else if (!it.isSuccessful) {
|
||||
listener.onError("Queue remove failed: HTTP ${it.code}")
|
||||
} else {
|
||||
listener.onStatus("Removed from queue")
|
||||
currentChannelFromQueuePatch(channelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun loadChannelState(channelId: String) {
|
||||
listener.onDebug("GET /api/channels/$channelId")
|
||||
val request = request("/api/channels/$channelId").build()
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
listener.onDebug("channel state failure: ${e.message ?: "network error"}")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
listener.onDebug("channel state HTTP ${it.code}")
|
||||
return
|
||||
}
|
||||
val json = JSONObject(it.body.string())
|
||||
listener.onDebug("channel state HTTP ${it.code}; queue=${json.optJSONArray("queue")?.length() ?: 0}")
|
||||
listener.onChannelState(json.toChannelState())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun currentChannelFromQueuePatch(channelId: String) {
|
||||
loadChannelState(channelId)
|
||||
loadChannels()
|
||||
}
|
||||
|
||||
private fun handleSocketMessage(text: String) {
|
||||
val json = JSONObject(text)
|
||||
val type = json.optString("type", "")
|
||||
val summary = when {
|
||||
type.isNotBlank() -> "type=$type"
|
||||
json.has("track") -> "state track=${json.optJSONObject("track")?.optString("title", "unknown") ?: "none"}"
|
||||
else -> "state"
|
||||
}
|
||||
listener.onDebug("WS message $summary")
|
||||
|
||||
when (type) {
|
||||
"" -> listener.onChannelState(json.toChannelState())
|
||||
"channel_list" -> {
|
||||
val channels = json.optJSONArray("channels")?.toChannels().orEmpty()
|
||||
listener.onChannels(channels)
|
||||
}
|
||||
"switched" -> listener.onSwitched(json.optString("channelId"))
|
||||
"kick" -> listener.onError("Disconnected: ${json.optString("reason", "kicked")}")
|
||||
"error" -> listener.onError(json.optString("message", "Server error"))
|
||||
else -> listener.onDebug("ignored server event type=$type")
|
||||
}
|
||||
}
|
||||
|
||||
private fun request(path: String): Request.Builder {
|
||||
val builder = Request.Builder()
|
||||
.url(SessionStore.httpUrl(path))
|
||||
.header("User-Agent", SessionStore.userAgent)
|
||||
if (SessionStore.cookieHeader.isNotBlank()) {
|
||||
builder.header("Cookie", SessionStore.cookieHeader)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
private fun captureCookie(response: Response) {
|
||||
response.headers("Set-Cookie")
|
||||
.map { it.substringBefore(";") }
|
||||
.firstOrNull { it.startsWith("blastoise_session=") }
|
||||
?.let {
|
||||
SessionStore.cookieHeader = if (it == "blastoise_session=") "" else it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,880 @@
|
|||
package com.peterino.blastoise
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import kotlin.math.abs
|
||||
import kotlin.random.Random
|
||||
|
||||
@UnstableApi
|
||||
class PlaybackService : MediaSessionService(), MusicRoomClient.Listener {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private lateinit var client: MusicRoomClient
|
||||
private lateinit var player: ExoPlayer
|
||||
private var mediaSession: MediaSession? = null
|
||||
|
||||
private var sourceMode = PlaybackSourceMode.RADIO
|
||||
private var authState = AuthState.CHECKING
|
||||
private var currentUser: UserSession? = null
|
||||
private var lastStatus = "Starting"
|
||||
private var lastChannels: List<ChannelInfo> = emptyList()
|
||||
private var libraryTracks: List<Track> = emptyList()
|
||||
private var libraryLoaded = false
|
||||
private var myPlaylists: List<Playlist> = emptyList()
|
||||
private var sharedPlaylists: List<Playlist> = emptyList()
|
||||
private var playlistsLoaded = false
|
||||
private var selectedPlaylistId: String? = null
|
||||
private var selectedPlaylist: Playlist? = null
|
||||
private var currentChannelId: String? = null
|
||||
private var currentTrackId: String? = null
|
||||
private var localLibraryIndex = -1
|
||||
private var lastPaused = true
|
||||
private var lastQueue: List<Track> = emptyList()
|
||||
private var lastQueueLoaded = false
|
||||
private var lastCurrentIndex = 0
|
||||
private var playbackMode = "repeat-all"
|
||||
private var lastChannelName = "No channel"
|
||||
private var lastTrackTitle = "No track"
|
||||
private var lastTrackDuration = 0.0
|
||||
private var lastServerTimestampMs = 0L
|
||||
private var lastStateRealtimeMs = 0L
|
||||
private var intentionalDisconnect = false
|
||||
private var reconnectAttempts = 0
|
||||
private val debugEvents = mutableListOf<String>()
|
||||
private val playbackModes = listOf("once", "repeat-all", "repeat-one", "shuffle")
|
||||
|
||||
private val ticker = object : Runnable {
|
||||
override fun run() {
|
||||
tickPlayback()
|
||||
mainHandler.postDelayed(this, 500)
|
||||
}
|
||||
}
|
||||
|
||||
private val reconnectRunnable = Runnable {
|
||||
val channelId = currentChannelId
|
||||
if (sourceMode == PlaybackSourceMode.RADIO && channelId != null && !intentionalDisconnect) {
|
||||
addDebugEvent("reconnect channel=$channelId attempt=${reconnectAttempts + 1}")
|
||||
reconnectAttempts += 1
|
||||
client.connectChannel(channelId)
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
SessionStore.load(this)
|
||||
client = MusicRoomClient(this, this)
|
||||
|
||||
val httpDataSourceFactory = DataSource.Factory {
|
||||
val requestProperties = mutableMapOf<String, String>()
|
||||
if (SessionStore.cookieHeader.isNotBlank()) {
|
||||
requestProperties["Cookie"] = SessionStore.cookieHeader
|
||||
}
|
||||
|
||||
DefaultHttpDataSource.Factory()
|
||||
.setUserAgent(SessionStore.userAgent)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
.setDefaultRequestProperties(requestProperties)
|
||||
.createDataSource()
|
||||
}
|
||||
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(this)
|
||||
.setDataSourceFactory(httpDataSourceFactory)
|
||||
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
player = ExoPlayer.Builder(this)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.setWakeMode(C.WAKE_MODE_LOCAL)
|
||||
.setAudioAttributes(audioAttributes, true)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
.apply {
|
||||
addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
handlePlaybackEnded()
|
||||
}
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (sourceMode == PlaybackSourceMode.LIBRARY) {
|
||||
lastPaused = !isPlaying
|
||||
}
|
||||
publishSnapshot()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
mediaSession = MediaSession.Builder(this, player).build()
|
||||
PlaybackBridge.service = this
|
||||
addDebugEvent("service created")
|
||||
publishSnapshot()
|
||||
validateSession()
|
||||
mainHandler.post(ticker)
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
if (!player.isPlaying) {
|
||||
pauseAllPlayersAndStopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
mainHandler.removeCallbacks(ticker)
|
||||
mainHandler.removeCallbacks(reconnectRunnable)
|
||||
intentionalDisconnect = true
|
||||
client.close()
|
||||
mediaSession?.run {
|
||||
player.release()
|
||||
release()
|
||||
}
|
||||
mediaSession = null
|
||||
PlaybackBridge.service = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
fun validateSession() = onMain {
|
||||
if (SessionStore.cookieHeader.isBlank()) {
|
||||
authState = AuthState.SIGNED_OUT
|
||||
currentUser = null
|
||||
lastStatus = "Sign in required"
|
||||
publishSnapshot()
|
||||
return@onMain
|
||||
}
|
||||
authState = AuthState.CHECKING
|
||||
lastStatus = "Checking session"
|
||||
publishSnapshot()
|
||||
client.loadMe()
|
||||
}
|
||||
|
||||
fun signIn(serverUrl: String, username: String, password: String) = onMain {
|
||||
SessionStore.setBaseUrl(serverUrl)
|
||||
SessionStore.save(this)
|
||||
authState = AuthState.CHECKING
|
||||
lastStatus = "Signing in"
|
||||
publishSnapshot()
|
||||
client.login(username.trim(), password)
|
||||
}
|
||||
|
||||
fun signUp(serverUrl: String, username: String, password: String) = onMain {
|
||||
SessionStore.setBaseUrl(serverUrl)
|
||||
SessionStore.save(this)
|
||||
authState = AuthState.CHECKING
|
||||
lastStatus = "Signing up"
|
||||
publishSnapshot()
|
||||
client.signup(username.trim(), password)
|
||||
}
|
||||
|
||||
fun signOut() = onMain {
|
||||
authState = AuthState.SIGNED_OUT
|
||||
currentUser = null
|
||||
intentionalDisconnect = true
|
||||
client.close()
|
||||
player.pause()
|
||||
player.clearMediaItems()
|
||||
currentTrackId = null
|
||||
myPlaylists = emptyList()
|
||||
sharedPlaylists = emptyList()
|
||||
playlistsLoaded = false
|
||||
selectedPlaylistId = null
|
||||
selectedPlaylist = null
|
||||
lastTrackTitle = "Sign in to join a room"
|
||||
lastTrackDuration = 0.0
|
||||
lastPaused = true
|
||||
lastStatus = "Signing out"
|
||||
publishSnapshot()
|
||||
client.logout()
|
||||
}
|
||||
|
||||
fun connectToServer(input: String = SessionStore.serverBaseUrl) = onMain {
|
||||
SessionStore.setBaseUrl(input)
|
||||
SessionStore.save(this)
|
||||
if (!hasSignedInUser()) {
|
||||
validateSession()
|
||||
return@onMain
|
||||
}
|
||||
intentionalDisconnect = true
|
||||
client.close()
|
||||
intentionalDisconnect = false
|
||||
reconnectAttempts = 0
|
||||
sourceMode = PlaybackSourceMode.RADIO
|
||||
currentChannelId = null
|
||||
currentTrackId = null
|
||||
localLibraryIndex = -1
|
||||
lastQueue = emptyList()
|
||||
lastQueueLoaded = false
|
||||
myPlaylists = emptyList()
|
||||
sharedPlaylists = emptyList()
|
||||
playlistsLoaded = false
|
||||
selectedPlaylistId = null
|
||||
selectedPlaylist = null
|
||||
lastCurrentIndex = 0
|
||||
playbackMode = "repeat-all"
|
||||
lastChannelName = "No channel"
|
||||
lastTrackTitle = "No track"
|
||||
lastTrackDuration = 0.0
|
||||
lastServerTimestampMs = 0L
|
||||
lastStateRealtimeMs = 0L
|
||||
lastStatus = "Loading"
|
||||
addDebugEvent("connect ${SessionStore.serverBaseUrl}")
|
||||
publishSnapshot()
|
||||
client.loadChannels()
|
||||
}
|
||||
|
||||
fun loadLibrary() = onMain {
|
||||
if (!requireSignedIn("Sign in to browse the library")) return@onMain
|
||||
client.loadLibrary()
|
||||
}
|
||||
|
||||
fun loadPlaylists() = onMain {
|
||||
if (!requireSignedIn("Sign in to load playlists")) return@onMain
|
||||
client.loadPlaylists()
|
||||
}
|
||||
|
||||
fun loadPlaylist(playlistId: String) = onMain {
|
||||
if (!requireSignedIn("Sign in to load playlists")) return@onMain
|
||||
selectedPlaylistId = playlistId
|
||||
selectedPlaylist = null
|
||||
publishSnapshot()
|
||||
client.loadPlaylist(playlistId)
|
||||
}
|
||||
|
||||
fun ensureRadioMode() = onMain {
|
||||
if (!requireSignedIn("Sign in to join rooms")) return@onMain
|
||||
sourceMode = PlaybackSourceMode.RADIO
|
||||
intentionalDisconnect = false
|
||||
val target = currentChannelId
|
||||
?: lastChannels.firstOrNull { it.isDefault }?.id
|
||||
?: lastChannels.firstOrNull()?.id
|
||||
if (target != null && currentTrackId == null) {
|
||||
joinChannel(target)
|
||||
} else {
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
fun enterLibraryMode() = onMain {
|
||||
if (!requireSignedIn("Sign in to browse the library")) return@onMain
|
||||
lastStatus = if (libraryLoaded) "Library search" else "Loading library"
|
||||
publishSnapshot()
|
||||
if (!libraryLoaded) client.loadLibrary()
|
||||
}
|
||||
|
||||
fun joinChannel(channelId: String) = onMain {
|
||||
if (!requireSignedIn("Sign in to join rooms")) return@onMain
|
||||
sourceMode = PlaybackSourceMode.RADIO
|
||||
intentionalDisconnect = false
|
||||
mainHandler.removeCallbacks(reconnectRunnable)
|
||||
currentChannelId = channelId
|
||||
localLibraryIndex = -1
|
||||
lastStatus = "Joining"
|
||||
addDebugEvent("join channel=$channelId")
|
||||
publishSnapshot()
|
||||
client.connectChannel(channelId)
|
||||
}
|
||||
|
||||
fun playLibraryTrack(index: Int) = onMain {
|
||||
if (index !in libraryTracks.indices) return@onMain
|
||||
sourceMode = PlaybackSourceMode.LIBRARY
|
||||
intentionalDisconnect = true
|
||||
client.close()
|
||||
val track = libraryTracks[index]
|
||||
localLibraryIndex = index
|
||||
currentTrackId = track.id
|
||||
lastTrackTitle = track.title
|
||||
lastTrackDuration = track.duration
|
||||
lastChannelName = "Library"
|
||||
lastPaused = false
|
||||
lastStatus = "Local"
|
||||
lastServerTimestampMs = 0L
|
||||
lastStateRealtimeMs = 0L
|
||||
player.setMediaItem(buildMediaItem(track), 0L)
|
||||
applyLocalPlaybackMode()
|
||||
player.prepare()
|
||||
player.play()
|
||||
addDebugEvent("local play ${track.title.take(40)}")
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
fun togglePlay() = onMain {
|
||||
if (sourceMode == PlaybackSourceMode.RADIO) {
|
||||
client.sendAction(if (lastPaused) "unpause" else "pause")
|
||||
return@onMain
|
||||
}
|
||||
if (player.isPlaying) {
|
||||
player.pause()
|
||||
lastPaused = true
|
||||
} else {
|
||||
if (localLibraryIndex < 0 && libraryTracks.isNotEmpty()) {
|
||||
playLibraryTrack(0)
|
||||
return@onMain
|
||||
}
|
||||
player.play()
|
||||
lastPaused = false
|
||||
}
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
fun previous() = onMain {
|
||||
if (sourceMode == PlaybackSourceMode.RADIO) {
|
||||
jumpRadio(-1)
|
||||
} else if (libraryTracks.isNotEmpty()) {
|
||||
val nextIndex = if (playbackMode == "shuffle" && libraryTracks.size > 1) {
|
||||
randomLibraryIndex()
|
||||
} else if (localLibraryIndex <= 0) {
|
||||
libraryTracks.lastIndex
|
||||
} else {
|
||||
localLibraryIndex - 1
|
||||
}
|
||||
playLibraryTrack(nextIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun next() = onMain {
|
||||
if (sourceMode == PlaybackSourceMode.RADIO) {
|
||||
jumpRadio(1)
|
||||
} else if (libraryTracks.isNotEmpty()) {
|
||||
playLibraryTrack(nextLibraryIndex())
|
||||
}
|
||||
}
|
||||
|
||||
fun seekBy(seconds: Int) = onMain {
|
||||
val durationMs = when {
|
||||
player.duration > 0 -> player.duration
|
||||
lastTrackDuration > 0.0 -> (lastTrackDuration * 1000.0).toLong()
|
||||
else -> Long.MAX_VALUE
|
||||
}
|
||||
val targetMs = (player.currentPosition + seconds * 1000L)
|
||||
.coerceAtLeast(0L)
|
||||
.coerceAtMost(durationMs)
|
||||
seekTo(targetMs / 1000.0)
|
||||
}
|
||||
|
||||
fun seekTo(seconds: Double) = onMain {
|
||||
val targetMs = (seconds * 1000.0).toLong().coerceAtLeast(0L)
|
||||
if (sourceMode == PlaybackSourceMode.RADIO) {
|
||||
client.sendSeek(seconds)
|
||||
} else {
|
||||
player.seekTo(targetMs)
|
||||
}
|
||||
addDebugEvent("seek ${"%.1f".format(seconds)}s")
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
fun stopPlayback() = onMain {
|
||||
player.pause()
|
||||
player.seekTo(0L)
|
||||
lastPaused = true
|
||||
if (sourceMode == PlaybackSourceMode.RADIO) {
|
||||
client.sendAction("pause")
|
||||
client.sendSeek(0.0)
|
||||
}
|
||||
addDebugEvent("stop")
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
fun stopPlaybackAndExit() = onMain {
|
||||
intentionalDisconnect = true
|
||||
mainHandler.removeCallbacks(reconnectRunnable)
|
||||
client.close()
|
||||
player.pause()
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
currentTrackId = null
|
||||
localLibraryIndex = -1
|
||||
lastPaused = true
|
||||
lastTrackTitle = "Stopped"
|
||||
lastTrackDuration = 0.0
|
||||
lastServerTimestampMs = 0L
|
||||
lastStateRealtimeMs = 0L
|
||||
lastStatus = "Stopped"
|
||||
addDebugEvent("stop and exit")
|
||||
publishSnapshot()
|
||||
pauseAllPlayersAndStopSelf()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
fun cyclePlaybackMode() = onMain {
|
||||
if (!requireSignedIn("Sign in to control rooms")) return@onMain
|
||||
val currentIndex = playbackModes.indexOf(playbackMode).coerceAtLeast(0)
|
||||
val nextMode = playbackModes[(currentIndex + 1) % playbackModes.size]
|
||||
playbackMode = nextMode
|
||||
if (sourceMode == PlaybackSourceMode.RADIO) {
|
||||
val channelId = currentChannelId ?: run {
|
||||
lastStatus = "No channel"
|
||||
publishSnapshot()
|
||||
return@onMain
|
||||
}
|
||||
client.setPlaybackMode(channelId, nextMode)
|
||||
} else {
|
||||
applyLocalPlaybackMode()
|
||||
lastStatus = "Mode ${modeLabel(nextMode)}"
|
||||
}
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
fun queueCurrentTrack(playNext: Boolean) = onMain {
|
||||
val trackId = currentTrackId ?: libraryTracks.getOrNull(localLibraryIndex)?.id
|
||||
if (trackId == null) {
|
||||
lastStatus = "No track"
|
||||
publishSnapshot()
|
||||
return@onMain
|
||||
}
|
||||
queueTrackIds(listOf(trackId), playNext)
|
||||
}
|
||||
|
||||
fun queueTrack(track: Track, playNext: Boolean) = onMain {
|
||||
queueTrackIds(listOf(track.id), playNext)
|
||||
}
|
||||
|
||||
fun addPlaylistToQueue(playlist: Playlist, playNext: Boolean) = onMain {
|
||||
if (playlist.trackIds.isEmpty()) {
|
||||
lastStatus = "Empty playlist"
|
||||
publishSnapshot()
|
||||
return@onMain
|
||||
}
|
||||
queueTrackIds(playlist.trackIds, playNext)
|
||||
}
|
||||
|
||||
fun queueTrackIds(trackIds: List<String>, playNext: Boolean) = onMain {
|
||||
if (!requireSignedIn("Sign in to change the room queue")) return@onMain
|
||||
val channelId = currentChannelId
|
||||
?: lastChannels.firstOrNull { it.isDefault }?.id
|
||||
?: lastChannels.firstOrNull()?.id
|
||||
if (channelId == null) {
|
||||
lastStatus = "No channel"
|
||||
publishSnapshot()
|
||||
return@onMain
|
||||
}
|
||||
currentChannelId = channelId
|
||||
val insertAt = if (playNext) (lastCurrentIndex + 1).coerceAtLeast(0) else null
|
||||
client.addTracksToQueue(channelId, trackIds, insertAt)
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
fun jumpToQueueIndex(index: Int) = onMain {
|
||||
if (!requireSignedIn("Sign in to control rooms")) return@onMain
|
||||
if (index !in lastQueue.indices) return@onMain
|
||||
client.sendJump(index)
|
||||
lastStatus = "Jumping"
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
fun removeQueueIndex(index: Int) = onMain {
|
||||
if (!requireSignedIn("Sign in to change the room queue")) return@onMain
|
||||
val channelId = currentChannelId ?: run {
|
||||
lastStatus = "No room"
|
||||
publishSnapshot()
|
||||
return@onMain
|
||||
}
|
||||
if (index !in lastQueue.indices) return@onMain
|
||||
client.removeTracksFromQueue(channelId, listOf(index))
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
override fun onStatus(message: String) {
|
||||
onMain {
|
||||
if (sourceMode == PlaybackSourceMode.LIBRARY && message == "Disconnected") {
|
||||
lastStatus = if (libraryLoaded) "Library" else "Loading library"
|
||||
publishSnapshot()
|
||||
return@onMain
|
||||
}
|
||||
lastStatus = message
|
||||
if (message == "Disconnected" && sourceMode == PlaybackSourceMode.RADIO && !intentionalDisconnect) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDebug(message: String) {
|
||||
onMain {
|
||||
addDebugEvent(message)
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(message: String) {
|
||||
onMain {
|
||||
if (sourceMode == PlaybackSourceMode.LIBRARY && intentionalDisconnect) {
|
||||
lastStatus = if (libraryLoaded) "Library" else "Loading library"
|
||||
addDebugEvent("local mode ignored websocket error: $message")
|
||||
publishSnapshot()
|
||||
return@onMain
|
||||
}
|
||||
lastStatus = if (
|
||||
!hasSignedInUser() &&
|
||||
(message.contains("401") || message.contains("403") || message.contains("denied", ignoreCase = true))
|
||||
) {
|
||||
"Sign in required"
|
||||
} else {
|
||||
message
|
||||
}
|
||||
addDebugEvent("ERROR $message")
|
||||
if (sourceMode == PlaybackSourceMode.RADIO && !intentionalDisconnect) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChannels(channels: List<ChannelInfo>) {
|
||||
onMain {
|
||||
if (!hasSignedInUser()) {
|
||||
lastStatus = "Sign in required"
|
||||
publishSnapshot()
|
||||
return@onMain
|
||||
}
|
||||
lastChannels = channels
|
||||
val target = currentChannelId
|
||||
?: channels.firstOrNull { it.isDefault }?.id
|
||||
?: channels.firstOrNull()?.id
|
||||
if (target != null && currentChannelId == null && sourceMode == PlaybackSourceMode.RADIO) {
|
||||
joinChannel(target)
|
||||
} else {
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLibrary(tracks: List<Track>) {
|
||||
onMain {
|
||||
libraryTracks = tracks
|
||||
libraryLoaded = true
|
||||
if (sourceMode == PlaybackSourceMode.LIBRARY && lastTrackTitle == "No track") {
|
||||
lastStatus = "Library ready"
|
||||
}
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaylists(playlists: PlaylistBundle) {
|
||||
onMain {
|
||||
myPlaylists = playlists.mine
|
||||
sharedPlaylists = playlists.shared
|
||||
playlistsLoaded = true
|
||||
val all = allPlaylists()
|
||||
if (selectedPlaylistId == null || all.none { it.id == selectedPlaylistId }) {
|
||||
selectedPlaylistId = all.firstOrNull()?.id
|
||||
selectedPlaylist = null
|
||||
}
|
||||
selectedPlaylistId?.let { client.loadPlaylist(it) }
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaylistDetail(playlist: Playlist) {
|
||||
onMain {
|
||||
selectedPlaylist = playlist
|
||||
selectedPlaylistId = playlist.id
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSwitched(channelId: String) {
|
||||
onMain {
|
||||
currentChannelId = channelId
|
||||
lastStatus = "Tuned"
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuth(user: UserSession?) {
|
||||
onMain {
|
||||
currentUser = user
|
||||
authState = when {
|
||||
user == null -> AuthState.SIGNED_OUT
|
||||
user.isGuest -> AuthState.GUEST
|
||||
else -> AuthState.SIGNED_IN
|
||||
}
|
||||
if (!hasSignedInUser()) {
|
||||
intentionalDisconnect = true
|
||||
client.close()
|
||||
player.pause()
|
||||
lastPaused = true
|
||||
lastStatus = if (authState == AuthState.GUEST) "Sign in required" else "Signed out"
|
||||
addDebugEvent("auth blocked guest=${user?.isGuest == true}")
|
||||
publishSnapshot()
|
||||
return@onMain
|
||||
}
|
||||
|
||||
val signedInUser = currentUser ?: return@onMain
|
||||
intentionalDisconnect = false
|
||||
reconnectAttempts = 0
|
||||
lastStatus = "Signed in as ${signedInUser.username}"
|
||||
addDebugEvent("auth user=${signedInUser.username}")
|
||||
publishSnapshot()
|
||||
client.loadChannels()
|
||||
if (!libraryLoaded) client.loadLibrary()
|
||||
if (!playlistsLoaded) client.loadPlaylists()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChannelState(state: ChannelState) {
|
||||
onMain {
|
||||
if (sourceMode != PlaybackSourceMode.RADIO) return@onMain
|
||||
reconnectAttempts = 0
|
||||
mainHandler.removeCallbacks(reconnectRunnable)
|
||||
currentChannelId = state.channelId.ifBlank { currentChannelId }
|
||||
lastChannelName = state.channelName.ifBlank { "Channel" }
|
||||
lastPaused = state.paused
|
||||
playbackMode = state.playbackMode.ifBlank { playbackMode }
|
||||
if (state.queue != null) {
|
||||
lastQueue = state.queue
|
||||
lastQueueLoaded = true
|
||||
}
|
||||
lastCurrentIndex = state.currentIndex
|
||||
lastServerTimestampMs = (state.currentTimestamp * 1000.0).toLong().coerceAtLeast(0L)
|
||||
lastStateRealtimeMs = SystemClock.elapsedRealtime()
|
||||
|
||||
val track = state.track
|
||||
if (track == null) {
|
||||
currentTrackId = null
|
||||
lastTrackTitle = "No track"
|
||||
lastTrackDuration = 0.0
|
||||
player.pause()
|
||||
publishSnapshot()
|
||||
return@onMain
|
||||
}
|
||||
|
||||
lastTrackTitle = track.title
|
||||
lastTrackDuration = track.duration
|
||||
|
||||
val targetPositionMs = lastServerTimestampMs
|
||||
if (currentTrackId != track.id) {
|
||||
currentTrackId = track.id
|
||||
player.setMediaItem(buildMediaItem(track), targetPositionMs)
|
||||
player.prepare()
|
||||
if (state.paused) player.pause() else player.play()
|
||||
publishSnapshot()
|
||||
return@onMain
|
||||
}
|
||||
|
||||
val driftMs = abs(player.currentPosition - targetPositionMs)
|
||||
if (driftMs >= 2000L) {
|
||||
player.seekTo(targetPositionMs)
|
||||
}
|
||||
|
||||
if (state.paused && player.isPlaying) {
|
||||
player.pause()
|
||||
} else if (!state.paused && !player.isPlaying) {
|
||||
player.play()
|
||||
}
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
private fun tickPlayback() {
|
||||
if (sourceMode == PlaybackSourceMode.RADIO &&
|
||||
currentTrackId != null &&
|
||||
!lastPaused &&
|
||||
player.playbackState == Player.STATE_READY
|
||||
) {
|
||||
val driftMs = player.currentPosition - expectedPositionMs()
|
||||
if (abs(driftMs) >= 2000L) {
|
||||
player.seekTo(expectedPositionMs())
|
||||
addDebugEvent("service drift correction ${driftMs}ms")
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceMode == PlaybackSourceMode.LIBRARY) {
|
||||
lastPaused = !player.isPlaying
|
||||
if (player.duration > 0 && lastTrackDuration <= 0.0) {
|
||||
lastTrackDuration = player.duration / 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
private fun handlePlaybackEnded() {
|
||||
if (sourceMode != PlaybackSourceMode.LIBRARY) return
|
||||
when (playbackMode) {
|
||||
"repeat-one" -> {
|
||||
player.seekTo(0L)
|
||||
player.play()
|
||||
}
|
||||
"repeat-all", "shuffle" -> {
|
||||
if (libraryTracks.isNotEmpty()) playLibraryTrack(nextLibraryIndex())
|
||||
}
|
||||
else -> {
|
||||
lastPaused = true
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (currentChannelId == null) return
|
||||
mainHandler.removeCallbacks(reconnectRunnable)
|
||||
val delayMs = (3000L * (reconnectAttempts + 1)).coerceAtMost(30000L)
|
||||
addDebugEvent("schedule reconnect in ${delayMs}ms")
|
||||
mainHandler.postDelayed(reconnectRunnable, delayMs)
|
||||
}
|
||||
|
||||
private fun jumpRadio(delta: Int) {
|
||||
if (lastQueue.isEmpty()) return
|
||||
val target = (lastCurrentIndex + delta + lastQueue.size) % lastQueue.size
|
||||
client.sendJump(target)
|
||||
}
|
||||
|
||||
private fun applyLocalPlaybackMode() {
|
||||
player.repeatMode = if (playbackMode == "repeat-one") {
|
||||
Player.REPEAT_MODE_ONE
|
||||
} else {
|
||||
Player.REPEAT_MODE_OFF
|
||||
}
|
||||
player.shuffleModeEnabled = playbackMode == "shuffle"
|
||||
}
|
||||
|
||||
private fun nextLibraryIndex(): Int {
|
||||
if (libraryTracks.isEmpty()) return -1
|
||||
if (playbackMode == "shuffle" && libraryTracks.size > 1) return randomLibraryIndex()
|
||||
return if (localLibraryIndex < 0) 0 else (localLibraryIndex + 1) % libraryTracks.size
|
||||
}
|
||||
|
||||
private fun randomLibraryIndex(): Int {
|
||||
if (libraryTracks.size <= 1) return 0
|
||||
var next = Random.nextInt(libraryTracks.size)
|
||||
while (next == localLibraryIndex) {
|
||||
next = Random.nextInt(libraryTracks.size)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private fun modeLabel(mode: String): String {
|
||||
return when (mode) {
|
||||
"once" -> "LOOP OFF"
|
||||
"repeat-all" -> "LOOP ALL"
|
||||
"repeat-one" -> "LOOP ONE"
|
||||
"shuffle" -> "SHUFFLE"
|
||||
else -> mode.uppercase()
|
||||
}
|
||||
}
|
||||
|
||||
private fun allPlaylists(): List<Playlist> {
|
||||
return myPlaylists + sharedPlaylists
|
||||
}
|
||||
|
||||
private fun hasSignedInUser(): Boolean {
|
||||
return authState == AuthState.SIGNED_IN && currentUser?.isSignedIn == true
|
||||
}
|
||||
|
||||
private fun requireSignedIn(message: String): Boolean {
|
||||
if (hasSignedInUser()) return true
|
||||
lastStatus = message
|
||||
addDebugEvent("auth required")
|
||||
publishSnapshot()
|
||||
return false
|
||||
}
|
||||
|
||||
private fun buildMediaItem(track: Track): MediaItem {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(track.title)
|
||||
.setArtist(if (sourceMode == PlaybackSourceMode.RADIO) lastChannelName else "Blastoise Library")
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(track.id)
|
||||
.setUri(SessionStore.trackUrl(track.id))
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun expectedPositionMs(): Long {
|
||||
if (sourceMode == PlaybackSourceMode.LIBRARY) return player.currentPosition
|
||||
if (lastStateRealtimeMs == 0L) return 0L
|
||||
return if (lastPaused) {
|
||||
lastServerTimestampMs
|
||||
} else {
|
||||
lastServerTimestampMs + (SystemClock.elapsedRealtime() - lastStateRealtimeMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishSnapshot() {
|
||||
val expectedMs = expectedPositionMs()
|
||||
val playerMs = player.currentPosition
|
||||
val driftMs = if (sourceMode == PlaybackSourceMode.RADIO && currentTrackId != null) playerMs - expectedMs else 0L
|
||||
PlaybackBridge.publish(
|
||||
PlaybackSnapshot(
|
||||
sourceMode = sourceMode,
|
||||
authState = authState,
|
||||
currentUser = currentUser,
|
||||
status = lastStatus,
|
||||
channels = lastChannels,
|
||||
libraryTracks = libraryTracks,
|
||||
libraryLoaded = libraryLoaded,
|
||||
myPlaylists = myPlaylists,
|
||||
sharedPlaylists = sharedPlaylists,
|
||||
playlistsLoaded = playlistsLoaded,
|
||||
selectedPlaylistId = selectedPlaylistId,
|
||||
selectedPlaylist = selectedPlaylist,
|
||||
currentChannelId = currentChannelId,
|
||||
currentTrackId = currentTrackId,
|
||||
localLibraryIndex = localLibraryIndex,
|
||||
currentRoomListeners = lastChannels.firstOrNull { it.id == currentChannelId }?.listeners.orEmpty(),
|
||||
paused = lastPaused,
|
||||
queue = lastQueue,
|
||||
queueLoaded = lastQueueLoaded,
|
||||
currentIndex = lastCurrentIndex,
|
||||
playbackMode = playbackMode,
|
||||
channelName = lastChannelName,
|
||||
trackTitle = lastTrackTitle,
|
||||
trackDuration = lastTrackDuration,
|
||||
serverTimestampMs = lastServerTimestampMs,
|
||||
stateRealtimeMs = lastStateRealtimeMs,
|
||||
expectedPositionMs = expectedMs,
|
||||
playerPositionMs = playerMs,
|
||||
driftMs = driftMs,
|
||||
playbackState = playbackStateLabel(),
|
||||
isPlaying = player.isPlaying,
|
||||
debugEvents = debugEvents.toList(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun playbackStateLabel(): String {
|
||||
return when (player.playbackState) {
|
||||
Player.STATE_IDLE -> "idle"
|
||||
Player.STATE_BUFFERING -> "buffering"
|
||||
Player.STATE_READY -> "ready"
|
||||
Player.STATE_ENDED -> "ended"
|
||||
else -> "none"
|
||||
}
|
||||
}
|
||||
|
||||
private fun addDebugEvent(message: String) {
|
||||
val stamp = android.text.format.DateFormat.format("HH:mm:ss", System.currentTimeMillis()).toString()
|
||||
debugEvents.add("$stamp $message")
|
||||
while (debugEvents.size > 8) debugEvents.removeAt(0)
|
||||
}
|
||||
|
||||
private fun onMain(block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
} else {
|
||||
mainHandler.post(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package com.peterino.blastoise
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
||||
enum class PlaybackSourceMode {
|
||||
RADIO,
|
||||
LIBRARY,
|
||||
}
|
||||
|
||||
enum class AuthState {
|
||||
CHECKING,
|
||||
SIGNED_OUT,
|
||||
GUEST,
|
||||
SIGNED_IN,
|
||||
}
|
||||
|
||||
data class PlaybackSnapshot(
|
||||
val sourceMode: PlaybackSourceMode = PlaybackSourceMode.RADIO,
|
||||
val authState: AuthState = AuthState.CHECKING,
|
||||
val currentUser: UserSession? = null,
|
||||
val status: String = "Starting",
|
||||
val channels: List<ChannelInfo> = emptyList(),
|
||||
val libraryTracks: List<Track> = emptyList(),
|
||||
val libraryLoaded: Boolean = false,
|
||||
val myPlaylists: List<Playlist> = emptyList(),
|
||||
val sharedPlaylists: List<Playlist> = emptyList(),
|
||||
val playlistsLoaded: Boolean = false,
|
||||
val selectedPlaylistId: String? = null,
|
||||
val selectedPlaylist: Playlist? = null,
|
||||
val currentChannelId: String? = null,
|
||||
val currentTrackId: String? = null,
|
||||
val localLibraryIndex: Int = -1,
|
||||
val currentRoomListeners: List<String> = emptyList(),
|
||||
val paused: Boolean = true,
|
||||
val queue: List<Track> = emptyList(),
|
||||
val queueLoaded: Boolean = false,
|
||||
val currentIndex: Int = 0,
|
||||
val playbackMode: String = "repeat-all",
|
||||
val channelName: String = "No channel",
|
||||
val trackTitle: String = "No track",
|
||||
val trackDuration: Double = 0.0,
|
||||
val serverTimestampMs: Long = 0L,
|
||||
val stateRealtimeMs: Long = 0L,
|
||||
val expectedPositionMs: Long = 0L,
|
||||
val playerPositionMs: Long = 0L,
|
||||
val driftMs: Long = 0L,
|
||||
val playbackState: String = "none",
|
||||
val isPlaying: Boolean = false,
|
||||
val debugEvents: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
interface PlaybackSnapshotListener {
|
||||
fun onPlaybackSnapshot(snapshot: PlaybackSnapshot)
|
||||
}
|
||||
|
||||
object PlaybackBridge {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val listeners = linkedSetOf<PlaybackSnapshotListener>()
|
||||
|
||||
@Volatile
|
||||
var service: PlaybackService? = null
|
||||
|
||||
@Volatile
|
||||
var snapshot: PlaybackSnapshot = PlaybackSnapshot()
|
||||
private set
|
||||
|
||||
fun register(listener: PlaybackSnapshotListener) {
|
||||
mainHandler.post {
|
||||
listeners.add(listener)
|
||||
listener.onPlaybackSnapshot(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
fun unregister(listener: PlaybackSnapshotListener) {
|
||||
mainHandler.post {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun publish(next: PlaybackSnapshot) {
|
||||
mainHandler.post {
|
||||
snapshot = next
|
||||
listeners.toList().forEach { it.onPlaybackSnapshot(next) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package com.peterino.blastoise
|
||||
|
||||
import android.content.Context
|
||||
import java.net.URI
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
object SessionStore {
|
||||
const val defaultServerBaseUrl = "https://tunes.peterino.com"
|
||||
private const val legacyDefaultServerBaseUrl = "http://mhsgroove.peterino.com:3001"
|
||||
const val userAgent = "BlastoiseAndroid/0.1"
|
||||
|
||||
@Volatile
|
||||
var serverBaseUrl: String = defaultServerBaseUrl
|
||||
|
||||
@Volatile
|
||||
var cookieHeader: String = ""
|
||||
|
||||
@Volatile
|
||||
var themeKey: String = "seraph"
|
||||
|
||||
fun load(context: Context) {
|
||||
val prefs = context.getSharedPreferences("blastoise", Context.MODE_PRIVATE)
|
||||
val storedBaseUrl = prefs.getString("serverBaseUrl", defaultServerBaseUrl).orEmpty().trim()
|
||||
serverBaseUrl = when {
|
||||
storedBaseUrl.isBlank() -> defaultServerBaseUrl
|
||||
storedBaseUrl.trimEnd('/') == legacyDefaultServerBaseUrl -> defaultServerBaseUrl
|
||||
else -> storedBaseUrl.trimEnd('/')
|
||||
}
|
||||
cookieHeader = prefs.getString("cookieHeader", "") ?: ""
|
||||
themeKey = prefs.getString("themeKey", "seraph") ?: "seraph"
|
||||
}
|
||||
|
||||
fun save(context: Context) {
|
||||
context.getSharedPreferences("blastoise", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("serverBaseUrl", serverBaseUrl)
|
||||
.putString("cookieHeader", cookieHeader)
|
||||
.putString("themeKey", themeKey)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun setBaseUrl(input: String) {
|
||||
val trimmed = input.trim().trimEnd('/')
|
||||
serverBaseUrl = when {
|
||||
trimmed.isEmpty() -> defaultServerBaseUrl
|
||||
trimmed.startsWith("http://") || trimmed.startsWith("https://") -> trimmed
|
||||
else -> "http://$trimmed"
|
||||
}
|
||||
}
|
||||
|
||||
fun httpUrl(path: String): String {
|
||||
return serverBaseUrl.trimEnd('/') + path
|
||||
}
|
||||
|
||||
fun wsUrl(channelId: String): String {
|
||||
val uri = URI(serverBaseUrl)
|
||||
val scheme = if (uri.scheme == "https") "wss" else "ws"
|
||||
val authority = uri.rawAuthority
|
||||
return "$scheme://$authority/api/channels/${encodePath(channelId)}/ws"
|
||||
}
|
||||
|
||||
fun trackUrl(trackId: String): String {
|
||||
return httpUrl("/api/tracks/${encodePath(trackId)}")
|
||||
}
|
||||
|
||||
private fun encodePath(value: String): String {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()).replace("+", "%20")
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
|
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4,10L7,4L10,8L14,8L17,4L20,10L20,17L17,20L7,20L4,17Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M2,14L6,14L6,16L2,16ZM18,14L22,14L22,16L18,16ZM3,18L7,17L7,19L3,20ZM17,17L21,18L21,20L17,19Z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M1,9l2,2c4.97,-4.97 13.03,-4.97 18,0l2,-2C16.93,2.93 7.08,2.93 1,9zM9,17l3,3 3,-3c-1.65,-1.66 -4.34,-1.66 -6,0zM5,13l2,2c2.76,-2.76 7.24,-2.76 10,0l2,-2C15.14,9.14 8.87,9.14 5,13z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M8,4h8l1,2h4v2H3V6h4l1,-2zM5,10h14l-1,10H6L5,10zM9,12v6h2v-6H9zM13,12v6h2v-6h-2z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M5,6.8C5,4.7 8.1,3 12,3C15.9,3 19,4.7 19,6.8C19,8.9 15.9,10.6 12,10.6C8.1,10.6 5,8.9 5,6.8Z"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeLineCap="square"
|
||||
android:strokeLineJoin="miter"
|
||||
android:strokeWidth="2" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M7,13L11,13L11,21L7,21ZM13,13L17,13L17,21L13,21ZM4,15L2,18L5,18ZM20,15L22,18L19,18Z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M10,4l2,2h8v14H4V4h6zM6,8v10h12V8H6zM14,10v4.6c0,1.32 -1.08,2.4 -2.4,2.4S9.2,15.92 9.2,14.6s1.08,-2.4 2.4,-2.4c0.35,0 0.69,0.08 1,0.22V10h1.4z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M6,5h4v14H6V5zM14,5h4v14h-4V5z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M8,5a3,3 0,1 0,0 6a3,3 0,0 0,0 -6zM16.5,6a2.5,2.5 0,1 0,0 5a2.5,2.5 0,0 0,0 -5zM8,13c-3.3,0 -6,1.8 -6,4v2h12v-2c0,-2.2 -2.7,-4 -6,-4zM16.5,13c-0.9,0 -1.75,0.14 -2.5,0.4c1.25,0.95 2,2.18 2,3.6v2h6v-1.7c0,-2.02 -2.45,-3.3 -5.5,-3.3z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M8,5v14l11,-7L8,5z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4,5h9v2H4V5zM4,9h9v2H4V9zM4,13h6v2H4v-2zM15,8l6,4 -6,4v-3h-3v-2h3V8z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4,5h12v2H4V5zM4,10h12v2H4v-2zM4,15h8v2H4v-2zM17,14l5,3 -5,3v-6z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M11,3h2v10h-2V3zM7.05,6.05l1.42,1.42A6,6 0,1 0,15.53 7.47l1.42,-1.42A8,8 0,1 1,7.05 6.05z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4,5h14v2H4V5zM4,10h14v2H4v-2zM4,15h10v2H4v-2zM18,14l4,2.5 -4,2.5v-5z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M3,5h12v2H3V5zM3,10h12v2H3v-2zM3,15h8v2H3v-2zM18,11v3h3v2h-3v3h-2v-3h-3v-2h3v-3h2z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M3.2,6.2L2.4,4.4 12,1l9.6,3.4 -0.8,1.8L12,3.1 3.2,6.2zM4,8h16v12H4V8zM6,10v8h12v-8H6zM8,12h5v2H8v-2zM8,15h8v2H8v-2z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M7,7h11v3l4,-4 -4,-4v3H5v6h2V7zM17,17H6v-3l-4,4 4,4v-3h13v-6h-2v4z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M10,4a6,6 0,1 0,0 12a6,6 0,0 0,0 -12zM10,6a4,4 0,1 1,0 8a4,4 0,0 1,0 -8zM15,15l5,5 -1.5,1.5 -5,-5L15,15z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M11,6V3L5,8l6,5V9c3.31,0 6,2.69 6,6 0,1.3 -0.41,2.5 -1.12,3.48l1.5,1.5C18.39,18.62 19,16.9 19,15c0,-4.97 -4.03,-9 -8,-9zM8.8,16H7v-3.2l-0.9,0.7 -0.8,-1.1 2,-1.4h1.5v5zM14.2,12.5h-2.4v0.9c0.28,-0.13 0.58,-0.2 0.95,-0.2 1.2,0 2,0.83 2,1.93 0,1.24 -0.93,2.07 -2.35,2.07 -1,0 -1.75,-0.33 -2.27,-0.98l1.03,-0.93c0.33,0.37 0.68,0.55 1.17,0.55 0.5,0 0.82,-0.28 0.82,-0.72 0,-0.45 -0.34,-0.72 -0.87,-0.72 -0.37,0 -0.68,0.12 -0.98,0.34l-0.95,-0.47 0.18,-3.05h3.67v1.28z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M13,6V3l6,5 -6,5V9c-3.31,0 -6,2.69 -6,6 0,1.3 0.41,2.5 1.12,3.48l-1.5,1.5C5.61,18.62 5,16.9 5,15c0,-4.97 4.03,-9 8,-9zM9.8,16H8v-3.2l-0.9,0.7 -0.8,-1.1 2,-1.4h1.5v5zM15.2,12.5h-2.4v0.9c0.28,-0.13 0.58,-0.2 0.95,-0.2 1.2,0 2,0.83 2,1.93 0,1.24 -0.93,2.07 -2.35,2.07 -1,0 -1.75,-0.33 -2.27,-0.98l1.03,-0.93c0.33,0.37 0.68,0.55 1.17,0.55 0.5,0 0.82,-0.28 0.82,-0.72 0,-0.45 -0.34,-0.72 -0.87,-0.72 -0.37,0 -0.68,0.12 -0.98,0.34l-0.95,-0.47 0.18,-3.05h3.67v1.28z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M18,6l-8.5,6L18,18V6zM6,6v12h2V6H6z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="480dp"
|
||||
android:height="480dp"
|
||||
android:viewportWidth="480"
|
||||
android:viewportHeight="480">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M20,0 L132,0 L132,5 L32,5 L32,15 L10,15 L10,72 L0,72 L0,20 L20,20 Z M348,0 L460,0 L460,20 L480,20 L480,72 L470,72 L470,15 L448,15 L448,5 L348,5 Z M0,408 L10,408 L10,465 L32,465 L32,475 L132,475 L132,480 L20,480 L20,460 L0,460 Z M470,408 L480,408 L480,460 L460,460 L460,480 L348,480 L348,475 L448,475 L448,465 L470,465 Z M0,142 L8,142 L8,210 L0,210 Z M472,156 L480,156 L480,228 L472,228 Z M0,284 L8,284 L8,338 L0,338 Z M472,304 L480,304 L480,358 L472,358 Z M196,0 L284,0 L284,5 L196,5 Z M196,475 L284,475 L284,480 L196,480 Z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="480dp"
|
||||
android:height="480dp"
|
||||
android:viewportWidth="480"
|
||||
android:viewportHeight="480">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M240,12 L256,28 L240,44 L224,28 Z M36,20 L104,20 L90,30 L36,30 Z M444,20 L376,20 L390,30 L444,30 Z M36,450 L104,450 L90,460 L36,460 Z M444,450 L376,450 L390,460 L444,460 Z M20,58 L30,58 L30,112 L20,112 Z M450,58 L460,58 L460,112 L450,112 Z M20,368 L30,368 L30,422 L20,422 Z M450,368 L460,368 L460,422 L450,422 Z" />
|
||||
</vector>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">Blastoise</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<resources>
|
||||
<style name="AppTheme" parent="@android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:statusBarColor">#101418</item>
|
||||
<item name="android:navigationBarColor">#101418</item>
|
||||
<item name="android:colorAccent">#58A6FF</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("com.android.application") version "9.2.0" apply false
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
Binary file not shown.
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
@REM Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
@REM
|
||||
@REM This source code is licensed under the MIT license found in the
|
||||
@REM LICENSE file in the root directory of this source tree.
|
||||
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "BlastoiseNative"
|
||||
include(":app")
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit fd6fc4d757b2f8ad4ff5e6715d6a4a9a560a35a3
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
@echo off
|
||||
|
||||
echo ============================================
|
||||
echo Blastoise Native Android - Dev Setup
|
||||
echo Installs all prerequisites via Scoop
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
:: Ensure Scoop shims are in PATH
|
||||
set "PATH=%PATH%;%USERPROFILE%\scoop\shims"
|
||||
|
||||
:: Step 1: Scoop
|
||||
where scoop >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [1/5] Installing Scoop...
|
||||
powershell -ExecutionPolicy Bypass -Command "Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force; iwr -useb get.scoop.sh | iex"
|
||||
set "PATH=%PATH%;%USERPROFILE%\scoop\shims"
|
||||
where scoop >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Scoop installation failed. Install manually from https://scoop.sh
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Scoop installed.
|
||||
) else (
|
||||
echo [1/5] Scoop already installed.
|
||||
)
|
||||
echo.
|
||||
|
||||
:: Step 2: Buckets
|
||||
echo [2/5] Adding Scoop buckets...
|
||||
call scoop bucket add java 2>nul
|
||||
call scoop bucket add extras 2>nul
|
||||
echo Done.
|
||||
echo.
|
||||
|
||||
:: Step 3: Install tools
|
||||
echo [3/5] Installing JDK 17 and Android CLI tools...
|
||||
echo This may take several minutes.
|
||||
echo.
|
||||
|
||||
call scoop install temurin17-jdk
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to install JDK 17
|
||||
pause & exit /b 1
|
||||
)
|
||||
|
||||
call scoop install android-clt
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to install Android CLI tools
|
||||
pause & exit /b 1
|
||||
)
|
||||
|
||||
echo All tools installed.
|
||||
echo.
|
||||
|
||||
:: Step 4: Environment variables
|
||||
echo [4/5] Setting environment variables...
|
||||
|
||||
set "JAVA_HOME=%USERPROFILE%\scoop\apps\temurin17-jdk\current"
|
||||
if not exist "%JAVA_HOME%" (
|
||||
for /f "delims=" %%i in ('where java 2^>nul') do (
|
||||
set "JAVA_PATH=%%i"
|
||||
goto :found_java
|
||||
)
|
||||
:found_java
|
||||
if defined JAVA_PATH (
|
||||
for %%i in ("%JAVA_PATH%") do set "JAVA_HOME=%%~dpi.."
|
||||
)
|
||||
)
|
||||
|
||||
set "ANDROID_HOME=%LOCALAPPDATA%\Android\Sdk"
|
||||
|
||||
echo JAVA_HOME = %JAVA_HOME%
|
||||
echo ANDROID_HOME = %ANDROID_HOME%
|
||||
|
||||
powershell -Command "[Environment]::SetEnvironmentVariable('JAVA_HOME', '%JAVA_HOME%', 'User')"
|
||||
powershell -Command "[Environment]::SetEnvironmentVariable('ANDROID_HOME', '%ANDROID_HOME%', 'User')"
|
||||
|
||||
set "SDK_TOOLS=%ANDROID_HOME%\platform-tools;%ANDROID_HOME%\cmdline-tools\latest\bin"
|
||||
powershell -Command "$p = [Environment]::GetEnvironmentVariable('Path','User'); if($p -notlike '*platform-tools*'){[Environment]::SetEnvironmentVariable('Path', $p + ';%SDK_TOOLS%', 'User')}"
|
||||
set "PATH=%PATH%;%SDK_TOOLS%"
|
||||
echo Done.
|
||||
echo.
|
||||
|
||||
:: Step 5: Android SDK (headless via sdkmanager)
|
||||
echo [5/5] Installing Android SDK components...
|
||||
|
||||
set "SDKMANAGER=%ANDROID_HOME%\cmdline-tools\latest\bin\sdkmanager.bat"
|
||||
|
||||
if not exist "%SDKMANAGER%" (
|
||||
echo sdkmanager not found at:
|
||||
echo %SDKMANAGER%
|
||||
echo.
|
||||
echo Trying Scoop android-clt path...
|
||||
set "SDKMANAGER=%USERPROFILE%\scoop\apps\android-clt\current\bin\sdkmanager.bat"
|
||||
)
|
||||
|
||||
if not exist "%SDKMANAGER%" (
|
||||
echo ERROR: sdkmanager not found.
|
||||
echo Install android-clt via Scoop first.
|
||||
pause & exit /b 1
|
||||
)
|
||||
|
||||
echo Accepting licenses...
|
||||
echo y | call "%SDKMANAGER%" --licenses >nul 2>&1
|
||||
|
||||
echo Installing platform-tools, emulator, android-36, build-tools-36, and an API 36 system image...
|
||||
call "%SDKMANAGER%" "platform-tools" "emulator" "platforms;android-36" "build-tools;36.0.0" "system-images;android-36;google_apis;x86_64"
|
||||
echo Done.
|
||||
echo.
|
||||
|
||||
echo Creating CLI emulator if needed...
|
||||
avdmanager list avd | findstr "Medium_Phone_API_36" >nul
|
||||
if errorlevel 1 (
|
||||
echo no | avdmanager create avd -n Medium_Phone_API_36 -k "system-images;android-36;google_apis;x86_64" -d "medium_phone"
|
||||
)
|
||||
echo Done.
|
||||
echo.
|
||||
|
||||
echo.
|
||||
echo ============================================
|
||||
echo Setup complete!
|
||||
echo.
|
||||
echo Tools installed via Scoop:
|
||||
echo JDK 17, Android CLI
|
||||
echo.
|
||||
echo Android SDK installed headlessly:
|
||||
echo platform-tools, emulator, android-36, build-tools-36
|
||||
echo.
|
||||
echo Environment:
|
||||
echo JAVA_HOME = %JAVA_HOME%
|
||||
echo ANDROID_HOME = %ANDROID_HOME%
|
||||
echo.
|
||||
echo NOTE: Restart your terminal for
|
||||
echo permanent env var changes.
|
||||
echo ============================================
|
||||
|
||||
pause
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
@echo off
|
||||
|
||||
echo ============================================
|
||||
echo Blastoise Native Android - Run App
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
:: Ensure Scoop shims and SDK tools are in PATH
|
||||
set "PATH=%PATH%;%USERPROFILE%\scoop\shims"
|
||||
set "JAVA_HOME=%USERPROFILE%\scoop\apps\temurin17-jdk\current"
|
||||
set "ANDROID_HOME=%LOCALAPPDATA%\Android\Sdk"
|
||||
set "PATH=%PATH%;%ANDROID_HOME%\platform-tools;%ANDROID_HOME%\emulator;%ANDROID_HOME%\cmdline-tools\latest\bin"
|
||||
set "PROJECT_DIR=%~dp0BlastoiseNative"
|
||||
|
||||
:: Start emulator if not already running
|
||||
adb devices 2>nul | findstr "emulator" >nul
|
||||
if errorlevel 1 (
|
||||
echo Starting emulator...
|
||||
start "" "%ANDROID_HOME%\emulator\emulator.exe" -avd Medium_Phone_API_36
|
||||
echo Waiting for emulator to boot...
|
||||
adb wait-for-device
|
||||
:wait_boot
|
||||
adb shell getprop sys.boot_completed 2>nul | findstr "1" >nul
|
||||
if errorlevel 1 (
|
||||
timeout /t 2 /nobreak >nul
|
||||
goto :wait_boot
|
||||
)
|
||||
echo Emulator ready.
|
||||
echo.
|
||||
)
|
||||
|
||||
echo Building and installing Blastoise...
|
||||
echo.
|
||||
pushd "%PROJECT_DIR%"
|
||||
call gradlew.bat :app:installDebug
|
||||
if errorlevel 1 (
|
||||
popd
|
||||
echo.
|
||||
echo Build or install failed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
popd
|
||||
|
||||
echo Launching app...
|
||||
adb shell am start -n com.peterino.blastoise/.MainActivity
|
||||
|
||||
echo.
|
||||
echo ============================================
|
||||
echo App is installed and launched.
|
||||
echo ============================================
|
||||
pause
|
||||
|
|
@ -5,6 +5,8 @@ export interface Track {
|
|||
filename: string; // Original filename
|
||||
title: string; // Display title
|
||||
duration: number;
|
||||
replayGainDb?: number | null;
|
||||
replayPeak?: number | null;
|
||||
}
|
||||
|
||||
export type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle";
|
||||
|
|
|
|||
14
config.ts
14
config.ts
|
|
@ -13,12 +13,20 @@ export interface YtdlpConfig {
|
|||
updateCheckInterval: number;
|
||||
}
|
||||
|
||||
export interface ReplayGainConfig {
|
||||
enabled: boolean;
|
||||
command: string;
|
||||
truePeak: boolean;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
port: number;
|
||||
musicDir: string;
|
||||
allowGuests: boolean;
|
||||
defaultPermissions: string[];
|
||||
ytdlp?: YtdlpConfig;
|
||||
replayGain?: ReplayGainConfig;
|
||||
}
|
||||
|
||||
const CONFIG_PATH = join(import.meta.dir, "config.json");
|
||||
|
|
@ -38,6 +46,12 @@ export const DEFAULT_CONFIG: Config = {
|
|||
allowPlaylists: true,
|
||||
autoUpdate: true,
|
||||
updateCheckInterval: 86400
|
||||
},
|
||||
replayGain: {
|
||||
enabled: true,
|
||||
command: "rsgain",
|
||||
truePeak: false,
|
||||
timeoutMs: 120000
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
2
db.ts
2
db.ts
|
|
@ -89,6 +89,8 @@ export interface Track {
|
|||
album: string | null;
|
||||
duration: number;
|
||||
size: number;
|
||||
replayGainDb: number | null;
|
||||
replayPeak: number | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,176 @@
|
|||
# Blastoise API Reference
|
||||
|
||||
Blastoise is a synchronized music server. The server owns channel time and
|
||||
queues; clients play audio locally.
|
||||
|
||||
```text
|
||||
Reference HTTP: http://mhsgroove.peterino.com:3001
|
||||
Reference WS: ws://mhsgroove.peterino.com:3001
|
||||
Local HTTP: http://localhost:3001
|
||||
Local WS: ws://localhost:3001
|
||||
```
|
||||
|
||||
Auth is an HttpOnly cookie named `blastoise_session`. Same-origin browser apps
|
||||
can use normal `fetch`. Separate-origin browser apps need a same-origin proxy
|
||||
or CORS with credentials. Native apps must store `Set-Cookie` and send it as
|
||||
`Cookie` on HTTP and WebSocket requests.
|
||||
|
||||
Full details: [api-reference-full.md](./api-reference-full.md)
|
||||
|
||||
## Golden Rule
|
||||
|
||||
Use `track.id` for every machine operation:
|
||||
|
||||
```text
|
||||
GET /api/tracks/:trackId
|
||||
```
|
||||
|
||||
`track.id` is a content hash like `sha256:...`. `filename` and `title` are only
|
||||
for display. Queue entries, playlists, cache keys, direct links, and audio URLs
|
||||
should all use `track.id`.
|
||||
|
||||
## Core Shapes
|
||||
|
||||
```ts
|
||||
type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle";
|
||||
|
||||
type Track = {
|
||||
id: string; filename: string; title: string | null;
|
||||
artist?: string | null; album?: string | null; duration: number;
|
||||
replayGainDb?: number | null; replayPeak?: number | null; available?: boolean;
|
||||
};
|
||||
|
||||
type ChannelInfo = {
|
||||
id: string; name: string; description: string; trackCount: number;
|
||||
listenerCount: number; listeners: string[]; isDefault: boolean;
|
||||
createdBy: number | null;
|
||||
};
|
||||
|
||||
type ChannelState = {
|
||||
track: Track | null; currentTimestamp: number; channelName: string;
|
||||
channelId: string; description: string; paused: boolean; currentIndex: number;
|
||||
listenerCount: number; isDefault: boolean; playbackMode: PlaybackMode;
|
||||
queue?: Track[];
|
||||
};
|
||||
|
||||
type Playlist = {
|
||||
id: string; name: string; description: string; ownerId: number;
|
||||
ownerName?: string; isPublic: boolean; shareToken: string | null;
|
||||
trackIds: string[]; createdAt: number; updatedAt: number;
|
||||
};
|
||||
```
|
||||
|
||||
`ChannelState.queue` is optional. It appears on WebSocket connect, queue
|
||||
changes, and periodic refreshes. Keep the last known queue when omitted.
|
||||
|
||||
## Startup
|
||||
|
||||
```text
|
||||
GET /api/status
|
||||
GET /api/auth/me
|
||||
GET /api/library
|
||||
GET /api/channels
|
||||
WS /api/channels/:channelId/ws
|
||||
```
|
||||
|
||||
Choose a channel: saved channel, else `isDefault`, else first channel.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Area | Endpoints |
|
||||
|---|---|
|
||||
| Status | `GET /api/status` |
|
||||
| Auth | `POST /api/auth/signup`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`, `POST /api/auth/kick-others` |
|
||||
| Channels | `GET /api/channels`, `POST /api/channels`, `GET/PATCH/DELETE /api/channels/:id` |
|
||||
| Playback control | `POST /api/channels/:id/jump`, `POST /api/channels/:id/seek`, `POST /api/channels/:id/mode` |
|
||||
| Queue | `PATCH /api/channels/:id/queue` |
|
||||
| Library/audio | `GET /api/library`, `GET /api/tracks/:trackId`, `POST /api/upload` |
|
||||
| Playlists | `GET/POST /api/playlists`, `GET/PATCH/DELETE /api/playlists/:id`, `PATCH /api/playlists/:id/tracks` |
|
||||
| Sharing | `POST/DELETE /api/playlists/:id/share`, `GET/POST /api/playlists/shared/:token` |
|
||||
| URL import | `POST /api/fetch`, `POST /api/fetch/confirm`, `GET /api/fetch`, `DELETE /api/fetch/:itemId`, `DELETE /api/fetch` |
|
||||
|
||||
Common bodies:
|
||||
|
||||
```json
|
||||
{ "username": "test", "password": "testuser" }
|
||||
{ "name": "Channel or playlist name", "description": "optional" }
|
||||
{ "mode": "repeat-all" }
|
||||
{ "index": 3 }
|
||||
{ "timestamp": 45.5 }
|
||||
```
|
||||
|
||||
Queue and playlist track mutation:
|
||||
|
||||
```json
|
||||
{ "set": ["sha256:a", "sha256:b"] }
|
||||
{ "add": ["sha256:c"], "insertAt": 2 }
|
||||
{ "remove": [3, 4] }
|
||||
{ "move": [5, 6], "to": 1 }
|
||||
```
|
||||
|
||||
Remove/move use positions, not track IDs. Duplicate tracks are allowed.
|
||||
|
||||
Audio supports range requests:
|
||||
|
||||
```text
|
||||
Range: bytes=0-999999
|
||||
```
|
||||
|
||||
## WebSocket
|
||||
|
||||
Connect to:
|
||||
|
||||
```text
|
||||
ws://mhsgroove.peterino.com:3001/api/channels/:channelId/ws
|
||||
```
|
||||
|
||||
Client messages:
|
||||
|
||||
```json
|
||||
{ "action": "switch", "channelId": "abc123" }
|
||||
{ "action": "pause" }
|
||||
{ "action": "unpause" }
|
||||
{ "action": "seek", "timestamp": 45.5 }
|
||||
{ "action": "jump", "index": 3 }
|
||||
```
|
||||
|
||||
Server messages:
|
||||
|
||||
```json
|
||||
{ "type": "channel_list", "channels": [] }
|
||||
{ "type": "switched", "channelId": "abc123" }
|
||||
{ "type": "kick", "reason": "Kicked by another session" }
|
||||
{ "type": "toast", "message": "Added: Song", "toastType": "info" }
|
||||
{ "type": "scan_progress", "scanning": true, "processed": 1, "total": 20 }
|
||||
{ "type": "fetch_progress", "id": "job", "status": "downloading", "progress": 50 }
|
||||
```
|
||||
|
||||
Any message without `type` is a `ChannelState`.
|
||||
|
||||
Guests can listen and switch channels, but cannot control playback or mutate
|
||||
queues. Unauthorized WebSocket control messages are ignored.
|
||||
|
||||
## Sync Algorithm
|
||||
|
||||
On every `ChannelState`:
|
||||
|
||||
1. Store the state and `performance.now()`.
|
||||
2. If `state.queue` exists, replace the local queue cache.
|
||||
3. If `state.track` is null, pause and clear the player.
|
||||
4. If `state.track.id` changed, set `audio.src` to `/api/tracks/:trackId` and
|
||||
seek to `state.currentTimestamp`.
|
||||
5. If same track and drift is `>= 2s`, seek to `state.currentTimestamp`.
|
||||
6. If `state.paused`, pause. Otherwise call `audio.play()`.
|
||||
7. Between WebSocket updates, estimate time as
|
||||
`state.currentTimestamp + elapsedSeconds`, unless paused.
|
||||
|
||||
The server is the source of truth.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Some errors are JSON `{ "error": "..." }`; some are plain text. Handle both.
|
||||
- `GET /api/channels/:id` does not include the queue. WebSocket connect does.
|
||||
- `POST /api/playlists/shared/:token` copies a playlist; there is no `/copy`.
|
||||
- Cache by `track.id`, never by filename.
|
||||
- The server does not decode audio. Clients are synchronized local players.
|
||||
|
||||
|
|
@ -0,0 +1,585 @@
|
|||
# Build Me A Blastoise Frontend
|
||||
|
||||
This is a pasteable build brief for an LLM or coding agent. It tells the agent
|
||||
how to build a frontend for a Blastoise music server without needing to read the
|
||||
server code.
|
||||
|
||||
Reference the short API contract in:
|
||||
|
||||
```text
|
||||
docs/api-reference.md
|
||||
```
|
||||
|
||||
Use the full reference for edge cases:
|
||||
|
||||
```text
|
||||
docs/api-reference-full.md
|
||||
```
|
||||
|
||||
## Paste This Prompt Into Your LLM
|
||||
|
||||
```text
|
||||
You are building a frontend for Blastoise, a synchronized music streaming
|
||||
server. Build the actual app, not a landing page.
|
||||
|
||||
Use the Blastoise API documented in docs/api-reference.md. The server owns
|
||||
channel state and time. The client owns UI, local audio playback, local caching,
|
||||
and drift correction.
|
||||
|
||||
Reference server for testing:
|
||||
- HTTP base URL: http://mhsgroove.peterino.com:3001
|
||||
- WebSocket base URL: ws://mhsgroove.peterino.com:3001
|
||||
|
||||
Core rule:
|
||||
- Always identify tracks by track.id.
|
||||
- Always play audio from /api/tracks/:trackId.
|
||||
- filename and title are display fields only.
|
||||
|
||||
Build an app with:
|
||||
- Auth screen: login, signup, and guest mode when /api/status says guests are
|
||||
allowed.
|
||||
- Channel list: load /api/channels, show listener counts, connect to a channel
|
||||
WebSocket, support switching channels.
|
||||
- Now playing player: show current track, time, duration, play/pause, seek,
|
||||
previous/next, playback mode.
|
||||
- Library: list tracks from /api/library, search/filter, click a track to play
|
||||
locally, add tracks to queue.
|
||||
- Queue: render the current channel queue, highlight currentIndex, add/remove,
|
||||
move/reorder when the user has control permission.
|
||||
- Playlists: list /api/playlists, show playlist details, add playlists/tracks
|
||||
to queue, create/edit/delete owned playlists.
|
||||
- Optional URL import UI if /api/status reports ytdlp.enabled and
|
||||
ytdlp.available.
|
||||
|
||||
Do not assume the WebSocket always includes queue. It includes queue on connect,
|
||||
after queue changes, and periodic refreshes. Keep the last known queue until a
|
||||
new queue arrives.
|
||||
|
||||
Do not use alert() or prompt(). Use inline inputs, modals, toasts, or standard
|
||||
UI components.
|
||||
|
||||
Auth uses an HttpOnly cookie named blastoise_session. If this app is served
|
||||
from the same origin as the server, browser fetch calls can use relative URLs.
|
||||
If this app is hosted separately, either proxy API requests through the same
|
||||
origin or add CORS/credentials support to the server.
|
||||
|
||||
Implement robust API helpers that handle JSON errors and plain text errors.
|
||||
Some Blastoise endpoints return JSON error objects, while some return plain
|
||||
text.
|
||||
|
||||
Synced playback algorithm:
|
||||
1. Connect to WS /api/channels/:channelId/ws.
|
||||
2. When a normal ChannelState message arrives, store it with performance.now().
|
||||
3. If state.queue exists, replace the local queue cache.
|
||||
4. If state.track is null, pause and clear the player.
|
||||
5. If track.id changed, set audio.src to /api/tracks/:trackId, seek to
|
||||
state.currentTimestamp, then play unless state.paused.
|
||||
6. If track.id is the same and abs(audio.currentTime - state.currentTimestamp)
|
||||
>= 2, seek to state.currentTimestamp.
|
||||
7. If state.paused, pause locally. If not paused, play locally.
|
||||
8. Between WebSocket updates, estimate server time as
|
||||
state.currentTimestamp + elapsedSeconds since receipt, unless paused.
|
||||
|
||||
Control actions:
|
||||
- Send WebSocket { action: "pause" } and { action: "unpause" } for play/pause.
|
||||
- Send WebSocket { action: "seek", timestamp } for seek.
|
||||
- Send WebSocket { action: "jump", index } for queue jumps.
|
||||
- Send WebSocket { action: "switch", channelId } to switch channels.
|
||||
- Use REST PATCH /api/channels/:channelId/queue for add/remove/move/set queue.
|
||||
- Use REST POST /api/channels/:channelId/mode for playback mode.
|
||||
|
||||
Use track.id for local caching. If you build caching, store complete audio blobs
|
||||
in IndexedDB under track.id. Range requests to /api/tracks/:trackId are
|
||||
supported.
|
||||
|
||||
Make the interface responsive. Desktop can use panels for Channels, Library,
|
||||
Queue, and Playlists. Mobile should use tabs or a single-panel navigation.
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
Follow this order. It keeps the project useful from the first milestone and
|
||||
prevents sync bugs from getting buried under UI.
|
||||
|
||||
### Step 1: Create The API Client
|
||||
|
||||
Build a small wrapper around `fetch`.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Use relative URLs when the frontend is same-origin.
|
||||
- Allow an `API_BASE` override for native or separately hosted builds.
|
||||
- Send `credentials: "include"` for browser fetch calls.
|
||||
- Parse successful JSON.
|
||||
- On errors, try JSON first, then fall back to text.
|
||||
- Expose helpers for JSON, form upload, and raw audio URLs.
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```ts
|
||||
const API_BASE = "";
|
||||
|
||||
async function apiJson(path: string, options: RequestInit = {}) {
|
||||
const res = await fetch(API_BASE + path, {
|
||||
credentials: "include",
|
||||
...options,
|
||||
headers: {
|
||||
...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data: any = null;
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
typeof data === "object" && data
|
||||
? data.error || data.message || `HTTP ${res.status}`
|
||||
: data || `HTTP ${res.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function trackUrl(trackId: string) {
|
||||
return `${API_BASE}/api/tracks/${encodeURIComponent(trackId)}`;
|
||||
}
|
||||
```
|
||||
|
||||
Native apps should store `Set-Cookie` from login/signup/me and send it as
|
||||
`Cookie` in later HTTP and WebSocket requests.
|
||||
|
||||
### Step 2: Load Status And Session
|
||||
|
||||
On app start:
|
||||
|
||||
```text
|
||||
GET /api/status
|
||||
GET /api/auth/me
|
||||
```
|
||||
|
||||
Use `/api/status` to decide whether to show:
|
||||
|
||||
- guest mode,
|
||||
- signup,
|
||||
- URL import.
|
||||
|
||||
Use `/api/auth/me` to get the user and effective permissions. When guests are
|
||||
enabled, this call can create a guest session.
|
||||
|
||||
Auth actions:
|
||||
|
||||
```text
|
||||
POST /api/auth/login { username, password }
|
||||
POST /api/auth/signup { username, password }
|
||||
POST /api/auth/logout
|
||||
```
|
||||
|
||||
After login, signup, logout, or guest creation, reload:
|
||||
|
||||
```text
|
||||
GET /api/auth/me
|
||||
GET /api/library
|
||||
GET /api/channels
|
||||
GET /api/playlists
|
||||
```
|
||||
|
||||
### Step 3: Load Library And Channels
|
||||
|
||||
Load:
|
||||
|
||||
```text
|
||||
GET /api/library
|
||||
GET /api/channels
|
||||
```
|
||||
|
||||
Store tracks in two forms:
|
||||
|
||||
```ts
|
||||
const library: Track[] = [];
|
||||
const tracksById = new Map<string, Track>();
|
||||
```
|
||||
|
||||
Pick the channel:
|
||||
|
||||
1. Last saved channel ID if still present.
|
||||
2. The channel with `isDefault: true`.
|
||||
3. The first channel.
|
||||
|
||||
Then connect the WebSocket.
|
||||
|
||||
### Step 4: Build WebSocket State Handling
|
||||
|
||||
Connect:
|
||||
|
||||
```ts
|
||||
function wsUrl(channelId: string) {
|
||||
const base = API_BASE || window.location.origin;
|
||||
const url = new URL(base);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
url.pathname = `/api/channels/${encodeURIComponent(channelId)}/ws`;
|
||||
return url.toString();
|
||||
}
|
||||
```
|
||||
|
||||
Handle message types:
|
||||
|
||||
```ts
|
||||
function onSocketMessage(data: any) {
|
||||
if (data.type === "channel_list") {
|
||||
setChannels(data.channels);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "switched") {
|
||||
setCurrentChannelId(data.channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "kick") {
|
||||
disconnectAndShowLoginOrToast(data.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "toast") {
|
||||
showToast(data.message, data.toastType);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "scan_progress") {
|
||||
updateScanProgress(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof data.type === "string" && data.type.startsWith("fetch_")) {
|
||||
updateFetchTask(data);
|
||||
return;
|
||||
}
|
||||
|
||||
applyChannelState(data);
|
||||
}
|
||||
```
|
||||
|
||||
Reconnect while the user wants sync. Use a short delay such as 2 or 3 seconds.
|
||||
|
||||
### Step 5: Implement The Player Correctly
|
||||
|
||||
Keep this state:
|
||||
|
||||
```ts
|
||||
let channelState: ChannelState | null = null;
|
||||
let channelStateReceivedAt = 0;
|
||||
let currentTrackId: string | null = null;
|
||||
let queue: Track[] = [];
|
||||
```
|
||||
|
||||
Apply state:
|
||||
|
||||
```ts
|
||||
async function applyChannelState(state: ChannelState) {
|
||||
channelState = state;
|
||||
channelStateReceivedAt = performance.now();
|
||||
|
||||
if (state.queue) queue = state.queue;
|
||||
|
||||
if (!state.track) {
|
||||
audio.pause();
|
||||
currentTrackId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const target = state.currentTimestamp;
|
||||
const nextTrackId = state.track.id;
|
||||
|
||||
if (nextTrackId !== currentTrackId) {
|
||||
currentTrackId = nextTrackId;
|
||||
audio.src = getPlayableUrl(nextTrackId);
|
||||
audio.currentTime = target;
|
||||
} else if (Math.abs(audio.currentTime - target) >= 2) {
|
||||
audio.currentTime = target;
|
||||
}
|
||||
|
||||
if (state.paused) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play().catch(() => showClickToPlay());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Estimate current synced time for progress UI:
|
||||
|
||||
```ts
|
||||
function syncedTime() {
|
||||
if (!channelState?.track) return 0;
|
||||
if (channelState.paused) return channelState.currentTimestamp;
|
||||
return channelState.currentTimestamp + (performance.now() - channelStateReceivedAt) / 1000;
|
||||
}
|
||||
```
|
||||
|
||||
Use the audio element's actual `currentTime` while audio is playing, but use
|
||||
`syncedTime()` while waiting to play, paused, reconnecting, or rendering remote
|
||||
state.
|
||||
|
||||
### Step 6: Add Controls
|
||||
|
||||
Use WebSocket for simple channel controls:
|
||||
|
||||
```ts
|
||||
ws.send(JSON.stringify({ action: "pause" }));
|
||||
ws.send(JSON.stringify({ action: "unpause" }));
|
||||
ws.send(JSON.stringify({ action: "seek", timestamp }));
|
||||
ws.send(JSON.stringify({ action: "jump", index }));
|
||||
ws.send(JSON.stringify({ action: "switch", channelId }));
|
||||
```
|
||||
|
||||
Use REST for queue mutation:
|
||||
|
||||
```text
|
||||
PATCH /api/channels/:channelId/queue
|
||||
```
|
||||
|
||||
Bodies:
|
||||
|
||||
```json
|
||||
{ "add": ["sha256:track"], "insertAt": 3 }
|
||||
{ "remove": [2] }
|
||||
{ "move": [5], "to": 1 }
|
||||
{ "set": ["sha256:a", "sha256:b"] }
|
||||
```
|
||||
|
||||
Use REST for playback mode:
|
||||
|
||||
```text
|
||||
POST /api/channels/:channelId/mode
|
||||
{ "mode": "shuffle" }
|
||||
```
|
||||
|
||||
If a control returns `403`, show a permission toast. Guests can listen but
|
||||
cannot control.
|
||||
|
||||
### Step 7: Render Library, Queue, And Local Playback
|
||||
|
||||
Library:
|
||||
|
||||
- Render `/api/library`.
|
||||
- Search over title, filename, artist, and album.
|
||||
- Add selected tracks to queue with `PATCH /api/channels/:id/queue`.
|
||||
- Play a track locally by setting the audio source to `/api/tracks/:trackId`
|
||||
and disconnecting or marking the player unsynced.
|
||||
|
||||
Queue:
|
||||
|
||||
- Render the last known `queue`.
|
||||
- Highlight `currentIndex`.
|
||||
- Jump by index.
|
||||
- Remove by index.
|
||||
- Reorder by index.
|
||||
- Remember that duplicate track IDs can exist in the queue. Queue operations
|
||||
that remove or move tracks must use positions, not IDs.
|
||||
|
||||
Local playback:
|
||||
|
||||
- It is okay to let users preview/play a single track outside channel sync.
|
||||
- Keep this mode visually distinct from synced playback.
|
||||
- Offer a "sync" button to reconnect to the selected channel.
|
||||
|
||||
### Step 8: Add Playlists
|
||||
|
||||
Load:
|
||||
|
||||
```text
|
||||
GET /api/playlists
|
||||
```
|
||||
|
||||
Render two lists:
|
||||
|
||||
- `mine`
|
||||
- `shared`
|
||||
|
||||
Details:
|
||||
|
||||
```text
|
||||
GET /api/playlists/:playlistId
|
||||
```
|
||||
|
||||
Join `playlist.trackIds` with `tracksById` from the library to render track
|
||||
titles.
|
||||
|
||||
Common actions:
|
||||
|
||||
```text
|
||||
POST /api/playlists
|
||||
PATCH /api/playlists/:id
|
||||
DELETE /api/playlists/:id
|
||||
PATCH /api/playlists/:id/tracks
|
||||
POST /api/playlists/:id/share
|
||||
DELETE /api/playlists/:id/share
|
||||
POST /api/playlists/shared/:token
|
||||
```
|
||||
|
||||
To add a playlist to queue:
|
||||
|
||||
```json
|
||||
{ "add": ["sha256:a", "sha256:b"] }
|
||||
```
|
||||
|
||||
To play next:
|
||||
|
||||
```json
|
||||
{ "add": ["sha256:a", "sha256:b"], "insertAt": currentIndex + 1 }
|
||||
```
|
||||
|
||||
### Step 9: Add Upload And URL Import
|
||||
|
||||
Upload:
|
||||
|
||||
```text
|
||||
POST /api/upload
|
||||
multipart/form-data field: file
|
||||
```
|
||||
|
||||
Accepted file extensions:
|
||||
|
||||
```text
|
||||
.mp3 .ogg .flac .wav .m4a .aac .opus .wma .mp4
|
||||
```
|
||||
|
||||
URL import is optional. Show it only when:
|
||||
|
||||
```ts
|
||||
status.ytdlp?.enabled && status.ytdlp?.available
|
||||
```
|
||||
|
||||
Flow:
|
||||
|
||||
```text
|
||||
POST /api/fetch { url }
|
||||
```
|
||||
|
||||
If response is `type: "single"`, show a queued/download task.
|
||||
|
||||
If response is `type: "playlist"`, show a confirmation modal, then:
|
||||
|
||||
```text
|
||||
POST /api/fetch/confirm { playlistTitle, items }
|
||||
```
|
||||
|
||||
Poll:
|
||||
|
||||
```text
|
||||
GET /api/fetch
|
||||
```
|
||||
|
||||
Listen for WebSocket progress messages:
|
||||
|
||||
```text
|
||||
fetch_progress
|
||||
fetch_complete
|
||||
fetch_error
|
||||
fetch_cancelled
|
||||
```
|
||||
|
||||
### Step 10: Add Optional Local Caching
|
||||
|
||||
Caching is not needed for a valid frontend, but it is one of Blastoise's best
|
||||
features.
|
||||
|
||||
Use IndexedDB:
|
||||
|
||||
```ts
|
||||
interface CachedTrack {
|
||||
id: string;
|
||||
blob: Blob;
|
||||
contentType: string;
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Key by `track.id`.
|
||||
- Never key by filename.
|
||||
- Prefer cached blob URLs for playback.
|
||||
- Fall back to `/api/tracks/:trackId`.
|
||||
- Use range requests to prefetch seek segments if you want a buffer bar.
|
||||
- Revoke blob URLs when replacing or deleting cached blobs.
|
||||
|
||||
Simple mode:
|
||||
|
||||
1. When a user plays a track, fetch the full file in the background.
|
||||
2. Store it in IndexedDB under `track.id`.
|
||||
3. Next time, play from `URL.createObjectURL(blob)`.
|
||||
|
||||
Advanced mode:
|
||||
|
||||
1. Divide each track into virtual segments.
|
||||
2. Use `Range: bytes=start-end` requests to fill missing segments.
|
||||
3. When all segments are present, download and persist the full blob.
|
||||
|
||||
### Step 11: Validate The App
|
||||
|
||||
Manual smoke test:
|
||||
|
||||
1. Start the server with `bun run server.ts`.
|
||||
2. Open the frontend.
|
||||
3. Load status and auth state.
|
||||
4. Continue as guest or log in with the test user if configured.
|
||||
5. Load library and channels.
|
||||
6. Connect to the default channel WebSocket.
|
||||
7. Confirm first WebSocket state includes `queue`.
|
||||
8. Confirm audio source uses `/api/tracks/:trackId`.
|
||||
9. Seek locally after a state update and confirm drift correction snaps back.
|
||||
10. Pause/unpause from one client and confirm another client follows.
|
||||
11. Add a track to queue and confirm both clients receive a state with `queue`.
|
||||
12. Switch channels and confirm the server sends `switched`.
|
||||
13. Test mobile layout.
|
||||
|
||||
Permission smoke test:
|
||||
|
||||
1. Use a guest session.
|
||||
2. Confirm listening works.
|
||||
3. Try pause/seek/jump.
|
||||
4. Confirm the UI reports lack of permission or no-ops gracefully.
|
||||
|
||||
Playlist smoke test:
|
||||
|
||||
1. Create a playlist as a non-guest user.
|
||||
2. Add tracks to it.
|
||||
3. Add the playlist to queue.
|
||||
4. Make it public or generate a share token.
|
||||
5. Load it through the shared endpoint.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
| Symptom | Likely Cause |
|
||||
|---|---|
|
||||
| Audio 404s | The app used `filename` instead of `track.id` in `/api/tracks/:id`. |
|
||||
| Queue disappears after a state update | The client replaced queue with `undefined`; WebSocket queue is optional. |
|
||||
| Sync slowly drifts | The client only uses local audio time and does not correct against server timestamps. |
|
||||
| Guests can see controls that do nothing | Guests cannot control playback even if they can listen. |
|
||||
| Queue remove deletes the wrong duplicate | The UI removed by track ID instead of queue position. |
|
||||
| Login works in same-origin dev but not hosted frontend | Cookie auth needs same-origin, a reverse proxy, or CORS with credentials. |
|
||||
| Shared playlist copy fails | The route is `POST /api/playlists/shared/:token`, with no `/copy` suffix. |
|
||||
| Native WebSocket connects as guest after login | The client did not send the stored session cookie in the WebSocket request. |
|
||||
|
||||
## Minimal Viable Scope
|
||||
|
||||
If you want the smallest useful Blastoise frontend, build only:
|
||||
|
||||
- `GET /api/auth/me`
|
||||
- `GET /api/library`
|
||||
- `GET /api/channels`
|
||||
- `WS /api/channels/:id/ws`
|
||||
- `GET /api/tracks/:trackId`
|
||||
- WebSocket actions: `switch`, `pause`, `unpause`, `seek`, `jump`
|
||||
|
||||
That is enough to make a synchronized player.
|
||||
24
init.ts
24
init.ts
|
|
@ -63,6 +63,8 @@ export function buildTracksFromIds(trackIds: string[], lib: Library): Track[] {
|
|||
filename: libTrack.filename,
|
||||
title: libTrack.title || libTrack.filename.replace(/\.[^.]+$/, ""),
|
||||
duration: libTrack.duration,
|
||||
replayGainDb: libTrack.replayGainDb,
|
||||
replayPeak: libTrack.replayPeak,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +80,8 @@ export function getAllLibraryTracks(lib: Library): Track[] {
|
|||
filename: t.filename,
|
||||
title: t.title || t.filename.replace(/\.[^.]+$/, ""),
|
||||
duration: t.duration,
|
||||
replayGainDb: t.replayGainDb,
|
||||
replayPeak: t.replayPeak,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +114,7 @@ export async function init(): Promise<void> {
|
|||
});
|
||||
|
||||
// Initialize library
|
||||
const library = new Library(MUSIC_DIR);
|
||||
const library = new Library(MUSIC_DIR, "./library_cache.db", config.replayGain ?? DEFAULT_CONFIG.replayGain);
|
||||
setLibrary(library);
|
||||
|
||||
// Track pending playlist additions (title -> {playlistId, playlistName, userId})
|
||||
|
|
@ -155,7 +159,7 @@ export async function init(): Promise<void> {
|
|||
}
|
||||
|
||||
// Helper to check if track matches a pending playlist addition
|
||||
function checkPendingPlaylistAddition(track: { id: string; title?: string; filename?: string }) {
|
||||
function checkPendingPlaylistAddition(track: { id: string; title?: string | null; filename?: string | null }) {
|
||||
if (pendingPlaylistTracks.size === 0) return;
|
||||
|
||||
const trackTitle = normalizeForMatch(track.title || "");
|
||||
|
|
@ -351,11 +355,19 @@ export async function init(): Promise<void> {
|
|||
const allTracks = library.getAllTracks().map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
duration: t.duration
|
||||
duration: t.duration,
|
||||
replayGainDb: t.replayGainDb,
|
||||
replayPeak: t.replayPeak,
|
||||
}));
|
||||
broadcastToAll({
|
||||
type: "track_added",
|
||||
track: { id: track.id, title: track.title, duration: track.duration },
|
||||
track: {
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
duration: track.duration,
|
||||
replayGainDb: track.replayGainDb,
|
||||
replayPeak: track.replayPeak,
|
||||
},
|
||||
library: allTracks
|
||||
});
|
||||
});
|
||||
|
|
@ -366,7 +378,9 @@ export async function init(): Promise<void> {
|
|||
const allTracks = library.getAllTracks().map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
duration: t.duration
|
||||
duration: t.duration,
|
||||
replayGainDb: t.replayGainDb,
|
||||
replayPeak: t.replayPeak,
|
||||
}));
|
||||
broadcastToAll({
|
||||
type: "track_removed",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,380 @@
|
|||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1A2B3C4D5E6F700000000001 /* BlastoisePingApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000101 /* BlastoisePingApp.swift */; };
|
||||
1A2B3C4D5E6F700000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000102 /* ContentView.swift */; };
|
||||
1A2B3C4D5E6F700000000004 /* pixelify_sans.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000105 /* pixelify_sans.ttf */; };
|
||||
1A2B3C4D5E6F700000000010 /* AppTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000110 /* AppTypes.swift */; };
|
||||
1A2B3C4D5E6F700000000011 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000111 /* AppModel.swift */; };
|
||||
1A2B3C4D5E6F700000000012 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000112 /* Theme.swift */; };
|
||||
1A2B3C4D5E6F700000000013 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000113 /* AuthView.swift */; };
|
||||
1A2B3C4D5E6F700000000014 /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000114 /* HeaderView.swift */; };
|
||||
1A2B3C4D5E6F700000000015 /* PlayerDeckView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000115 /* PlayerDeckView.swift */; };
|
||||
1A2B3C4D5E6F700000000016 /* Panels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000116 /* Panels.swift */; };
|
||||
1A2B3C4D5E6F700000000017 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000117 /* Components.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1A2B3C4D5E6F700000000100 /* BlastoisePing.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BlastoisePing.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1A2B3C4D5E6F700000000101 /* BlastoisePingApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlastoisePingApp.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D5E6F700000000102 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D5E6F700000000103 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
1A2B3C4D5E6F700000000105 /* pixelify_sans.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Fonts/pixelify_sans.ttf; sourceTree = "<group>"; };
|
||||
1A2B3C4D5E6F700000000110 /* AppTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTypes.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D5E6F700000000111 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D5E6F700000000112 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D5E6F700000000113 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D5E6F700000000114 /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D5E6F700000000115 /* PlayerDeckView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerDeckView.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D5E6F700000000116 /* Panels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D5E6F700000000117 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
1A2B3C4D5E6F700000000200 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1A2B3C4D5E6F700000000300 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C4D5E6F700000000301 /* BlastoisePing */,
|
||||
1A2B3C4D5E6F700000000302 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1A2B3C4D5E6F700000000301 /* BlastoisePing */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C4D5E6F700000000101 /* BlastoisePingApp.swift */,
|
||||
1A2B3C4D5E6F700000000102 /* ContentView.swift */,
|
||||
1A2B3C4D5E6F700000000304 /* State */,
|
||||
1A2B3C4D5E6F700000000303 /* Models */,
|
||||
1A2B3C4D5E6F700000000305 /* UI */,
|
||||
1A2B3C4D5E6F700000000306 /* Views */,
|
||||
1A2B3C4D5E6F700000000103 /* Info.plist */,
|
||||
1A2B3C4D5E6F700000000105 /* pixelify_sans.ttf */,
|
||||
);
|
||||
path = BlastoisePing;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1A2B3C4D5E6F700000000303 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C4D5E6F700000000110 /* AppTypes.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1A2B3C4D5E6F700000000304 /* State */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C4D5E6F700000000111 /* AppModel.swift */,
|
||||
);
|
||||
path = State;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1A2B3C4D5E6F700000000305 /* UI */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C4D5E6F700000000112 /* Theme.swift */,
|
||||
);
|
||||
path = UI;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1A2B3C4D5E6F700000000306 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C4D5E6F700000000113 /* AuthView.swift */,
|
||||
1A2B3C4D5E6F700000000114 /* HeaderView.swift */,
|
||||
1A2B3C4D5E6F700000000115 /* PlayerDeckView.swift */,
|
||||
1A2B3C4D5E6F700000000116 /* Panels.swift */,
|
||||
1A2B3C4D5E6F700000000117 /* Components.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1A2B3C4D5E6F700000000302 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C4D5E6F700000000100 /* BlastoisePing.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
1A2B3C4D5E6F700000000400 /* BlastoisePing */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 1A2B3C4D5E6F700000000701 /* Build configuration list for PBXNativeTarget "BlastoisePing" */;
|
||||
buildPhases = (
|
||||
1A2B3C4D5E6F700000000500 /* Sources */,
|
||||
1A2B3C4D5E6F700000000600 /* Resources */,
|
||||
1A2B3C4D5E6F700000000200 /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = BlastoisePing;
|
||||
productName = BlastoisePing;
|
||||
productReference = 1A2B3C4D5E6F700000000100 /* BlastoisePing.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
1A2B3C4D5E6F700000000800 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1540;
|
||||
LastUpgradeCheck = 1540;
|
||||
TargetAttributes = {
|
||||
1A2B3C4D5E6F700000000400 = {
|
||||
CreatedOnToolsVersion = 15.4;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 1A2B3C4D5E6F700000000700 /* Build configuration list for PBXProject "BlastoisePing" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 1A2B3C4D5E6F700000000300;
|
||||
productRefGroup = 1A2B3C4D5E6F700000000302 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
1A2B3C4D5E6F700000000400 /* BlastoisePing */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
1A2B3C4D5E6F700000000600 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1A2B3C4D5E6F700000000004 /* pixelify_sans.ttf in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
1A2B3C4D5E6F700000000500 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1A2B3C4D5E6F700000000001 /* BlastoisePingApp.swift in Sources */,
|
||||
1A2B3C4D5E6F700000000002 /* ContentView.swift in Sources */,
|
||||
1A2B3C4D5E6F700000000010 /* AppTypes.swift in Sources */,
|
||||
1A2B3C4D5E6F700000000011 /* AppModel.swift in Sources */,
|
||||
1A2B3C4D5E6F700000000012 /* Theme.swift in Sources */,
|
||||
1A2B3C4D5E6F700000000013 /* AuthView.swift in Sources */,
|
||||
1A2B3C4D5E6F700000000014 /* HeaderView.swift in Sources */,
|
||||
1A2B3C4D5E6F700000000015 /* PlayerDeckView.swift in Sources */,
|
||||
1A2B3C4D5E6F700000000016 /* Panels.swift in Sources */,
|
||||
1A2B3C4D5E6F700000000017 /* Components.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
1A2B3C4D5E6F700000000900 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1A2B3C4D5E6F700000000901 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
1A2B3C4D5E6F700000000902 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = BlastoisePing/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.peterino.blastoiseping;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1A2B3C4D5E6F700000000903 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = BlastoisePing/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.peterino.blastoiseping;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
1A2B3C4D5E6F700000000700 /* Build configuration list for PBXProject "BlastoisePing" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1A2B3C4D5E6F700000000900 /* Debug */,
|
||||
1A2B3C4D5E6F700000000901 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
1A2B3C4D5E6F700000000701 /* Build configuration list for PBXNativeTarget "BlastoisePing" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1A2B3C4D5E6F700000000902 /* Debug */,
|
||||
1A2B3C4D5E6F700000000903 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 1A2B3C4D5E6F700000000800 /* Project object */;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct BlastoisePingApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var model = AppModel()
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var selectedTab: MainTab = .rooms
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Theme.background.ignoresSafeArea()
|
||||
|
||||
if model.authState == .signedIn {
|
||||
mainApp
|
||||
} else {
|
||||
AuthView(
|
||||
model: model,
|
||||
username: $username,
|
||||
password: $password
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Blastoise")
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbarBackground(Theme.background, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.font(Theme.bodyFont)
|
||||
.buttonBorderShape(.roundedRectangle(radius: Theme.corner))
|
||||
}
|
||||
.onChange(of: model.authState) { _, authState in
|
||||
if authState == .signedIn {
|
||||
password = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mainApp: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
HeaderView(model: model)
|
||||
PlayerDeckView(model: model)
|
||||
tabStrip
|
||||
selectedPanel
|
||||
DebugFooterView(model: model)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
}
|
||||
|
||||
private var tabStrip: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(MainTab.allCases) { tab in
|
||||
Button {
|
||||
selectedTab = tab
|
||||
if tab == .library {
|
||||
Task { await model.loadLibraryIfNeeded() }
|
||||
} else if tab == .playlists {
|
||||
Task { await model.loadPlaylistsIfNeeded() }
|
||||
}
|
||||
} label: {
|
||||
Label(tab.title, systemImage: tab.icon)
|
||||
.labelStyle(.iconOnly)
|
||||
.frame(width: 44, height: 40)
|
||||
.background(selectedTab == tab ? Theme.accent : Theme.panel2)
|
||||
.foregroundStyle(selectedTab == tab ? Theme.background : Theme.text)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||
}
|
||||
.accessibilityLabel(tab.title)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var selectedPanel: some View {
|
||||
switch selectedTab {
|
||||
case .rooms:
|
||||
RoomsPanel(model: model)
|
||||
case .queue:
|
||||
QueuePanel(model: model)
|
||||
case .people:
|
||||
PeoplePanel(model: model)
|
||||
case .library:
|
||||
LibraryPanel(model: model)
|
||||
case .playlists:
|
||||
PlaylistsPanel(model: model)
|
||||
case .debug:
|
||||
DebugPanel(model: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum MainTab: String, CaseIterable, Identifiable {
|
||||
case rooms
|
||||
case queue
|
||||
case people
|
||||
case library
|
||||
case playlists
|
||||
case debug
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .rooms: return "Rooms"
|
||||
case .queue: return "Queue"
|
||||
case .people: return "People"
|
||||
case .library: return "Library"
|
||||
case .playlists: return "Lists"
|
||||
case .debug: return "Debug"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .rooms: return "radio"
|
||||
case .queue: return "list.bullet"
|
||||
case .people: return "person.2"
|
||||
case .library: return "music.note.list"
|
||||
case .playlists: return "rectangle.stack"
|
||||
case .debug: return "waveform.path.ecg"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -0,0 +1,63 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Blastoise</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Blastoise Ping can check a server running on your local network.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>pixelify_sans.ttf</string>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
import Foundation
|
||||
|
||||
enum SourceMode: String {
|
||||
case radio = "RADIO"
|
||||
case library = "LIBRARY"
|
||||
}
|
||||
|
||||
enum AuthState: String {
|
||||
case checking = "CHECKING"
|
||||
case signedOut = "SIGNED OUT"
|
||||
case signedIn = "SIGNED IN"
|
||||
}
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidURL
|
||||
case file(String)
|
||||
case http(Int, String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "Invalid URL"
|
||||
case .file(let message):
|
||||
return message
|
||||
case .http(let status, let body):
|
||||
return "HTTP \(status): \(body)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Track: Codable, Hashable, Identifiable {
|
||||
var id: String
|
||||
var filename: String
|
||||
var title: String
|
||||
var duration: Double
|
||||
var artist: String?
|
||||
var album: String?
|
||||
var available: Bool?
|
||||
|
||||
init(
|
||||
id: String,
|
||||
filename: String,
|
||||
title: String,
|
||||
duration: Double,
|
||||
artist: String? = nil,
|
||||
album: String? = nil,
|
||||
available: Bool? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.filename = filename
|
||||
self.title = title
|
||||
self.duration = duration
|
||||
self.artist = artist
|
||||
self.album = album
|
||||
self.available = available
|
||||
}
|
||||
}
|
||||
|
||||
struct ChannelInfo: Decodable, Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let description: String
|
||||
let listenerCount: Int
|
||||
let isDefault: Bool
|
||||
let trackCount: Int
|
||||
let listeners: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case description
|
||||
case listenerCount
|
||||
case isDefault
|
||||
case trackCount
|
||||
case listeners
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
name = try c.decodeIfPresent(String.self, forKey: .name) ?? "Room"
|
||||
description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
|
||||
listenerCount = try c.decodeIfPresent(Int.self, forKey: .listenerCount) ?? 0
|
||||
isDefault = try c.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false
|
||||
trackCount = try c.decodeIfPresent(Int.self, forKey: .trackCount) ?? 0
|
||||
listeners = try c.decodeIfPresent([String].self, forKey: .listeners) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct ChannelState: Decodable {
|
||||
let track: Track?
|
||||
let currentTimestamp: Double
|
||||
let channelName: String
|
||||
let channelId: String
|
||||
let paused: Bool
|
||||
let queue: [Track]?
|
||||
let currentIndex: Int
|
||||
let playbackMode: String
|
||||
let listenerCount: Int
|
||||
let listeners: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case track
|
||||
case currentTimestamp
|
||||
case channelName
|
||||
case channelId
|
||||
case paused
|
||||
case queue
|
||||
case currentIndex
|
||||
case playbackMode
|
||||
case listenerCount
|
||||
case listeners
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
track = try c.decodeIfPresent(Track.self, forKey: .track)
|
||||
currentTimestamp = try c.decodeIfPresent(Double.self, forKey: .currentTimestamp) ?? 0
|
||||
channelName = try c.decodeIfPresent(String.self, forKey: .channelName) ?? ""
|
||||
channelId = try c.decodeIfPresent(String.self, forKey: .channelId) ?? ""
|
||||
paused = try c.decodeIfPresent(Bool.self, forKey: .paused) ?? true
|
||||
queue = try c.decodeIfPresent([Track].self, forKey: .queue)
|
||||
currentIndex = try c.decodeIfPresent(Int.self, forKey: .currentIndex) ?? 0
|
||||
playbackMode = try c.decodeIfPresent(String.self, forKey: .playbackMode) ?? "repeat-all"
|
||||
listenerCount = try c.decodeIfPresent(Int.self, forKey: .listenerCount) ?? 0
|
||||
listeners = try c.decodeIfPresent([String].self, forKey: .listeners) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaylistBundle: Decodable {
|
||||
let mine: [Playlist]
|
||||
let shared: [Playlist]
|
||||
}
|
||||
|
||||
struct Playlist: Decodable, Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let description: String
|
||||
let ownerId: Int
|
||||
let ownerName: String
|
||||
let isPublic: Bool
|
||||
let shareToken: String?
|
||||
let trackIds: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case description
|
||||
case ownerId
|
||||
case ownerName
|
||||
case isPublic
|
||||
case shareToken
|
||||
case trackIds
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
name = try c.decodeIfPresent(String.self, forKey: .name) ?? "Playlist"
|
||||
description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
|
||||
ownerId = try c.decodeIfPresent(Int.self, forKey: .ownerId) ?? 0
|
||||
ownerName = try c.decodeIfPresent(String.self, forKey: .ownerName) ?? ""
|
||||
isPublic = try c.decodeIfPresent(Bool.self, forKey: .isPublic) ?? false
|
||||
shareToken = try c.decodeIfPresent(String.self, forKey: .shareToken)
|
||||
trackIds = try c.decodeIfPresent([String].self, forKey: .trackIds) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct UserSession: Decodable {
|
||||
let id: Int
|
||||
let username: String
|
||||
let isAdmin: Bool
|
||||
let isGuest: Bool
|
||||
let permissions: [Permission]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case username
|
||||
case isAdmin
|
||||
case isAdminSnake = "is_admin"
|
||||
case isGuest
|
||||
case isGuestSnake = "is_guest"
|
||||
case permissions
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decodeIfPresent(Int.self, forKey: .id) ?? 0
|
||||
username = try c.decodeIfPresent(String.self, forKey: .username) ?? "guest"
|
||||
isAdmin = try c.decodeIfPresent(Bool.self, forKey: .isAdmin)
|
||||
?? c.decodeIfPresent(Bool.self, forKey: .isAdminSnake)
|
||||
?? false
|
||||
isGuest = try c.decodeIfPresent(Bool.self, forKey: .isGuest)
|
||||
?? c.decodeIfPresent(Bool.self, forKey: .isGuestSnake)
|
||||
?? false
|
||||
permissions = try c.decodeIfPresent([Permission].self, forKey: .permissions) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct Permission: Decodable {
|
||||
let resourceType: String
|
||||
let resourceId: String?
|
||||
let permission: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case resourceType
|
||||
case resourceTypeSnake = "resource_type"
|
||||
case resourceId
|
||||
case resourceIdSnake = "resource_id"
|
||||
case permission
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
resourceType = try c.decodeIfPresent(String.self, forKey: .resourceType)
|
||||
?? c.decodeIfPresent(String.self, forKey: .resourceTypeSnake)
|
||||
?? ""
|
||||
resourceId = try c.decodeIfPresent(String.self, forKey: .resourceId)
|
||||
?? c.decodeIfPresent(String.self, forKey: .resourceIdSnake)
|
||||
permission = try c.decodeIfPresent(String.self, forKey: .permission) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthEnvelope: Decodable {
|
||||
let user: UserSession?
|
||||
let permissions: [Permission]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case user
|
||||
case permissions
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
user = try c.decodeIfPresent(UserSession.self, forKey: .user)
|
||||
permissions = try c.decodeIfPresent([Permission].self, forKey: .permissions) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct QueueResponse: Decodable {
|
||||
let success: Bool?
|
||||
let queueLength: Int?
|
||||
}
|
||||
|
||||
struct ModeResponse: Decodable {
|
||||
let success: Bool?
|
||||
let playbackMode: String?
|
||||
}
|
||||
|
||||
struct FetchItem: Codable, Hashable {
|
||||
let id: String?
|
||||
let url: String
|
||||
let title: String
|
||||
}
|
||||
|
||||
struct FetchPlaylistResponse: Decodable {
|
||||
let type: String
|
||||
let title: String
|
||||
let count: Int
|
||||
let items: [FetchItem]
|
||||
let requiresConfirmation: Bool?
|
||||
}
|
||||
|
||||
struct FetchSingleResponse: Decodable {
|
||||
let type: String
|
||||
let id: String?
|
||||
let title: String
|
||||
let queueType: String?
|
||||
}
|
||||
|
||||
enum FetchResponse: Decodable {
|
||||
case single(FetchSingleResponse)
|
||||
case playlist(FetchPlaylistResponse)
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try c.decodeIfPresent(String.self, forKey: .type)
|
||||
switch type {
|
||||
case "playlist":
|
||||
self = .playlist(try FetchPlaylistResponse(from: decoder))
|
||||
default:
|
||||
self = .single(try FetchSingleResponse(from: decoder))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FetchConfirmResponse: Decodable {
|
||||
let message: String
|
||||
let queueType: String?
|
||||
let estimatedTime: String?
|
||||
let playlistId: String?
|
||||
let playlistName: String?
|
||||
let items: [FetchItem]?
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,77 @@
|
|||
import SwiftUI
|
||||
|
||||
struct Theme {
|
||||
static let background = Color(red: 0.055, green: 0.052, blue: 0.067)
|
||||
static let panel = Color(red: 0.112, green: 0.105, blue: 0.135)
|
||||
static let panel2 = Color(red: 0.170, green: 0.157, blue: 0.205)
|
||||
static let stroke = Color(red: 0.475, green: 0.425, blue: 0.545)
|
||||
static let text = Color(red: 0.965, green: 0.930, blue: 0.760)
|
||||
static let muted = Color(red: 0.640, green: 0.585, blue: 0.710)
|
||||
static let accent = Color(red: 1.000, green: 0.812, blue: 0.176)
|
||||
static let ready = Color(red: 0.350, green: 0.820, blue: 1.000)
|
||||
static let amber = Color(red: 1.000, green: 0.570, blue: 0.240)
|
||||
static let red = Color(red: 1.000, green: 0.310, blue: 0.340)
|
||||
|
||||
static let corner: CGFloat = 0
|
||||
static let smallCorner: CGFloat = 0
|
||||
|
||||
static func pixel(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
|
||||
.custom("PixelifySans-Regular", size: size).weight(weight)
|
||||
}
|
||||
|
||||
static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
|
||||
pixel(size, weight: weight).monospacedDigit()
|
||||
}
|
||||
|
||||
static let bodyFont = pixel(16)
|
||||
static let headlineFont = pixel(19, weight: .semibold)
|
||||
static let captionFont = pixel(13)
|
||||
static let microFont = mono(11, weight: .semibold)
|
||||
static func display(_ size: CGFloat) -> Font { pixel(size, weight: .bold) }
|
||||
}
|
||||
|
||||
extension View {
|
||||
func panel() -> some View {
|
||||
self
|
||||
.padding(14)
|
||||
.background(Theme.panel)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.corner)
|
||||
.stroke(Theme.stroke, lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||
}
|
||||
|
||||
func rowStyle(isActive: Bool = false) -> some View {
|
||||
self
|
||||
.padding(10)
|
||||
.background(isActive ? Theme.panel2.opacity(1.0) : Theme.panel2.opacity(0.76))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.corner)
|
||||
.stroke(isActive ? Theme.accent : Theme.stroke.opacity(0.38), lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||
}
|
||||
|
||||
func textFieldStyle() -> some View {
|
||||
self
|
||||
.padding(12)
|
||||
.foregroundStyle(Theme.text)
|
||||
.background(Theme.panel2)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.corner)
|
||||
.stroke(Theme.stroke, lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||
}
|
||||
}
|
||||
|
||||
func formatTime(_ ms: Int64) -> String {
|
||||
let total = max(0, Int(ms / 1000))
|
||||
return "\(total / 60):" + String(format: "%02d", total % 60)
|
||||
}
|
||||
|
||||
func formatDuration(_ duration: TimeInterval) -> String {
|
||||
guard duration.isFinite, duration > 0 else { return "--:--" }
|
||||
return formatTime(Int64(duration * 1000))
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import SwiftUI
|
||||
|
||||
struct AuthView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@Binding var username: String
|
||||
@Binding var password: String
|
||||
@FocusState private var focused: Field?
|
||||
|
||||
private enum Field {
|
||||
case server
|
||||
case username
|
||||
case password
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("BLASTOISE")
|
||||
.font(Theme.display(40))
|
||||
.foregroundStyle(Theme.text)
|
||||
Text("Tune into a shared room, stream the queue, and keep your local player in sync.")
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label("Server", systemImage: "server.rack")
|
||||
.foregroundStyle(Theme.text)
|
||||
.font(Theme.headlineFont)
|
||||
field("http://host:3001", text: $model.serverURL, field: .server)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
model.serverURL = "http://mhsgroove.peterino.com:3001"
|
||||
} label: {
|
||||
Label("Default", systemImage: "radio")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
model.serverURL = "http://localhost:3001"
|
||||
} label: {
|
||||
Label("Local", systemImage: "desktopcomputer")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.panel()
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label("Account", systemImage: "person.crop.circle")
|
||||
.foregroundStyle(Theme.text)
|
||||
.font(Theme.headlineFont)
|
||||
field("username", text: $username, field: .username)
|
||||
SecureField("password", text: $password)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused($focused, equals: .password)
|
||||
.textFieldStyle()
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
focused = nil
|
||||
Task { await model.signIn(username: username, password: password) }
|
||||
} label: {
|
||||
Label("Sign In", systemImage: "arrow.right.circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Theme.accent)
|
||||
|
||||
Button {
|
||||
focused = nil
|
||||
Task { await model.signUp(username: username, password: password) }
|
||||
} label: {
|
||||
Label("Sign Up", systemImage: "person.badge.plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.panel()
|
||||
|
||||
StatusStrip(model: model)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: 640, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
private func field(_ placeholder: String, text: Binding<String>, field: Field) -> some View {
|
||||
TextField(placeholder, text: text)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(field == .server ? .URL : .default)
|
||||
.focused($focused, equals: field)
|
||||
.textFieldStyle()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import SwiftUI
|
||||
|
||||
struct DebugFooterView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(model.authState == .signedIn ? Theme.ready : Theme.amber)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(model.status)
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.muted)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusStrip: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(model.authState == .checking ? Theme.amber : model.authState == .signedIn ? Theme.ready : Theme.red)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(model.status)
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.muted)
|
||||
Spacer()
|
||||
}
|
||||
.panel()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanelTitle: View {
|
||||
private let title: String
|
||||
private let icon: String
|
||||
|
||||
init(_ title: String, icon: String) {
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Label(title, systemImage: icon)
|
||||
.font(Theme.headlineFont)
|
||||
.foregroundStyle(Theme.text)
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyLine: View {
|
||||
private let text: String
|
||||
|
||||
init(_ text: String) {
|
||||
self.text = text
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(Theme.captionFont)
|
||||
.foregroundStyle(Theme.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Theme.panel2)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||
}
|
||||
}
|
||||
|
||||
struct TrackLine<Actions: View>: View {
|
||||
let track: Track
|
||||
let isActive: Bool
|
||||
let subtitle: String
|
||||
@ViewBuilder let actions: () -> Actions
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Rectangle()
|
||||
.fill(isActive ? Theme.ready : Theme.amber)
|
||||
.frame(width: 4)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(track.title)
|
||||
.font(Theme.pixel(16, weight: .semibold))
|
||||
.foregroundStyle(Theme.text)
|
||||
.lineLimit(2)
|
||||
Text(subtitle)
|
||||
.font(Theme.captionFont)
|
||||
.foregroundStyle(Theme.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
actions()
|
||||
}
|
||||
.rowStyle(isActive: isActive)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import SwiftUI
|
||||
|
||||
struct HeaderView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("BLASTOISE")
|
||||
.font(Theme.display(28))
|
||||
.foregroundStyle(Theme.text)
|
||||
Text(model.currentUser?.username ?? "signed out")
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
Task { await model.connectToServer() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.frame(width: 38, height: 36)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task { await model.logout() }
|
||||
} label: {
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
.frame(width: 38, height: 36)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "server.rack")
|
||||
.foregroundStyle(Theme.amber)
|
||||
TextField("server", text: $model.serverURL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.text)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Theme.panel2)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||
}
|
||||
.padding(14)
|
||||
.background(Theme.background)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct RoomsPanel: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
PanelTitle("Rooms", icon: "radio")
|
||||
if model.channels.isEmpty {
|
||||
EmptyLine("No rooms loaded")
|
||||
} else {
|
||||
ForEach(model.channels) { channel in
|
||||
HStack(spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(channel.name)
|
||||
.font(Theme.headlineFont)
|
||||
.foregroundStyle(Theme.text)
|
||||
if channel.isDefault {
|
||||
Text("DEFAULT")
|
||||
.font(Theme.microFont)
|
||||
.foregroundStyle(Theme.background)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Theme.amber)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner))
|
||||
}
|
||||
}
|
||||
Text(channel.description.isEmpty ? "\(channel.trackCount) tracks" : channel.description)
|
||||
.font(Theme.captionFont)
|
||||
.foregroundStyle(Theme.muted)
|
||||
Text("\(channel.listenerCount) listener(s)")
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.ready)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await model.joinChannel(channel.id) }
|
||||
} label: {
|
||||
Image(systemName: model.currentChannelId == channel.id ? "checkmark.circle.fill" : "dot.radiowaves.left.and.right")
|
||||
.frame(width: 44, height: 38)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(model.currentChannelId == channel.id ? Theme.ready : Theme.accent)
|
||||
}
|
||||
.rowStyle(isActive: model.currentChannelId == channel.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.panel()
|
||||
}
|
||||
}
|
||||
|
||||
struct QueuePanel: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
PanelTitle("Queue", icon: "list.bullet")
|
||||
if model.queue.isEmpty {
|
||||
EmptyLine(model.queueLoaded ? "Queue is empty" : "Queue not loaded")
|
||||
} else {
|
||||
ForEach(Array(model.queue.prefix(80).enumerated()), id: \.offset) { index, track in
|
||||
TrackLine(
|
||||
track: track,
|
||||
isActive: index == model.currentIndex,
|
||||
subtitle: "#\(index + 1) \(formatDuration(track.duration))"
|
||||
) {
|
||||
Button {
|
||||
model.jumpToQueueIndex(index)
|
||||
} label: {
|
||||
Image(systemName: "play.fill")
|
||||
.frame(width: 38, height: 34)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task { await model.removeQueueIndex(index) }
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.frame(width: 38, height: 34)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.panel()
|
||||
}
|
||||
}
|
||||
|
||||
struct PeoplePanel: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
PanelTitle("People", icon: "person.2")
|
||||
if model.listeners.isEmpty {
|
||||
EmptyLine("No listener names in this room yet")
|
||||
} else {
|
||||
ForEach(model.listeners, id: \.self) { listener in
|
||||
HStack {
|
||||
Image(systemName: listener == model.currentUser?.username ? "person.fill.checkmark" : "person.fill")
|
||||
.foregroundStyle(listener == model.currentUser?.username ? Theme.ready : Theme.muted)
|
||||
Text(listener)
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
if listener == model.currentUser?.username {
|
||||
Text("YOU")
|
||||
.font(Theme.microFont)
|
||||
.foregroundStyle(Theme.ready)
|
||||
}
|
||||
}
|
||||
.rowStyle(isActive: listener == model.currentUser?.username)
|
||||
}
|
||||
}
|
||||
}
|
||||
.panel()
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryPanel: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@State private var query = ""
|
||||
@State private var fetchURL = ""
|
||||
@State private var fileImporterPresented = false
|
||||
|
||||
var matches: [Track] {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let base = model.libraryTracks
|
||||
if trimmed.isEmpty {
|
||||
return Array(base.prefix(80))
|
||||
}
|
||||
return Array(base.filter {
|
||||
$0.title.lowercased().contains(trimmed) ||
|
||||
$0.filename.lowercased().contains(trimmed) ||
|
||||
($0.artist ?? "").lowercased().contains(trimmed)
|
||||
}.prefix(80))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
PanelTitle("Library", icon: "music.note.list")
|
||||
importTools
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(Theme.muted)
|
||||
TextField("Search tracks", text: $query)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.foregroundStyle(Theme.text)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Theme.panel2)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||
|
||||
if !model.libraryLoaded {
|
||||
EmptyLine("Loading library")
|
||||
} else if matches.isEmpty {
|
||||
EmptyLine("No matching tracks")
|
||||
} else {
|
||||
ForEach(matches) { track in
|
||||
TrackLine(
|
||||
track: track,
|
||||
isActive: model.sourceMode == .library && model.currentTrackId == track.id,
|
||||
subtitle: track.artist ?? track.filename
|
||||
) {
|
||||
Button {
|
||||
model.playLibraryTrack(track)
|
||||
} label: {
|
||||
Image(systemName: "play.fill")
|
||||
.frame(width: 38, height: 34)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Theme.accent)
|
||||
|
||||
Menu {
|
||||
Button("Add to Queue") {
|
||||
Task { await model.queueTrack(track, playNext: false) }
|
||||
}
|
||||
Button("Play Next") {
|
||||
Task { await model.queueTrack(track, playNext: true) }
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.frame(width: 38, height: 34)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.panel()
|
||||
.fileImporter(
|
||||
isPresented: $fileImporterPresented,
|
||||
allowedContentTypes: [.audio, .movie],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
Task { await model.uploadFiles(urls) }
|
||||
case .failure(let error):
|
||||
model.importStatus = "File picker failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var importTools: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
fileImporterPresented = true
|
||||
} label: {
|
||||
Label(model.isUploading ? "Uploading" : "Upload Files", systemImage: "square.and.arrow.up")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Theme.accent)
|
||||
.disabled(model.isUploading)
|
||||
|
||||
Button {
|
||||
Task { await model.loadLibrary() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.frame(width: 42, height: 34)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel("Reload Library")
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "link")
|
||||
.foregroundStyle(Theme.muted)
|
||||
TextField("Fetch from website URL", text: $fetchURL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
.foregroundStyle(Theme.text)
|
||||
Button {
|
||||
Task { await model.fetchFromWebsite(fetchURL) }
|
||||
} label: {
|
||||
Image(systemName: model.isFetching ? "hourglass" : "arrow.down.circle")
|
||||
.frame(width: 40, height: 34)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(model.isFetching)
|
||||
.accessibilityLabel("Fetch URL")
|
||||
}
|
||||
.padding(10)
|
||||
.background(Theme.panel2)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||
|
||||
if let playlist = model.pendingFetchPlaylist {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Playlist found")
|
||||
.font(Theme.mono(12, weight: .bold))
|
||||
.foregroundStyle(Theme.amber)
|
||||
Text("\(playlist.title) · \(playlist.count) items")
|
||||
.font(Theme.pixel(16, weight: .semibold))
|
||||
.foregroundStyle(Theme.text)
|
||||
.lineLimit(2)
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await model.confirmFetchPlaylist() }
|
||||
} label: {
|
||||
Label("Queue Playlist", systemImage: "checkmark.circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Theme.ready)
|
||||
.disabled(model.isFetching)
|
||||
|
||||
Button {
|
||||
model.cancelFetchPlaylist()
|
||||
} label: {
|
||||
Label("Cancel", systemImage: "xmark")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Theme.panel2)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||
}
|
||||
|
||||
if !model.importStatus.isEmpty {
|
||||
Text(model.importStatus)
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.muted)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaylistsPanel: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
PanelTitle("Playlists", icon: "rectangle.stack")
|
||||
if model.allPlaylists.isEmpty {
|
||||
EmptyLine(model.playlistsLoaded ? "No playlists" : "Loading playlists")
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(model.allPlaylists.prefix(40)) { playlist in
|
||||
let isSelected = model.selectedPlaylistId == playlist.id
|
||||
Button {
|
||||
Task { await model.loadPlaylist(playlist.id) }
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(playlist.name)
|
||||
.font(Theme.pixel(16, weight: .bold))
|
||||
.foregroundStyle(isSelected ? Theme.background : Theme.text)
|
||||
.lineLimit(1)
|
||||
Text("\(playlist.trackIds.count) tracks")
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(isSelected ? Theme.background.opacity(0.72) : Theme.muted)
|
||||
}
|
||||
.frame(width: 150, alignment: .leading)
|
||||
.padding(10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(isSelected ? Theme.accent : Theme.panel2)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.corner)
|
||||
.stroke(isSelected ? Theme.text : Theme.stroke.opacity(0.38), lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let playlist = model.selectedPlaylist {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(playlist.name)
|
||||
.font(Theme.headlineFont)
|
||||
.foregroundStyle(Theme.text)
|
||||
Text(playlist.ownerName.isEmpty ? "\(playlist.trackIds.count) tracks" : "by \(playlist.ownerName)")
|
||||
.font(Theme.captionFont)
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await model.addPlaylistToQueue(playlist, playNext: false) }
|
||||
} label: {
|
||||
Image(systemName: "text.badge.plus")
|
||||
.frame(width: 38, height: 34)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button {
|
||||
Task { await model.addPlaylistToQueue(playlist, playNext: true) }
|
||||
} label: {
|
||||
Image(systemName: "text.line.first.and.arrowtriangle.forward")
|
||||
.frame(width: 38, height: 34)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
ForEach(Array(playlist.trackIds.prefix(80).enumerated()), id: \.offset) { index, trackId in
|
||||
let track = model.track(for: trackId) ?? Track(id: trackId, filename: trackId, title: trackId, duration: 0)
|
||||
TrackLine(
|
||||
track: track,
|
||||
isActive: model.currentTrackId == track.id,
|
||||
subtitle: "#\(index + 1)"
|
||||
) {
|
||||
Button {
|
||||
Task { await model.queueTrack(track, playNext: false) }
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.frame(width: 38, height: 34)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button {
|
||||
Task { await model.queueTrack(track, playNext: true) }
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.to.line")
|
||||
.frame(width: 38, height: 34)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.panel()
|
||||
}
|
||||
}
|
||||
|
||||
struct DebugPanel: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
PanelTitle("Diagnostics", icon: "waveform.path.ecg")
|
||||
debugRow("Server", model.serverURL)
|
||||
debugRow("Auth", model.authState.rawValue)
|
||||
debugRow("User", model.currentUser?.username ?? "-")
|
||||
debugRow("Room", model.currentChannelId ?? "-")
|
||||
debugRow("Track", model.currentTrackId ?? "-")
|
||||
debugRow("Expected", "\(model.expectedPositionMs)ms")
|
||||
debugRow("Player", "\(model.playerPositionMs)ms")
|
||||
debugRow("Drift", "\(model.driftMs)ms")
|
||||
Divider().overlay(Theme.stroke)
|
||||
ForEach(model.debugEvents, id: \.self) { event in
|
||||
Text(event)
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.panel()
|
||||
}
|
||||
|
||||
private func debugRow(_ label: String, _ value: String) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
Text(label)
|
||||
.foregroundStyle(Theme.muted)
|
||||
.frame(width: 78, alignment: .leading)
|
||||
Text(value)
|
||||
.foregroundStyle(Theme.text)
|
||||
.textSelection(.enabled)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(Theme.mono(12))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import SwiftUI
|
||||
|
||||
struct PlayerDeckView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var progress: Double {
|
||||
guard model.trackDuration > 0 else { return 0 }
|
||||
return min(1, max(0, Double(model.playerPositionMs) / (model.trackDuration * 1000)))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(model.sourceMode.rawValue)
|
||||
.font(Theme.mono(12, weight: .bold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text(model.channelName)
|
||||
.font(Theme.headlineFont)
|
||||
.foregroundStyle(Theme.text)
|
||||
Text(model.trackTitle)
|
||||
.font(Theme.pixel(22, weight: .bold))
|
||||
.foregroundStyle(Theme.text)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(model.playbackMode.uppercased())
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.amber)
|
||||
Text(model.playbackState.uppercased())
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(model.isPlaying ? Theme.ready : Theme.muted)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProgressView(value: progress)
|
||||
.tint(Theme.ready)
|
||||
.background(Theme.panel2)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner))
|
||||
|
||||
HStack {
|
||||
Text(formatTime(model.playerPositionMs))
|
||||
Spacer()
|
||||
Text(formatDuration(model.trackDuration))
|
||||
}
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
iconButton("backward.end.fill") { model.previous() }
|
||||
iconButton("gobackward.15") { model.seekBy(seconds: -15) }
|
||||
Button {
|
||||
model.togglePlay()
|
||||
} label: {
|
||||
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(Theme.pixel(24, weight: .bold))
|
||||
.frame(width: 58, height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Theme.accent)
|
||||
iconButton("goforward.15") { model.seekBy(seconds: 15) }
|
||||
iconButton("forward.end.fill") { model.next() }
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
actionButton("Mode", icon: "repeat") {
|
||||
Task { await model.cyclePlaybackMode() }
|
||||
}
|
||||
actionButton("Queue", icon: "text.badge.plus") {
|
||||
Task { await model.queueCurrent(playNext: false) }
|
||||
}
|
||||
actionButton("Next", icon: "text.line.first.and.arrowtriangle.forward") {
|
||||
Task { await model.queueCurrent(playNext: true) }
|
||||
}
|
||||
actionButton("Stop", icon: "power") {
|
||||
model.stopAndExit()
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
meter("DRIFT", "\(model.driftMs)ms", model.sourceMode == .radio && abs(model.driftMs) > 1800 ? Theme.amber : Theme.ready)
|
||||
meter("ROOMS", "\(model.channels.count)", Theme.text)
|
||||
meter("QUEUE", "\(model.queue.count)", Theme.text)
|
||||
}
|
||||
}
|
||||
.panel()
|
||||
}
|
||||
|
||||
private func iconButton(_ systemName: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
private func actionButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Label(title, systemImage: icon)
|
||||
.labelStyle(.iconOnly)
|
||||
.frame(maxWidth: .infinity, minHeight: 38)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
|
||||
private func meter(_ label: String, _ value: String, _ color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(Theme.microFont)
|
||||
.foregroundStyle(Theme.muted)
|
||||
Text(value)
|
||||
.font(Theme.mono(13, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(Theme.panel2)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Blastoise iOS Sketch
|
||||
|
||||
Native SwiftUI sketch for the Blastoise/MusicRoom server.
|
||||
|
||||
## What It Does
|
||||
|
||||
- Defaults to `http://mhsgroove.peterino.com:3001`.
|
||||
- Signs in or signs up with the server.
|
||||
- Loads rooms, queue state, people, library, and playlists.
|
||||
- Connects to a room WebSocket and streams `/api/tracks/:id` through `AVPlayer`.
|
||||
- Applies server timestamp sync and drift correction.
|
||||
- Supports local library playback, queue/play-next actions, queue jumps/removes, and playback mode cycling.
|
||||
- Uses one compact broadcast-console theme.
|
||||
|
||||
## Code Layout
|
||||
|
||||
- `BlastoisePingApp.swift` - app entrypoint.
|
||||
- `ContentView.swift` - signed-in/signed-out shell and tab routing.
|
||||
- `Models/AppTypes.swift` - API response models and shared enums.
|
||||
- `State/AppModel.swift` - app state, server requests, WebSocket sync, uploads, and playback coordination.
|
||||
- `UI/Theme.swift` - pixel-art palette, typography, reusable view chrome, and time formatting.
|
||||
- `Views/` - focused SwiftUI screens and reusable row/panel components.
|
||||
|
||||
## Open
|
||||
|
||||
```bash
|
||||
open ios/BlastoisePing/BlastoisePing.xcodeproj
|
||||
```
|
||||
|
||||
The app currently allows arbitrary HTTP loads in `Info.plist` so it can reach the existing plain-HTTP test server and local development servers. Narrow that before any public distribution.
|
||||
|
|
@ -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.
|
||||
227
library.ts
227
library.ts
|
|
@ -1,4 +1,5 @@
|
|||
import { Database } from "bun:sqlite";
|
||||
import { spawn } from "child_process";
|
||||
import { createHash } from "crypto";
|
||||
import { watch, type FSWatcher } from "fs";
|
||||
import { readdir, stat } from "fs/promises";
|
||||
|
|
@ -8,6 +9,24 @@ import { type Track } from "./db";
|
|||
|
||||
const HASH_CHUNK_SIZE = 64 * 1024; // 64KB
|
||||
const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]);
|
||||
const DEFAULT_REPLAY_GAIN_CONFIG: ReplayGainScanConfig = {
|
||||
enabled: true,
|
||||
command: "rsgain",
|
||||
truePeak: false,
|
||||
timeoutMs: 120000,
|
||||
};
|
||||
|
||||
export interface ReplayGainScanConfig {
|
||||
enabled?: boolean;
|
||||
command?: string;
|
||||
truePeak?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface ReplayGainScanResult {
|
||||
replayGainDb: number;
|
||||
replayPeak: number;
|
||||
}
|
||||
|
||||
export interface LibraryTrack extends Track {
|
||||
filename: string;
|
||||
|
|
@ -33,7 +52,9 @@ export class Library {
|
|||
private trackInfo = new Map<string, LibraryTrack>(); // trackId -> full info
|
||||
private watcher: FSWatcher | null = null;
|
||||
private eventListeners = new Map<LibraryEventType, Set<LibraryEventCallback>>();
|
||||
private pendingFiles = new Map<string, NodeJS.Timeout>(); // filepath -> debounce timer
|
||||
private pendingFiles = new Map<string, ReturnType<typeof setTimeout>>(); // filepath -> debounce timer
|
||||
private replayGainConfig: Required<ReplayGainScanConfig>;
|
||||
private replayGainAvailable: boolean | null = null;
|
||||
|
||||
// Scan progress tracking
|
||||
private _scanProgress = { scanning: false, processed: 0, total: 0 };
|
||||
|
|
@ -43,9 +64,19 @@ export class Library {
|
|||
return { ...this._scanProgress };
|
||||
}
|
||||
|
||||
constructor(musicDir: string, cacheDbPath: string = "./library_cache.db") {
|
||||
constructor(
|
||||
musicDir: string,
|
||||
cacheDbPath: string = "./library_cache.db",
|
||||
replayGainConfig: ReplayGainScanConfig = {},
|
||||
) {
|
||||
this.musicDir = musicDir;
|
||||
this.cacheDb = new Database(cacheDbPath);
|
||||
this.replayGainConfig = {
|
||||
enabled: replayGainConfig.enabled ?? DEFAULT_REPLAY_GAIN_CONFIG.enabled,
|
||||
command: replayGainConfig.command ?? DEFAULT_REPLAY_GAIN_CONFIG.command,
|
||||
truePeak: replayGainConfig.truePeak ?? DEFAULT_REPLAY_GAIN_CONFIG.truePeak,
|
||||
timeoutMs: replayGainConfig.timeoutMs ?? DEFAULT_REPLAY_GAIN_CONFIG.timeoutMs,
|
||||
};
|
||||
this.cacheDb.run("PRAGMA journal_mode = WAL");
|
||||
this.initCacheDb();
|
||||
}
|
||||
|
|
@ -71,9 +102,13 @@ export class Library {
|
|||
album TEXT,
|
||||
duration REAL NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
replay_gain_db REAL,
|
||||
replay_peak REAL,
|
||||
created_at INTEGER DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
this.addColumnIfMissing("tracks", "replay_gain_db", "REAL");
|
||||
this.addColumnIfMissing("tracks", "replay_peak", "REAL");
|
||||
|
||||
// Library activity log
|
||||
this.cacheDb.run(`
|
||||
|
|
@ -91,6 +126,12 @@ export class Library {
|
|||
this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_library_log_time ON library_log(timestamp DESC)`);
|
||||
}
|
||||
|
||||
private addColumnIfMissing(table: string, column: string, definition: string): void {
|
||||
const columns = this.cacheDb.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (columns.some((c) => c.name === column)) return;
|
||||
this.cacheDb.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
logActivity(action: string, track: { id?: string; filename?: string; title?: string | null }, user?: { id: number; username: string } | null): void {
|
||||
this.cacheDb.query(`
|
||||
INSERT INTO library_log (action, track_id, filename, title, user_id, username)
|
||||
|
|
@ -102,15 +143,35 @@ export class Library {
|
|||
return this.cacheDb.query(`SELECT * FROM library_log ORDER BY timestamp DESC LIMIT ?`).all(limit) as any;
|
||||
}
|
||||
|
||||
private upsertTrack(track: { id: string; title: string | null; artist: string | null; album: string | null; duration: number; size: number }): void {
|
||||
private upsertTrack(track: {
|
||||
id: string;
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
duration: number;
|
||||
size: number;
|
||||
replayGainDb: number | null;
|
||||
replayPeak: number | null;
|
||||
}): void {
|
||||
this.cacheDb.query(`
|
||||
INSERT INTO tracks (id, title, artist, album, duration, size)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO tracks (id, title, artist, album, duration, size, replay_gain_db, replay_peak)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = COALESCE(excluded.title, title),
|
||||
artist = COALESCE(excluded.artist, artist),
|
||||
album = COALESCE(excluded.album, album)
|
||||
`).run(track.id, track.title, track.artist, track.album, track.duration, track.size);
|
||||
album = COALESCE(excluded.album, album),
|
||||
replay_gain_db = COALESCE(excluded.replay_gain_db, replay_gain_db),
|
||||
replay_peak = COALESCE(excluded.replay_peak, replay_peak)
|
||||
`).run(
|
||||
track.id,
|
||||
track.title,
|
||||
track.artist,
|
||||
track.album,
|
||||
track.duration,
|
||||
track.size,
|
||||
track.replayGainDb,
|
||||
track.replayPeak,
|
||||
);
|
||||
}
|
||||
|
||||
async computeTrackId(filePath: string): Promise<string> {
|
||||
|
|
@ -157,6 +218,124 @@ export class Library {
|
|||
return AUDIO_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
private shouldAttemptReplayGain(): boolean {
|
||||
return this.replayGainConfig.enabled && this.replayGainAvailable !== false;
|
||||
}
|
||||
|
||||
private needsReplayGain(track: LibraryTrack | null | undefined): boolean {
|
||||
return !track || track.replayGainDb == null || track.replayPeak == null;
|
||||
}
|
||||
|
||||
private async ensureReplayGainAvailable(): Promise<boolean> {
|
||||
if (!this.replayGainConfig.enabled) return false;
|
||||
if (this.replayGainAvailable !== null) return this.replayGainAvailable;
|
||||
|
||||
try {
|
||||
const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000);
|
||||
const version = stdout.trim().split(/\r?\n/)[0] || "available";
|
||||
console.log(`[Library] rsgain found: ${version}`);
|
||||
this.replayGainAvailable = true;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
console.warn(`[Library] rsgain unavailable, ReplayGain scan skipped: ${message}`);
|
||||
this.replayGainAvailable = false;
|
||||
}
|
||||
|
||||
return this.replayGainAvailable;
|
||||
}
|
||||
|
||||
private async scanReplayGain(filePath: string): Promise<ReplayGainScanResult | null> {
|
||||
if (!(await this.ensureReplayGainAvailable())) return null;
|
||||
|
||||
const args = [
|
||||
"custom",
|
||||
"--output",
|
||||
"--tagmode",
|
||||
"s",
|
||||
"--clip-mode",
|
||||
"n",
|
||||
"--quiet",
|
||||
];
|
||||
if (this.replayGainConfig.truePeak) {
|
||||
args.push("--true-peak");
|
||||
}
|
||||
args.push(filePath);
|
||||
|
||||
try {
|
||||
const { stdout } = await this.runCommand(this.replayGainConfig.command, args, this.replayGainConfig.timeoutMs);
|
||||
return this.parseReplayGainOutput(stdout);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
console.warn(`[Library] rsgain failed for ${filePath}: ${message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private parseReplayGainOutput(output: string): ReplayGainScanResult | null {
|
||||
const lines = output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.length > 0);
|
||||
const headerIndex = lines.findIndex((line) => line.startsWith("Filename\t"));
|
||||
if (headerIndex === -1) return null;
|
||||
|
||||
const dataLine = lines.slice(headerIndex + 1).find((line) => !line.startsWith("sep="));
|
||||
if (!dataLine) return null;
|
||||
|
||||
const cells = dataLine.split("\t");
|
||||
if (cells.length !== 7) return null;
|
||||
|
||||
const replayGainDb = Number.parseFloat(cells[2]);
|
||||
const replayPeak = Number.parseFloat(cells[3]);
|
||||
|
||||
if (!Number.isFinite(replayGainDb) || !Number.isFinite(replayPeak)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { replayGainDb, replayPeak };
|
||||
}
|
||||
|
||||
private runCommand(command: string, args: string[], timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args, { windowsHide: true });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let finished = false;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const finish = (callback: () => void) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeout);
|
||||
callback();
|
||||
};
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
proc.kill();
|
||||
finish(() => reject(new Error(`timed out after ${timeoutMs}ms`)));
|
||||
}, timeoutMs);
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
proc.on("error", (e) => {
|
||||
finish(() => reject(e));
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
finish(() => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async processFile(filePath: string): Promise<LibraryTrack | null> {
|
||||
const relativePath = relative(this.musicDir, filePath);
|
||||
const filename = filePath.substring(filePath.lastIndexOf("/") + 1).replace(/\\/g, "/").split("/").pop() || filePath;
|
||||
|
|
@ -194,6 +373,13 @@ export class Library {
|
|||
// Use defaults
|
||||
}
|
||||
|
||||
const existingTrack = this.trackInfo.get(trackId);
|
||||
const replayGain = this.needsReplayGain(existingTrack)
|
||||
? await this.scanReplayGain(filePath)
|
||||
: null;
|
||||
const replayGainDb = replayGain?.replayGainDb ?? existingTrack?.replayGainDb ?? null;
|
||||
const replayPeak = replayGain?.replayPeak ?? existingTrack?.replayPeak ?? null;
|
||||
|
||||
const track: LibraryTrack = {
|
||||
id: trackId,
|
||||
filename,
|
||||
|
|
@ -203,6 +389,8 @@ export class Library {
|
|||
album,
|
||||
duration,
|
||||
size,
|
||||
replayGainDb,
|
||||
replayPeak,
|
||||
available: true,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
|
@ -215,6 +403,8 @@ export class Library {
|
|||
album,
|
||||
duration,
|
||||
size,
|
||||
replayGainDb,
|
||||
replayPeak,
|
||||
});
|
||||
|
||||
return track;
|
||||
|
|
@ -229,7 +419,17 @@ export class Library {
|
|||
|
||||
// Single query joining file_cache and tracks - all in cacheDb now
|
||||
const cachedTracks = this.cacheDb.query(`
|
||||
SELECT fc.path, fc.track_id, t.title, t.artist, t.album, t.duration
|
||||
SELECT
|
||||
fc.path,
|
||||
fc.track_id,
|
||||
t.title,
|
||||
t.artist,
|
||||
t.album,
|
||||
t.duration,
|
||||
t.size,
|
||||
t.replay_gain_db,
|
||||
t.replay_peak,
|
||||
t.created_at
|
||||
FROM file_cache fc
|
||||
LEFT JOIN tracks t ON fc.track_id = t.id
|
||||
`).all() as Array<{
|
||||
|
|
@ -239,6 +439,10 @@ export class Library {
|
|||
artist: string | null;
|
||||
album: string | null;
|
||||
duration: number | null;
|
||||
size: number | null;
|
||||
replay_gain_db: number | null;
|
||||
replay_peak: number | null;
|
||||
created_at: number | null;
|
||||
}>;
|
||||
|
||||
for (const row of cachedTracks) {
|
||||
|
|
@ -253,7 +457,11 @@ export class Library {
|
|||
artist: row.artist || null,
|
||||
album: row.album || null,
|
||||
duration: row.duration || 0,
|
||||
size: row.size || 0,
|
||||
replayGainDb: row.replay_gain_db,
|
||||
replayPeak: row.replay_peak,
|
||||
available: true,
|
||||
created_at: row.created_at || 0,
|
||||
};
|
||||
|
||||
this.trackMap.set(track.id, fullPath);
|
||||
|
|
@ -312,10 +520,13 @@ export class Library {
|
|||
try {
|
||||
const fileStat = await stat(fullPath);
|
||||
if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) {
|
||||
const cachedTrack = this.trackInfo.get(cacheEntry.track_id);
|
||||
if (!this.shouldAttemptReplayGain() || !this.needsReplayGain(cachedTrack)) {
|
||||
skipped++;
|
||||
this._scanProgress.processed++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export function handleGetLibrary(req: Request, server: any): Response {
|
|||
artist: t.artist,
|
||||
album: t.album,
|
||||
duration: t.duration,
|
||||
replayGainDb: t.replayGainDb,
|
||||
replayPeak: t.replayPeak,
|
||||
available: t.available,
|
||||
}));
|
||||
return Response.json(tracks, { headers });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
|
||||
echo ============================================
|
||||
echo Blastoise Native Android - Build and Launch
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
set "ROOT_DIR=%~dp0"
|
||||
set "PROJECT_DIR=%ROOT_DIR%android\BlastoiseNative"
|
||||
set "APK_PATH=%PROJECT_DIR%\app\build\outputs\apk\debug\app-debug.apk"
|
||||
set "PACKAGE_ACTIVITY=com.peterino.blastoise/.MainActivity"
|
||||
|
||||
if "%~1"=="" (
|
||||
set "AVD_NAME=Medium_Phone_API_36.0"
|
||||
) else (
|
||||
set "AVD_NAME=%~1"
|
||||
)
|
||||
|
||||
set "PATH=%USERPROFILE%\scoop\shims;%PATH%"
|
||||
if not defined JAVA_HOME (
|
||||
if exist "%USERPROFILE%\scoop\apps\temurin17-jdk\current" (
|
||||
set "JAVA_HOME=%USERPROFILE%\scoop\apps\temurin17-jdk\current"
|
||||
)
|
||||
)
|
||||
if not defined ANDROID_HOME set "ANDROID_HOME=%LOCALAPPDATA%\Android\Sdk"
|
||||
set "PATH=%ANDROID_HOME%\platform-tools;%ANDROID_HOME%\emulator;%ANDROID_HOME%\cmdline-tools\latest\bin;%PATH%"
|
||||
set "ADB_EXE=%ANDROID_HOME%\platform-tools\adb.exe"
|
||||
set "EMULATOR_EXE=%ANDROID_HOME%\emulator\emulator.exe"
|
||||
|
||||
if not exist "%PROJECT_DIR%\gradlew.bat" (
|
||||
echo ERROR: Native Android project not found:
|
||||
echo %PROJECT_DIR%
|
||||
goto :fail
|
||||
)
|
||||
|
||||
if not exist "%ADB_EXE%" (
|
||||
echo ERROR: adb not found at:
|
||||
echo %ADB_EXE%
|
||||
echo Run android\dev-setup.bat first.
|
||||
goto :fail
|
||||
)
|
||||
|
||||
if not exist "%EMULATOR_EXE%" (
|
||||
echo ERROR: Android emulator not found at:
|
||||
echo %EMULATOR_EXE%
|
||||
echo Run android\dev-setup.bat first.
|
||||
goto :fail
|
||||
)
|
||||
|
||||
"%EMULATOR_EXE%" -list-avds | findstr /X /C:"%AVD_NAME%" >nul
|
||||
if errorlevel 1 (
|
||||
echo ERROR: AVD "%AVD_NAME%" was not found.
|
||||
echo.
|
||||
echo Available AVDs:
|
||||
"%EMULATOR_EXE%" -list-avds
|
||||
echo.
|
||||
echo Pass a different AVD name as the first argument, for example:
|
||||
echo run-android-native.bat Your_AVD_Name
|
||||
goto :fail
|
||||
)
|
||||
|
||||
echo Building debug APK...
|
||||
pushd "%PROJECT_DIR%"
|
||||
call gradlew.bat :app:assembleDebug
|
||||
if errorlevel 1 (
|
||||
popd
|
||||
echo.
|
||||
echo ERROR: Gradle build failed.
|
||||
goto :fail
|
||||
)
|
||||
popd
|
||||
|
||||
if not exist "%APK_PATH%" (
|
||||
echo ERROR: APK was not created:
|
||||
echo %APK_PATH%
|
||||
goto :fail
|
||||
)
|
||||
|
||||
"%ADB_EXE%" devices | findstr /R /C:"emulator-[0-9][0-9]*[ ]*device" >nul
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Starting emulator "%AVD_NAME%"...
|
||||
start "" "%EMULATOR_EXE%" -avd "%AVD_NAME%"
|
||||
echo Waiting for emulator device...
|
||||
set /a WAIT_DEVICE_COUNT=0
|
||||
:wait_device
|
||||
"%ADB_EXE%" devices | findstr /R /C:"emulator-[0-9][0-9]*[ ]*device" >nul
|
||||
if errorlevel 1 (
|
||||
set /a WAIT_DEVICE_COUNT+=1
|
||||
if !WAIT_DEVICE_COUNT! GEQ 90 (
|
||||
echo ERROR: Emulator did not appear within 180 seconds.
|
||||
goto :fail
|
||||
)
|
||||
timeout /t 2 /nobreak >nul
|
||||
goto :wait_device
|
||||
)
|
||||
)
|
||||
|
||||
echo Waiting for Android boot completion...
|
||||
set /a WAIT_BOOT_COUNT=0
|
||||
:wait_boot
|
||||
set "BOOT_DONE="
|
||||
for /f "delims=" %%b in ('"%ADB_EXE%" shell getprop sys.boot_completed 2^>nul') do set "BOOT_DONE=%%b"
|
||||
if not "%BOOT_DONE%"=="1" (
|
||||
set /a WAIT_BOOT_COUNT+=1
|
||||
if !WAIT_BOOT_COUNT! GEQ 120 (
|
||||
echo ERROR: Android did not finish booting within 240 seconds.
|
||||
goto :fail
|
||||
)
|
||||
timeout /t 2 /nobreak >nul
|
||||
goto :wait_boot
|
||||
)
|
||||
|
||||
echo Installing APK...
|
||||
"%ADB_EXE%" install -r "%APK_PATH%"
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ERROR: APK install failed.
|
||||
goto :fail
|
||||
)
|
||||
|
||||
echo Launching app...
|
||||
"%ADB_EXE%" shell am start -n %PACKAGE_ACTIVITY%
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ERROR: App launch failed.
|
||||
goto :fail
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ============================================
|
||||
echo Blastoise is built, installed, and running.
|
||||
echo ============================================
|
||||
goto :done
|
||||
|
||||
:fail
|
||||
echo.
|
||||
echo ============================================
|
||||
echo Failed.
|
||||
echo ============================================
|
||||
if not "%NO_PAUSE%"=="1" pause
|
||||
exit /b 1
|
||||
|
||||
:done
|
||||
if not "%NO_PAUSE%"=="1" pause
|
||||
exit /b 0
|
||||
Loading…
Reference in New Issue