Add Kotlin Multiplatform mobile client
A Compose Multiplatform app (mobile/) targeting Android, iOS, and desktop,
sharing one Kotlin codebase: OIDC login via PKCE against the same Zitadel
instance the web player uses (loopback redirect on desktop, a musify://
custom scheme on mobile), a token store per platform, and a first pass at
browsing/streaming tracks through the existing API.
deploy/compose.yml's zitadel-init gains a third OIDC app registration
("Musify Native", public client, no secret) alongside the existing API and
web apps, writing its client id to NATIVE_CLIENT_ID in the env file the same
way the other two already do.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
package com.musify.app
|
||||
|
||||
// Passed as -Dmusify.* JVM args by the `compose.desktop.application` block
|
||||
// (composeApp/build.gradle.kts), sourced from mobile/gradle.properties.
|
||||
actual object AppConfig {
|
||||
actual val apiBaseUrl: String = System.getProperty("musify.apiBaseUrl", "http://localhost:5111")
|
||||
actual val authIssuer: String = System.getProperty("musify.authIssuer", "http://host.docker.internal:8080")
|
||||
actual val authClientId: String = System.getProperty("musify.authClientId", "")
|
||||
actual val nativeRedirectUri: String = "http://127.0.0.1:17453/callback"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.musify.app
|
||||
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
fun main() = application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "Musify",
|
||||
state = WindowState(size = DpSize(420.dp, 760.dp), position = WindowPosition(alignment = androidx.compose.ui.Alignment.Center))
|
||||
) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.musify.app
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
|
||||
actual fun createHttpClient(): HttpClient = HttpClient(CIO) {
|
||||
install(ContentNegotiation) { json(jsonConfig) }
|
||||
install(Logging) { level = LogLevel.INFO }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.musify.app.audio
|
||||
|
||||
import javafx.application.Platform as JfxPlatform
|
||||
import javafx.scene.media.Media
|
||||
import javafx.scene.media.MediaPlayer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Boots the JavaFX toolkit headlessly (no Stage/window) so `MediaPlayer` can
|
||||
* decode/play audio inside a plain Compose Desktop (Swing/Skiko) app.
|
||||
*/
|
||||
private object JavaFxBootstrap {
|
||||
private var started = false
|
||||
|
||||
@Synchronized
|
||||
fun ensureStarted() {
|
||||
if (started) return
|
||||
started = true
|
||||
try {
|
||||
JfxPlatform.startup {}
|
||||
JfxPlatform.setImplicitExit(false)
|
||||
} catch (_: IllegalStateException) {
|
||||
// Toolkit already running (e.g. re-entrant init) — nothing to do.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actual class AudioPlayer actual constructor() {
|
||||
private val _state = MutableStateFlow(PlaybackState.IDLE)
|
||||
actual val state: StateFlow<PlaybackState> = _state.asStateFlow()
|
||||
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
|
||||
actual fun play(url: String) {
|
||||
JavaFxBootstrap.ensureStarted()
|
||||
_state.value = PlaybackState.LOADING
|
||||
JfxPlatform.runLater {
|
||||
mediaPlayer?.stop()
|
||||
mediaPlayer?.dispose()
|
||||
val player = MediaPlayer(Media(url))
|
||||
mediaPlayer = player
|
||||
player.setOnPlaying { _state.value = PlaybackState.PLAYING }
|
||||
player.setOnPaused { _state.value = PlaybackState.PAUSED }
|
||||
player.setOnStopped { _state.value = PlaybackState.IDLE }
|
||||
player.setOnEndOfMedia { _state.value = PlaybackState.PAUSED }
|
||||
player.setOnError { _state.value = PlaybackState.ERROR }
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
actual fun pause() {
|
||||
if (mediaPlayer == null) return
|
||||
JfxPlatform.runLater { mediaPlayer?.pause() }
|
||||
}
|
||||
|
||||
actual fun resume() {
|
||||
if (mediaPlayer == null) return
|
||||
JfxPlatform.runLater { mediaPlayer?.play() }
|
||||
}
|
||||
|
||||
actual fun stop() {
|
||||
// Nothing was ever played (JavaFX toolkit never booted) — runLater would
|
||||
// throw "Toolkit not initialized" here, e.g. when closing the app cold.
|
||||
if (mediaPlayer == null) {
|
||||
_state.value = PlaybackState.IDLE
|
||||
return
|
||||
}
|
||||
JfxPlatform.runLater { mediaPlayer?.stop() }
|
||||
_state.value = PlaybackState.IDLE
|
||||
}
|
||||
|
||||
actual fun dispose() {
|
||||
if (mediaPlayer == null) return
|
||||
JfxPlatform.runLater {
|
||||
mediaPlayer?.dispose()
|
||||
mediaPlayer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.musify.app.auth
|
||||
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.Desktop
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* RFC 8252 §7.3 "loopback interface redirection": a one-shot local HTTP
|
||||
* server receives the Authorization Code callback in the system browser, then
|
||||
* shuts itself down. `redirectUri` must match `http://127.0.0.1:17453/callback`
|
||||
* exactly, as registered for the "Musify Native" Zitadel app.
|
||||
*/
|
||||
actual class BrowserAuthLauncher actual constructor() {
|
||||
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String {
|
||||
val redirect = URI(redirectUri)
|
||||
val port = redirect.port.takeIf { it > 0 } ?: error("El redirect URI de Desktop necesita un puerto explícito.")
|
||||
val path = redirect.path.ifEmpty { "/" }
|
||||
|
||||
val result = CompletableDeferred<String>()
|
||||
val server = HttpServer.create(InetSocketAddress("127.0.0.1", port), 0)
|
||||
server.createContext(path) { exchange ->
|
||||
val query = exchange.requestURI.rawQuery.orEmpty()
|
||||
val body = """
|
||||
<html><body style="font-family:sans-serif;padding:2rem">
|
||||
<h2>Musify</h2><p>Ya puedes volver a la app y cerrar esta pestaña.</p>
|
||||
</body></html>
|
||||
""".trimIndent().toByteArray()
|
||||
exchange.responseHeaders.add("Content-Type", "text/html; charset=utf-8")
|
||||
exchange.sendResponseHeaders(200, body.size.toLong())
|
||||
exchange.responseBody.use { it.write(body) }
|
||||
result.complete("$redirectUri?$query")
|
||||
}
|
||||
server.start()
|
||||
|
||||
return try {
|
||||
withContext(Dispatchers.IO) { Desktop.getDesktop().browse(URI(authorizeUrl)) }
|
||||
result.await()
|
||||
} finally {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.musify.app.auth
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
actual fun sha256(input: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(input)
|
||||
|
||||
actual fun nowEpochSeconds(): Long = System.currentTimeMillis() / 1000
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.musify.app.auth
|
||||
|
||||
import com.musify.app.jsonConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import java.io.File
|
||||
|
||||
actual class TokenStore actual constructor() {
|
||||
private val file = File(System.getProperty("user.home"), ".musify/tokens.json")
|
||||
|
||||
actual suspend fun save(tokens: StoredTokens) = withContext(Dispatchers.IO) {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(jsonConfig.encodeToString(tokens))
|
||||
}
|
||||
|
||||
actual suspend fun load(): StoredTokens? = withContext(Dispatchers.IO) {
|
||||
if (!file.exists()) return@withContext null
|
||||
try {
|
||||
jsonConfig.decodeFromString<StoredTokens>(file.readText())
|
||||
} catch (t: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun clear(): Unit = withContext(Dispatchers.IO) {
|
||||
file.delete()
|
||||
Unit
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user