Add native Android streaming app themes

This commit is contained in:
peterino2 2026-06-05 10:05:57 -07:00
parent 25d37ec9dd
commit af8fbc836b
19 changed files with 2451 additions and 574 deletions

View File

@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View File

@ -16,6 +16,8 @@ data class ChannelInfo(
val description: String, val description: String,
val listenerCount: Int, val listenerCount: Int,
val isDefault: Boolean, val isDefault: Boolean,
val trackCount: Int = 0,
val listeners: List<String> = emptyList(),
) )
data class ChannelState( data class ChannelState(
@ -27,6 +29,8 @@ data class ChannelState(
val queue: List<Track>?, val queue: List<Track>?,
val currentIndex: Int, val currentIndex: Int,
val playbackMode: String, val playbackMode: String,
val listenerCount: Int,
val listeners: List<String>,
) )
data class Playlist( data class Playlist(
@ -45,6 +49,23 @@ data class PlaylistBundle(
val shared: 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 { fun JSONObject.toTrack(): Track {
return Track( return Track(
id = optString("id", optString("filename")), id = optString("id", optString("filename")),
@ -61,6 +82,8 @@ fun JSONObject.toChannelInfo(): ChannelInfo {
description = optString("description", ""), description = optString("description", ""),
listenerCount = optInt("listenerCount", 0), listenerCount = optInt("listenerCount", 0),
isDefault = optBoolean("isDefault", false), isDefault = optBoolean("isDefault", false),
trackCount = optInt("trackCount", 0),
listeners = optJSONArray("listeners").toStringList(),
) )
} }
@ -82,6 +105,32 @@ fun JSONObject.toChannelState(): ChannelState {
queue = queue, queue = queue,
currentIndex = optInt("currentIndex", 0), currentIndex = optInt("currentIndex", 0),
playbackMode = optString("playbackMode", "repeat-all"), 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(),
) )
} }
@ -115,3 +164,8 @@ fun JSONArray?.toStringList(): List<String> {
if (this == null) return emptyList() if (this == null) return emptyList()
return (0 until length()).mapNotNull { optString(it).takeIf(String::isNotBlank) } 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() }
}

View File

@ -28,6 +28,7 @@ class MusicRoomClient(
fun onPlaylistDetail(playlist: Playlist) fun onPlaylistDetail(playlist: Playlist)
fun onChannelState(state: ChannelState) fun onChannelState(state: ChannelState)
fun onSwitched(channelId: String) fun onSwitched(channelId: String)
fun onAuth(user: UserSession?)
fun onError(message: String) fun onError(message: String)
} }
@ -69,9 +70,109 @@ class MusicRoomClient(
listener.onError("Login failed: HTTP ${it.code}") listener.onError("Login failed: HTTP ${it.code}")
return return
} }
val session = JSONObject(it.body.string()).toUserSession()
listener.onAuth(session)
SessionStore.save(context) SessionStore.save(context)
listener.onStatus("Signed in") listener.onStatus("Signed in")
loadChannels() 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)
} }
} }
}) })
@ -304,6 +405,39 @@ class MusicRoomClient(
}) })
} }
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) { private fun loadChannelState(channelId: String) {
listener.onDebug("GET /api/channels/$channelId") listener.onDebug("GET /api/channels/$channelId")
val request = request("/api/channels/$channelId").build() val request = request("/api/channels/$channelId").build()
@ -368,6 +502,8 @@ class MusicRoomClient(
response.headers("Set-Cookie") response.headers("Set-Cookie")
.map { it.substringBefore(";") } .map { it.substringBefore(";") }
.firstOrNull { it.startsWith("blastoise_session=") } .firstOrNull { it.startsWith("blastoise_session=") }
?.let { SessionStore.cookieHeader = it } ?.let {
SessionStore.cookieHeader = if (it == "blastoise_session=") "" else it
}
} }
} }

View File

@ -1,7 +1,14 @@
package com.peterino.blastoise 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.AudioAttributes
import androidx.media3.common.C 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.common.util.UnstableApi
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.DefaultHttpDataSource
@ -9,13 +16,67 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import kotlin.math.abs
import kotlin.random.Random
@UnstableApi @UnstableApi
class PlaybackService : MediaSessionService() { 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 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() { override fun onCreate() {
super.onCreate() super.onCreate()
SessionStore.load(this)
client = MusicRoomClient(this, this)
val httpDataSourceFactory = DataSource.Factory { val httpDataSourceFactory = DataSource.Factory {
val requestProperties = mutableMapOf<String, String>() val requestProperties = mutableMapOf<String, String>()
@ -38,25 +99,782 @@ class PlaybackService : MediaSessionService() {
.setUsage(C.USAGE_MEDIA) .setUsage(C.USAGE_MEDIA)
.build() .build()
val player = ExoPlayer.Builder(this) player = ExoPlayer.Builder(this)
.setMediaSourceFactory(mediaSourceFactory) .setMediaSourceFactory(mediaSourceFactory)
.setWakeMode(C.WAKE_MODE_LOCAL)
.setAudioAttributes(audioAttributes, true) .setAudioAttributes(audioAttributes, true)
.setHandleAudioBecomingNoisy(true) .setHandleAudioBecomingNoisy(true)
.build() .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() mediaSession = MediaSession.Builder(this, player).build()
PlaybackBridge.service = this
addDebugEvent("service created")
publishSnapshot()
validateSession()
mainHandler.post(ticker)
} }
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
return mediaSession return mediaSession
} }
override fun onTaskRemoved(rootIntent: Intent?) {
if (!player.isPlaying) {
pauseAllPlayersAndStopSelf()
}
}
override fun onDestroy() { override fun onDestroy() {
mainHandler.removeCallbacks(ticker)
mainHandler.removeCallbacks(reconnectRunnable)
intentionalDisconnect = true
client.close()
mediaSession?.run { mediaSession?.run {
player.release() player.release()
release() release()
} }
mediaSession = null mediaSession = null
PlaybackBridge.service = null
super.onDestroy() 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)
}
}
} }

View File

@ -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) }
}
}
}

View File

@ -15,6 +15,9 @@ object SessionStore {
@Volatile @Volatile
var cookieHeader: String = "" var cookieHeader: String = ""
@Volatile
var themeKey: String = "seraph"
fun load(context: Context) { fun load(context: Context) {
val prefs = context.getSharedPreferences("blastoise", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("blastoise", Context.MODE_PRIVATE)
serverBaseUrl = prefs.getString("serverBaseUrl", defaultServerBaseUrl) ?: defaultServerBaseUrl serverBaseUrl = prefs.getString("serverBaseUrl", defaultServerBaseUrl) ?: defaultServerBaseUrl
@ -22,6 +25,7 @@ object SessionStore {
serverBaseUrl = defaultServerBaseUrl serverBaseUrl = defaultServerBaseUrl
} }
cookieHeader = prefs.getString("cookieHeader", "") ?: "" cookieHeader = prefs.getString("cookieHeader", "") ?: ""
themeKey = prefs.getString("themeKey", "seraph") ?: "seraph"
} }
fun save(context: Context) { fun save(context: Context) {
@ -29,6 +33,7 @@ object SessionStore {
.edit() .edit()
.putString("serverBaseUrl", serverBaseUrl) .putString("serverBaseUrl", serverBaseUrl)
.putString("cookieHeader", cookieHeader) .putString("cookieHeader", cookieHeader)
.putString("themeKey", themeKey)
.apply() .apply()
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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