Add native Android streaming app themes

This commit is contained in:
peterino2 2026-06-05 10:05:57 -07:00
parent 25d37ec9dd
commit 9874ea3cdb
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">
<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" />

View File

@ -16,6 +16,8 @@ data class ChannelInfo(
val description: String,
val listenerCount: Int,
val isDefault: Boolean,
val trackCount: Int = 0,
val listeners: List<String> = emptyList(),
)
data class ChannelState(
@ -27,6 +29,8 @@ data class ChannelState(
val queue: List<Track>?,
val currentIndex: Int,
val playbackMode: String,
val listenerCount: Int,
val listeners: List<String>,
)
data class Playlist(
@ -45,6 +49,23 @@ data class PlaylistBundle(
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")),
@ -61,6 +82,8 @@ fun JSONObject.toChannelInfo(): ChannelInfo {
description = optString("description", ""),
listenerCount = optInt("listenerCount", 0),
isDefault = optBoolean("isDefault", false),
trackCount = optInt("trackCount", 0),
listeners = optJSONArray("listeners").toStringList(),
)
}
@ -82,6 +105,32 @@ fun JSONObject.toChannelState(): ChannelState {
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(),
)
}
@ -115,3 +164,8 @@ 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() }
}

View File

@ -28,6 +28,7 @@ class MusicRoomClient(
fun onPlaylistDetail(playlist: Playlist)
fun onChannelState(state: ChannelState)
fun onSwitched(channelId: String)
fun onAuth(user: UserSession?)
fun onError(message: String)
}
@ -69,9 +70,109 @@ class MusicRoomClient(
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")
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) {
listener.onDebug("GET /api/channels/$channelId")
val request = request("/api/channels/$channelId").build()
@ -368,6 +502,8 @@ class MusicRoomClient(
response.headers("Set-Cookie")
.map { it.substringBefore(";") }
.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
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
@ -9,13 +16,67 @@ 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() {
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>()
@ -38,25 +99,782 @@ class PlaybackService : MediaSessionService() {
.setUsage(C.USAGE_MEDIA)
.build()
val player = ExoPlayer.Builder(this)
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)
}
}
}

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