diff --git a/android/BlastoiseNative/.gitignore b/android/BlastoiseNative/.gitignore new file mode 100644 index 0000000..5b3641b --- /dev/null +++ b/android/BlastoiseNative/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +app/build/ +local.properties +*.iml diff --git a/android/BlastoiseNative/DESIGN.md b/android/BlastoiseNative/DESIGN.md new file mode 100644 index 0000000..03a6454 --- /dev/null +++ b/android/BlastoiseNative/DESIGN.md @@ -0,0 +1,34 @@ +# Blastoise Native Android Design Brief + +Design direction: broadcast-console music app, not generic AI dashboard. + +## Principles + +- Put the listening task first: current channel, current track, transport, progress, channel switcher, and queue preview must stay visible on the main screen. +- Use explicit states: connected, loading, disconnected, guest session, drift, buffering, and playback state are visible without opening logs. +- Avoid generic AI decoration: no random blobs, ornamental gradients, nested cards, oversized empty hero areas, or decorative metrics. +- Keep controls direct and tactile: large touch targets, clear labels, predictable placement, no hidden primary actions. +- Keep diagnostics subordinate: useful for development, but visually quieter than playback. + +## Theme + +- Personality: late-night radio console, compact and technical. +- Display/body font: Rajdhani. +- Diagnostics font: JetBrains Mono. +- Background: near-black. +- Surfaces: layered blue-black panels. +- Accents: safety orange for primary actions, level-meter green for live sync, brass for warnings/session state, restrained red for failures. +- Avoid blue, cyan, and purple as dominant interface colors. +- Corners: squared receiver hardware. Use small 2-4dp radii, never bubbly cards. + +## Layout + +- Header: app identity plus connection status. +- Server row: compact editable endpoint and reconnect action. +- Mode switch: RADIO and LIBRARY are first-class modes. +- Main deck: station/library identity, track title, progress, transport, session/sync health. +- Transport controls: use a real player strip with previous, seek back, large play/pause, seek forward, and next. Avoid generic equal-width text buttons for playback. +- Radio channels: station cards that feel like playlists/radio rooms the user can join. +- Library: dense track rows for direct local-style MP3 playback from the server library. +- Queue: next few tracks for Radio mode, not the whole library. +- Diagnostics: compact, monospaced, last few events. diff --git a/android/BlastoiseNative/README.md b/android/BlastoiseNative/README.md new file mode 100644 index 0000000..dcadea8 --- /dev/null +++ b/android/BlastoiseNative/README.md @@ -0,0 +1,45 @@ +# Blastoise Native Android + +Native Kotlin listener app for the Blastoise/MusicRoom server. + +## Build + +From this directory: + +```bat +gradlew.bat :app:assembleDebug +``` + +The debug APK is written to: + +```text +app\build\outputs\apk\debug\app-debug.apk +``` + +## Run + +Use the root Android helper: + +```bat +..\run.bat +``` + +Or install manually: + +```bat +gradlew.bat :app:installDebug +adb shell am start -n com.peterino.blastoise/.MainActivity +``` + +## MVP scope + +- Defaults to `http://mhsgroove.peterino.com:3001` and auto-connects on launch. +- Saves a server URL locally. +- Uses `/api/channels` to establish an authenticated or guest session. +- Supports `/api/auth/login` for named users. +- Connects to `/api/channels/:id/ws`. +- Plays `/api/tracks/:id` through AndroidX Media3. +- Corrects playback drift when the server timestamp differs by 2 seconds or more. +- Shows channel switching, queue preview, sync health, drift, session state, and recent WebSocket events on the main screen. + +Offline track caching is intentionally not included yet. diff --git a/android/BlastoiseNative/app/build.gradle.kts b/android/BlastoiseNative/app/build.gradle.kts new file mode 100644 index 0000000..2382ab5 --- /dev/null +++ b/android/BlastoiseNative/app/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + id("com.android.application") +} + +android { + namespace = "com.peterino.blastoise" + compileSdk = 36 + + defaultConfig { + applicationId = "com.peterino.blastoise" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } +} + +dependencies { + val media3Version = "1.10.1" + + implementation("androidx.media3:media3-exoplayer:$media3Version") + implementation("androidx.media3:media3-session:$media3Version") + implementation("androidx.media3:media3-common:$media3Version") + implementation("com.squareup.okhttp3:okhttp:5.3.0") +} diff --git a/android/BlastoiseNative/app/src/main/AndroidManifest.xml b/android/BlastoiseNative/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d4dcb17 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..5f9a767 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/MainActivity.kt @@ -0,0 +1,1507 @@ +package com.peterino.blastoise + +import android.Manifest +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.ColorStateList +import android.graphics.Canvas +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.TextUtils +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +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 + private var iconSizePx = 0 + + init { + isClickable = true + isFocusable = true + } + + fun setIcon(drawable: Drawable?, color: Int, sizePx: Int) { + icon = drawable?.mutate()?.apply { setTint(color) } + iconSizePx = sizePx + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val drawable = icon ?: return + val availableWidth = width - paddingLeft - paddingRight + val availableHeight = height - paddingTop - paddingBottom + val size = min(iconSizePx, min(availableWidth, availableHeight)).coerceAtLeast(0) + val left = paddingLeft + (availableWidth - size) / 2 + val top = paddingTop + (availableHeight - size) / 2 + drawable.setBounds(left, top, left + size, top + size) + drawable.draw(canvas) + } +} + +class MainActivity : Activity(), MusicRoomClient.Listener { + private enum class ScreenMode { RADIO, 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 + + private lateinit var displayFont: Typeface + private lateinit var bodyFont: Typeface + private lateinit var bodyStrongFont: Typeface + private lateinit var monoFont: Typeface + + private lateinit var serverInput: EditText + private lateinit var statusBadge: TextView + private lateinit var radioTab: TextView + private lateinit var libraryTab: TextView + private lateinit var playlistTab: TextView + private lateinit var modeLabel: TextView + private lateinit var sourceLabel: TextView + private lateinit var titleText: TextView + private lateinit var metaText: TextView + private lateinit var elapsedText: TextView + private lateinit var durationText: TextView + private lateinit var progressSeek: SeekBar + private lateinit var prevButton: CenteredIconButton + private lateinit var rewindButton: CenteredIconButton + private lateinit var playButton: CenteredIconButton + private lateinit var forwardButton: CenteredIconButton + private lateinit var nextButton: CenteredIconButton + private lateinit var playbackModeButton: TextView + private lateinit var queueCurrentButton: TextView + private lateinit var playNextCurrentButton: TextView + private lateinit var syncBadge: TextView + private lateinit var driftBadge: TextView + private lateinit var sessionBadge: TextView + private lateinit var radioContent: LinearLayout + private lateinit var libraryContent: LinearLayout + private lateinit var playlistContent: LinearLayout + private lateinit var debugText: TextView + + private var screenMode = ScreenMode.RADIO + private var currentChannelId: String? = null + private var currentTrackId: String? = null + private var localLibraryIndex = -1 + private var lastPaused = true + 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 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 pendingState: ChannelState? = null + 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() { + updateDeck() + mainHandler.postDelayed(this, 500) + } + } + + 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) + maybeRequestNotificationPermission() + buildUi() + connectMediaController() + mainHandler.post(ticker) + mainHandler.post { connectToServer() } + } + + override fun onDestroy() { + mainHandler.removeCallbacks(ticker) + client.close() + 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 onDebug(message: String) = runOnUiThread { + addDebugEvent(message) + updateDeck() + } + + 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 + 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() + } + updateDeck() + } + + private fun buildUi() { + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(18), dp(16), dp(18), dp(24)) + setBackgroundColor(BG) + } + + val scroll = ScrollView(this).apply { + isFillViewport = true + setBackgroundColor(BG) + addView(root) + } + + 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) + root.addView(header, matchWrap()) + + val serverRow = row().apply { + gravity = Gravity.CENTER_VERTICAL + setPadding(0, dp(14), 0, dp(10)) + } + serverInput = EditText(this).apply { + setSingleLine(true) + textSize = 14f + typeface = bodyFont + setText(SessionStore.serverBaseUrl) + setTextColor(TEXT) + setHintTextColor(MUTED) + hint = "Server URL" + setPadding(dp(12), 0, dp(12), 0) + 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 { + setOnClickListener { connectToServer() } + }, LinearLayout.LayoutParams(dp(132), dp(48)).apply { leftMargin = dp(8) }) + root.addView(serverRow, 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) } } + tabs.addView(radioTab, 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))) + + val deck = panel().apply { + setPadding(dp(16), dp(16), dp(16), dp(14)) + } + val deckTop = row().apply { gravity = Gravity.CENTER_VERTICAL } + val badge = TextView(this).apply { + text = "3001" + textSize = 22f + typeface = displayFont + letterSpacing = 0.04f + gravity = Gravity.CENTER + setTextColor(BG) + background = box(ACCENT) + } + deckTop.addView(badge, LinearLayout.LayoutParams(dp(70), dp(70))) + + val deckMeta = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(14), 0, 0, 0) + } + modeLabel = text("RADIO", 12f, GREEN, Typeface.BOLD) + sourceLabel = text("No channel", 24f, TEXT, Typeface.BOLD).apply { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + metaText = text("JOIN A STATION", 13f, MUTED) + deckMeta.addView(modeLabel, matchWrap()) + deckMeta.addView(sourceLabel, matchWrap()) + deckMeta.addView(metaText, matchWrap()) + deckTop.addView(deckMeta, weightWrap(1f)) + deck.addView(deckTop, matchWrap()) + + titleText = text("No track", 28f, TEXT, Typeface.BOLD).apply { + setPadding(0, dp(18), 0, dp(10)) + maxLines = 3 + ellipsize = TextUtils.TruncateAt.END + } + deck.addView(titleText, matchWrap()) + + val timeRow = row().apply { gravity = Gravity.CENTER_VERTICAL } + elapsedText = text("0:00", 13f, MUTED, Typeface.BOLD) + durationText = text("--:--", 13f, MUTED, Typeface.BOLD).apply { gravity = Gravity.END } + timeRow.addView(elapsedText, weightWrap(1f)) + timeRow.addView(durationText, weightWrap(1f)) + deck.addView(timeRow, matchWrap()) + + progressSeek = SeekBar(this).apply { + max = 10000 + progressTintList = ColorStateList.valueOf(GREEN) + progressBackgroundTintList = ColorStateList.valueOf(PANEL2) + thumbTintList = ColorStateList.valueOf(TEXT) + setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onStartTrackingTouch(seekBar: SeekBar) { + userSeeking = true + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + val duration = lastTrackDuration + if (duration > 0.0) { + val target = duration * seekBar.progress.toDouble() / seekBar.max.toDouble() + if (isRadioBacked()) { + client.sendSeek(target) + } else { + controller?.seekTo((target * 1000.0).toLong()) + } + } + userSeeking = false + } + + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) = Unit + }) + } + deck.addView(progressSeek, matchWrap()) + + val deckFace = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + background = box(PANEL2, STROKE) + setPadding(dp(10), dp(10), dp(10), dp(10)) + } + + val transportLabel = row().apply { gravity = Gravity.CENTER_VERTICAL } + transportLabel.addView(text("TRANSPORT", 12f, MUTED, Typeface.BOLD).apply { + letterSpacing = 0.08f + }, weightWrap(1f)) + transportLabel.addView(text("TRACK / SEEK", 12f, MUTED, Typeface.BOLD).apply { + gravity = Gravity.END + letterSpacing = 0.08f + }, weightWrap(1f)) + deckFace.addView(transportLabel, matchWrap()) + + val controls = row().apply { + gravity = Gravity.CENTER + setPadding(0, dp(8), 0, 0) + } + prevButton = iconButton(R.drawable.ic_skip_previous, PANEL, TEXT, 26).apply { setOnClickListener { previous() } } + rewindButton = iconButton(R.drawable.ic_seek_back, PANEL, TEXT, 28).apply { setOnClickListener { seekBy(-15) } } + playButton = iconButton(R.drawable.ic_play, GREEN, BG, 30, stroke = false).apply { setOnClickListener { togglePlay() } } + forwardButton = iconButton(R.drawable.ic_seek_forward, PANEL, TEXT, 28).apply { setOnClickListener { seekBy(15) } } + nextButton = iconButton(R.drawable.ic_skip_next, PANEL, TEXT, 26).apply { setOnClickListener { next() } } + controls.addView(prevButton, smallControlParams()) + controls.addView(rewindButton, smallControlParams()) + controls.addView(playButton, mainControlParams()) + controls.addView(forwardButton, smallControlParams()) + controls.addView(nextButton, smallControlParams()) + deckFace.addView(controls, matchWrap()) + + val queueControls = row().apply { + gravity = Gravity.CENTER + setPadding(0, dp(8), 0, 0) + } + playbackModeButton = transportButton("LOOP ALL", PANEL, TEXT, iconRes = R.drawable.ic_repeat).apply { setOnClickListener { cyclePlaybackMode() } } + queueCurrentButton = transportButton("QUEUE", PANEL, TEXT, iconRes = R.drawable.ic_queue_add).apply { setOnClickListener { queueCurrentTrack(false) } } + playNextCurrentButton = transportButton("NEXT", PANEL, TEXT, iconRes = R.drawable.ic_play_next).apply { setOnClickListener { queueCurrentTrack(true) } } + queueControls.addView(playbackModeButton, wideControlParams()) + 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 } + syncBadge = tag("LIVE", GREEN, BG) + driftBadge = tag("DRIFT", PANEL2, TEXT) + sessionBadge = tag("SESSION", PURPLE, BG) + meters.addView(syncBadge) + meters.addView(driftBadge, leftGap()) + meters.addView(sessionBadge, leftGap()) + deck.addView(meters, matchWrap()) + root.addView(deck, matchWrapWithTop(dp(12))) + + radioContent = 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))) + + val debugPanel = panel().apply { + setPadding(dp(14), dp(12), dp(14), dp(12)) + } + debugPanel.addView(sectionTitle("DIAGNOSTICS"), matchWrap()) + debugText = TextView(this).apply { + textSize = 11f + typeface = monoFont + setTextColor(MUTED) + setPadding(0, dp(8), 0, 0) + text = "Starting..." + } + debugPanel.addView(debugText, matchWrap()) + root.addView(debugPanel, matchWrapWithTop(dp(16))) + + setContentView(scroll) + renderMode() + } + + private fun connectToServer() { + SessionStore.setBaseUrl(serverInput.text.toString()) + serverInput.setText(SessionStore.serverBaseUrl) + 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() + } + } + + private fun switchMode(mode: ScreenMode) { + if (screenMode == mode) return + screenMode = mode + 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() + } + ScreenMode.RADIO -> { + lastStatus = "Radio" + currentTrackId = null + localLibraryIndex = -1 + val target = currentChannelId ?: lastChannels.firstOrNull { it.isDefault }?.id ?: lastChannels.firstOrNull()?.id + if (target != null) joinChannel(target, ScreenMode.RADIO) + } + ScreenMode.PLAYLISTS -> { + lastStatus = if (playlistsLoaded) "Playlists" else "Loading 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) + } + } + } + updateDeck() + } + + 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) + 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 + libraryContent.visibility = if (screenMode == ScreenMode.LIBRARY) View.VISIBLE else View.GONE + playlistContent.visibility = if (screenMode == ScreenMode.PLAYLISTS) View.VISIBLE else View.GONE + renderStations() + renderLibrary() + renderPlaylists() + } + + private fun joinChannel(channelId: String, targetMode: ScreenMode = ScreenMode.RADIO) { + screenMode = targetMode + renderMode() + currentChannelId = channelId + localLibraryIndex = -1 + lastStatus = "Joining" + renderStations() + updateDeck() + client.connectChannel(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() + } + + 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() + } + + private fun queueCurrentTrack(playNext: Boolean) { + val trackId = currentTrackId ?: libraryTracks.getOrNull(localLibraryIndex)?.id + if (trackId == null) { + lastStatus = "No track" + updateDeck() + return + } + queueTrackIds(listOf(trackId), playNext) + } + + private fun queueTrack(track: Track, playNext: Boolean) { + queueTrackIds(listOf(track.id), 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) + } + + private fun addPlaylistToQueue(playlist: Playlist, playNext: Boolean) { + if (playlist.trackIds.isEmpty()) { + lastStatus = "Empty playlist" + updateDeck() + return + } + queueTrackIds(playlist.trackIds, playNext) + } + + private fun selectPlaylist(playlistId: String) { + selectedPlaylistId = playlistId + selectedPlaylist = null + renderPlaylists() + client.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() + } + + 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) + } + } + + private fun next() { + if (isRadioBacked()) { + jumpRadio(1) + } else if (libraryTracks.isNotEmpty()) { + val nextIndex = nextLibraryIndex() + playLibraryTrack(nextIndex) + } + } + + 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() + } + + 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() + } + + private fun jumpRadio(delta: Int) { + if (lastQueue.isEmpty()) return + val target = (lastCurrentIndex + delta + lastQueue.size) % lastQueue.size + client.sendJump(target) + } + + private fun isRadioBacked(): Boolean { + return screenMode == ScreenMode.RADIO || screenMode == ScreenMode.PLAYLISTS + } + + private fun applyLocalPlaybackMode() { + controller?.repeatMode = if (playbackMode == "repeat-one") { + Player.REPEAT_MODE_ONE + } else { + Player.REPEAT_MODE_OFF + } + } + + 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" + "repeat-all" -> "LOOP ALL" + "repeat-one" -> "LOOP ONE" + "shuffle" -> "SHUFFLE" + else -> mode.uppercase() + } + } + + private fun allPlaylists(): List { + return myPlaylists + sharedPlaylists + } + + private fun trackById(trackId: String): Track { + return libraryTracks.firstOrNull { it.id == trackId } + ?: lastQueue.firstOrNull { it.id == trackId } + ?: Track(trackId, trackId, trackId.take(24), 0.0) + } + + private fun renderStations() { + if (!::radioContent.isInitialized) return + radioContent.removeAllViews() + radioContent.addView(sectionTitle("RADIO STATIONS"), matchWrap()) + if (lastChannels.isEmpty()) { + radioContent.addView(emptyLine("Loading stations..."), matchWrapWithTop(dp(8))) + return + } + + for (channel in lastChannels) { + val selected = channel.id == currentChannelId + val card = row().apply { + gravity = Gravity.CENTER_VERTICAL + setPadding(0, 0, 0, 0) + background = box(if (selected) PANEL2 else PANEL, if (selected) ACCENT else STROKE) + } + val rail = View(this).apply { + setBackgroundColor(if (selected) ACCENT else STROKE) + } + card.addView(rail, LinearLayout.LayoutParams(dp(5), ViewGroup.LayoutParams.MATCH_PARENT)) + + val info = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(12), dp(10), dp(10), dp(10)) + } + info.addView(text(channel.name, 21f, TEXT, Typeface.BOLD).apply { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, matchWrap()) + val detail = when { + channel.description.isNotBlank() -> channel.description + channel.isDefault -> "Default library broadcast" + else -> "Shared station" + } + info.addView(text("$detail | ${channel.listenerCount} listening", 13f, MUTED), matchWrap()) + card.addView(info, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + + val join = button( + if (selected) "LIVE" else "JOIN", + if (selected) GREEN else ACCENT, + BG, + Typeface.BOLD, + R.drawable.ic_radio, + ) + join.setOnClickListener { joinChannel(channel.id) } + card.setOnClickListener { joinChannel(channel.id) } + card.addView(join, LinearLayout.LayoutParams(dp(96), dp(48)).apply { rightMargin = dp(10) }) + radioContent.addView(card, matchWrapWithTop(dp(8))) + } + + radioContent.addView(sectionTitle("UP NEXT"), matchWrapWithTop(dp(16))) + val queuePanel = panel().apply { + setPadding(dp(12), dp(10), dp(12), dp(10)) + } + when { + !lastQueueLoaded -> queuePanel.addView(emptyLine("Waiting for queue snapshot"), matchWrap()) + lastQueue.isEmpty() -> queuePanel.addView(emptyLine("Queue is empty"), matchWrap()) + lastQueue.size == 1 -> queuePanel.addView(emptyLine("Only this track is queued"), matchWrap()) + else -> { + val count = min(4, lastQueue.size - 1) + for (offset in 1..count) { + val index = (lastCurrentIndex + offset) % lastQueue.size + val track = lastQueue[index] + queuePanel.addView(trackLine("${offset}. ${track.title}", if (offset == 1) TEXT else MUTED), matchWrap()) + } + } + } + radioContent.addView(queuePanel, matchWrapWithTop(dp(8))) + } + + private fun renderLibrary() { + if (!::libraryContent.isInitialized) return + libraryContent.removeAllViews() + libraryContent.addView(sectionTitle("LIBRARY"), matchWrap()) + libraryContent.addView(text( + if (libraryLoaded) "${libraryTracks.size} tracks available for local playback" else "Loading local player library...", + 14f, + MUTED, + ), matchWrapWithTop(dp(2))) + + if (!libraryLoaded) { + libraryContent.addView(emptyLine("Library will appear here"), matchWrapWithTop(dp(12))) + return + } + if (libraryTracks.isEmpty()) { + libraryContent.addView(emptyLine("No tracks found"), matchWrapWithTop(dp(12))) + return + } + + val maxRows = min(80, libraryTracks.size) + for (i in 0 until maxRows) { + val track = libraryTracks[i] + val selected = i == localLibraryIndex + val row = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(12), dp(9), dp(10), dp(9)) + background = box(if (selected) PANEL2 else PANEL, if (selected) ACCENT else STROKE) + setOnClickListener { playLibraryTrack(i) } + } + val index = text((i + 1).toString().padStart(2, '0'), 13f, if (selected) ACCENT else MUTED, Typeface.BOLD).apply { + gravity = Gravity.CENTER + } + row.addView(index, LinearLayout.LayoutParams(dp(34), ViewGroup.LayoutParams.WRAP_CONTENT)) + + val title = text(track.title, 17f, if (selected) TEXT else MUTED, if (selected) Typeface.BOLD else Typeface.NORMAL).apply { + maxLines = 1 + 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 { + 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)).apply { rightMargin = dp(8) }) + 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))) + } + } + + private fun renderPlaylists() { + if (!::playlistContent.isInitialized) return + playlistContent.removeAllViews() + playlistContent.addView(sectionTitle("PLAYLISTS"), matchWrap()) + playlistContent.addView(text( + if (playlistsLoaded) "${myPlaylists.size} mine | ${sharedPlaylists.size} shared" else "Loading playlist controls...", + 14f, + MUTED, + ), matchWrapWithTop(dp(2))) + + if (!playlistsLoaded) { + playlistContent.addView(emptyLine("Playlists will appear here"), matchWrapWithTop(dp(12))) + return + } + + val all = allPlaylists() + if (all.isEmpty()) { + playlistContent.addView(emptyLine("No playlists found"), matchWrapWithTop(dp(12))) + return + } + + val listPanel = panel().apply { + setPadding(dp(10), dp(8), dp(10), dp(10)) + } + for (playlist in all.take(24)) { + val selected = playlist.id == selectedPlaylistId + val isMine = myPlaylists.any { it.id == playlist.id } + 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) + 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 { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, matchWrap()) + val owner = when { + isMine -> "mine" + playlist.ownerName.isNotBlank() -> "by ${playlist.ownerName}" + else -> "shared" + } + info.addView(text("${playlist.trackIds.size} tracks | $owner", 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))) + } + playlistContent.addView(listPanel, matchWrapWithTop(dp(8))) + + val selected = selectedPlaylist + val selectedId = selectedPlaylistId + playlistContent.addView(sectionTitle("PLAYLIST TRACKS"), matchWrapWithTop(dp(16))) + if (selectedId != null && selected == null) { + playlistContent.addView(emptyLine("Loading playlist..."), matchWrapWithTop(dp(8))) + return + } + if (selected == null) { + playlistContent.addView(emptyLine("Select a playlist"), matchWrapWithTop(dp(8))) + return + } + + val detailPanel = panel().apply { + setPadding(dp(10), dp(8), dp(10), dp(10)) + } + val detailHeader = row().apply { gravity = Gravity.CENTER_VERTICAL } + detailHeader.addView(text(selected.name, 20f, 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))) + } + for (i in 0 until maxRows) { + 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)) + background = box(PANEL, STROKE) + } + row.addView(text((i + 1).toString().padStart(2, '0'), 12f, MUTED, Typeface.BOLD).apply { + gravity = Gravity.CENTER + }, LinearLayout.LayoutParams(dp(32), ViewGroup.LayoutParams.WRAP_CONTENT)) + row.addView(text(track.title, 16f, MUTED).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))) + } + if (selected.trackIds.size > maxRows) { + detailPanel.addView(emptyLine("Showing first $maxRows tracks"), matchWrapWithTop(dp(8))) + } + playlistContent.addView(detailPanel, matchWrapWithTop(dp(8))) + } + + private fun connectMediaController() { + val token = SessionToken(this, ComponentName(this, PlaybackService::class.java)) + val future = MediaController.Builder(this, token).buildAsync() + controllerFuture = future + 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) } + updateDeck() + }, + Executor { command -> runOnUiThread(command) }, + ) + } + + 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 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" + statusBadge.text = "LIBRARY" + } + statusBadge.background = box(statusColor()) + modeLabel.text = when (screenMode) { + ScreenMode.RADIO -> "RADIO" + ScreenMode.LIBRARY -> "LIBRARY" + ScreenMode.PLAYLISTS -> "PLAYLISTS" + } + sourceLabel.text = if (screenMode == ScreenMode.LIBRARY) "Local files" else lastChannelName + metaText.text = when (screenMode) { + ScreenMode.RADIO -> "JOINED STATION" + ScreenMode.LIBRARY -> "${libraryTracks.size} TRACKS | MP3 PLAYER MODE" + ScreenMode.PLAYLISTS -> "${allPlaylists().size} PLAYLISTS | QUEUE CONTROLS" + } + 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) + val modeForeground = if (playbackMode == "shuffle") BG else TEXT + playbackModeButton.setTextColor(modeForeground) + playbackModeButton.background = box(if (playbackMode == "shuffle") ACCENT else PANEL, STROKE) + setButtonIcon( + playbackModeButton, + if (playbackMode == "shuffle") R.drawable.ic_shuffle else R.drawable.ic_repeat, + modeForeground, + 20, + ) + val currentActionColor = if (currentTrackId == null) MUTED else TEXT + queueCurrentButton.setTextColor(currentActionColor) + playNextCurrentButton.setTextColor(currentActionColor) + setButtonIcon(queueCurrentButton, R.drawable.ic_queue_add, currentActionColor, 20) + setButtonIcon(playNextCurrentButton, R.drawable.ic_play_next, currentActionColor, 20) + 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" + + val visualPositionMs = if (isRadioBacked()) expectedMs else playerMs + 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) + .toInt() + .coerceIn(0, progressSeek.max) + progressSeek.progress = progress + } else if (!userSeeking && durationMs <= 0) { + progressSeek.progress = 0 + } + + 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("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("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 + ) { + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 100) + } + } + + private fun fmtTime(totalSeconds: Long): String { + val seconds = max(0L, totalSeconds) + val minutes = seconds / 60 + val remainder = seconds % 60 + return "$minutes:${remainder.toString().padStart(2, '0')}" + } + + private fun statusColor(): Int { + return when { + lastStatus.contains("error", ignoreCase = true) -> RED + lastStatus.contains("disconnect", ignoreCase = true) -> RED + lastStatus.contains("loading", ignoreCase = true) -> AMBER + else -> GREEN + } + } + + private fun driftColor(driftMs: Long): Int { + val drift = abs(driftMs) + return when { + drift < 1000 -> GREEN + drift < 2000 -> AMBER + else -> RED + } + } + + private fun row() = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + } + + private fun panel() = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + background = box(PANEL, STROKE) + } + + private fun text(value: String, size: Float, color: Int, style: Int = Typeface.NORMAL) = + TextView(this).apply { + text = value + textSize = size + setTextColor(color) + typeface = when (style) { + Typeface.BOLD -> displayFont + else -> bodyFont + } + includeFontPadding = true + } + + private fun sectionTitle(value: String) = text(value, 17f, TEXT, Typeface.BOLD).apply { + letterSpacing = 0.05f + } + + private fun emptyLine(value: String) = text(value, 14f, MUTED).apply { + setPadding(0, dp(4), 0, dp(4)) + } + + private fun trackLine(value: String, color: Int) = text(value, 15f, color).apply { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + setPadding(0, dp(5), 0, dp(5)) + } + + private fun button( + label: String, + bg: Int, + fg: Int, + style: Int = Typeface.NORMAL, + iconRes: Int? = null, + iconSize: Int = 18, + ) = TextView(this).apply { + text = label + textSize = 14f + typeface = if (style == Typeface.BOLD) bodyStrongFont else bodyFont + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + gravity = Gravity.CENTER + setTextColor(fg) + letterSpacing = 0.06f + minHeight = dp(44) + setPadding(dp(12), 0, dp(12), 0) + background = box(bg) + setButtonIcon(this, iconRes, fg, iconSize) + if (label.isBlank() && iconRes != null) contentDescription = iconDescription(iconRes) + isClickable = true + isFocusable = true + } + + private fun transportButton( + label: String, + bg: Int, + fg: Int, + style: Int = Typeface.NORMAL, + iconRes: Int? = null, + iconSize: Int = 22, + ) = TextView(this).apply { + text = label + textSize = if (style == Typeface.BOLD) 15f else 13f + typeface = if (style == Typeface.BOLD) bodyStrongFont else monoFont + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + gravity = Gravity.CENTER + setTextColor(fg) + letterSpacing = 0.06f + minHeight = dp(54) + setPadding(dp(6), 0, dp(6), 0) + background = box(bg, if (bg == GREEN) null else STROKE) + setButtonIcon(this, iconRes, fg, iconSize) + if (label.isBlank() && iconRes != null) contentDescription = iconDescription(iconRes) + isClickable = true + isFocusable = true + } + + private fun iconButton( + iconRes: Int, + bg: Int, + fg: Int, + iconSize: Int = 24, + stroke: Boolean = true, + ) = CenteredIconButton(this).apply { + background = box(bg, if (stroke) STROKE else null) + 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, + ) + + private fun setButtonIcon(view: TextView, iconRes: Int?, color: Int, sizeDp: Int = 18) { + if (iconRes == null) { + view.setCompoundDrawables(null, null, null, null) + return + } + val drawable = getDrawable(iconRes)?.mutate() ?: return + val size = dp(sizeDp) + drawable.setBounds(0, 0, size, size) + drawable.setTint(color) + if (view.text.isNullOrBlank()) { + view.setCompoundDrawables(drawable, null, null, null) + view.compoundDrawablePadding = 0 + } else { + view.setCompoundDrawables(drawable, null, null, null) + view.compoundDrawablePadding = dp(7) + } + } + + private fun setIconButtonIcon(view: CenteredIconButton, iconRes: Int, color: Int, sizeDp: Int = 24) { + view.setIcon(getDrawable(iconRes), color, dp(sizeDp)) + } + + private fun iconDescription(iconRes: Int): String { + return when (iconRes) { + R.drawable.ic_connect -> "Connect" + R.drawable.ic_library -> "Library" + R.drawable.ic_pause -> "Pause" + R.drawable.ic_play -> "Play" + R.drawable.ic_play_next -> "Play next" + R.drawable.ic_playlist -> "Playlists" + R.drawable.ic_queue_add -> "Add to queue" + R.drawable.ic_radio -> "Radio" + R.drawable.ic_repeat -> "Playback mode" + R.drawable.ic_seek_back -> "Seek back 15 seconds" + R.drawable.ic_seek_forward -> "Seek forward 15 seconds" + R.drawable.ic_shuffle -> "Shuffle" + R.drawable.ic_skip_next -> "Next track" + R.drawable.ic_skip_previous -> "Previous track" + else -> "Control" + } + } + + private fun tag(label: String, bg: Int, fg: Int) = TextView(this).apply { + text = label + textSize = 12f + typeface = bodyStrongFont + gravity = Gravity.CENTER + setTextColor(fg) + letterSpacing = 0.06f + 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 matchWrap() = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + + private fun matchWrapWithTop(top: Int) = matchWrap().apply { + topMargin = top + } + + private fun weightWrap(weight: Float) = LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + weight, + ) + + private fun leftGap() = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + leftMargin = dp(8) + } + + private fun smallControlParams() = LinearLayout.LayoutParams( + 0, + dp(56), + 1.25f, + ).apply { + leftMargin = dp(3) + rightMargin = dp(3) + } + + private fun mainControlParams() = LinearLayout.LayoutParams( + 0, + dp(64), + 1.7f, + ).apply { + leftMargin = dp(4) + rightMargin = dp(4) + } + + private fun wideControlParams() = LinearLayout.LayoutParams( + 0, + dp(52), + 1f, + ).apply { + leftMargin = dp(3) + rightMargin = dp(3) + } + + 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() + } +} 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 new file mode 100644 index 0000000..4c2986b --- /dev/null +++ b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/Models.kt @@ -0,0 +1,117 @@ +package com.peterino.blastoise + +import org.json.JSONArray +import org.json.JSONObject + +data class Track( + val id: String, + val filename: String, + val title: String, + val duration: Double, +) + +data class ChannelInfo( + val id: String, + val name: String, + val description: String, + val listenerCount: Int, + val isDefault: Boolean, +) + +data class ChannelState( + val track: Track?, + val currentTimestamp: Double, + val channelName: String, + val channelId: String, + val paused: Boolean, + val queue: List?, + val currentIndex: Int, + val playbackMode: String, +) + +data class Playlist( + val id: String, + val name: String, + val description: String, + val ownerId: Int, + val ownerName: String, + val isPublic: Boolean, + val shareToken: String?, + val trackIds: List, +) + +data class PlaylistBundle( + val mine: List, + val shared: List, +) + +fun JSONObject.toTrack(): Track { + return Track( + id = optString("id", optString("filename")), + filename = optString("filename"), + title = optString("title", optString("filename", "Unknown")), + duration = optDouble("duration", 0.0), + ) +} + +fun JSONObject.toChannelInfo(): ChannelInfo { + return ChannelInfo( + id = getString("id"), + name = optString("name", "Channel"), + description = optString("description", ""), + listenerCount = optInt("listenerCount", 0), + isDefault = optBoolean("isDefault", false), + ) +} + +fun JSONObject.toChannelState(): ChannelState { + val trackJson = optJSONObject("track") + val queueJson = optJSONArray("queue") + val queue = if (queueJson == null) { + null + } else { + (0 until queueJson.length()).map { queueJson.getJSONObject(it).toTrack() } + } + + return ChannelState( + track = trackJson?.toTrack(), + currentTimestamp = optDouble("currentTimestamp", 0.0), + channelName = optString("channelName", ""), + channelId = optString("channelId", ""), + paused = optBoolean("paused", true), + queue = queue, + currentIndex = optInt("currentIndex", 0), + playbackMode = optString("playbackMode", "repeat-all"), + ) +} + +fun JSONObject.toPlaylist(): Playlist { + return Playlist( + id = getString("id"), + name = optString("name", "Playlist"), + description = optString("description", ""), + ownerId = optInt("ownerId", 0), + ownerName = optString("ownerName", ""), + isPublic = optBoolean("isPublic", false), + shareToken = if (isNull("shareToken")) null else getString("shareToken"), + trackIds = optJSONArray("trackIds").toStringList(), + ) +} + +fun JSONArray.toChannels(): List { + return (0 until length()).map { getJSONObject(it).toChannelInfo() } +} + +fun JSONArray.toTracks(): List { + return (0 until length()).map { getJSONObject(it).toTrack() } +} + +fun JSONArray?.toPlaylists(): List { + if (this == null) return emptyList() + return (0 until length()).map { getJSONObject(it).toPlaylist() } +} + +fun JSONArray?.toStringList(): List { + if (this == null) return emptyList() + return (0 until length()).mapNotNull { optString(it).takeIf(String::isNotBlank) } +} 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 new file mode 100644 index 0000000..faaa16d --- /dev/null +++ b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/MusicRoomClient.kt @@ -0,0 +1,373 @@ +package com.peterino.blastoise + +import android.content.Context +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +class MusicRoomClient( + private val context: Context, + private val listener: Listener, +) { + interface Listener { + fun onStatus(message: String) + fun onDebug(message: String) + fun onChannels(channels: List) + fun onLibrary(tracks: List) + fun onPlaylists(playlists: PlaylistBundle) + fun onPlaylistDetail(playlist: Playlist) + fun onChannelState(state: ChannelState) + fun onSwitched(channelId: String) + fun onError(message: String) + } + + private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.SECONDS) + .build() + + private var socket: WebSocket? = null + + fun close() { + socket?.close(1000, "closing") + socket = null + } + + fun login(username: String, password: String) { + listener.onDebug("POST /api/auth/login") + val body = JSONObject() + .put("username", username) + .put("password", password) + .toString() + .toRequestBody("application/json".toMediaType()) + + val request = request("/api/auth/login") + .post(body) + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + listener.onDebug("login failure: ${e.message ?: "network error"}") + listener.onError("Login failed: ${e.message ?: "network error"}") + } + + override fun onResponse(call: Call, response: Response) { + response.use { + captureCookie(it) + listener.onDebug("login HTTP ${it.code}; cookie=${SessionStore.cookieHeader.isNotBlank()}") + if (!it.isSuccessful) { + listener.onError("Login failed: HTTP ${it.code}") + return + } + SessionStore.save(context) + listener.onStatus("Signed in") + loadChannels() + } + } + }) + } + + fun loadChannels() { + listener.onDebug("GET /api/channels ${SessionStore.serverBaseUrl}") + val request = request("/api/channels").build() + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + listener.onDebug("channels failure: ${e.message ?: "network error"}") + listener.onError("Could not load channels: ${e.message ?: "network error"}") + } + + override fun onResponse(call: Call, response: Response) { + response.use { + captureCookie(it) + SessionStore.save(context) + listener.onDebug("channels HTTP ${it.code}; cookie=${SessionStore.cookieHeader.isNotBlank()}") + if (!it.isSuccessful) { + listener.onError("Could not load channels: HTTP ${it.code}") + return + } + val json = it.body.string() + val channels = JSONArray(json).toChannels() + listener.onDebug("channels parsed: ${channels.size}") + listener.onChannels(channels) + listener.onStatus("Loaded ${channels.size} channel(s)") + } + } + }) + } + + fun loadLibrary() { + listener.onDebug("GET /api/library") + val request = request("/api/library").build() + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + listener.onDebug("library failure: ${e.message ?: "network error"}") + listener.onError("Could not load library: ${e.message ?: "network error"}") + } + + override fun onResponse(call: Call, response: Response) { + response.use { + captureCookie(it) + SessionStore.save(context) + listener.onDebug("library HTTP ${it.code}; cookie=${SessionStore.cookieHeader.isNotBlank()}") + if (!it.isSuccessful) { + listener.onError("Could not load library: HTTP ${it.code}") + return + } + val tracks = JSONArray(it.body.string()).toTracks() + listener.onDebug("library parsed: ${tracks.size}") + listener.onLibrary(tracks) + } + } + }) + } + + fun loadPlaylists() { + listener.onDebug("GET /api/playlists") + val request = request("/api/playlists").build() + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + listener.onDebug("playlists failure: ${e.message ?: "network error"}") + listener.onError("Could not load playlists: ${e.message ?: "network error"}") + } + + override fun onResponse(call: Call, response: Response) { + response.use { + captureCookie(it) + SessionStore.save(context) + listener.onDebug("playlists HTTP ${it.code}; cookie=${SessionStore.cookieHeader.isNotBlank()}") + if (!it.isSuccessful) { + listener.onError("Could not load playlists: HTTP ${it.code}") + return + } + val json = JSONObject(it.body.string()) + val bundle = PlaylistBundle( + mine = json.optJSONArray("mine").toPlaylists(), + shared = json.optJSONArray("shared").toPlaylists(), + ) + listener.onDebug("playlists parsed: mine=${bundle.mine.size} shared=${bundle.shared.size}") + listener.onPlaylists(bundle) + } + } + }) + } + + fun loadPlaylist(playlistId: String) { + listener.onDebug("GET /api/playlists/$playlistId") + val request = request("/api/playlists/$playlistId").build() + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + listener.onDebug("playlist detail failure: ${e.message ?: "network error"}") + listener.onError("Could not load playlist: ${e.message ?: "network error"}") + } + + override fun onResponse(call: Call, response: Response) { + response.use { + captureCookie(it) + SessionStore.save(context) + listener.onDebug("playlist detail HTTP ${it.code}") + if (!it.isSuccessful) { + listener.onError("Could not load playlist: HTTP ${it.code}") + return + } + listener.onPlaylistDetail(JSONObject(it.body.string()).toPlaylist()) + } + } + }) + } + + fun connectChannel(channelId: String) { + socket?.close(1000, "switching") + listener.onDebug("WS connecting channel=$channelId") + val builder = Request.Builder() + .url(SessionStore.wsUrl(channelId)) + .header("User-Agent", SessionStore.userAgent) + if (SessionStore.cookieHeader.isNotBlank()) { + builder.header("Cookie", SessionStore.cookieHeader) + } + + socket = client.newWebSocket(builder.build(), object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + listener.onDebug("WS open HTTP ${response.code}") + listener.onStatus("Connected") + loadChannelState(channelId) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + handleSocketMessage(text) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + listener.onDebug("WS failure ${response?.code ?: "-"}: ${t.message ?: "websocket error"}") + listener.onError("Disconnected: ${t.message ?: "websocket error"}") + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + listener.onDebug("WS closed $code $reason") + listener.onStatus("Disconnected") + } + }) + } + + fun sendAction(action: String) { + listener.onDebug("WS send action=$action") + socket?.send(JSONObject().put("action", action).toString()) + } + + fun sendSeek(timestamp: Double) { + listener.onDebug("WS send seek=${"%.1f".format(timestamp)}") + socket?.send( + JSONObject() + .put("action", "seek") + .put("timestamp", timestamp) + .toString() + ) + } + + fun sendJump(index: Int) { + listener.onDebug("WS send jump=$index") + socket?.send( + JSONObject() + .put("action", "jump") + .put("index", index) + .toString() + ) + } + + fun setPlaybackMode(channelId: String, mode: String) { + listener.onDebug("POST /api/channels/$channelId/mode $mode") + val body = JSONObject() + .put("mode", mode) + .toString() + .toRequestBody("application/json".toMediaType()) + val request = request("/api/channels/$channelId/mode") + .post(body) + .build() + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + listener.onDebug("mode failure: ${e.message ?: "network error"}") + listener.onError("Mode failed: ${e.message ?: "network error"}") + } + + override fun onResponse(call: Call, response: Response) { + response.use { + listener.onDebug("mode HTTP ${it.code}") + if (it.code == 403) { + listener.onError("Mode denied") + } else if (!it.isSuccessful) { + listener.onError("Mode failed: HTTP ${it.code}") + } else { + listener.onStatus("Mode set") + } + } + } + }) + } + + fun addTracksToQueue(channelId: String, trackIds: List, insertAt: Int? = null) { + if (trackIds.isEmpty()) return + val insertLabel = insertAt?.let { " insertAt=$it" } ?: "" + listener.onDebug("PATCH /api/channels/$channelId/queue add=${trackIds.size}$insertLabel") + val bodyJson = JSONObject().put("add", JSONArray(trackIds)) + if (insertAt != null) bodyJson.put("insertAt", insertAt) + val request = request("/api/channels/$channelId/queue") + .patch(bodyJson.toString().toRequestBody("application/json".toMediaType())) + .build() + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + listener.onDebug("queue add failure: ${e.message ?: "network error"}") + listener.onError("Queue failed: ${e.message ?: "network error"}") + } + + override fun onResponse(call: Call, response: Response) { + response.use { + listener.onDebug("queue add HTTP ${it.code}") + if (it.code == 403) { + listener.onError("Queue denied") + } else if (!it.isSuccessful) { + listener.onError("Queue failed: HTTP ${it.code}") + } else { + listener.onStatus(if (insertAt == null) "Queued ${trackIds.size}" else "Play next queued") + currentChannelFromQueuePatch(channelId) + } + } + } + }) + } + + private fun loadChannelState(channelId: String) { + listener.onDebug("GET /api/channels/$channelId") + val request = request("/api/channels/$channelId").build() + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + listener.onDebug("channel state failure: ${e.message ?: "network error"}") + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { + listener.onDebug("channel state HTTP ${it.code}") + return + } + val json = JSONObject(it.body.string()) + listener.onDebug("channel state HTTP ${it.code}; queue=${json.optJSONArray("queue")?.length() ?: 0}") + listener.onChannelState(json.toChannelState()) + } + } + }) + } + + private fun currentChannelFromQueuePatch(channelId: String) { + loadChannelState(channelId) + loadChannels() + } + + private fun handleSocketMessage(text: String) { + val json = JSONObject(text) + val type = json.optString("type", "") + val summary = when { + type.isNotBlank() -> "type=$type" + json.has("track") -> "state track=${json.optJSONObject("track")?.optString("title", "unknown") ?: "none"}" + else -> "state" + } + listener.onDebug("WS message $summary") + + when (type) { + "" -> listener.onChannelState(json.toChannelState()) + "channel_list" -> { + val channels = json.optJSONArray("channels")?.toChannels().orEmpty() + listener.onChannels(channels) + } + "switched" -> listener.onSwitched(json.optString("channelId")) + "kick" -> listener.onError("Disconnected: ${json.optString("reason", "kicked")}") + "error" -> listener.onError(json.optString("message", "Server error")) + else -> listener.onDebug("ignored server event type=$type") + } + } + + private fun request(path: String): Request.Builder { + val builder = Request.Builder() + .url(SessionStore.httpUrl(path)) + .header("User-Agent", SessionStore.userAgent) + if (SessionStore.cookieHeader.isNotBlank()) { + builder.header("Cookie", SessionStore.cookieHeader) + } + return builder + } + + private fun captureCookie(response: Response) { + response.headers("Set-Cookie") + .map { it.substringBefore(";") } + .firstOrNull { it.startsWith("blastoise_session=") } + ?.let { SessionStore.cookieHeader = 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 new file mode 100644 index 0000000..8151b63 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/PlaybackService.kt @@ -0,0 +1,62 @@ +package com.peterino.blastoise + +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService + +@UnstableApi +class PlaybackService : MediaSessionService() { + private var mediaSession: MediaSession? = null + + override fun onCreate() { + super.onCreate() + + val httpDataSourceFactory = DataSource.Factory { + val requestProperties = mutableMapOf() + if (SessionStore.cookieHeader.isNotBlank()) { + requestProperties["Cookie"] = SessionStore.cookieHeader + } + + DefaultHttpDataSource.Factory() + .setUserAgent(SessionStore.userAgent) + .setAllowCrossProtocolRedirects(true) + .setDefaultRequestProperties(requestProperties) + .createDataSource() + } + + val mediaSourceFactory = DefaultMediaSourceFactory(this) + .setDataSourceFactory(httpDataSourceFactory) + + val audioAttributes = AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .build() + + val player = ExoPlayer.Builder(this) + .setMediaSourceFactory(mediaSourceFactory) + .setAudioAttributes(audioAttributes, true) + .setHandleAudioBecomingNoisy(true) + .build() + + mediaSession = MediaSession.Builder(this, player).build() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + return mediaSession + } + + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + } + mediaSession = null + super.onDestroy() + } +} 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 new file mode 100644 index 0000000..62e2358 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/java/com/peterino/blastoise/SessionStore.kt @@ -0,0 +1,62 @@ +package com.peterino.blastoise + +import android.content.Context +import java.net.URI +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +object SessionStore { + const val defaultServerBaseUrl = "http://mhsgroove.peterino.com:3001" + const val userAgent = "BlastoiseAndroid/0.1" + + @Volatile + var serverBaseUrl: String = defaultServerBaseUrl + + @Volatile + var cookieHeader: String = "" + + fun load(context: Context) { + val prefs = context.getSharedPreferences("blastoise", Context.MODE_PRIVATE) + serverBaseUrl = prefs.getString("serverBaseUrl", defaultServerBaseUrl) ?: defaultServerBaseUrl + if (serverBaseUrl.isBlank()) { + serverBaseUrl = defaultServerBaseUrl + } + cookieHeader = prefs.getString("cookieHeader", "") ?: "" + } + + fun save(context: Context) { + context.getSharedPreferences("blastoise", Context.MODE_PRIVATE) + .edit() + .putString("serverBaseUrl", serverBaseUrl) + .putString("cookieHeader", cookieHeader) + .apply() + } + + fun setBaseUrl(input: String) { + val trimmed = input.trim().trimEnd('/') + serverBaseUrl = when { + trimmed.isEmpty() -> defaultServerBaseUrl + trimmed.startsWith("http://") || trimmed.startsWith("https://") -> trimmed + else -> "http://$trimmed" + } + } + + fun httpUrl(path: String): String { + return serverBaseUrl.trimEnd('/') + path + } + + fun wsUrl(channelId: String): String { + val uri = URI(serverBaseUrl) + val scheme = if (uri.scheme == "https") "wss" else "ws" + val authority = uri.rawAuthority + return "$scheme://$authority/api/channels/${encodePath(channelId)}/ws" + } + + fun trackUrl(trackId: String): String { + return httpUrl("/api/tracks/${encodePath(trackId)}") + } + + private fun encodePath(value: String): String { + return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()).replace("+", "%20") + } +} diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_connect.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_connect.xml new file mode 100644 index 0000000..8160088 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_connect.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_library.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_library.xml new file mode 100644 index 0000000..ad6b0b1 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_library.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_pause.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..d08e7fe --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_play.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..685b432 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_play_next.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_play_next.xml new file mode 100644 index 0000000..8826cd5 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_play_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_playlist.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_playlist.xml new file mode 100644 index 0000000..938dd4e --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_playlist.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_queue_add.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_queue_add.xml new file mode 100644 index 0000000..18a0bc7 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_queue_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_radio.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_radio.xml new file mode 100644 index 0000000..49c4b14 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_radio.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_repeat.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_repeat.xml new file mode 100644 index 0000000..78172d2 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_repeat.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_seek_back.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_seek_back.xml new file mode 100644 index 0000000..1a3350d --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_seek_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_seek_forward.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_seek_forward.xml new file mode 100644 index 0000000..693a713 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_seek_forward.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_shuffle.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_shuffle.xml new file mode 100644 index 0000000..9a7d2a7 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_shuffle.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_skip_next.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_skip_next.xml new file mode 100644 index 0000000..f944704 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_skip_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/drawable/ic_skip_previous.xml b/android/BlastoiseNative/app/src/main/res/drawable/ic_skip_previous.xml new file mode 100644 index 0000000..b686c56 --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/drawable/ic_skip_previous.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/app/src/main/res/font/jetbrains_mono_regular.ttf b/android/BlastoiseNative/app/src/main/res/font/jetbrains_mono_regular.ttf new file mode 100644 index 0000000..711830e Binary files /dev/null and b/android/BlastoiseNative/app/src/main/res/font/jetbrains_mono_regular.ttf differ diff --git a/android/BlastoiseNative/app/src/main/res/font/rajdhani_bold.ttf b/android/BlastoiseNative/app/src/main/res/font/rajdhani_bold.ttf new file mode 100644 index 0000000..47af157 Binary files /dev/null and b/android/BlastoiseNative/app/src/main/res/font/rajdhani_bold.ttf differ diff --git a/android/BlastoiseNative/app/src/main/res/font/rajdhani_regular.ttf b/android/BlastoiseNative/app/src/main/res/font/rajdhani_regular.ttf new file mode 100644 index 0000000..d25bd37 Binary files /dev/null and b/android/BlastoiseNative/app/src/main/res/font/rajdhani_regular.ttf differ diff --git a/android/BlastoiseNative/app/src/main/res/font/rajdhani_semibold.ttf b/android/BlastoiseNative/app/src/main/res/font/rajdhani_semibold.ttf new file mode 100644 index 0000000..d43750b Binary files /dev/null and b/android/BlastoiseNative/app/src/main/res/font/rajdhani_semibold.ttf differ diff --git a/android/BlastoiseNative/app/src/main/res/values/strings.xml b/android/BlastoiseNative/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7e1b7ef --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Blastoise + diff --git a/android/BlastoiseNative/app/src/main/res/values/styles.xml b/android/BlastoiseNative/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..11a80ce --- /dev/null +++ b/android/BlastoiseNative/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/BlastoiseNative/build.gradle.kts b/android/BlastoiseNative/build.gradle.kts new file mode 100644 index 0000000..5785972 --- /dev/null +++ b/android/BlastoiseNative/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("com.android.application") version "9.2.0" apply false +} diff --git a/android/BlastoiseNative/gradle.properties b/android/BlastoiseNative/gradle.properties new file mode 100644 index 0000000..2e11322 --- /dev/null +++ b/android/BlastoiseNative/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/android/BlastoiseNative/gradle/wrapper/gradle-wrapper.jar b/android/BlastoiseNative/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/android/BlastoiseNative/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/BlastoiseNative/gradle/wrapper/gradle-wrapper.properties b/android/BlastoiseNative/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c61a118 --- /dev/null +++ b/android/BlastoiseNative/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/BlastoiseNative/gradlew b/android/BlastoiseNative/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/android/BlastoiseNative/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/BlastoiseNative/gradlew.bat b/android/BlastoiseNative/gradlew.bat new file mode 100644 index 0000000..4626b90 --- /dev/null +++ b/android/BlastoiseNative/gradlew.bat @@ -0,0 +1,98 @@ +@REM Copyright (c) Meta Platforms, Inc. and affiliates. +@REM +@REM This source code is licensed under the MIT license found in the +@REM LICENSE file in the root directory of this source tree. + +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/BlastoiseNative/settings.gradle.kts b/android/BlastoiseNative/settings.gradle.kts new file mode 100644 index 0000000..27fee40 --- /dev/null +++ b/android/BlastoiseNative/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "BlastoiseNative" +include(":app") diff --git a/android/dev-setup.bat b/android/dev-setup.bat index 7e1db3b..47910e4 100644 --- a/android/dev-setup.bat +++ b/android/dev-setup.bat @@ -1,7 +1,7 @@ @echo off echo ============================================ -echo MusicRoom Android - Dev Setup +echo Blastoise Native Android - Dev Setup echo Installs all prerequisites via Scoop echo ============================================ echo. @@ -35,16 +35,10 @@ echo Done. echo. :: Step 3: Install tools -echo [3/5] Installing Node.js, JDK 17, Android CLI tools, and Gradle... +echo [3/5] Installing JDK 17 and Android CLI tools... echo This may take several minutes. echo. -call scoop install nodejs-lts -if errorlevel 1 ( - echo ERROR: Failed to install Node.js - pause & exit /b 1 -) - call scoop install temurin17-jdk if errorlevel 1 ( echo ERROR: Failed to install JDK 17 @@ -57,12 +51,6 @@ if errorlevel 1 ( pause & exit /b 1 ) -call scoop install gradle -if errorlevel 1 ( - echo ERROR: Failed to install Gradle - pause & exit /b 1 -) - echo All tools installed. echo. @@ -117,8 +105,16 @@ if not exist "%SDKMANAGER%" ( echo Accepting licenses... echo y | call "%SDKMANAGER%" --licenses >nul 2>&1 -echo Installing platform-tools, android-34, build-tools-34... -call "%SDKMANAGER%" "platform-tools" "platforms;android-34" "build-tools;34.0.0" +echo Installing platform-tools, emulator, android-36, build-tools-36, and an API 36 system image... +call "%SDKMANAGER%" "platform-tools" "emulator" "platforms;android-36" "build-tools;36.0.0" "system-images;android-36;google_apis;x86_64" +echo Done. +echo. + +echo Creating CLI emulator if needed... +avdmanager list avd | findstr "Medium_Phone_API_36" >nul +if errorlevel 1 ( + echo no | avdmanager create avd -n Medium_Phone_API_36 -k "system-images;android-36;google_apis;x86_64" -d "medium_phone" +) echo Done. echo. @@ -127,10 +123,10 @@ echo ============================================ echo Setup complete! echo. echo Tools installed via Scoop: -echo Node.js LTS, JDK 17, Android CLI, Gradle +echo JDK 17, Android CLI echo. echo Android SDK installed headlessly: -echo platform-tools, android-34, build-tools-34 +echo platform-tools, emulator, android-36, build-tools-36 echo. echo Environment: echo JAVA_HOME = %JAVA_HOME% diff --git a/android/run.bat b/android/run.bat index 4c8995f..3541cff 100644 --- a/android/run.bat +++ b/android/run.bat @@ -1,7 +1,7 @@ @echo off echo ============================================ -echo MusicRoom Android - Run App +echo Blastoise Native Android - Run App echo ============================================ echo. @@ -9,13 +9,14 @@ echo. set "PATH=%PATH%;%USERPROFILE%\scoop\shims" set "JAVA_HOME=%USERPROFILE%\scoop\apps\temurin17-jdk\current" set "ANDROID_HOME=%LOCALAPPDATA%\Android\Sdk" -set "PATH=%PATH%;%ANDROID_HOME%\platform-tools;%ANDROID_HOME%\emulator" +set "PATH=%PATH%;%ANDROID_HOME%\platform-tools;%ANDROID_HOME%\emulator;%ANDROID_HOME%\cmdline-tools\latest\bin" +set "PROJECT_DIR=%~dp0BlastoiseNative" :: Start emulator if not already running adb devices 2>nul | findstr "emulator" >nul if errorlevel 1 ( echo Starting emulator... - start "" "%ANDROID_HOME%\emulator\emulator.exe" -avd Medium_Phone_API_36.0 + start "" "%ANDROID_HOME%\emulator\emulator.exe" -avd Medium_Phone_API_36 echo Waiting for emulator to boot... adb wait-for-device :wait_boot @@ -28,16 +29,24 @@ if errorlevel 1 ( echo. ) -:: Build and install the app -::: Build, install, and run the app (starts Metro automatically) -echo Building and installing MusicRoom... +echo Building and installing Blastoise... echo. -call bunx react-native run-android +pushd "%PROJECT_DIR%" +call gradlew.bat :app:installDebug +if errorlevel 1 ( + popd + echo. + echo Build or install failed. + pause + exit /b 1 +) +popd + +echo Launching app... +adb shell am start -n com.peterino.blastoise/.MainActivity echo. echo ============================================ -echo App is running. Leave this window open -- -echo Metro is serving the JS bundle. -echo Press Ctrl+C to stop Metro when done. +echo App is installed and launched. echo ============================================ pause diff --git a/run-android-native.bat b/run-android-native.bat new file mode 100644 index 0000000..472b4e8 --- /dev/null +++ b/run-android-native.bat @@ -0,0 +1,147 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +echo ============================================ +echo Blastoise Native Android - Build and Launch +echo ============================================ +echo. + +set "ROOT_DIR=%~dp0" +set "PROJECT_DIR=%ROOT_DIR%android\BlastoiseNative" +set "APK_PATH=%PROJECT_DIR%\app\build\outputs\apk\debug\app-debug.apk" +set "PACKAGE_ACTIVITY=com.peterino.blastoise/.MainActivity" + +if "%~1"=="" ( + set "AVD_NAME=Medium_Phone_API_36.0" +) else ( + set "AVD_NAME=%~1" +) + +set "PATH=%USERPROFILE%\scoop\shims;%PATH%" +if not defined JAVA_HOME ( + if exist "%USERPROFILE%\scoop\apps\temurin17-jdk\current" ( + set "JAVA_HOME=%USERPROFILE%\scoop\apps\temurin17-jdk\current" + ) +) +if not defined ANDROID_HOME set "ANDROID_HOME=%LOCALAPPDATA%\Android\Sdk" +set "PATH=%ANDROID_HOME%\platform-tools;%ANDROID_HOME%\emulator;%ANDROID_HOME%\cmdline-tools\latest\bin;%PATH%" +set "ADB_EXE=%ANDROID_HOME%\platform-tools\adb.exe" +set "EMULATOR_EXE=%ANDROID_HOME%\emulator\emulator.exe" + +if not exist "%PROJECT_DIR%\gradlew.bat" ( + echo ERROR: Native Android project not found: + echo %PROJECT_DIR% + goto :fail +) + +if not exist "%ADB_EXE%" ( + echo ERROR: adb not found at: + echo %ADB_EXE% + echo Run android\dev-setup.bat first. + goto :fail +) + +if not exist "%EMULATOR_EXE%" ( + echo ERROR: Android emulator not found at: + echo %EMULATOR_EXE% + echo Run android\dev-setup.bat first. + goto :fail +) + +"%EMULATOR_EXE%" -list-avds | findstr /X /C:"%AVD_NAME%" >nul +if errorlevel 1 ( + echo ERROR: AVD "%AVD_NAME%" was not found. + echo. + echo Available AVDs: + "%EMULATOR_EXE%" -list-avds + echo. + echo Pass a different AVD name as the first argument, for example: + echo run-android-native.bat Your_AVD_Name + goto :fail +) + +echo Building debug APK... +pushd "%PROJECT_DIR%" +call gradlew.bat :app:assembleDebug +if errorlevel 1 ( + popd + echo. + echo ERROR: Gradle build failed. + goto :fail +) +popd + +if not exist "%APK_PATH%" ( + echo ERROR: APK was not created: + echo %APK_PATH% + goto :fail +) + +"%ADB_EXE%" devices | findstr /R /C:"emulator-[0-9][0-9]*[ ]*device" >nul +if errorlevel 1 ( + echo. + echo Starting emulator "%AVD_NAME%"... + start "" "%EMULATOR_EXE%" -avd "%AVD_NAME%" + echo Waiting for emulator device... + set /a WAIT_DEVICE_COUNT=0 + :wait_device + "%ADB_EXE%" devices | findstr /R /C:"emulator-[0-9][0-9]*[ ]*device" >nul + if errorlevel 1 ( + set /a WAIT_DEVICE_COUNT+=1 + if !WAIT_DEVICE_COUNT! GEQ 90 ( + echo ERROR: Emulator did not appear within 180 seconds. + goto :fail + ) + timeout /t 2 /nobreak >nul + goto :wait_device + ) +) + +echo Waiting for Android boot completion... +set /a WAIT_BOOT_COUNT=0 +:wait_boot +set "BOOT_DONE=" +for /f "delims=" %%b in ('"%ADB_EXE%" shell getprop sys.boot_completed 2^>nul') do set "BOOT_DONE=%%b" +if not "%BOOT_DONE%"=="1" ( + set /a WAIT_BOOT_COUNT+=1 + if !WAIT_BOOT_COUNT! GEQ 120 ( + echo ERROR: Android did not finish booting within 240 seconds. + goto :fail + ) + timeout /t 2 /nobreak >nul + goto :wait_boot +) + +echo Installing APK... +"%ADB_EXE%" install -r "%APK_PATH%" +if errorlevel 1 ( + echo. + echo ERROR: APK install failed. + goto :fail +) + +echo Launching app... +"%ADB_EXE%" shell am start -n %PACKAGE_ACTIVITY% +if errorlevel 1 ( + echo. + echo ERROR: App launch failed. + goto :fail +) + +echo. +echo ============================================ +echo Blastoise is built, installed, and running. +echo ============================================ +goto :done + +:fail +echo. +echo ============================================ +echo Failed. +echo ============================================ +if not "%NO_PAUSE%"=="1" pause +exit /b 1 + +:done +if not "%NO_PAUSE%"=="1" pause +exit /b 0