diff --git a/android/BlastoiseNative/app/src/main/AndroidManifest.xml b/android/BlastoiseNative/app/src/main/AndroidManifest.xml index d4dcb17..242bf9e 100644 --- a/android/BlastoiseNative/app/src/main/AndroidManifest.xml +++ b/android/BlastoiseNative/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/MainActivity.kt b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/MainActivity.kt index 5f9a767..97bf80d 100644 --- a/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/MainActivity.kt +++ b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/MainActivity.kt @@ -7,33 +7,38 @@ import android.content.Context import android.content.pm.PackageManager import android.content.res.ColorStateList import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PixelFormat import android.graphics.Typeface import android.graphics.drawable.Drawable -import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper -import android.os.SystemClock +import android.text.Editable +import android.text.InputType import android.text.TextUtils +import android.text.TextWatcher import android.view.Gravity +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.SeekBar import android.widget.TextView -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.Player import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import java.util.concurrent.Executor import kotlin.math.abs import kotlin.math.max import kotlin.math.min -import kotlin.random.Random private class CenteredIconButton(context: Context) : View(context) { private var icon: Drawable? = null @@ -63,10 +68,376 @@ private class CenteredIconButton(context: Context) : View(context) { } } -class MainActivity : Activity(), MusicRoomClient.Listener { - private enum class ScreenMode { RADIO, LIBRARY, PLAYLISTS } +private class PixelCatView(context: Context) : View(context) { + private val paint = Paint().apply { + style = Paint.Style.FILL + isAntiAlias = false + } + private val animator = Handler(Looper.getMainLooper()) + private var frame = 0 + private var progressFraction = 0.5f + private var bodyColor = 0xFF050607.toInt() + private var edgeColor = 0xFFA8E76C.toInt() + private var eyeColor = 0xFFC8FF83.toInt() + private var accentColor = 0xFFFF7AA8.toInt() + private var shadowColor = 0x66374134 + + private val tick = object : Runnable { + override fun run() { + frame = (frame + 1) % 4 + invalidate() + animator.postDelayed(this, 520) + } + } + + init { + setWillNotDraw(false) + isClickable = false + isFocusable = false + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + + fun setProgressFraction(value: Float) { + val next = value.coerceIn(0.04f, 0.96f) + if (abs(progressFraction - next) > 0.002f) { + progressFraction = next + invalidate() + } + } + + fun setPalette(body: Int, edge: Int, eye: Int, accent: Int, shadow: Int) { + bodyColor = body + edgeColor = edge + eyeColor = eye + accentColor = accent + shadowColor = shadow + invalidate() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + animator.removeCallbacks(tick) + animator.post(tick) + } + + override fun onDetachedFromWindow() { + animator.removeCallbacks(tick) + super.onDetachedFromWindow() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (width <= 0 || height <= 0) return + + val gridWidth = 43f + val gridHeight = 25f + val lineClearance = resources.displayMetrics.density * 31f + val availableHeight = (height - paddingTop - paddingBottom).toFloat() - lineClearance + val unit = min( + max(2.4f, resources.displayMetrics.density * 2.9f), + max(2.0f, availableHeight / gridHeight), + ) + val catWidth = gridWidth * unit + val minX = paddingLeft.toFloat() + unit + val maxX = (width - paddingRight).toFloat() - catWidth - unit + val x = if (maxX > minX) minX + (maxX - minX) * progressFraction else minX + val breathe = if (frame == 1) -unit * 0.35f else 0f + val y = ( + height - paddingBottom - lineClearance - gridHeight * unit + breathe + ).coerceAtLeast(paddingTop + unit * 0.5f) + val shadowY = y + gridHeight * unit - unit * 0.8f + + rect(canvas, x + unit * 7f, shadowY, unit * 28f, unit, shadowColor) + drawTail(canvas, x, y, unit) + drawBody(canvas, x, y, unit) + drawHead(canvas, x, y, unit) + drawPurr(canvas, x, y, unit) + } + + private fun drawTail(canvas: Canvas, x: Float, y: Float, unit: Float) { + if (frame % 2 == 0) { + px(canvas, x, y, 6, 16, 4, 1, edgeColor, unit) + px(canvas, x, y, 5, 14, 1, 2, edgeColor, unit) + px(canvas, x, y, 6, 12, 1, 2, edgeColor, unit) + px(canvas, x, y, 7, 11, 2, 1, edgeColor, unit) + px(canvas, x, y, 9, 12, 1, 1, edgeColor, unit) + px(canvas, x, y, 10, 13, 1, 4, edgeColor, unit) + px(canvas, x, y, 11, 17, 4, 1, edgeColor, unit) + px(canvas, x, y, 15, 18, 2, 1, edgeColor, unit) + px(canvas, x, y, 6, 14, 2, 2, bodyColor, unit) + px(canvas, x, y, 7, 12, 2, 1, bodyColor, unit) + px(canvas, x, y, 11, 14, 1, 3, bodyColor, unit) + } else { + px(canvas, x, y, 6, 17, 4, 1, edgeColor, unit) + px(canvas, x, y, 5, 15, 1, 2, edgeColor, unit) + px(canvas, x, y, 5, 13, 1, 2, edgeColor, unit) + px(canvas, x, y, 6, 12, 2, 1, edgeColor, unit) + px(canvas, x, y, 8, 13, 1, 1, edgeColor, unit) + px(canvas, x, y, 9, 14, 1, 4, edgeColor, unit) + px(canvas, x, y, 10, 18, 5, 1, edgeColor, unit) + px(canvas, x, y, 15, 19, 2, 1, edgeColor, unit) + px(canvas, x, y, 6, 15, 2, 2, bodyColor, unit) + px(canvas, x, y, 6, 13, 2, 1, bodyColor, unit) + px(canvas, x, y, 10, 15, 1, 3, bodyColor, unit) + } + } + + private fun drawBody(canvas: Canvas, x: Float, y: Float, unit: Float) { + px(canvas, x, y, 13, 17, 15, 6, bodyColor, unit) + px(canvas, x, y, 15, 15, 8, 2, bodyColor, unit) + px(canvas, x, y, 25, 14, 7, 5, accentColor, unit) + px(canvas, x, y, 28, 19, 4, 3, accentColor, unit) + + px(canvas, x, y, 13, 17, 1, 6, edgeColor, unit) + px(canvas, x, y, 14, 16, 1, 1, edgeColor, unit) + px(canvas, x, y, 15, 15, 8, 1, edgeColor, unit) + px(canvas, x, y, 23, 16, 2, 1, edgeColor, unit) + px(canvas, x, y, 25, 14, 7, 1, edgeColor, unit) + px(canvas, x, y, 32, 15, 1, 8, edgeColor, unit) + px(canvas, x, y, 14, 23, 18, 1, edgeColor, unit) + + px(canvas, x, y, 19, 21, 3, 3, accentColor, unit) + px(canvas, x, y, 34, 21, 3, 3, accentColor, unit) + } + + private fun drawHead(canvas: Canvas, x: Float, y: Float, unit: Float) { + px(canvas, x, y, 21, 6, 16, 11, bodyColor, unit) + px(canvas, x, y, 19, 10, 2, 4, bodyColor, unit) + px(canvas, x, y, 23, 3, 4, 3, bodyColor, unit) + px(canvas, x, y, 34, 3, 3, 3, bodyColor, unit) + + px(canvas, x, y, 21, 6, 16, 1, edgeColor, unit) + px(canvas, x, y, 21, 16, 16, 1, edgeColor, unit) + px(canvas, x, y, 21, 7, 1, 9, edgeColor, unit) + px(canvas, x, y, 36, 7, 1, 9, edgeColor, unit) + px(canvas, x, y, 19, 10, 2, 1, edgeColor, unit) + px(canvas, x, y, 19, 13, 2, 1, edgeColor, unit) + px(canvas, x, y, 23, 3, 4, 1, edgeColor, unit) + px(canvas, x, y, 23, 4, 1, 2, edgeColor, unit) + px(canvas, x, y, 26, 4, 1, 2, edgeColor, unit) + px(canvas, x, y, 34, 3, 3, 1, edgeColor, unit) + px(canvas, x, y, 34, 4, 1, 2, edgeColor, unit) + px(canvas, x, y, 36, 4, 1, 2, edgeColor, unit) + + px(canvas, x, y, 24, 8, 3, 3, eyeColor, unit) + px(canvas, x, y, 32, 8, 3, 3, eyeColor, unit) + px(canvas, x, y, 38, 9, 2, 1, edgeColor, unit) + px(canvas, x, y, 40, 7, 2, 1, edgeColor, unit) + px(canvas, x, y, 38, 12, 2, 1, edgeColor, unit) + px(canvas, x, y, 40, 14, 2, 1, edgeColor, unit) + } + + private fun drawPurr(canvas: Canvas, x: Float, y: Float, unit: Float) { + if (frame <= 1) { + px(canvas, x, y, 28, 1, 1, 1, edgeColor, unit) + px(canvas, x, y, 30, 2, 1, 1, edgeColor, unit) + px(canvas, x, y, 42, 9, 1, 1, edgeColor, unit) + } + } + + private fun px( + canvas: Canvas, + originX: Float, + originY: Float, + x: Int, + y: Int, + width: Int, + height: Int, + color: Int, + unit: Float, + ) { + rect(canvas, originX + x * unit, originY + y * unit, width * unit, height * unit, color) + } + + private fun rect(canvas: Canvas, left: Float, top: Float, width: Float, height: Float, color: Int) { + paint.color = color + canvas.drawRect(left, top, left + width, top + height, paint) + } +} + +private class PixelBoxDrawable( + private val fillColor: Int, + private val strokeColor: Int?, + private val unitPx: Float, + private val strokePx: Float, +) : Drawable() { + private val path = Path() + private val fillPaint = Paint().apply { + style = Paint.Style.FILL + isAntiAlias = false + } + private val strokePaint = Paint().apply { + style = Paint.Style.STROKE + strokeJoin = Paint.Join.MITER + strokeCap = Paint.Cap.SQUARE + isAntiAlias = false + } + private val chipPaint = Paint().apply { + style = Paint.Style.FILL + isAntiAlias = false + } + + override fun draw(canvas: Canvas) { + val b = bounds + if (b.width() <= 0 || b.height() <= 0) return + + val u = unitPx.coerceAtMost(min(b.width(), b.height()) / 4f).coerceAtLeast(1f) + val s = strokePx.coerceAtLeast(1f) + val l = b.left.toFloat() + s / 2f + val t = b.top.toFloat() + s / 2f + val r = b.right.toFloat() - s / 2f + val bottom = b.bottom.toFloat() - s / 2f + + path.reset() + path.moveTo(l + u, t) + path.lineTo(r - (u * 2f), t) + path.lineTo(r - (u * 2f), t + u) + path.lineTo(r, t + u) + path.lineTo(r, bottom - (u * 2f)) + path.lineTo(r - u, bottom - (u * 2f)) + path.lineTo(r - u, bottom) + path.lineTo(l + (u * 2f), bottom) + path.lineTo(l + (u * 2f), bottom - u) + path.lineTo(l, bottom - u) + path.lineTo(l, t + (u * 2f)) + path.lineTo(l + u, t + (u * 2f)) + path.close() + + fillPaint.color = fillColor + canvas.drawPath(path, fillPaint) + + val edge = strokeColor ?: return + strokePaint.color = edge + strokePaint.strokeWidth = s + canvas.drawPath(path, strokePaint) + + chipPaint.color = edge + canvas.drawRect(l, t + (u * 3f), l + s, t + (u * 5f), chipPaint) + canvas.drawRect(r - s, t + (u * 4f), r, t + (u * 7f), chipPaint) + canvas.drawRect(l + (u * 4f), bottom - s, l + (u * 7f), bottom, chipPaint) + canvas.drawRect(r - (u * 7f), t, r - (u * 4f), t + s, chipPaint) + } + + override fun setAlpha(alpha: Int) { + fillPaint.alpha = alpha + strokePaint.alpha = alpha + chipPaint.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + fillPaint.colorFilter = colorFilter + strokePaint.colorFilter = colorFilter + chipPaint.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT +} + +private class SeraphBoxDrawable( + private val fillColor: Int, + private val strokeColor: Int?, + private val unitPx: Float, + private val strokePx: Float, +) : Drawable() { + private val path = Path() + private val fillPaint = Paint().apply { + style = Paint.Style.FILL + isAntiAlias = true + } + private val strokePaint = Paint().apply { + style = Paint.Style.STROKE + strokeJoin = Paint.Join.MITER + strokeCap = Paint.Cap.SQUARE + isAntiAlias = true + } + private val accentPaint = Paint().apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.SQUARE + isAntiAlias = true + } + + override fun draw(canvas: Canvas) { + val b = bounds + if (b.width() <= 0 || b.height() <= 0) return + + val u = unitPx.coerceAtMost(min(b.width(), b.height()) / 5f).coerceAtLeast(1f) + val s = strokePx.coerceAtLeast(1f) + val l = b.left.toFloat() + s / 2f + val t = b.top.toFloat() + s / 2f + val r = b.right.toFloat() - s / 2f + val bottom = b.bottom.toFloat() - s / 2f + + path.reset() + path.moveTo(l + u, t) + path.lineTo(r - u, t) + path.lineTo(r, t + u) + path.lineTo(r, bottom - u) + path.lineTo(r - u, bottom) + path.lineTo(l + u, bottom) + path.lineTo(l, bottom - u) + path.lineTo(l, t + u) + path.close() + + fillPaint.color = fillColor + canvas.drawPath(path, fillPaint) + + val edge = strokeColor ?: return + strokePaint.color = edge + strokePaint.strokeWidth = s + canvas.drawPath(path, strokePaint) + + accentPaint.color = edge + accentPaint.alpha = 150 + accentPaint.strokeWidth = max(1f, s / 1.4f) + canvas.drawLine(l + u * 2f, t + u * 1.35f, r - u * 2f, t + u * 1.35f, accentPaint) + canvas.drawLine(l + u * 2f, bottom - u * 1.35f, r - u * 2f, bottom - u * 1.35f, accentPaint) + accentPaint.alpha = 255 + } + + override fun setAlpha(alpha: Int) { + fillPaint.alpha = alpha + strokePaint.alpha = alpha + accentPaint.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + fillPaint.colorFilter = colorFilter + strokePaint.colorFilter = colorFilter + accentPaint.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT +} + +private enum class FrameStyle { PIXEL, SERAPH } + +private data class ThemeSpec( + val key: String, + val label: String, + val bg: Int, + val panel: Int, + val panel2: Int, + val stroke: Int, + val text: Int, + val muted: Int, + val accent: Int, + val green: Int, + val amber: Int, + val red: Int, + val purple: Int, + val displayFont: Int, + val bodyFont: Int, + val bodyStrongFont: Int, + val monoFont: Int, + val frameStyle: FrameStyle, + val lightSystemBars: Boolean, +) + +class MainActivity : Activity(), PlaybackSnapshotListener { + private enum class ScreenMode { ROOMS, QUEUE, PEOPLE, LIBRARY, PLAYLISTS } - private lateinit var client: MusicRoomClient private val mainHandler = Handler(Looper.getMainLooper()) private var controllerFuture: com.google.common.util.concurrent.ListenableFuture? = null private var controller: MediaController? = null @@ -77,10 +448,19 @@ class MainActivity : Activity(), MusicRoomClient.Listener { private lateinit var monoFont: Typeface private lateinit var serverInput: EditText + private lateinit var authPanel: LinearLayout + private lateinit var appShell: LinearLayout + private lateinit var usernameInput: EditText + private lateinit var passwordInput: EditText + private lateinit var authMessage: TextView private lateinit var statusBadge: TextView private lateinit var radioTab: TextView + private lateinit var queueTab: TextView + private lateinit var peopleTab: TextView private lateinit var libraryTab: TextView private lateinit var playlistTab: TextView + private lateinit var themeButton: TextView + private lateinit var angelBanner: ImageView private lateinit var modeLabel: TextView private lateinit var sourceLabel: TextView private lateinit var titleText: TextView @@ -88,6 +468,7 @@ class MainActivity : Activity(), MusicRoomClient.Listener { private lateinit var elapsedText: TextView private lateinit var durationText: TextView private lateinit var progressSeek: SeekBar + private lateinit var pixelCat: PixelCatView private lateinit var prevButton: CenteredIconButton private lateinit var rewindButton: CenteredIconButton private lateinit var playButton: CenteredIconButton @@ -96,15 +477,22 @@ class MainActivity : Activity(), MusicRoomClient.Listener { private lateinit var playbackModeButton: TextView private lateinit var queueCurrentButton: TextView private lateinit var playNextCurrentButton: TextView + private lateinit var exitButton: TextView private lateinit var syncBadge: TextView private lateinit var driftBadge: TextView private lateinit var sessionBadge: TextView private lateinit var radioContent: LinearLayout + private lateinit var queueContent: LinearLayout + private lateinit var peopleContent: LinearLayout private lateinit var libraryContent: LinearLayout private lateinit var playlistContent: LinearLayout private lateinit var debugText: TextView - private var screenMode = ScreenMode.RADIO + private var screenMode = ScreenMode.ROOMS + private var authState = AuthState.CHECKING + private var currentUser: UserSession? = null + private var currentRoomListeners: List = emptyList() + private var libraryQuery = "" private var currentChannelId: String? = null private var currentTrackId: String? = null private var localLibraryIndex = -1 @@ -127,10 +515,13 @@ class MainActivity : Activity(), MusicRoomClient.Listener { private var lastTrackDuration = 0.0 private var lastServerTimestampMs = 0L private var lastStateRealtimeMs = 0L - private var pendingState: ChannelState? = null + private var lastExpectedPositionMs = 0L + private var lastPlayerPositionMs = 0L + private var lastDriftMs = 0L + private var lastPlaybackState = "none" + private var lastPlayerIsPlaying = false private var userSeeking = false private val debugEvents = mutableListOf() - private val playbackModes = listOf("once", "repeat-all", "repeat-one", "shuffle") private val ticker = object : Runnable { override fun run() { @@ -139,192 +530,198 @@ class MainActivity : Activity(), MusicRoomClient.Listener { } } + private val renderLibraryRunnable = Runnable { + renderLibrary() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - window.statusBarColor = BG - window.navigationBarColor = BG - displayFont = resources.getFont(R.font.rajdhani_bold) - bodyFont = resources.getFont(R.font.rajdhani_regular) - bodyStrongFont = resources.getFont(R.font.rajdhani_semibold) - monoFont = resources.getFont(R.font.jetbrains_mono_regular) SessionStore.load(this) - client = MusicRoomClient(this, this) + ACTIVE_THEME = themeForKey(SessionStore.themeKey) + applySystemBars() + loadThemeFonts() maybeRequestNotificationPermission() buildUi() + PlaybackBridge.register(this) connectMediaController() mainHandler.post(ticker) - mainHandler.post { connectToServer() } } override fun onDestroy() { mainHandler.removeCallbacks(ticker) - client.close() + mainHandler.removeCallbacks(renderLibraryRunnable) + PlaybackBridge.unregister(this) controllerFuture?.let { MediaController.releaseFuture(it) } controllerFuture = null controller = null super.onDestroy() } - override fun onStatus(message: String) = runOnUiThread { - if (screenMode == ScreenMode.LIBRARY && message == "Disconnected") { - lastStatus = "Library" - updateDeck() - return@runOnUiThread - } - lastStatus = message - updateDeck() - } + override fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) = runOnUiThread { + val authChanged = authState != snapshot.authState || currentUser != snapshot.currentUser + val stationsChanged = lastChannels != snapshot.channels || + lastQueue != snapshot.queue || + currentChannelId != snapshot.currentChannelId || + lastCurrentIndex != snapshot.currentIndex || + lastQueueLoaded != snapshot.queueLoaded + val peopleChanged = currentRoomListeners != snapshot.currentRoomListeners + val libraryChanged = libraryTracks !== snapshot.libraryTracks || + libraryLoaded != snapshot.libraryLoaded || + localLibraryIndex != snapshot.localLibraryIndex + val playlistsChanged = myPlaylists != snapshot.myPlaylists || + sharedPlaylists != snapshot.sharedPlaylists || + playlistsLoaded != snapshot.playlistsLoaded || + selectedPlaylistId != snapshot.selectedPlaylistId || + selectedPlaylist != snapshot.selectedPlaylist - override fun onDebug(message: String) = runOnUiThread { - addDebugEvent(message) - updateDeck() - } + authState = snapshot.authState + currentUser = snapshot.currentUser + lastStatus = snapshot.status + lastChannels = snapshot.channels + libraryTracks = snapshot.libraryTracks + libraryLoaded = snapshot.libraryLoaded + myPlaylists = snapshot.myPlaylists + sharedPlaylists = snapshot.sharedPlaylists + playlistsLoaded = snapshot.playlistsLoaded + selectedPlaylistId = snapshot.selectedPlaylistId + selectedPlaylist = snapshot.selectedPlaylist + currentChannelId = snapshot.currentChannelId + currentTrackId = snapshot.currentTrackId + localLibraryIndex = snapshot.localLibraryIndex + currentRoomListeners = snapshot.currentRoomListeners + lastPaused = snapshot.paused + lastQueue = snapshot.queue + lastQueueLoaded = snapshot.queueLoaded + lastCurrentIndex = snapshot.currentIndex + playbackMode = snapshot.playbackMode + lastChannelName = snapshot.channelName + lastTrackTitle = snapshot.trackTitle + lastTrackDuration = snapshot.trackDuration + lastServerTimestampMs = snapshot.serverTimestampMs + lastStateRealtimeMs = snapshot.stateRealtimeMs + lastExpectedPositionMs = snapshot.expectedPositionMs + lastPlayerPositionMs = snapshot.playerPositionMs + lastDriftMs = snapshot.driftMs + lastPlaybackState = snapshot.playbackState + lastPlayerIsPlaying = snapshot.isPlaying + debugEvents.clear() + debugEvents.addAll(snapshot.debugEvents) - override fun onError(message: String) = runOnUiThread { - if (screenMode == ScreenMode.LIBRARY && message.startsWith("Disconnected")) { - lastStatus = "Library" - updateDeck() - return@runOnUiThread - } - lastStatus = message - addDebugEvent("ERROR $message") - updateDeck() - } - - override fun onChannels(channels: List) = runOnUiThread { - lastChannels = channels - renderStations() - val target = currentChannelId - ?: channels.firstOrNull { it.isDefault }?.id - ?: channels.firstOrNull()?.id - if (target != null && currentChannelId == null && isRadioBacked()) { - joinChannel(target, screenMode) - } - updateDeck() - } - - override fun onLibrary(tracks: List) = runOnUiThread { - libraryTracks = tracks - libraryLoaded = true - renderLibrary() - renderPlaylists() - if (screenMode == ScreenMode.LIBRARY && lastTrackTitle == "No track") { - lastStatus = "Library ready" - } - updateDeck() - } - - override fun onPlaylists(playlists: PlaylistBundle) = runOnUiThread { - 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 - } - renderPlaylists() - selectedPlaylistId?.let { client.loadPlaylist(it) } - if (screenMode == ScreenMode.PLAYLISTS) { - lastStatus = "Playlists" - } - updateDeck() - } - - override fun onPlaylistDetail(playlist: Playlist) = runOnUiThread { - selectedPlaylist = playlist - selectedPlaylistId = playlist.id - renderPlaylists() - updateDeck() - } - - override fun onSwitched(channelId: String) = runOnUiThread { - currentChannelId = channelId - lastStatus = "Tuned" - renderStations() - updateDeck() - } - - override fun onChannelState(state: ChannelState) = runOnUiThread { - if (!isRadioBacked()) return@runOnUiThread - pendingState = state - 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 + if (authChanged) renderAuthState() + if (stationsChanged) { renderStations() - updateDeck() - return@runOnUiThread - } - - lastTrackTitle = track.title - lastTrackDuration = track.duration - renderStations() - - val player = controller - if (player == null) { - updateDeck() - return@runOnUiThread - } - - 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() - updateDeck() - return@runOnUiThread - } - - 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() + renderQueue() } + if (peopleChanged || stationsChanged) renderPeople() + if (libraryChanged) renderLibrary() + if (playlistsChanged || libraryChanged) renderPlaylists() updateDeck() } private fun buildUi() { + val baseLeftPadding = dp(18) + val baseTopPadding = dp(16) + val baseRightPadding = dp(18) + val baseBottomPadding = dp(24) val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL - setPadding(dp(18), dp(16), dp(18), dp(24)) + setPadding(baseLeftPadding, baseTopPadding, baseRightPadding, baseBottomPadding) + setBackgroundColor(Color.TRANSPARENT) + } + root.setOnApplyWindowInsetsListener { view, insets -> + view.setPadding( + baseLeftPadding + insets.systemWindowInsetLeft, + baseTopPadding + insets.systemWindowInsetTop, + baseRightPadding + insets.systemWindowInsetRight, + baseBottomPadding + insets.systemWindowInsetBottom, + ) + insets + } + + val bodyShell = FrameLayout(this).apply { setBackgroundColor(BG) } + val showAngelBody = ACTIVE_THEME.key == "seraph" + val bodyArt = ImageView(this).apply { + setImageResource(R.drawable.anime_angel_banner) + scaleType = ImageView.ScaleType.CENTER_CROP + alpha = if (showAngelBody) 0.82f else 0f + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + bodyShell.addView(bodyArt, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + )) + bodyShell.addView(View(this).apply { + setBackgroundColor(if (showAngelBody) withAlpha(BG, 174) else BG) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + )) val scroll = ScrollView(this).apply { isFillViewport = true - setBackgroundColor(BG) + setBackgroundColor(Color.TRANSPARENT) addView(root) } + bodyShell.addView(scroll, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + )) + scroll.post { root.requestApplyInsets() } val header = row().apply { gravity = Gravity.CENTER_VERTICAL } - header.addView(text("BLASTOISE", 34f, TEXT, Typeface.BOLD), weightWrap(1f)) - statusBadge = tag("Starting", AMBER, BG) - header.addView(statusBadge) + header.addView(text("BLASTOISE", 22f, TEXT, Typeface.BOLD).apply { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, weightWrap(1f)) + statusBadge = tag("Starting", AMBER, BG).apply { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + header.addView(statusBadge, LinearLayout.LayoutParams(dp(94), dp(42)).apply { + leftMargin = dp(6) + }) + themeButton = button( + "", + PANEL2, + ACCENT, + Typeface.BOLD, + if (ACTIVE_THEME.key == "cat") R.drawable.ic_cat else R.drawable.ic_halo, + 18, + ).apply { + contentDescription = "Switch theme" + setOnClickListener { cycleTheme() } + } + header.addView(themeButton, LinearLayout.LayoutParams(dp(46), dp(42)).apply { + leftMargin = dp(6) + }) + exitButton = button("EXIT", RED, TEXT, Typeface.BOLD, R.drawable.ic_power, 17).apply { + contentDescription = "Stop and exit" + setOnClickListener { stopAndExit() } + setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_UP) stopAndExit() + true + } + } + header.addView(exitButton, LinearLayout.LayoutParams(dp(92), dp(42)).apply { + leftMargin = dp(6) + }) root.addView(header, matchWrap()) + authPanel = panel().apply { + setPadding(dp(14), dp(14), dp(14), dp(14)) + } + authPanel.addView(text("ROOM ACCESS", 20f, TEXT, Typeface.BOLD), matchWrap()) + authMessage = text("Sign in to join rooms, see listeners, and change room queues.", 14f, MUTED).apply { + setPadding(0, dp(3), 0, dp(10)) + } + authPanel.addView(authMessage, matchWrap()) + val serverRow = row().apply { gravity = Gravity.CENTER_VERTICAL - setPadding(0, dp(14), 0, dp(10)) + setPadding(0, 0, 0, dp(10)) } serverInput = EditText(this).apply { setSingleLine(true) @@ -338,23 +735,72 @@ class MainActivity : Activity(), MusicRoomClient.Listener { background = box(PANEL, STROKE) } serverRow.addView(serverInput, LinearLayout.LayoutParams(0, dp(48), 1f)) - serverRow.addView(button("CONNECT", PANEL2, TEXT, iconRes = R.drawable.ic_connect).apply { + serverRow.addView(button("CHECK", PANEL2, TEXT, iconRes = R.drawable.ic_connect).apply { setOnClickListener { connectToServer() } }, LinearLayout.LayoutParams(dp(132), dp(48)).apply { leftMargin = dp(8) }) - root.addView(serverRow, matchWrap()) + authPanel.addView(serverRow, matchWrap()) + + usernameInput = EditText(this).apply { + setSingleLine(true) + textSize = 15f + typeface = bodyFont + setTextColor(TEXT) + setHintTextColor(MUTED) + hint = "Username" + setPadding(dp(12), 0, dp(12), 0) + background = box(PANEL2, STROKE) + } + authPanel.addView(usernameInput, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48))) + + passwordInput = EditText(this).apply { + setSingleLine(true) + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + textSize = 15f + typeface = bodyFont + setTextColor(TEXT) + setHintTextColor(MUTED) + hint = "Password" + setPadding(dp(12), 0, dp(12), 0) + background = box(PANEL2, STROKE) + } + authPanel.addView(passwordInput, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48)).apply { + topMargin = dp(8) + }) + + val authButtons = row().apply { + gravity = Gravity.CENTER_VERTICAL + setPadding(0, dp(10), 0, 0) + } + authButtons.addView(button("SIGN IN", GREEN, BG, Typeface.BOLD, R.drawable.ic_connect).apply { + setOnClickListener { signIn() } + }, LinearLayout.LayoutParams(0, dp(50), 1f).apply { rightMargin = dp(5) }) + authButtons.addView(button("SIGN UP", ACCENT, BG, Typeface.BOLD, R.drawable.ic_queue_add).apply { + setOnClickListener { signUp() } + }, LinearLayout.LayoutParams(0, dp(50), 1f).apply { leftMargin = dp(5) }) + authPanel.addView(authButtons, matchWrap()) + root.addView(authPanel, matchWrapWithTop(dp(12))) + + appShell = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + root.addView(appShell, matchWrap()) val tabs = row().apply { gravity = Gravity.CENTER_VERTICAL background = box(PANEL, STROKE) setPadding(dp(4), dp(4), dp(4), dp(4)) } - radioTab = tab("RADIO", true, R.drawable.ic_radio).apply { setOnClickListener { switchMode(ScreenMode.RADIO) } } - libraryTab = tab("LIBRARY", false, R.drawable.ic_library).apply { setOnClickListener { switchMode(ScreenMode.LIBRARY) } } - playlistTab = tab("PLAYLISTS", false, R.drawable.ic_playlist).apply { setOnClickListener { switchMode(ScreenMode.PLAYLISTS) } } + radioTab = tab("ROOMS", true, R.drawable.ic_radio).apply { setOnClickListener { switchMode(ScreenMode.ROOMS) } } + queueTab = tab("QUEUE", false, R.drawable.ic_queue).apply { setOnClickListener { switchMode(ScreenMode.QUEUE) } } + peopleTab = tab("PEOPLE", false, R.drawable.ic_people).apply { setOnClickListener { switchMode(ScreenMode.PEOPLE) } } + libraryTab = tab("LIB", false, R.drawable.ic_library).apply { setOnClickListener { switchMode(ScreenMode.LIBRARY) } } + playlistTab = tab("LISTS", false, R.drawable.ic_playlist).apply { setOnClickListener { switchMode(ScreenMode.PLAYLISTS) } } tabs.addView(radioTab, LinearLayout.LayoutParams(0, dp(44), 1f)) + tabs.addView(queueTab, LinearLayout.LayoutParams(0, dp(44), 1f)) + tabs.addView(peopleTab, LinearLayout.LayoutParams(0, dp(44), 1f)) tabs.addView(libraryTab, LinearLayout.LayoutParams(0, dp(44), 1f)) tabs.addView(playlistTab, LinearLayout.LayoutParams(0, dp(44), 1f)) - root.addView(tabs, matchWrapWithTop(dp(2))) + appShell.addView(tabs, matchWrapWithTop(dp(12))) val deck = panel().apply { setPadding(dp(16), dp(16), dp(16), dp(14)) @@ -364,7 +810,7 @@ class MainActivity : Activity(), MusicRoomClient.Listener { text = "3001" textSize = 22f typeface = displayFont - letterSpacing = 0.04f + letterSpacing = 0f gravity = Gravity.CENTER setTextColor(BG) background = box(ACCENT) @@ -387,6 +833,20 @@ class MainActivity : Activity(), MusicRoomClient.Listener { deckTop.addView(deckMeta, weightWrap(1f)) deck.addView(deckTop, matchWrap()) + angelBanner = ImageView(this).apply { + setImageResource(R.drawable.anime_angel_banner) + scaleType = ImageView.ScaleType.CENTER_CROP + cropToPadding = true + setPadding(dp(3), dp(3), dp(3), dp(3)) + background = box(PANEL2, STROKE) + alpha = 0.96f + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + visibility = if (ACTIVE_THEME.key == "seraph") View.VISIBLE else View.GONE + } + deck.addView(angelBanner, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(116)).apply { + topMargin = dp(14) + }) + titleText = text("No track", 28f, TEXT, Typeface.BOLD).apply { setPadding(0, dp(18), 0, dp(10)) maxLines = 3 @@ -416,9 +876,9 @@ class MainActivity : Activity(), MusicRoomClient.Listener { if (duration > 0.0) { val target = duration * seekBar.progress.toDouble() / seekBar.max.toDouble() if (isRadioBacked()) { - client.sendSeek(target) + playbackService()?.seekTo(target) } else { - controller?.seekTo((target * 1000.0).toLong()) + playbackService()?.seekTo(target) } } userSeeking = false @@ -427,7 +887,27 @@ class MainActivity : Activity(), MusicRoomClient.Listener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) = Unit }) } - deck.addView(progressSeek, matchWrap()) + val progressStack = FrameLayout(this) + progressStack.addView(progressSeek, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(42), + ).apply { + gravity = Gravity.BOTTOM + }) + pixelCat = PixelCatView(this).apply { + setPadding(dp(8), 0, dp(8), 0) + visibility = if (ACTIVE_THEME.key == "cat") View.VISIBLE else View.GONE + } + progressStack.addView(pixelCat, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(96), + ).apply { + gravity = Gravity.BOTTOM + }) + deck.addView(progressStack, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(104), + )) val deckFace = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -437,11 +917,11 @@ class MainActivity : Activity(), MusicRoomClient.Listener { val transportLabel = row().apply { gravity = Gravity.CENTER_VERTICAL } transportLabel.addView(text("TRANSPORT", 12f, MUTED, Typeface.BOLD).apply { - letterSpacing = 0.08f + letterSpacing = 0f }, weightWrap(1f)) transportLabel.addView(text("TRACK / SEEK", 12f, MUTED, Typeface.BOLD).apply { gravity = Gravity.END - letterSpacing = 0.08f + letterSpacing = 0f }, weightWrap(1f)) deckFace.addView(transportLabel, matchWrap()) @@ -472,6 +952,7 @@ class MainActivity : Activity(), MusicRoomClient.Listener { queueControls.addView(queueCurrentButton, wideControlParams()) queueControls.addView(playNextCurrentButton, wideControlParams()) deckFace.addView(queueControls, matchWrap()) + deck.addView(deckFace, matchWrapWithTop(dp(10))) val meters = row().apply { gravity = Gravity.CENTER_VERTICAL } @@ -481,15 +962,20 @@ class MainActivity : Activity(), MusicRoomClient.Listener { meters.addView(syncBadge) meters.addView(driftBadge, leftGap()) meters.addView(sessionBadge, leftGap()) + sessionBadge.setOnClickListener { signOut() } deck.addView(meters, matchWrap()) - root.addView(deck, matchWrapWithTop(dp(12))) + appShell.addView(deck, matchWrapWithTop(dp(12))) radioContent = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + queueContent = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + peopleContent = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } libraryContent = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } playlistContent = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } - root.addView(radioContent, matchWrapWithTop(dp(16))) - root.addView(libraryContent, matchWrapWithTop(dp(16))) - root.addView(playlistContent, matchWrapWithTop(dp(16))) + appShell.addView(radioContent, matchWrapWithTop(dp(16))) + appShell.addView(queueContent, matchWrapWithTop(dp(16))) + appShell.addView(peopleContent, matchWrapWithTop(dp(16))) + appShell.addView(libraryContent, matchWrapWithTop(dp(16))) + appShell.addView(playlistContent, matchWrapWithTop(dp(16))) val debugPanel = panel().apply { setPadding(dp(14), dp(12), dp(14), dp(12)) @@ -503,41 +989,75 @@ class MainActivity : Activity(), MusicRoomClient.Listener { text = "Starting..." } debugPanel.addView(debugText, matchWrap()) - root.addView(debugPanel, matchWrapWithTop(dp(16))) + appShell.addView(debugPanel, matchWrapWithTop(dp(16))) - setContentView(scroll) + setContentView(bodyShell) renderMode() + renderAuthState() } private fun connectToServer() { - SessionStore.setBaseUrl(serverInput.text.toString()) + val service = playbackService() ?: return + service.connectToServer(serverInput.text.toString()) serverInput.setText(SessionStore.serverBaseUrl) + if (screenMode == ScreenMode.LIBRARY) { + service.loadLibrary() + } else if (screenMode == ScreenMode.PLAYLISTS) { + service.loadPlaylists() + } + } + + private fun signIn() { + playbackService()?.signIn( + serverInput.text.toString(), + usernameInput.text.toString(), + passwordInput.text.toString(), + ) + } + + private fun signUp() { + playbackService()?.signUp( + serverInput.text.toString(), + usernameInput.text.toString(), + passwordInput.text.toString(), + ) + } + + private fun signOut() { + playbackService()?.signOut() + } + + private fun stopAndExit() { + playbackService()?.stopPlaybackAndExit() + finishAndRemoveTask() + } + + private fun loadThemeFonts() { + displayFont = resources.getFont(ACTIVE_THEME.displayFont) + bodyFont = resources.getFont(ACTIVE_THEME.bodyFont) + bodyStrongFont = resources.getFont(ACTIVE_THEME.bodyStrongFont) + monoFont = resources.getFont(ACTIVE_THEME.monoFont) + } + + private fun applySystemBars() { + window.statusBarColor = BG + window.navigationBarColor = BG + var flags = 0 + if (ACTIVE_THEME.lightSystemBars && Build.VERSION.SDK_INT >= 23) { + flags = flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } + if (ACTIVE_THEME.lightSystemBars && Build.VERSION.SDK_INT >= 26) { + flags = flags or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + } + window.decorView.systemUiVisibility = flags + } + + private fun cycleTheme() { + val currentIndex = THEMES.indexOfFirst { it.key == ACTIVE_THEME.key }.coerceAtLeast(0) + ACTIVE_THEME = THEMES[(currentIndex + 1) % THEMES.size] + SessionStore.themeKey = ACTIVE_THEME.key SessionStore.save(this) - currentChannelId = null - currentTrackId = null - localLibraryIndex = -1 - lastQueue = emptyList() - lastQueueLoaded = false - lastCurrentIndex = 0 - playbackMode = "repeat-all" - lastChannelName = "No channel" - lastTrackTitle = "No track" - lastTrackDuration = 0.0 - lastServerTimestampMs = 0L - lastStateRealtimeMs = 0L - addDebugEvent("connect ${SessionStore.serverBaseUrl}") - lastStatus = "Loading" - renderStations() - renderLibrary() - renderPlaylists() - updateDeck() - client.loadChannels() - if (screenMode == ScreenMode.LIBRARY || screenMode == ScreenMode.PLAYLISTS) { - client.loadLibrary() - } - if (screenMode == ScreenMode.PLAYLISTS) { - client.loadPlaylists() - } + recreate() } private fun switchMode(mode: ScreenMode) { @@ -546,36 +1066,33 @@ class MainActivity : Activity(), MusicRoomClient.Listener { renderMode() when (mode) { ScreenMode.LIBRARY -> { - client.close() - controller?.pause() - controller?.clearMediaItems() - currentTrackId = null - localLibraryIndex = -1 - lastChannelName = "Library" - lastTrackTitle = "Choose a track" - lastTrackDuration = 0.0 - lastPaused = true - lastServerTimestampMs = 0L - lastStateRealtimeMs = 0L - lastStatus = if (libraryLoaded) "Library" else "Loading library" - if (!libraryLoaded) client.loadLibrary() + playbackService()?.enterLibraryMode() } - ScreenMode.RADIO -> { - lastStatus = "Radio" - currentTrackId = null + ScreenMode.ROOMS -> { + lastStatus = "Rooms" localLibraryIndex = -1 val target = currentChannelId ?: lastChannels.firstOrNull { it.isDefault }?.id ?: lastChannels.firstOrNull()?.id - if (target != null) joinChannel(target, ScreenMode.RADIO) + if (target != null) { + joinChannel(target, ScreenMode.ROOMS) + } else { + playbackService()?.ensureRadioMode() + } + } + ScreenMode.QUEUE -> { + lastStatus = "Room queue" + localLibraryIndex = -1 + playbackService()?.ensureRadioMode() + } + ScreenMode.PEOPLE -> { + lastStatus = "Room people" + localLibraryIndex = -1 + playbackService()?.ensureRadioMode() } ScreenMode.PLAYLISTS -> { - lastStatus = if (playlistsLoaded) "Playlists" else "Loading playlists" + lastStatus = "Playlists" localLibraryIndex = -1 - if (!libraryLoaded) client.loadLibrary() - if (!playlistsLoaded) client.loadPlaylists() - val target = currentChannelId ?: lastChannels.firstOrNull { it.isDefault }?.id ?: lastChannels.firstOrNull()?.id - if (target != null && currentTrackId == null) { - joinChannel(target, ScreenMode.PLAYLISTS) - } + playbackService()?.ensureRadioMode() + playbackService()?.loadPlaylists() } } updateDeck() @@ -583,24 +1100,34 @@ class MainActivity : Activity(), MusicRoomClient.Listener { private fun renderMode() { if (!::radioContent.isInitialized) return - radioTab.background = box(if (screenMode == ScreenMode.RADIO) ACCENT else PANEL) - radioTab.setTextColor(if (screenMode == ScreenMode.RADIO) BG else MUTED) - setButtonIcon(radioTab, R.drawable.ic_radio, if (screenMode == ScreenMode.RADIO) BG else MUTED) + radioTab.background = box(if (screenMode == ScreenMode.ROOMS) ACCENT else PANEL) + radioTab.setTextColor(if (screenMode == ScreenMode.ROOMS) BG else MUTED) + setButtonIcon(radioTab, R.drawable.ic_radio, if (screenMode == ScreenMode.ROOMS) BG else MUTED) + queueTab.background = box(if (screenMode == ScreenMode.QUEUE) ACCENT else PANEL) + queueTab.setTextColor(if (screenMode == ScreenMode.QUEUE) BG else MUTED) + setButtonIcon(queueTab, R.drawable.ic_queue, if (screenMode == ScreenMode.QUEUE) BG else MUTED) + peopleTab.background = box(if (screenMode == ScreenMode.PEOPLE) ACCENT else PANEL) + peopleTab.setTextColor(if (screenMode == ScreenMode.PEOPLE) BG else MUTED) + setButtonIcon(peopleTab, R.drawable.ic_people, if (screenMode == ScreenMode.PEOPLE) BG else MUTED) libraryTab.background = box(if (screenMode == ScreenMode.LIBRARY) ACCENT else PANEL) libraryTab.setTextColor(if (screenMode == ScreenMode.LIBRARY) BG else MUTED) setButtonIcon(libraryTab, R.drawable.ic_library, if (screenMode == ScreenMode.LIBRARY) BG else MUTED) playlistTab.background = box(if (screenMode == ScreenMode.PLAYLISTS) ACCENT else PANEL) playlistTab.setTextColor(if (screenMode == ScreenMode.PLAYLISTS) BG else MUTED) setButtonIcon(playlistTab, R.drawable.ic_playlist, if (screenMode == ScreenMode.PLAYLISTS) BG else MUTED) - radioContent.visibility = if (screenMode == ScreenMode.RADIO) View.VISIBLE else View.GONE + radioContent.visibility = if (screenMode == ScreenMode.ROOMS) View.VISIBLE else View.GONE + queueContent.visibility = if (screenMode == ScreenMode.QUEUE) View.VISIBLE else View.GONE + peopleContent.visibility = if (screenMode == ScreenMode.PEOPLE) View.VISIBLE else View.GONE libraryContent.visibility = if (screenMode == ScreenMode.LIBRARY) View.VISIBLE else View.GONE playlistContent.visibility = if (screenMode == ScreenMode.PLAYLISTS) View.VISIBLE else View.GONE renderStations() + renderQueue() + renderPeople() renderLibrary() renderPlaylists() } - private fun joinChannel(channelId: String, targetMode: ScreenMode = ScreenMode.RADIO) { + private fun joinChannel(channelId: String, targetMode: ScreenMode = ScreenMode.ROOMS) { screenMode = targetMode renderMode() currentChannelId = channelId @@ -608,230 +1135,85 @@ class MainActivity : Activity(), MusicRoomClient.Listener { lastStatus = "Joining" renderStations() updateDeck() - client.connectChannel(channelId) + playbackService()?.joinChannel(channelId) } private fun playLibraryTrack(index: Int) { - if (index !in libraryTracks.indices) return - screenMode = ScreenMode.LIBRARY - renderMode() - 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 - controller?.run { - setMediaItem(buildMediaItem(track), 0L) - applyLocalPlaybackMode() - prepare() - play() - } - addDebugEvent("local play ${track.title.take(40)}") - renderLibrary() - updateDeck() - } - - private fun buildMediaItem(track: Track): MediaItem { - val metadata = MediaMetadata.Builder() - .setTitle(track.title) - .build() - return MediaItem.Builder() - .setMediaId(track.id) - .setUri(SessionStore.trackUrl(track.id)) - .setMediaMetadata(metadata) - .build() + val track = libraryTracks.getOrNull(index) ?: return + playbackService()?.queueTrack(track, false) } private fun cyclePlaybackMode() { - val currentIndex = playbackModes.indexOf(playbackMode).coerceAtLeast(0) - val nextMode = playbackModes[(currentIndex + 1) % playbackModes.size] - playbackMode = nextMode - if (isRadioBacked()) { - val channelId = currentChannelId ?: run { - lastStatus = "No channel" - updateDeck() - return - } - client.setPlaybackMode(channelId, nextMode) - } else { - applyLocalPlaybackMode() - lastStatus = "Mode ${modeLabel(nextMode)}" - } - updateDeck() + playbackService()?.cyclePlaybackMode() } private fun queueCurrentTrack(playNext: Boolean) { - val trackId = currentTrackId ?: libraryTracks.getOrNull(localLibraryIndex)?.id - if (trackId == null) { - lastStatus = "No track" - updateDeck() - return - } - queueTrackIds(listOf(trackId), playNext) + playbackService()?.queueCurrentTrack(playNext) } private fun queueTrack(track: Track, playNext: Boolean) { - queueTrackIds(listOf(track.id), playNext) + playbackService()?.queueTrack(track, playNext) } private fun queueTrackIds(trackIds: List, playNext: Boolean) { - val channelId = currentChannelId ?: lastChannels.firstOrNull { it.isDefault }?.id ?: lastChannels.firstOrNull()?.id - if (channelId == null) { - lastStatus = "No channel" - updateDeck() - return - } - currentChannelId = channelId - val insertAt = if (playNext) (lastCurrentIndex + 1).coerceAtLeast(0) else null - client.addTracksToQueue(channelId, trackIds, insertAt) + playbackService()?.queueTrackIds(trackIds, playNext) } private fun addPlaylistToQueue(playlist: Playlist, playNext: Boolean) { - if (playlist.trackIds.isEmpty()) { - lastStatus = "Empty playlist" - updateDeck() - return - } - queueTrackIds(playlist.trackIds, playNext) + playbackService()?.addPlaylistToQueue(playlist, playNext) } private fun selectPlaylist(playlistId: String) { selectedPlaylistId = playlistId selectedPlaylist = null - renderPlaylists() - client.loadPlaylist(playlistId) + playbackService()?.loadPlaylist(playlistId) } private fun togglePlay() { - if (isRadioBacked()) { - client.sendAction(if (lastPaused) "unpause" else "pause") - return - } - val player = controller ?: return - if (player.isPlaying) { - player.pause() - lastPaused = true - } else { - if (localLibraryIndex < 0 && libraryTracks.isNotEmpty()) { - playLibraryTrack(0) - return - } - player.play() - lastPaused = false - } - updateDeck() + playbackService()?.togglePlay() } private fun previous() { - if (isRadioBacked()) { - 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) - } + playbackService()?.previous() } private fun next() { - if (isRadioBacked()) { - jumpRadio(1) - } else if (libraryTracks.isNotEmpty()) { - val nextIndex = nextLibraryIndex() - playLibraryTrack(nextIndex) - } + playbackService()?.next() } private fun seekBy(seconds: Int) { - val player = controller ?: return - 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) - if (isRadioBacked()) { - client.sendSeek(targetMs / 1000.0) - } else { - player.seekTo(targetMs) - } - addDebugEvent("seek ${seconds}s") - updateDeck() + playbackService()?.seekBy(seconds) } private fun stopPlayback() { - val player = controller ?: return - player.pause() - player.seekTo(0L) - lastPaused = true - if (isRadioBacked()) { - client.sendAction("pause") - client.sendSeek(0.0) - } - addDebugEvent("stop") - updateDeck() + playbackService()?.stopPlayback() } private fun jumpRadio(delta: Int) { - if (lastQueue.isEmpty()) return - val target = (lastCurrentIndex + delta + lastQueue.size) % lastQueue.size - client.sendJump(target) + if (delta < 0) playbackService()?.previous() else playbackService()?.next() } private fun isRadioBacked(): Boolean { - return screenMode == ScreenMode.RADIO || screenMode == ScreenMode.PLAYLISTS + return true } - private fun applyLocalPlaybackMode() { - controller?.repeatMode = if (playbackMode == "repeat-one") { - Player.REPEAT_MODE_ONE - } else { - Player.REPEAT_MODE_OFF + private fun playbackService(): PlaybackService? { + return PlaybackBridge.service + } + + private fun renderAuthState() { + if (!::authPanel.isInitialized || !::appShell.isInitialized) return + val signedIn = authState == AuthState.SIGNED_IN && currentUser?.isSignedIn == true + authPanel.visibility = if (signedIn) View.GONE else View.VISIBLE + appShell.visibility = if (signedIn) View.VISIBLE else View.GONE + authMessage.text = when (authState) { + AuthState.CHECKING -> "Checking your saved Blastoise session..." + AuthState.GUEST -> "This server recognized you as a guest. Sign in to join rooms and edit queues." + AuthState.SIGNED_OUT -> "Sign in to join rooms, see listeners, and change room queues." + AuthState.SIGNED_IN -> "Signed in as ${currentUser?.username ?: "user"}." } } - private fun handlePlaybackEnded() { - if (screenMode != ScreenMode.LIBRARY) return - when (playbackMode) { - "repeat-one" -> controller?.run { - seekTo(0L) - play() - } - "repeat-all", "shuffle" -> next() - else -> { - lastPaused = true - updateDeck() - } - } - } - - 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" @@ -855,9 +1237,9 @@ class MainActivity : Activity(), MusicRoomClient.Listener { private fun renderStations() { if (!::radioContent.isInitialized) return radioContent.removeAllViews() - radioContent.addView(sectionTitle("RADIO STATIONS"), matchWrap()) + radioContent.addView(sectionTitle("ROOMS"), matchWrap()) if (lastChannels.isEmpty()) { - radioContent.addView(emptyLine("Loading stations..."), matchWrapWithTop(dp(8))) + radioContent.addView(emptyLine("Loading rooms..."), matchWrapWithTop(dp(8))) return } @@ -873,6 +1255,21 @@ class MainActivity : Activity(), MusicRoomClient.Listener { } card.addView(rail, LinearLayout.LayoutParams(dp(5), ViewGroup.LayoutParams.MATCH_PARENT)) + if (ACTIVE_THEME.key == "seraph") { + val roomArt = ImageView(this).apply { + setImageResource(R.drawable.anime_angel_room_badge) + scaleType = ImageView.ScaleType.CENTER_CROP + cropToPadding = true + setPadding(dp(2), dp(2), dp(2), dp(2)) + background = box(BG, if (selected) ACCENT else STROKE) + alpha = if (selected) 1f else 0.88f + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + card.addView(roomArt, LinearLayout.LayoutParams(dp(56), dp(56)).apply { + leftMargin = dp(10) + }) + } + val info = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL setPadding(dp(12), dp(10), dp(10), dp(10)) @@ -883,10 +1280,10 @@ class MainActivity : Activity(), MusicRoomClient.Listener { }, matchWrap()) val detail = when { channel.description.isNotBlank() -> channel.description - channel.isDefault -> "Default library broadcast" - else -> "Shared station" + channel.isDefault -> "Default library room" + else -> "Shared room" } - info.addView(text("$detail | ${channel.listenerCount} listening", 13f, MUTED), matchWrap()) + info.addView(text("$detail | ${channel.listenerCount} listening | ${channel.trackCount} queued", 13f, MUTED), matchWrap()) card.addView(info, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) val join = button( @@ -902,7 +1299,7 @@ class MainActivity : Activity(), MusicRoomClient.Listener { radioContent.addView(card, matchWrapWithTop(dp(8))) } - radioContent.addView(sectionTitle("UP NEXT"), matchWrapWithTop(dp(16))) + radioContent.addView(sectionTitle("UP NEXT IN THIS ROOM"), matchWrapWithTop(dp(16))) val queuePanel = panel().apply { setPadding(dp(12), dp(10), dp(12), dp(10)) } @@ -925,13 +1322,38 @@ class MainActivity : Activity(), MusicRoomClient.Listener { private fun renderLibrary() { if (!::libraryContent.isInitialized) return libraryContent.removeAllViews() - libraryContent.addView(sectionTitle("LIBRARY"), matchWrap()) + libraryContent.addView(sectionTitle("LIBRARY BROWSER"), matchWrap()) libraryContent.addView(text( - if (libraryLoaded) "${libraryTracks.size} tracks available for local playback" else "Loading local player library...", + if (libraryLoaded) "${libraryTracks.size} tracks available to add into ${lastChannelName.ifBlank { "the current room" }}" else "Loading room library...", 14f, MUTED, ), matchWrapWithTop(dp(2))) + val searchInput = EditText(this).apply { + setSingleLine(true) + textSize = 15f + typeface = bodyFont + setTextColor(TEXT) + setHintTextColor(MUTED) + hint = "Search songs" + setText(libraryQuery) + setSelection(text.length) + setPadding(dp(12), 0, dp(12), 0) + background = box(PANEL2, STROKE) + addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + libraryQuery = s?.toString().orEmpty() + mainHandler.removeCallbacks(renderLibraryRunnable) + mainHandler.postDelayed(renderLibraryRunnable, 120) + } + override fun afterTextChanged(s: Editable?) = Unit + }) + } + libraryContent.addView(searchInput, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48)).apply { + topMargin = dp(10) + }) + if (!libraryLoaded) { libraryContent.addView(emptyLine("Library will appear here"), matchWrapWithTop(dp(12))) return @@ -941,9 +1363,22 @@ class MainActivity : Activity(), MusicRoomClient.Listener { return } - val maxRows = min(80, libraryTracks.size) - for (i in 0 until maxRows) { - val track = libraryTracks[i] + val query = libraryQuery.trim() + val indexedTracks = libraryTracks.withIndex().filter { (_, track) -> + query.isBlank() || + track.title.contains(query, ignoreCase = true) || + track.filename.contains(query, ignoreCase = true) + } + if (indexedTracks.isEmpty()) { + libraryContent.addView(emptyLine("No matches"), matchWrapWithTop(dp(12))) + return + } + + val maxRows = min(80, indexedTracks.size) + for (rowIndex in 0 until maxRows) { + val indexed = indexedTracks[rowIndex] + val i = indexed.index + val track = indexed.value val selected = i == localLibraryIndex val row = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL @@ -962,20 +1397,20 @@ class MainActivity : Activity(), MusicRoomClient.Listener { ellipsize = TextUtils.TruncateAt.END } row.addView(title, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - row.addView(iconButton(R.drawable.ic_queue_add, if (selected) ACCENT else PANEL2, if (selected) BG else TEXT, 22).apply { + row.addView(trackActionButton(R.drawable.ic_queue_add, if (selected) ACCENT else MUTED).apply { setOnClickListener { queueTrack(track, false) } - }, LinearLayout.LayoutParams(dp(50), dp(40)).apply { rightMargin = dp(5) }) - row.addView(iconButton(R.drawable.ic_play_next, PANEL2, TEXT, 22).apply { + }, trackActionParams()) + row.addView(trackActionButton(R.drawable.ic_play_next, if (selected) TEXT else MUTED).apply { setOnClickListener { queueTrack(track, true) } - }, LinearLayout.LayoutParams(dp(62), dp(40)).apply { rightMargin = dp(8) }) + }, trackActionParams()) row.addView(text(fmtTime(track.duration.toLong()), 13f, MUTED, Typeface.BOLD), LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, )) libraryContent.addView(row, matchWrapWithTop(dp(6))) } - if (libraryTracks.size > maxRows) { - libraryContent.addView(emptyLine("Showing first $maxRows tracks"), matchWrapWithTop(dp(8))) + if (indexedTracks.size > maxRows) { + libraryContent.addView(emptyLine("Showing first $maxRows matches"), matchWrapWithTop(dp(8))) } } @@ -984,7 +1419,7 @@ class MainActivity : Activity(), MusicRoomClient.Listener { playlistContent.removeAllViews() playlistContent.addView(sectionTitle("PLAYLISTS"), matchWrap()) playlistContent.addView(text( - if (playlistsLoaded) "${myPlaylists.size} mine | ${sharedPlaylists.size} shared" else "Loading playlist controls...", + if (playlistsLoaded) "${myPlaylists.size} mine | ${sharedPlaylists.size} shared | add into ${lastChannelName.ifBlank { "current room" }}" else "Loading playlists...", 14f, MUTED, ), matchWrapWithTop(dp(2))) @@ -994,54 +1429,69 @@ class MainActivity : Activity(), MusicRoomClient.Listener { return } - val all = allPlaylists() - if (all.isEmpty()) { - playlistContent.addView(emptyLine("No playlists found"), matchWrapWithTop(dp(12))) + val playlists = allPlaylists() + if (playlists.isEmpty()) { + playlistContent.addView(emptyLine("No playlists found for this account"), matchWrapWithTop(dp(12))) return } - val listPanel = panel().apply { - setPadding(dp(10), dp(8), dp(10), dp(10)) - } - for (playlist in all.take(24)) { + val maxPlaylistRows = min(40, playlists.size) + for (i in 0 until maxPlaylistRows) { + val playlist = playlists[i] val selected = playlist.id == selectedPlaylistId - val isMine = myPlaylists.any { it.id == playlist.id } + val mine = myPlaylists.any { it.id == playlist.id } + val actionPlaylist = selectedPlaylist?.takeIf { it.id == playlist.id } ?: playlist val row = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - setPadding(dp(10), dp(8), dp(8), dp(8)) - background = box(if (selected) PANEL2 else BG, if (selected) ACCENT else STROKE) + setPadding(dp(12), dp(10), dp(8), dp(10)) + background = box(if (selected) PANEL2 else PANEL, if (selected) ACCENT else STROKE) setOnClickListener { selectPlaylist(playlist.id) } } val info = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } - info.addView(text(playlist.name, 18f, if (selected) TEXT else MUTED, if (selected) Typeface.BOLD else Typeface.NORMAL).apply { + info.addView(text(playlist.name, 19f, if (selected) TEXT else MUTED, if (selected) Typeface.BOLD else Typeface.NORMAL).apply { maxLines = 1 ellipsize = TextUtils.TruncateAt.END }, matchWrap()) val owner = when { - isMine -> "mine" + mine -> "mine" playlist.ownerName.isNotBlank() -> "by ${playlist.ownerName}" else -> "shared" } - info.addView(text("${playlist.trackIds.size} tracks | $owner", 12f, MUTED), matchWrap()) + val visibility = if (playlist.isPublic) "public" else "private" + info.addView(text("${playlist.trackIds.size} tracks | $owner | $visibility", 12f, MUTED), matchWrap()) row.addView(info, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - row.addView(iconButton(R.drawable.ic_queue_add, if (selected) ACCENT else PANEL2, if (selected) BG else TEXT, 22).apply { - setOnClickListener { addPlaylistToQueue(playlist, false) } - }, LinearLayout.LayoutParams(dp(70), dp(44)).apply { rightMargin = dp(5) }) - row.addView(iconButton(R.drawable.ic_play_next, PANEL2, TEXT, 22).apply { - setOnClickListener { addPlaylistToQueue(playlist, true) } - }, LinearLayout.LayoutParams(dp(62), dp(44))) - listPanel.addView(row, matchWrapWithTop(dp(6))) + row.addView(trackActionButton(R.drawable.ic_queue_add, if (selected) ACCENT else MUTED).apply { + setOnClickListener { + if (actionPlaylist.trackIds.isEmpty()) { + selectPlaylist(playlist.id) + } else { + addPlaylistToQueue(actionPlaylist, false) + } + } + }, trackActionParams()) + row.addView(trackActionButton(R.drawable.ic_play_next, if (selected) TEXT else MUTED).apply { + setOnClickListener { + if (actionPlaylist.trackIds.isEmpty()) { + selectPlaylist(playlist.id) + } else { + addPlaylistToQueue(actionPlaylist, true) + } + } + }, trackActionParams()) + playlistContent.addView(row, matchWrapWithTop(dp(6))) + } + if (playlists.size > maxPlaylistRows) { + playlistContent.addView(emptyLine("Showing first $maxPlaylistRows playlists"), matchWrapWithTop(dp(8))) } - playlistContent.addView(listPanel, matchWrapWithTop(dp(8))) - val selected = selectedPlaylist + playlistContent.addView(sectionTitle("SELECTED LIST"), matchWrapWithTop(dp(16))) val selectedId = selectedPlaylistId - playlistContent.addView(sectionTitle("PLAYLIST TRACKS"), matchWrapWithTop(dp(16))) + val selected = selectedPlaylist if (selectedId != null && selected == null) { - playlistContent.addView(emptyLine("Loading playlist..."), matchWrapWithTop(dp(8))) + playlistContent.addView(emptyLine("Loading playlist tracks..."), matchWrapWithTop(dp(8))) return } if (selected == null) { @@ -1049,53 +1499,165 @@ class MainActivity : Activity(), MusicRoomClient.Listener { return } - val detailPanel = panel().apply { - setPadding(dp(10), dp(8), dp(10), dp(10)) + val header = row().apply { + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(12), dp(10), dp(8), dp(10)) + background = box(PANEL2, ACCENT) } - val detailHeader = row().apply { gravity = Gravity.CENTER_VERTICAL } - detailHeader.addView(text(selected.name, 20f, TEXT, Typeface.BOLD).apply { + val selectedInfo = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + selectedInfo.addView(text(selected.name, 22f, TEXT, Typeface.BOLD).apply { maxLines = 1 ellipsize = TextUtils.TruncateAt.END - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - detailHeader.addView(iconButton(R.drawable.ic_queue_add, ACCENT, BG, 24).apply { - setOnClickListener { addPlaylistToQueue(selected, false) } - }, LinearLayout.LayoutParams(dp(98), dp(44)).apply { rightMargin = dp(5) }) - detailHeader.addView(iconButton(R.drawable.ic_play_next, PANEL2, TEXT, 24).apply { - setOnClickListener { addPlaylistToQueue(selected, true) } - }, LinearLayout.LayoutParams(dp(88), dp(44))) - detailPanel.addView(detailHeader, matchWrap()) - - val maxRows = min(60, selected.trackIds.size) - if (maxRows == 0) { - detailPanel.addView(emptyLine("Playlist is empty"), matchWrapWithTop(dp(8))) + }, matchWrap()) + val description = selected.description.ifBlank { + "${selected.trackIds.size} tracks ready for the room queue" } - for (i in 0 until maxRows) { + selectedInfo.addView(text(description, 13f, MUTED).apply { + maxLines = 2 + ellipsize = TextUtils.TruncateAt.END + }, matchWrap()) + header.addView(selectedInfo, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + header.addView(trackActionButton(R.drawable.ic_queue_add, ACCENT, 22).apply { + setOnClickListener { addPlaylistToQueue(selected, false) } + }, trackActionParams()) + header.addView(trackActionButton(R.drawable.ic_play_next, TEXT, 22).apply { + setOnClickListener { addPlaylistToQueue(selected, true) } + }, trackActionParams()) + playlistContent.addView(header, matchWrapWithTop(dp(8))) + + if (selected.trackIds.isEmpty()) { + playlistContent.addView(emptyLine("This playlist is empty"), matchWrapWithTop(dp(8))) + return + } + + val maxTrackRows = min(80, selected.trackIds.size) + for (i in 0 until maxTrackRows) { val track = trackById(selected.trackIds[i]) val row = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - setPadding(dp(10), dp(7), dp(8), dp(7)) + setPadding(dp(10), dp(8), dp(8), dp(8)) background = box(PANEL, STROKE) } - row.addView(text((i + 1).toString().padStart(2, '0'), 12f, MUTED, Typeface.BOLD).apply { + row.addView(text((i + 1).toString().padStart(2, '0'), 13f, MUTED, Typeface.BOLD).apply { gravity = Gravity.CENTER - }, LinearLayout.LayoutParams(dp(32), ViewGroup.LayoutParams.WRAP_CONTENT)) - row.addView(text(track.title, 16f, MUTED).apply { + }, LinearLayout.LayoutParams(dp(36), ViewGroup.LayoutParams.WRAP_CONTENT)) + val info = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + info.addView(text(track.title, 17f, MUTED).apply { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, matchWrap()) + info.addView(text(fmtTime(track.duration.toLong()), 12f, MUTED), matchWrap()) + row.addView(info, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + row.addView(trackActionButton(R.drawable.ic_queue_add, MUTED).apply { + setOnClickListener { queueTrack(track, false) } + }, trackActionParams()) + row.addView(trackActionButton(R.drawable.ic_play_next, MUTED).apply { + setOnClickListener { queueTrack(track, true) } + }, trackActionParams()) + playlistContent.addView(row, matchWrapWithTop(dp(6))) + } + if (selected.trackIds.size > maxTrackRows) { + playlistContent.addView(emptyLine("Showing first $maxTrackRows tracks"), matchWrapWithTop(dp(8))) + } + } + + private fun renderQueue() { + if (!::queueContent.isInitialized) return + queueContent.removeAllViews() + queueContent.addView(sectionTitle("ROOM QUEUE"), matchWrap()) + queueContent.addView(text( + if (currentChannelId == null) "Join a room before editing its queue" else "${lastQueue.size} tracks in ${lastChannelName.ifBlank { "this room" }}", + 14f, + MUTED, + ), matchWrapWithTop(dp(2))) + + if (!lastQueueLoaded) { + queueContent.addView(emptyLine("Waiting for the room queue snapshot"), matchWrapWithTop(dp(12))) + return + } + if (lastQueue.isEmpty()) { + queueContent.addView(emptyLine("This room queue is empty"), matchWrapWithTop(dp(12))) + return + } + + val maxRows = min(80, lastQueue.size) + for (i in 0 until maxRows) { + val track = lastQueue[i] + val current = i == lastCurrentIndex + val row = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(10), dp(8), dp(8), dp(8)) + background = box(if (current) PANEL2 else PANEL, if (current) GREEN else STROKE) + setOnClickListener { playbackService()?.jumpToQueueIndex(i) } + } + row.addView(text((i + 1).toString().padStart(2, '0'), 13f, if (current) GREEN else MUTED, Typeface.BOLD).apply { + gravity = Gravity.CENTER + }, LinearLayout.LayoutParams(dp(36), ViewGroup.LayoutParams.WRAP_CONTENT)) + val info = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + info.addView(text(track.title, 17f, if (current) TEXT else MUTED, if (current) Typeface.BOLD else Typeface.NORMAL).apply { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, matchWrap()) + info.addView(text(if (current) "now playing" else fmtTime(track.duration.toLong()), 12f, MUTED), matchWrap()) + row.addView(info, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + row.addView(trackActionButton(R.drawable.ic_play, if (current) GREEN else MUTED, if (current) 23 else 21).apply { + setOnClickListener { playbackService()?.jumpToQueueIndex(i) } + }, trackActionParams()) + row.addView(trackActionButton(R.drawable.ic_delete, RED, 21).apply { + setOnClickListener { playbackService()?.removeQueueIndex(i) } + }, trackActionParams()) + queueContent.addView(row, matchWrapWithTop(dp(6))) + } + if (lastQueue.size > maxRows) { + queueContent.addView(emptyLine("Showing first $maxRows queued tracks"), matchWrapWithTop(dp(8))) + } + } + + private fun renderPeople() { + if (!::peopleContent.isInitialized) return + peopleContent.removeAllViews() + peopleContent.addView(sectionTitle("PEOPLE IN ROOM"), matchWrap()) + val roomName = lastChannelName.ifBlank { "No room" } + peopleContent.addView(text(roomName, 22f, TEXT, Typeface.BOLD).apply { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, matchWrapWithTop(dp(4))) + + val listeners = currentRoomListeners.ifEmpty { + lastChannels.firstOrNull { it.id == currentChannelId }?.listeners.orEmpty() + } + if (currentChannelId == null) { + peopleContent.addView(emptyLine("Join a room to see who is listening with you"), matchWrapWithTop(dp(12))) + return + } + if (listeners.isEmpty()) { + peopleContent.addView(emptyLine("No listener names reported yet"), matchWrapWithTop(dp(12))) + return + } + + for (name in listeners) { + val mine = currentUser?.username == name + val row = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(12), dp(10), dp(12), dp(10)) + background = box(if (mine) PANEL2 else PANEL, if (mine) GREEN else STROKE) + } + row.addView(text(if (mine) "YOU" else "USER", 12f, if (mine) GREEN else MUTED, Typeface.BOLD), LinearLayout.LayoutParams(dp(58), ViewGroup.LayoutParams.WRAP_CONTENT)) + row.addView(text(name, 19f, if (mine) TEXT else MUTED, if (mine) Typeface.BOLD else Typeface.NORMAL).apply { maxLines = 1 ellipsize = TextUtils.TruncateAt.END }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - row.addView(iconButton(R.drawable.ic_queue_add, PANEL2, TEXT, 22).apply { - setOnClickListener { queueTrack(track, false) } - }, LinearLayout.LayoutParams(dp(50), dp(40)).apply { rightMargin = dp(5) }) - row.addView(iconButton(R.drawable.ic_play_next, PANEL2, TEXT, 22).apply { - setOnClickListener { queueTrack(track, true) } - }, LinearLayout.LayoutParams(dp(62), dp(40))) - detailPanel.addView(row, matchWrapWithTop(dp(6))) + peopleContent.addView(row, matchWrapWithTop(dp(6))) } - if (selected.trackIds.size > maxRows) { - detailPanel.addView(emptyLine("Showing first $maxRows tracks"), matchWrapWithTop(dp(8))) - } - playlistContent.addView(detailPanel, matchWrapWithTop(dp(8))) } private fun connectMediaController() { @@ -1105,15 +1667,16 @@ class MainActivity : Activity(), MusicRoomClient.Listener { future.addListener( { controller = future.get() - controller?.addListener(object : Player.Listener { - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_ENDED) { - runOnUiThread { handlePlaybackEnded() } - } - } - }) lastStatus = "Player ready" - pendingState?.let { onChannelState(it) } + val service = playbackService() + if (PlaybackBridge.snapshot.authState == AuthState.CHECKING) { + service?.validateSession() + } else if ( + PlaybackBridge.snapshot.authState == AuthState.SIGNED_IN && + PlaybackBridge.snapshot.channels.isEmpty() + ) { + service?.connectToServer(SessionStore.serverBaseUrl) + } updateDeck() }, Executor { command -> runOnUiThread(command) }, @@ -1123,52 +1686,42 @@ class MainActivity : Activity(), MusicRoomClient.Listener { private fun updateDeck() { if (!::titleText.isInitialized) return - val player = controller - val expectedMs = expectedPositionMs() - val playerMs = player?.currentPosition ?: 0L - val driftMs = if (isRadioBacked() && currentTrackId != null) playerMs - expectedMs else 0L + val expectedMs = lastExpectedPositionMs + val playerMs = lastPlayerPositionMs + val driftMs = lastDriftMs val durationMs = (lastTrackDuration * 1000.0).toLong() - if (isRadioBacked() && - !userSeeking && - currentTrackId != null && - !lastPaused && - player != null && - player.playbackState == 3 && - abs(driftMs) >= 2000L - ) { - player.seekTo(expectedMs) - addDebugEvent("local drift correction ${driftMs}ms") - } - - if (screenMode == ScreenMode.LIBRARY && player != null) { - lastPaused = !player.isPlaying - if (player.duration > 0 && lastTrackDuration <= 0.0) { - lastTrackDuration = player.duration / 1000.0 - } - } - statusBadge.text = lastStatus.uppercase() if (screenMode == ScreenMode.LIBRARY && lastStatus == "Disconnected") { - lastStatus = "Library" + lastStatus = "Library search" statusBadge.text = "LIBRARY" } statusBadge.background = box(statusColor()) modeLabel.text = when (screenMode) { - ScreenMode.RADIO -> "RADIO" + ScreenMode.ROOMS -> "ROOM" + ScreenMode.QUEUE -> "QUEUE" + ScreenMode.PEOPLE -> "PEOPLE" ScreenMode.LIBRARY -> "LIBRARY" - ScreenMode.PLAYLISTS -> "PLAYLISTS" + ScreenMode.PLAYLISTS -> "LISTS" } - sourceLabel.text = if (screenMode == ScreenMode.LIBRARY) "Local files" else lastChannelName + sourceLabel.text = lastChannelName.ifBlank { "No room" } metaText.text = when (screenMode) { - ScreenMode.RADIO -> "JOINED STATION" - ScreenMode.LIBRARY -> "${libraryTracks.size} TRACKS | MP3 PLAYER MODE" - ScreenMode.PLAYLISTS -> "${allPlaylists().size} PLAYLISTS | QUEUE CONTROLS" + ScreenMode.ROOMS -> "JOINED ROOM" + ScreenMode.QUEUE -> "${lastQueue.size} TRACKS | ROOM QUEUE" + ScreenMode.PEOPLE -> "${currentRoomListeners.size} LISTENING" + ScreenMode.LIBRARY -> "${libraryTracks.size} TRACKS | ADD TO ROOM" + ScreenMode.PLAYLISTS -> "${allPlaylists().size} PLAYLISTS | ADD TO ROOM" } titleText.text = lastTrackTitle setIconButtonIcon(playButton, if (lastPaused) R.drawable.ic_play else R.drawable.ic_pause, BG, 30) playButton.contentDescription = if (lastPaused) "Play" else "Pause" playbackModeButton.text = modeLabel(playbackMode) + themeButton.text = "" + themeButton.setTextColor(ACCENT) + themeButton.background = box(PANEL2, STROKE) + setButtonIcon(themeButton, if (ACTIVE_THEME.key == "cat") R.drawable.ic_cat else R.drawable.ic_halo, ACCENT, 18) + angelBanner.visibility = if (ACTIVE_THEME.key == "seraph") View.VISIBLE else View.GONE + angelBanner.background = box(PANEL2, STROKE) val modeForeground = if (playbackMode == "shuffle") BG else TEXT playbackModeButton.setTextColor(modeForeground) playbackModeButton.background = box(if (playbackMode == "shuffle") ACCENT else PANEL, STROKE) @@ -1186,60 +1739,51 @@ class MainActivity : Activity(), MusicRoomClient.Listener { syncBadge.text = if (isRadioBacked()) "LIVE SYNC" else "LOCAL" driftBadge.text = if (isRadioBacked()) "DRIFT ${driftMs}MS" else "DIRECT PLAY" driftBadge.background = box(if (isRadioBacked()) driftColor(driftMs) else AMBER) - sessionBadge.text = if (SessionStore.cookieHeader.isBlank()) "NO SESSION" else "GUEST SESSION" + sessionBadge.text = currentUser?.username?.uppercase()?.let { "SIGNED IN: $it" } ?: "SIGN IN" val visualPositionMs = if (isRadioBacked()) expectedMs else playerMs + val progressFraction = if (durationMs > 0) { + (visualPositionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) + } else { + 0.5f + } elapsedText.text = fmtTime(visualPositionMs / 1000) durationText.text = if (durationMs > 0) fmtTime(durationMs / 1000) else "--:--" if (!userSeeking && durationMs > 0) { - val progress = ((visualPositionMs.toDouble() / durationMs.toDouble()) * progressSeek.max) + val progress = (progressFraction * progressSeek.max) .toInt() .coerceIn(0, progressSeek.max) progressSeek.progress = progress } else if (!userSeeking && durationMs <= 0) { progressSeek.progress = 0 } + pixelCat.visibility = if (ACTIVE_THEME.key == "cat") View.VISIBLE else View.GONE + pixelCat.setPalette( + body = 0xFF020402.toInt(), + edge = ACCENT, + eye = AMBER, + accent = TEXT, + shadow = withAlpha(PURPLE, 72), + ) + pixelCat.setProgressFraction(0.22f) - val playbackState = when (player?.playbackState) { - 1 -> "idle" - 2 -> "buffering" - 3 -> "ready" - 4 -> "ended" - else -> "none" - } val title = if (lastTrackTitle.length > 64) lastTrackTitle.take(61) + "..." else lastTrackTitle debugText.text = buildString { appendLine("mode: $screenMode") appendLine("server: ${SessionStore.serverBaseUrl}") - appendLine("cookie: ${if (SessionStore.cookieHeader.isBlank()) "none" else "yes"}") - appendLine("channels: ${lastChannels.size} current=${currentChannelId ?: "-"}") - appendLine("library: ${libraryTracks.size} selected=$localLibraryIndex") + appendLine("auth: $authState user=${currentUser?.username ?: "-"} cookie=${if (SessionStore.cookieHeader.isBlank()) "none" else "yes"}") + appendLine("rooms: ${lastChannels.size} current=${currentChannelId ?: "-"} listeners=${currentRoomListeners.size}") + appendLine("library: ${libraryTracks.size} query=${libraryQuery.ifBlank { "-" }}") appendLine("playlists: mine=${myPlaylists.size} shared=${sharedPlaylists.size} selected=${selectedPlaylistId ?: "-"}") appendLine("playbackMode: $playbackMode") appendLine("track: ${currentTrackId?.take(24) ?: "-"}") appendLine("title: $title") - appendLine("expected=${expectedMs}ms player=${playerMs}ms drift=${driftMs}ms state=$playbackState playing=${player?.isPlaying ?: false}") + appendLine("expected=${expectedMs}ms player=${playerMs}ms drift=${driftMs}ms state=$lastPlaybackState playing=$lastPlayerIsPlaying") appendLine("events:") debugEvents.takeLast(5).forEach { appendLine(it) } } } - private fun expectedPositionMs(): Long { - if (screenMode == ScreenMode.LIBRARY) return controller?.currentPosition ?: 0L - if (lastStateRealtimeMs == 0L) return 0L - return if (lastPaused) { - lastServerTimestampMs - } else { - lastServerTimestampMs + (SystemClock.elapsedRealtime() - lastStateRealtimeMs) - } - } - - 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 maybeRequestNotificationPermission() { if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED @@ -1280,6 +1824,7 @@ class MainActivity : Activity(), MusicRoomClient.Listener { private fun panel() = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL background = box(PANEL, STROKE) + foreground = if (ACTIVE_THEME.frameStyle == FrameStyle.PIXEL) frameOverlay(STROKE) else null } private fun text(value: String, size: Float, color: Int, style: Int = Typeface.NORMAL) = @@ -1295,7 +1840,7 @@ class MainActivity : Activity(), MusicRoomClient.Listener { } private fun sectionTitle(value: String) = text(value, 17f, TEXT, Typeface.BOLD).apply { - letterSpacing = 0.05f + letterSpacing = 0f } private fun emptyLine(value: String) = text(value, 14f, MUTED).apply { @@ -1317,13 +1862,13 @@ class MainActivity : Activity(), MusicRoomClient.Listener { iconSize: Int = 18, ) = TextView(this).apply { text = label - textSize = 14f + textSize = 13f typeface = if (style == Typeface.BOLD) bodyStrongFont else bodyFont maxLines = 1 ellipsize = TextUtils.TruncateAt.END gravity = Gravity.CENTER setTextColor(fg) - letterSpacing = 0.06f + letterSpacing = 0f minHeight = dp(44) setPadding(dp(12), 0, dp(12), 0) background = box(bg) @@ -1342,13 +1887,13 @@ class MainActivity : Activity(), MusicRoomClient.Listener { iconSize: Int = 22, ) = TextView(this).apply { text = label - textSize = if (style == Typeface.BOLD) 15f else 13f + textSize = if (style == Typeface.BOLD) 14f else 12f typeface = if (style == Typeface.BOLD) bodyStrongFont else monoFont maxLines = 1 ellipsize = TextUtils.TruncateAt.END gravity = Gravity.CENTER setTextColor(fg) - letterSpacing = 0.06f + letterSpacing = 0f minHeight = dp(54) setPadding(dp(6), 0, dp(6), 0) background = box(bg, if (bg == GREEN) null else STROKE) @@ -1370,13 +1915,29 @@ class MainActivity : Activity(), MusicRoomClient.Listener { contentDescription = iconDescription(iconRes) } + private fun trackActionButton( + iconRes: Int, + fg: Int, + iconSize: Int = 21, + ) = CenteredIconButton(this).apply { + background = box(0x00000000) + setIconButtonIcon(this, iconRes, fg, iconSize) + contentDescription = iconDescription(iconRes) + } + private fun tab(label: String, active: Boolean, iconRes: Int) = button( label, if (active) ACCENT else PANEL, if (active) BG else MUTED, Typeface.BOLD, iconRes, - ) + 16, + ).apply { + textSize = 12f + letterSpacing = 0f + setPadding(dp(4), 0, dp(4), 0) + compoundDrawablePadding = dp(4) + } private fun setButtonIcon(view: TextView, iconRes: Int?, color: Int, sizeDp: Int = 18) { if (iconRes == null) { @@ -1403,14 +1964,21 @@ class MainActivity : Activity(), MusicRoomClient.Listener { private fun iconDescription(iconRes: Int): String { return when (iconRes) { R.drawable.ic_connect -> "Connect" + R.drawable.ic_cat -> "Switch theme" + R.drawable.ic_delete -> "Remove" + R.drawable.ic_halo -> "Switch theme" R.drawable.ic_library -> "Library" R.drawable.ic_pause -> "Pause" + R.drawable.ic_people -> "People" R.drawable.ic_play -> "Play" R.drawable.ic_play_next -> "Play next" R.drawable.ic_playlist -> "Playlists" + R.drawable.ic_power -> "Stop and exit" + R.drawable.ic_queue -> "Queue" R.drawable.ic_queue_add -> "Add to queue" R.drawable.ic_radio -> "Radio" R.drawable.ic_repeat -> "Playback mode" + R.drawable.ic_search -> "Search" R.drawable.ic_seek_back -> "Seek back 15 seconds" R.drawable.ic_seek_forward -> "Seek forward 15 seconds" R.drawable.ic_shuffle -> "Shuffle" @@ -1426,17 +1994,56 @@ class MainActivity : Activity(), MusicRoomClient.Listener { typeface = bodyStrongFont gravity = Gravity.CENTER setTextColor(fg) - letterSpacing = 0.06f + letterSpacing = 0f setPadding(dp(10), dp(6), dp(10), dp(6)) background = box(bg) } - private fun box(color: Int, stroke: Int? = null) = - GradientDrawable().apply { - setColor(color) - cornerRadius = dp(3).toFloat() - if (stroke != null) setStroke(dp(1), stroke) + private fun box(color: Int, stroke: Int? = null): Drawable { + val fill = themedFill(color) + val edge = stroke ?: when { + color ushr 24 == 0 -> null + color == PANEL || color == PANEL2 -> STROKE + else -> BG } + return when (ACTIVE_THEME.frameStyle) { + FrameStyle.PIXEL -> PixelBoxDrawable( + fillColor = fill, + strokeColor = edge, + unitPx = dp(4).toFloat(), + strokePx = dp(1).toFloat(), + ) + FrameStyle.SERAPH -> SeraphBoxDrawable( + fillColor = fill, + strokeColor = edge, + unitPx = dp(9).toFloat(), + strokePx = dp(1).toFloat(), + ) + } + } + + private fun themedFill(color: Int): Int { + if (ACTIVE_THEME.key != "seraph") return color + return when (color) { + PANEL -> withAlpha(color, 240) + PANEL2 -> withAlpha(color, 226) + else -> color + } + } + + private fun withAlpha(color: Int, alpha: Int): Int { + return (color and 0x00FFFFFF) or (alpha.coerceIn(0, 255) shl 24) + } + + private fun frameOverlay(color: Int): Drawable? { + val overlay = when (ACTIVE_THEME.frameStyle) { + FrameStyle.PIXEL -> R.drawable.pixel_frame_overlay + FrameStyle.SERAPH -> R.drawable.seraph_frame_overlay + } + return getDrawable(overlay)?.mutate()?.apply { + setTint(color) + } + } private fun matchWrap() = LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -1487,21 +2094,99 @@ class MainActivity : Activity(), MusicRoomClient.Listener { rightMargin = dp(3) } + private fun trackActionParams() = LinearLayout.LayoutParams( + dp(44), + dp(44), + ).apply { + leftMargin = dp(2) + } + private fun dp(value: Int): Int { return (value * resources.displayMetrics.density + 0.5f).toInt() } companion object { - private const val BG = 0xFF0D0B09.toInt() - private const val PANEL = 0xFF191511.toInt() - private const val PANEL2 = 0xFF262019.toInt() - private const val STROKE = 0xFF4A3A2B.toInt() - private const val TEXT = 0xFFFFF4E3.toInt() - private const val MUTED = 0xFFB8A995.toInt() - private const val ACCENT = 0xFFFF6B35.toInt() - private const val GREEN = 0xFFA7E15D.toInt() - private const val AMBER = 0xFFE0A94F.toInt() - private const val RED = 0xFFD63B2E.toInt() - private const val PURPLE = 0xFFC78944.toInt() + private val THEMES = listOf( + ThemeSpec( + key = "arcade", + label = "PIXEL", + bg = 0xFF0D0B09.toInt(), + panel = 0xFF191511.toInt(), + panel2 = 0xFF262019.toInt(), + stroke = 0xFF4A3A2B.toInt(), + text = 0xFFFFF4E3.toInt(), + muted = 0xFFB8A995.toInt(), + accent = 0xFFFF6B35.toInt(), + green = 0xFFA7E15D.toInt(), + amber = 0xFFE0A94F.toInt(), + red = 0xFFD63B2E.toInt(), + purple = 0xFFC78944.toInt(), + displayFont = R.font.pixelify_sans, + bodyFont = R.font.pixelify_sans, + bodyStrongFont = R.font.pixelify_sans, + monoFont = R.font.pixelify_sans, + frameStyle = FrameStyle.PIXEL, + lightSystemBars = false, + ), + ThemeSpec( + key = "seraph", + label = "AURA", + bg = 0xFFF7F2FF.toInt(), + panel = 0xFFFFFCF6.toInt(), + panel2 = 0xFFF0E8FF.toInt(), + stroke = 0xFFD8B95A.toInt(), + text = 0xFF30243E.toInt(), + muted = 0xFF7F708D.toInt(), + accent = 0xFF83C4FF.toInt(), + green = 0xFF73D9C7.toInt(), + amber = 0xFFF2C665.toInt(), + red = 0xFFFF7188.toInt(), + purple = 0xFFD5B8FF.toInt(), + displayFont = R.font.rajdhani_bold, + bodyFont = R.font.rajdhani_regular, + bodyStrongFont = R.font.rajdhani_semibold, + monoFont = R.font.jetbrains_mono_regular, + frameStyle = FrameStyle.SERAPH, + lightSystemBars = true, + ), + ThemeSpec( + key = "cat", + label = "BLACK CAT", + bg = 0xFF070908.toInt(), + panel = 0xFF101511.toInt(), + panel2 = 0xFF182019.toInt(), + stroke = 0xFF5D704F.toInt(), + text = 0xFFF2F7E6.toInt(), + muted = 0xFF9CAB91.toInt(), + accent = 0xFFB7F06A.toInt(), + green = 0xFFC9FF74.toInt(), + amber = 0xFFE7D86A.toInt(), + red = 0xFFFF7AA8.toInt(), + purple = 0xFF9E7BFF.toInt(), + displayFont = R.font.pixelify_sans, + bodyFont = R.font.pixelify_sans, + bodyStrongFont = R.font.pixelify_sans, + monoFont = R.font.pixelify_sans, + frameStyle = FrameStyle.PIXEL, + lightSystemBars = false, + ), + ) + private var ACTIVE_THEME = THEMES.first() + + private fun themeForKey(key: String): ThemeSpec { + return THEMES.firstOrNull { it.key == key } ?: THEMES.first() + } + + private val BG get() = ACTIVE_THEME.bg + private val PANEL get() = ACTIVE_THEME.panel + private val PANEL2 get() = ACTIVE_THEME.panel2 + private val STROKE get() = ACTIVE_THEME.stroke + private val TEXT get() = ACTIVE_THEME.text + private val MUTED get() = ACTIVE_THEME.muted + private val ACCENT get() = ACTIVE_THEME.accent + private val GREEN get() = ACTIVE_THEME.green + private val AMBER get() = ACTIVE_THEME.amber + private val RED get() = ACTIVE_THEME.red + private val PURPLE get() = ACTIVE_THEME.purple } } diff --git a/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/Models.kt b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/Models.kt index 4c2986b..1e4b8ec 100644 --- a/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/Models.kt +++ b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/Models.kt @@ -16,6 +16,8 @@ data class ChannelInfo( val description: String, val listenerCount: Int, val isDefault: Boolean, + val trackCount: Int = 0, + val listeners: List = emptyList(), ) data class ChannelState( @@ -27,6 +29,8 @@ data class ChannelState( val queue: List?, val currentIndex: Int, val playbackMode: String, + val listenerCount: Int, + val listeners: List, ) data class Playlist( @@ -45,6 +49,23 @@ data class PlaylistBundle( val shared: List, ) +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 = 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 { if (this == null) return emptyList() return (0 until length()).mapNotNull { optString(it).takeIf(String::isNotBlank) } } + +fun JSONArray?.toPermissions(): List { + if (this == null) return emptyList() + return (0 until length()).map { getJSONObject(it).toPermission() } +} diff --git a/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/MusicRoomClient.kt b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/MusicRoomClient.kt index faaa16d..03be103 100644 --- a/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/MusicRoomClient.kt +++ b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/MusicRoomClient.kt @@ -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) { + 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 + } } } diff --git a/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/PlaybackService.kt b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/PlaybackService.kt index 8151b63..663fe69 100644 --- a/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/PlaybackService.kt +++ b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/PlaybackService.kt @@ -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 = emptyList() + private var libraryTracks: List = emptyList() + private var libraryLoaded = false + private var myPlaylists: List = emptyList() + private var sharedPlaylists: List = 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 = 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() + 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() @@ -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, 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) { + 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) { + 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 { + 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) + } + } } diff --git a/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/PlaybackSnapshot.kt b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/PlaybackSnapshot.kt new file mode 100644 index 0000000..37e633c --- /dev/null +++ b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/PlaybackSnapshot.kt @@ -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 = emptyList(), + val libraryTracks: List = emptyList(), + val libraryLoaded: Boolean = false, + val myPlaylists: List = emptyList(), + val sharedPlaylists: List = 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 = emptyList(), + val paused: Boolean = true, + val queue: List = 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 = emptyList(), +) + +interface PlaybackSnapshotListener { + fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) +} + +object PlaybackBridge { + private val mainHandler = Handler(Looper.getMainLooper()) + private val listeners = linkedSetOf() + + @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) } + } + } +} diff --git a/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/SessionStore.kt b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/SessionStore.kt index 62e2358..587bf3c 100644 --- a/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/SessionStore.kt +++ b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/SessionStore.kt @@ -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() } diff --git a/android/BlastoiseNative/app/src/main/res/drawable-nodpi/anime_angel_banner.png b/android/BlastoiseNative/app/src/main/res/drawable-nodpi/anime_angel_banner.png new file mode 100644 index 0000000..25b0a77 Binary files /dev/null and b/android/BlastoiseNative/app/src/main/res/drawable-nodpi/anime_angel_banner.png differ diff --git a/android/BlastoiseNative/app/src/main/res/drawable-nodpi/anime_angel_room_badge.png b/android/BlastoiseNative/app/src/main/res/drawable-nodpi/anime_angel_room_badge.png new file mode 100644 index 0000000..a2d56ba Binary files /dev/null and b/android/BlastoiseNative/app/src/main/res/drawable-nodpi/anime_angel_room_badge.png differ diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_cat.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_cat.xml new file mode 100644 index 0000000..bf5f30c --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_cat.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_delete.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..bb01641 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_halo.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_halo.xml new file mode 100644 index 0000000..5b85232 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_halo.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_people.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_people.xml new file mode 100644 index 0000000..6e44b09 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_people.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_power.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_power.xml new file mode 100644 index 0000000..db68ccc --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_power.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_queue.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_queue.xml new file mode 100644 index 0000000..d026588 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_queue.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_search.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..38f1e87 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/pixel_frame_overlay.xml b/android/BlastoiseNative/app/src/main/res/drawable/pixel_frame_overlay.xml new file mode 100644 index 0000000..58c4af9 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/pixel_frame_overlay.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/seraph_frame_overlay.xml b/android/BlastoiseNative/app/src/main/res/drawable/seraph_frame_overlay.xml new file mode 100644 index 0000000..fac6bf7 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/seraph_frame_overlay.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/font/pixelify_sans.ttf b/android/BlastoiseNative/app/src/main/res/font/pixelify_sans.ttf new file mode 100644 index 0000000..2d7eb38 Binary files /dev/null and b/android/BlastoiseNative/app/src/main/res/font/pixelify_sans.ttf differ