Add native Android streaming app themes
This commit is contained in:
parent
25d37ec9dd
commit
af8fbc836b
|
|
@ -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" />
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
|
@ -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="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="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="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="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="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.
Loading…
Reference in New Issue