From fa4bdbe68770816b82c0f37bba11c820f4a0b392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Wed, 9 Sep 2026 15:51:10 +0100 Subject: [PATCH 1/6] build: bundle Google OAuth client and fix AppImage/jlink packaging - generate BuildConfig from WREN_GOOGLE_CLIENT_ID/SECRET env vars so releases ship a desktop OAuth client; ~/.config/wren/oauth.json stays as an override. CI distro jobs read the values from repo secrets - add jdk.jsobject and jdk.xml.dom to the jlinked runtime: javafx-web requires them and the packaged app crashed on WebView creation - build-appimage.sh resolves a JDK 21 via mise, runs the Kotlin compiler in-process (daemon rejects 4-part JDK versions) and writes the image through a temp file so a running AppImage no longer blocks the build - ignore squashfs-root/ and client_secret*.json --- .github/workflows/gradle-ci.yml | 3 ++ .gitignore | 2 ++ build-appimage.sh | 44 +++++++++++++++++++++++++++-- desktop/build.gradle.kts | 49 ++++++++++++++++++++++++++++++++- 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gradle-ci.yml b/.github/workflows/gradle-ci.yml index 8097550..5e5003b 100644 --- a/.github/workflows/gradle-ci.yml +++ b/.github/workflows/gradle-ci.yml @@ -43,6 +43,9 @@ jobs: cache: gradle - uses: gradle/actions/setup-gradle@v6 - name: Build ${{ matrix.name }} distributable + env: + WREN_GOOGLE_CLIENT_ID: ${{ secrets.WREN_GOOGLE_CLIENT_ID }} + WREN_GOOGLE_CLIENT_SECRET: ${{ secrets.WREN_GOOGLE_CLIENT_SECRET }} run: ./gradlew :desktop:package${{ matrix.format }} - name: Dump jpackage logs on failure if: failure() diff --git a/.gitignore b/.gitignore index 40101a7..2c13ee4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ gradle.properties api.json local.properties dist-artifacts/ +squashfs-root/ +client_secret*.json diff --git a/build-appimage.sh b/build-appimage.sh index 1aba620..285b37d 100755 --- a/build-appimage.sh +++ b/build-appimage.sh @@ -7,9 +7,37 @@ OUTPUT="Wren.AppImage" APPIMAGETOOL_URL="https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" APPIMAGETOOL="./appimagetool" +# 0. Resolve a JDK 21 for Gradle. The Kotlin toolchain is pinned to 21 and Gradle +# can't auto-provision it, so look in mise first (our version manager), then JAVA_HOME. +# Passing the path explicitly also makes the toolchain resolvable when PATH has a newer JDK. +resolve_jdk21() { + local candidate + if command -v mise &>/dev/null; then + candidate=$(mise where java@21 2>/dev/null || true) + [ -n "$candidate" ] && [ -x "$candidate/bin/java" ] && { echo "$candidate"; return; } + fi + for candidate in "${MISE_DATA_DIR:-$HOME/.local/share/mise}"/installs/java/*21*; do + [ -x "$candidate/bin/java" ] && { readlink -f "$candidate"; return; } + done + if [ -n "${JAVA_HOME:-}" ] && "$JAVA_HOME/bin/java" -version 2>&1 | grep -q '"21'; then + echo "$JAVA_HOME"; return + fi + return 1 +} + +if ! JDK21=$(resolve_jdk21); then + echo "ERROR: No JDK 21 found. Install one with: mise install java@21" >&2 + exit 1 +fi +echo ">>> Using JDK 21 at $JDK21" +export JAVA_HOME="$JDK21" + # 1. Build distributable echo ">>> Building distributable..." -./gradlew :desktop:createDistributable +# in-process: the Kotlin compile daemon chokes on 4-part JDK versions (e.g. Corretto 25.0.4.1) +./gradlew :desktop:createDistributable \ + -Porg.gradle.java.installations.paths="$JDK21" \ + -Pkotlin.compiler.execution.strategy=in-process DIST_DIR="desktop/build/compose/binaries/main/app/${APP_NAME}" if [ ! -d "$DIST_DIR" ]; then @@ -77,9 +105,19 @@ else fi fi -# 4. Package AppImage +# 4. Package AppImage — extract appimagetool first when libfuse2 is missing echo ">>> Packaging AppImage..." -ARCH=x86_64 "$APPIMAGETOOL" "$APP_DIR" "$OUTPUT" +if ! ldconfig -p 2>/dev/null | grep -q libfuse.so.2; then + if [ ! -d "squashfs-root" ]; then + echo ">>> libfuse2 not found — extracting appimagetool to run it without FUSE" + "$APPIMAGETOOL" --appimage-extract >/dev/null + fi + APPIMAGETOOL="./squashfs-root/AppRun" +fi +# Write to a temp file and rename: overwriting in place fails with "Text file busy" +# while a previous build of the AppImage is still running. +ARCH=x86_64 "$APPIMAGETOOL" "$APP_DIR" "$OUTPUT.tmp" +mv -f "$OUTPUT.tmp" "$OUTPUT" echo "" echo "Done! Created: $OUTPUT" diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index db2706d..cce5c93 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -19,6 +19,37 @@ val javacppPlatform = run { } } +// ── Bundled Google OAuth client ────────────────────────────────────────────── +// Desktop-app OAuth clients have no real secret (Google documents this), so the +// release build bakes a default client in. Values come from the environment (CI +// secrets / local shell), never from a tracked file. Users can still override them +// with ~/.config/wren/oauth.json. +val googleClientId = providers.environmentVariable("WREN_GOOGLE_CLIENT_ID").orElse("") +val googleClientSecret = providers.environmentVariable("WREN_GOOGLE_CLIENT_SECRET").orElse("") +val buildConfigDir = layout.buildDirectory.dir("generated/buildconfig") + +val generateBuildConfig by tasks.registering { + inputs.property("googleClientId", googleClientId) + inputs.property("googleClientSecret", googleClientSecret) + outputs.dir(buildConfigDir) + doLast { + val file = buildConfigDir.get().file("BuildConfig.kt").asFile + file.parentFile.mkdirs() + file.writeText( + """ + |// Generated by :desktop:generateBuildConfig — do not edit. + |object BuildConfig { + | const val GOOGLE_CLIENT_ID: String = "${googleClientId.get()}" + | const val GOOGLE_CLIENT_SECRET: String = "${googleClientSecret.get()}" + |} + |""".trimMargin() + ) + } +} + +sourceSets.main { kotlin.srcDir(buildConfigDir) } +tasks.withType().configureEach { dependsOn(generateBuildConfig) } + dependencies { implementation(project(":shared")) implementation(compose.desktop.currentOs) @@ -29,6 +60,20 @@ dependencies { implementation("org.bytedeco:javacv:1.5.11") implementation("org.bytedeco:ffmpeg:7.1-1.5.11") implementation("org.bytedeco:ffmpeg:7.1-1.5.11:$javacppPlatform") + // JavaFX WebView for embedded SoundCloud login + val jfxClassifier = when { + javacppPlatform.contains("linux") -> "linux" + javacppPlatform.contains("macosx-arm") -> "mac-aarch64" + javacppPlatform.contains("macosx") -> "mac" + javacppPlatform.contains("windows") -> "win" + else -> "linux" + } + implementation("org.openjfx:javafx-base:21.0.4:$jfxClassifier") + implementation("org.openjfx:javafx-web:21.0.4:$jfxClassifier") + implementation("org.openjfx:javafx-controls:21.0.4:$jfxClassifier") + implementation("org.openjfx:javafx-graphics:21.0.4:$jfxClassifier") + implementation("org.openjfx:javafx-media:21.0.4:$jfxClassifier") + implementation("org.openjfx:javafx-swing:21.0.4:$jfxClassifier") testImplementation("org.junit.jupiter:junit-jupiter:5.11.3") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } @@ -53,7 +98,9 @@ compose.desktop { targetFormats(TargetFormat.Deb, TargetFormat.Rpm, TargetFormat.Msi, TargetFormat.Dmg) packageName = "wren" packageVersion = appVersion - modules("java.net.http") + // javafx-web (SoundCloud login WebView) needs jdk.jsobject + jdk.xml.dom in the + // jlinked runtime; the rest come from `./gradlew :desktop:suggestRuntimeModules`. + modules("java.instrument", "java.management", "java.net.http", "jdk.jfr", "jdk.jsobject", "jdk.unsupported", "jdk.xml.dom") linux { iconFile.set(project.file("wren.png")) } From 1584cb4b68e1d568111d2126bebe82fc7c90b9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Wed, 9 Sep 2026 15:51:10 +0100 Subject: [PATCH 2/6] feat(auth): local profile with independent Google and SoundCloud sessions - replace AuthManager with AuthStore (profile + per-provider session files), GoogleAuth and SoundCloudAuth; AuthEvents lets Compose observe connect/disconnect without the auth layer depending on the UI - Google: ephemeral loopback port, cancellable accept and single-flight listener fix "Address already in use" on retries; open the browser via Desktop, $BROWSER or xdg-open with a copy-link fallback in the dialog - SoundCloud: native PKCE flow against secure.soundcloud.com using the scraped web client id; only the authorization page runs in the JavaFX WebView, the callback is intercepted and the code exchanged natively. Toolkit started once, popup handler and desktop UA for social logins, refresh token stored and refreshed near expiry - ProfileDialog manages both sessions; AuthDialog removed - main.kt: detect compositor scale (Hyprland, GDK_SCALE, Xft.dpi) so the UI renders at native resolution under fractional scaling --- .../src/main/kotlin/api/YtMusicPlaylists.kt | 12 +- desktop/src/main/kotlin/api/YtMusicSearch.kt | 2 +- desktop/src/main/kotlin/auth/AuthEvents.kt | 18 ++ desktop/src/main/kotlin/auth/AuthManager.kt | 113 ------- desktop/src/main/kotlin/auth/GoogleAuth.kt | 84 ++++++ desktop/src/main/kotlin/auth/LocalProfile.kt | 170 +++++++++++ desktop/src/main/kotlin/auth/OAuthFlow.kt | 127 ++++++-- .../src/main/kotlin/auth/SoundCloudAuth.kt | 132 ++++++++ .../src/main/kotlin/auth/SoundCloudOAuth.kt | 145 +++++++++ desktop/src/main/kotlin/main.kt | 58 +++- desktop/src/main/kotlin/ui/AuthDialog.kt | 164 ---------- desktop/src/main/kotlin/ui/ProfileDialog.kt | 282 ++++++++++++++++++ .../main/kotlin/ui/SoundCloudLoginWindow.kt | 174 +++++++++++ desktop/src/main/kotlin/util/Browser.kt | 42 +++ desktop/src/test/kotlin/auth/AuthStoreTest.kt | 106 +++++++ .../test/kotlin/auth/SoundCloudAuthTest.kt | 52 ++++ .../test/kotlin/auth/SoundCloudOAuthTest.kt | 59 ++++ 17 files changed, 1427 insertions(+), 313 deletions(-) create mode 100644 desktop/src/main/kotlin/auth/AuthEvents.kt delete mode 100644 desktop/src/main/kotlin/auth/AuthManager.kt create mode 100644 desktop/src/main/kotlin/auth/GoogleAuth.kt create mode 100644 desktop/src/main/kotlin/auth/LocalProfile.kt create mode 100644 desktop/src/main/kotlin/auth/SoundCloudAuth.kt create mode 100644 desktop/src/main/kotlin/auth/SoundCloudOAuth.kt delete mode 100644 desktop/src/main/kotlin/ui/AuthDialog.kt create mode 100644 desktop/src/main/kotlin/ui/ProfileDialog.kt create mode 100644 desktop/src/main/kotlin/ui/SoundCloudLoginWindow.kt create mode 100644 desktop/src/main/kotlin/util/Browser.kt create mode 100644 desktop/src/test/kotlin/auth/AuthStoreTest.kt create mode 100644 desktop/src/test/kotlin/auth/SoundCloudAuthTest.kt create mode 100644 desktop/src/test/kotlin/auth/SoundCloudOAuthTest.kt diff --git a/desktop/src/main/kotlin/api/YtMusicPlaylists.kt b/desktop/src/main/kotlin/api/YtMusicPlaylists.kt index 74e2443..c2ba7d8 100644 --- a/desktop/src/main/kotlin/api/YtMusicPlaylists.kt +++ b/desktop/src/main/kotlin/api/YtMusicPlaylists.kt @@ -1,6 +1,6 @@ package api -import auth.AuthManager +import auth.GoogleAuth import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -18,8 +18,8 @@ private val ytApiClient = HttpClient.newHttpClient() private val ytApiJson = Json { ignoreUnknownKeys = true } suspend fun fetchUserPlaylists(): List = withContext(Dispatchers.IO) { - AuthManager.ensureValidToken() - val token = AuthManager.accessToken ?: return@withContext emptyList() + GoogleAuth.ensureValidToken() + val token = GoogleAuth.accessToken ?: return@withContext emptyList() val response = ytApiClient.send( HttpRequest.newBuilder() .uri(URI.create("https://www.googleapis.com/youtube/v3/playlists?part=snippet,contentDetails&mine=true&maxResults=50")) @@ -123,7 +123,7 @@ private suspend fun fetchPlaylistCoverFromBrowse(playlistId: String): String? = } suspend fun fetchPlaylistTracks(playlistId: String): List = withContext(Dispatchers.IO) { - val token = AuthManager.accessToken ?: return@withContext emptyList() + val token = GoogleAuth.accessToken ?: return@withContext emptyList() val response = ytApiClient.send( HttpRequest.newBuilder() .uri(URI.create("https://www.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails&playlistId=$playlistId&maxResults=50")) @@ -169,7 +169,7 @@ suspend fun fetchPlaylistTracks(playlistId: String): List = withC suspend fun fetchSubscriberCounts(channelIds: List): Map = withContext(Dispatchers.IO) { if (channelIds.isEmpty()) return@withContext emptyMap() - val token = AuthManager.accessToken ?: return@withContext emptyMap() + val token = GoogleAuth.accessToken ?: return@withContext emptyMap() val ids = channelIds.joinToString(",") val response = ytApiClient.send( HttpRequest.newBuilder() @@ -190,7 +190,7 @@ suspend fun fetchSubscriberCounts(channelIds: List): Map = suspend fun fetchViewCounts(videoIds: List): Map = withContext(Dispatchers.IO) { if (videoIds.isEmpty()) return@withContext emptyMap() - val token = AuthManager.accessToken ?: return@withContext emptyMap() + val token = GoogleAuth.accessToken ?: return@withContext emptyMap() val ids = videoIds.joinToString(",") val response = ytApiClient.send( HttpRequest.newBuilder() diff --git a/desktop/src/main/kotlin/api/YtMusicSearch.kt b/desktop/src/main/kotlin/api/YtMusicSearch.kt index b8286f5..99d09e9 100644 --- a/desktop/src/main/kotlin/api/YtMusicSearch.kt +++ b/desktop/src/main/kotlin/api/YtMusicSearch.kt @@ -1,6 +1,6 @@ package api -import auth.AuthManager +import auth.GoogleAuth import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.* diff --git a/desktop/src/main/kotlin/auth/AuthEvents.kt b/desktop/src/main/kotlin/auth/AuthEvents.kt new file mode 100644 index 0000000..58b2e71 --- /dev/null +++ b/desktop/src/main/kotlin/auth/AuthEvents.kt @@ -0,0 +1,18 @@ +package auth + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Bumped whenever any provider connects or disconnects, so Compose UI can + * observe auth changes without the auth objects depending on Compose. + */ +object AuthEvents { + private val _version = MutableStateFlow(0) + val version: StateFlow = _version.asStateFlow() + + fun notifyChanged() { + _version.value++ + } +} diff --git a/desktop/src/main/kotlin/auth/AuthManager.kt b/desktop/src/main/kotlin/auth/AuthManager.kt deleted file mode 100644 index c41d03a..0000000 --- a/desktop/src/main/kotlin/auth/AuthManager.kt +++ /dev/null @@ -1,113 +0,0 @@ -package auth - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.* -import util.Log -import java.io.File - -object AuthManager { - private val configDir = File(System.getProperty("user.home"), ".config/wren") - private val tokensFile = File(configDir, "tokens.json") - private val accountNameFile = File(configDir, "account_name") - private val avatarUrlFile = File(configDir, "avatar_url") - - // yt-dlp reads its YouTube OAuth cache from here - private val ytdlpCacheFile = File(System.getProperty("user.home"), ".cache/yt-dlp/youtube/oauth2.json") - - private val json = Json { ignoreUnknownKeys = true } - - private var tokens: OAuthTokens? = loadTokens() - - val isAuthenticated: Boolean get() = tokens != null - val accountName: String? get() = if (accountNameFile.exists()) accountNameFile.readText().trim().takeIf { it.isNotEmpty() } else null - val avatarUrl: String? get() = if (avatarUrlFile.exists()) avatarUrlFile.readText().trim().takeIf { it.isNotEmpty() } else null - - val accessToken: String? get() = tokens?.accessToken - - val authArgs: List - get() { - val token = tokens?.accessToken ?: return emptyList() - return listOf("--add-header", "Authorization:Bearer $token") - } - - private fun loadTokens(): OAuthTokens? = runCatching { - if (!tokensFile.exists()) return null - val obj = json.parseToJsonElement(tokensFile.readText()).jsonObject - OAuthTokens( - accessToken = obj["access_token"]!!.jsonPrimitive.content, - refreshToken = obj["refresh_token"]!!.jsonPrimitive.content, - expiresAt = obj["expires_at"]!!.jsonPrimitive.long - ) - }.getOrNull() - - suspend fun saveTokens(newTokens: OAuthTokens) = withContext(Dispatchers.IO) { - tokens = newTokens - configDir.mkdirs() - tokensFile.writeText( - buildJsonObject { - put("access_token", newTokens.accessToken) - put("refresh_token", newTokens.refreshToken) - put("expires_at", newTokens.expiresAt) - }.toString() - ) - writeYtdlpCache(newTokens) - fetchAndSaveAccountName() - } - - suspend fun ensureValidToken() = withContext(Dispatchers.IO) { - val current = tokens ?: return@withContext - val nowSecs = System.currentTimeMillis() / 1000 - if (nowSecs >= current.expiresAt - 60) { - runCatching { - val refreshed = refreshToken(current.refreshToken) - saveTokens(refreshed) - }.onFailure { Log.e("AuthManager", "Failed to refresh OAuth token", it) } - } else { - writeYtdlpCache(current) - } - } - - fun logout() { - tokens = null - tokensFile.delete() - accountNameFile.delete() - avatarUrlFile.delete() - ytdlpCacheFile.delete() - } - - private fun writeYtdlpCache(t: OAuthTokens) { - ytdlpCacheFile.parentFile.mkdirs() - ytdlpCacheFile.writeText( - buildJsonObject { - put("access_token", t.accessToken) - put("expires", t.expiresAt.toDouble()) - put("refresh_token", t.refreshToken) - put("token_type", "Bearer") - }.toString() - ) - } - - private fun fetchAndSaveAccountName() { - // Account name fetched via YouTube Data API using the access token - runCatching { - val token = tokens?.accessToken ?: return - val request = java.net.http.HttpRequest.newBuilder() - .uri(java.net.URI.create("https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true")) - .header("Authorization", "Bearer $token") - .GET() - .build() - val response = java.net.http.HttpClient.newHttpClient() - .send(request, java.net.http.HttpResponse.BodyHandlers.ofString()).body() - val obj = json.parseToJsonElement(response).jsonObject - val snippet = obj["items"]?.jsonArray?.firstOrNull()?.jsonObject?.get("snippet")?.jsonObject - val name = snippet?.get("title")?.jsonPrimitive?.content - if (!name.isNullOrEmpty()) accountNameFile.writeText(name) - val avatar = snippet?.get("thumbnails")?.jsonObject - ?.get("medium")?.jsonObject?.get("url")?.jsonPrimitive?.content - ?: snippet?.get("thumbnails")?.jsonObject - ?.get("default")?.jsonObject?.get("url")?.jsonPrimitive?.content - if (!avatar.isNullOrEmpty()) avatarUrlFile.writeText(avatar) - }.onFailure { Log.e("AuthManager", "Failed to fetch account name/avatar", it) } - } -} diff --git a/desktop/src/main/kotlin/auth/GoogleAuth.kt b/desktop/src/main/kotlin/auth/GoogleAuth.kt new file mode 100644 index 0000000..9d47094 --- /dev/null +++ b/desktop/src/main/kotlin/auth/GoogleAuth.kt @@ -0,0 +1,84 @@ +package auth + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.* +import util.Log + +object GoogleAuth { + private var session: GoogleSession? = AuthStore.googleSession() + + val isAuthenticated: Boolean get() = session != null + val accountName: String? get() = session?.accountName + val avatarUrl: String? get() = session?.avatarUrl + val accessToken: String? get() = session?.accessToken + + val authArgs: List + get() { + val token = session?.accessToken ?: return emptyList() + return listOf("--add-header", "Authorization:Bearer $token") + } + + suspend fun connect(tokens: OAuthTokens) = withContext(Dispatchers.IO) { + val sc = GoogleSession( + accessToken = tokens.accessToken, + refreshToken = tokens.refreshToken, + expiresAt = tokens.expiresAt + ) + AuthStore.saveGoogle(sc) + session = sc + fetchAndSaveAccountName() + AuthEvents.notifyChanged() + } + + suspend fun ensureValidToken() = withContext(Dispatchers.IO) { + val s = session ?: return@withContext + val nowSecs = System.currentTimeMillis() / 1000 + if (nowSecs >= s.expiresAt - 60) { + runCatching { + val refreshed = refreshToken(s.refreshToken) + val newSession = GoogleSession( + accessToken = refreshed.accessToken, + refreshToken = refreshed.refreshToken, + expiresAt = refreshed.expiresAt, + accountName = s.accountName, + avatarUrl = s.avatarUrl + ) + AuthStore.saveGoogle(newSession) + session = newSession + }.onFailure { Log.e("GoogleAuth", "Failed to refresh OAuth token", it) } + } + } + + fun disconnect() { + session = null + AuthStore.disconnectGoogle() + AuthEvents.notifyChanged() + } + + private fun fetchAndSaveAccountName() { + runCatching { + val token = session?.accessToken ?: return + val request = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create("https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true")) + .header("Authorization", "Bearer $token") + .GET() + .build() + val response = java.net.http.HttpClient.newHttpClient() + .send(request, java.net.http.HttpResponse.BodyHandlers.ofString()).body() + val obj = Json { ignoreUnknownKeys = true }.parseToJsonElement(response).jsonObject + val snippet = obj["items"]?.jsonArray?.firstOrNull()?.jsonObject?.get("snippet")?.jsonObject + val name = snippet?.get("title")?.jsonPrimitive?.content + val avatar = snippet?.get("thumbnails")?.jsonObject + ?.get("medium")?.jsonObject?.get("url")?.jsonPrimitive?.content + ?: snippet?.get("thumbnails")?.jsonObject + ?.get("default")?.jsonObject?.get("url")?.jsonPrimitive?.content + val updated = session?.copy( + accountName = name?.takeIf { it.isNotEmpty() }, + avatarUrl = avatar?.takeIf { it.isNotEmpty() } + ) ?: return + AuthStore.saveGoogle(updated) + session = updated + }.onFailure { Log.e("GoogleAuth", "Failed to fetch account name/avatar", it) } + } +} diff --git a/desktop/src/main/kotlin/auth/LocalProfile.kt b/desktop/src/main/kotlin/auth/LocalProfile.kt new file mode 100644 index 0000000..8275846 --- /dev/null +++ b/desktop/src/main/kotlin/auth/LocalProfile.kt @@ -0,0 +1,170 @@ +package auth + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import util.Log +import java.io.File +import java.util.UUID + +// ── Configurable root ──────────────────────────────────────────────────────── + +private val configDir: File + get() = System.getProperty("wren.config.dir") + ?.let { File(it) } + ?: File(System.getProperty("user.home"), ".config/wren") + +private fun dir(name: String) = File(configDir, name).also { it.mkdirs() } + +private val json = Json { ignoreUnknownKeys = true } + +// ── Models ─────────────────────────────────────────────────────────────────── + +@Serializable +data class LocalProfile( + val id: String = UUID.randomUUID().toString(), + val displayName: String = "wren", + val createdAt: Long = System.currentTimeMillis() +) + +@Serializable +data class GoogleSession( + val accessToken: String, + val refreshToken: String, + val expiresAt: Long, + val accountName: String? = null, + val avatarUrl: String? = null +) + +@Serializable +data class SoundCloudSession( + val accessToken: String, + val obtainedAt: Long = System.currentTimeMillis(), + val refreshToken: String? = null, + val expiresAt: Long? = null, + val userId: Long? = null, + val username: String? = null, + val avatarUrl: String? = null, + val permalink: String? = null +) + +// ── Store ──────────────────────────────────────────────────────────────────── + +object AuthStore { + + // ── Profile ────────────────────────────────────────────────────────────── + + private val profileFile get() = File(configDir, "profile.json") + + fun profile(): LocalProfile { + if (!profileFile.exists()) { + val p = LocalProfile() + saveProfile(p) + return p + } + return runCatching { + json.decodeFromString(profileFile.readText()) + }.getOrNull()?.also { + if (!profileFile.exists()) saveProfile(it) + } ?: LocalProfile().also { saveProfile(it) } + } + + private fun saveProfile(p: LocalProfile) { + profileFile.writeText(json.encodeToString(LocalProfile.serializer(), p)) + } + + fun updateDisplayName(name: String) { + val p = profile().copy(displayName = name) + saveProfile(p) + } + + // ── Google session ─────────────────────────────────────────────────────── + + private val googleFile get() = File(dir("sessions"), "google.json") + + fun googleSession(): GoogleSession? { + if (!googleFile.exists()) return null + return runCatching { + json.decodeFromString(googleFile.readText()) + }.getOrNull() + } + + fun saveGoogle(session: GoogleSession) { + googleFile.writeText(json.encodeToString(GoogleSession.serializer(), session)) + writeYtdlpCache(session) + } + + fun disconnectGoogle() { + googleFile.delete() + ytdlpCacheFile.delete() + } + + // ── SoundCloud session ─────────────────────────────────────────────────── + + private val soundcloudFile get() = File(dir("sessions"), "soundcloud.json") + + fun soundcloudSession(): SoundCloudSession? { + if (!soundcloudFile.exists()) return null + return runCatching { + json.decodeFromString(soundcloudFile.readText()) + }.getOrNull() + } + + fun saveSoundCloud(session: SoundCloudSession) { + soundcloudFile.writeText(json.encodeToString(SoundCloudSession.serializer(), session)) + } + + fun disconnectSoundCloud() { + soundcloudFile.delete() + } + + // ── Legacy migration ───────────────────────────────────────────────────── + + private val legacyTokensFile get() = File(configDir, "tokens.json") + private val legacyAccountFile get() = File(configDir, "account_name") + private val legacyAvatarFile get() = File(configDir, "avatar_url") + + // yt-dlp reads its YouTube OAuth cache from here + private val ytdlpCacheFile = File(System.getProperty("user.home"), ".cache/yt-dlp/youtube/oauth2.json") + + fun migrateLegacy() { + if (!legacyTokensFile.exists()) return + if (googleSession() != null) { + // Session already migrated — clean up legacy files + legacyTokensFile.delete() + legacyAccountFile.delete() + legacyAvatarFile.delete() + return + } + runCatching { + val obj = json.parseToJsonElement(legacyTokensFile.readText()).jsonObject + val session = GoogleSession( + accessToken = obj["access_token"]!!.jsonPrimitive.content, + refreshToken = obj["refresh_token"]!!.jsonPrimitive.content, + expiresAt = obj["expires_at"]!!.jsonPrimitive.content.toLong(), + accountName = if (legacyAccountFile.exists()) legacyAccountFile.readText().trim().takeIf { it.isNotEmpty() } else null, + avatarUrl = if (legacyAvatarFile.exists()) legacyAvatarFile.readText().trim().takeIf { it.isNotEmpty() } else null + ) + saveGoogle(session) + legacyTokensFile.delete() + legacyAccountFile.delete() + legacyAvatarFile.delete() + Log.i("AuthStore", "Migrated legacy Google tokens to sessions/google.json") + }.onFailure { Log.e("AuthStore", "Failed to migrate legacy tokens", it) } + } + + private fun writeYtdlpCache(session: GoogleSession) { + ytdlpCacheFile.parentFile.mkdirs() + ytdlpCacheFile.writeText( + buildJsonObject { + put("access_token", session.accessToken) + put("expires", session.expiresAt.toDouble()) + put("refresh_token", session.refreshToken) + put("token_type", "Bearer") + }.toString() + ) + } +} diff --git a/desktop/src/main/kotlin/auth/OAuthFlow.kt b/desktop/src/main/kotlin/auth/OAuthFlow.kt index 1aff98d..64f171b 100644 --- a/desktop/src/main/kotlin/auth/OAuthFlow.kt +++ b/desktop/src/main/kotlin/auth/OAuthFlow.kt @@ -1,7 +1,10 @@ package auth import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException import kotlinx.serialization.json.* import java.net.InetSocketAddress import java.net.ServerSocket @@ -17,8 +20,16 @@ private val httpClient: HttpClient = HttpClient.newHttpClient() private val json = Json { ignoreUnknownKeys = true } private const val SCOPE = "https://www.googleapis.com/auth/youtube" -private const val REDIRECT_PORT = 8765 -private const val REDIRECT_URI = "http://localhost:$REDIRECT_PORT" +// Google "Desktop app" clients accept any loopback port, so each attempt binds an +// ephemeral one and puts it in the redirect URI. A fixed port broke with +// "Address already in use" whenever a previous attempt was still waiting. +private const val DEFAULT_REDIRECT_PORT = 8765 +private const val CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 +private fun redirectUri(port: Int) = "http://localhost:$port" + +/** The listener of the attempt in flight, closed when a new attempt starts (single-flight). */ +private var pendingCallback: ServerSocket? = null +private val pendingLock = Any() private val credentialsFile = java.io.File(System.getProperty("user.home"), ".config/wren/oauth.json") @@ -32,7 +43,8 @@ data class OAuthTokens( data class AuthState( val authUrl: String, - val codeVerifier: String + val codeVerifier: String, + val redirectPort: Int = DEFAULT_REDIRECT_PORT ) private val random = Random() @@ -49,23 +61,38 @@ fun computeCodeChallenge(verifier: String): String { return Base64.getUrlEncoder().withoutPadding().encodeToString(hash) } -fun buildAuthUrl(clientId: String): AuthState { +fun buildAuthUrl(clientId: String, redirectPort: Int = DEFAULT_REDIRECT_PORT): AuthState { val codeVerifier = generateCodeVerifier() val codeChallenge = computeCodeChallenge(codeVerifier) val url = "https://accounts.google.com/o/oauth2/v2/auth" + "?client_id=$clientId" + - "&redirect_uri=${encode(REDIRECT_URI)}" + + "&redirect_uri=${encode(redirectUri(redirectPort))}" + "&response_type=code" + "&scope=${encode(SCOPE)}" + "&access_type=offline" + "&prompt=consent" + "&code_challenge=${encode(codeChallenge)}" + "&code_challenge_method=S256" - return AuthState(url, codeVerifier) + return AuthState(url, codeVerifier, redirectPort) } -fun loadCredentials(): OAuthCredentials? = runCatching { +/** + * OAuth client to use, in priority order: + * 1. `~/.config/wren/oauth.json` — user-supplied override (own Cloud project) + * 2. The client bundled at build time (see `generateBuildConfig` in build.gradle.kts) + * Returns null when neither is available, i.e. a source build without env vars. + */ +fun loadCredentials(): OAuthCredentials? = loadCredentialsFile() ?: bundledCredentials() + +/** True when Google login is possible at all (bundled or user-provided client). */ +val hasGoogleCredentials: Boolean get() = loadCredentials() != null + +private fun bundledCredentials(): OAuthCredentials? = + OAuthCredentials(BuildConfig.GOOGLE_CLIENT_ID, BuildConfig.GOOGLE_CLIENT_SECRET) + .takeIf { it.clientId.isNotBlank() && it.clientSecret.isNotBlank() } + +private fun loadCredentialsFile(): OAuthCredentials? = runCatching { if (!credentialsFile.exists()) return null val root = json.parseToJsonElement(credentialsFile.readText()).jsonObject val obj = root["installed"]?.jsonObject ?: root["web"]?.jsonObject ?: root @@ -75,32 +102,80 @@ fun loadCredentials(): OAuthCredentials? = runCatching { ) }.getOrNull() -suspend fun waitForAuthCode(): String = withContext(Dispatchers.IO) { - ServerSocket().use { server -> - server.reuseAddress = true - server.bind(InetSocketAddress("localhost", REDIRECT_PORT)) - server.accept().use { socket -> - val request = socket.getInputStream().bufferedReader().readLine() ?: "" - // GET /?code=XXX HTTP/1.1 - val code = request.substringAfter("?").substringBefore(" ") - .split("&").firstOrNull { it.startsWith("code=") } - ?.removePrefix("code=") - ?: throw Exception("No authorization code received") - - val html = "" + - "

Authorization complete!

You can close this window.

" - val response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: ${html.length}\r\n\r\n$html" - socket.getOutputStream().write(response.toByteArray()) - code +/** + * Full Google sign-in: bind the loopback listener first (ephemeral port), build the + * auth URL for that port, hand it to [openBrowser], wait for the redirect, exchange the code. + * Cancelling the calling coroutine closes the listener; starting a new attempt closes + * the previous one, so a forgotten browser tab can never block the next login. + */ +suspend fun runGoogleLogin(creds: OAuthCredentials, openBrowser: (String) -> Unit): OAuthTokens { + val server = withContext(Dispatchers.IO) { + ServerSocket().apply { + reuseAddress = true + soTimeout = CALLBACK_TIMEOUT_MS + bind(InetSocketAddress("localhost", 0)) + } + } + synchronized(pendingLock) { + pendingCallback?.let { runCatching { it.close() } } + pendingCallback = server + } + try { + val authState = buildAuthUrl(creds.clientId, server.localPort) + openBrowser(authState.authUrl) + val code = waitForAuthCode(server) + return exchangeCode(code, creds, authState.codeVerifier, server.localPort) + } finally { + synchronized(pendingLock) { if (pendingCallback === server) pendingCallback = null } + runCatching { server.close() } + } +} + +/** Blocks on accept() in IO, but closes the socket on cancellation so the coroutine really stops. */ +private suspend fun waitForAuthCode(server: ServerSocket): String = withContext(Dispatchers.IO) { + suspendCancellableCoroutine { cont -> + cont.invokeOnCancellation { runCatching { server.close() } } + try { + server.accept().use { socket -> + val request = socket.getInputStream().bufferedReader().readLine() ?: "" + // GET /?code=XXX HTTP/1.1 + val query = request.substringAfter("?", "").substringBefore(" ") + val params = query.split("&").associate { it.substringBefore("=") to it.substringAfter("=", "") } + val error = params["error"] + val code = params["code"] + + val (title, body) = when { + code != null -> "Authorization complete!" to "You can close this window." + else -> "Authorization failed" to (error ?: "no authorization code received") + } + val html = "" + + "

$title

$body

" + val response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: ${html.toByteArray().size}\r\n\r\n$html" + socket.getOutputStream().write(response.toByteArray()) + + if (code != null) cont.resume(java.net.URLDecoder.decode(code, "UTF-8")) + else cont.resumeWithException(Exception("Google returned: ${error ?: "no authorization code"}")) + } + } catch (e: java.net.SocketTimeoutException) { + cont.resumeWithException(Exception("Timed out waiting for the browser authorization")) + } catch (e: Exception) { + if (cont.isActive) cont.resumeWithException( + if (server.isClosed) Exception("Login cancelled") else e + ) } } } -suspend fun exchangeCode(code: String, creds: OAuthCredentials, codeVerifier: String): OAuthTokens = withContext(Dispatchers.IO) { +suspend fun exchangeCode( + code: String, + creds: OAuthCredentials, + codeVerifier: String, + redirectPort: Int = DEFAULT_REDIRECT_PORT +): OAuthTokens = withContext(Dispatchers.IO) { val body = "code=${encode(code)}" + "&client_id=${creds.clientId}" + "&client_secret=${creds.clientSecret}" + - "&redirect_uri=${encode(REDIRECT_URI)}" + + "&redirect_uri=${encode(redirectUri(redirectPort))}" + "&grant_type=authorization_code" + "&code_verifier=${encode(codeVerifier)}" val response = post("https://oauth2.googleapis.com/token", body) diff --git a/desktop/src/main/kotlin/auth/SoundCloudAuth.kt b/desktop/src/main/kotlin/auth/SoundCloudAuth.kt new file mode 100644 index 0000000..d726fff --- /dev/null +++ b/desktop/src/main/kotlin/auth/SoundCloudAuth.kt @@ -0,0 +1,132 @@ +package auth + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.* +import util.Log +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +private val scAuthClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(10)) + .build() + +private val scAuthJson = Json { ignoreUnknownKeys = true } +private const val SC_AUTH_UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" + +object SoundCloudAuth { + private var session: SoundCloudSession? = AuthStore.soundcloudSession() + + val isAuthenticated: Boolean get() = session != null + val username: String? get() = session?.username + val avatarUrl: String? get() = session?.avatarUrl + val userId: Long? get() = session?.userId + val accessToken: String? get() = session?.accessToken + + /** + * Validate a SoundCloud OAuth token (pasted manually) by fetching the user profile. + * Tries api-v2 first (undocumented but used by the web player), then legacy api. + */ + suspend fun connect(token: String): SoundCloudSession = + connect(SoundCloudOAuth.Tokens(accessToken = token, refreshToken = null, expiresAt = null)) + + /** Store tokens from the native PKCE flow after validating them against /me. */ + suspend fun connect(tokens: SoundCloudOAuth.Tokens): SoundCloudSession = withContext(Dispatchers.IO) { + val me = fetchMe(tokens.accessToken) + ?: throw IllegalArgumentException("Invalid SoundCloud token — could not fetch user profile") + + val scSession = SoundCloudSession( + accessToken = tokens.accessToken, + refreshToken = tokens.refreshToken, + expiresAt = tokens.expiresAt, + userId = me.id, + username = me.username, + avatarUrl = me.avatarUrl, + permalink = me.permalink + ) + AuthStore.saveSoundCloud(scSession) + session = scSession + AuthEvents.notifyChanged() + scSession + } + + /** + * Refresh the access token when it is about to expire. Only sessions created by the + * PKCE flow carry a refresh token; pasted tokens are left as-is. + */ + suspend fun ensureValidToken() = withContext(Dispatchers.IO) { + val s = session ?: return@withContext + val refresh = s.refreshToken ?: return@withContext + val expiresAt = s.expiresAt ?: return@withContext + if (System.currentTimeMillis() / 1000 < expiresAt - 60) return@withContext + runCatching { + val refreshed = SoundCloudOAuth.refresh(refresh, api.scClientId()) + val updated = s.copy( + accessToken = refreshed.accessToken, + refreshToken = refreshed.refreshToken ?: refresh, + expiresAt = refreshed.expiresAt, + obtainedAt = System.currentTimeMillis() + ) + AuthStore.saveSoundCloud(updated) + session = updated + }.onFailure { Log.e("SoundCloudAuth", "Failed to refresh SoundCloud token", it) } + } + + fun disconnect() { + session = null + AuthStore.disconnectSoundCloud() + AuthEvents.notifyChanged() + } + + // ── /me fetch (internal for testing) ───────────────────────────────────── + + internal data class MeResult( + val id: Long, + val username: String, + val avatarUrl: String?, + val permalink: String? + ) + + internal suspend fun fetchMe(token: String): MeResult? = withContext(Dispatchers.IO) { + // Try api-v2 first (the web player uses this with Authorization: OAuth header) + fetchMeV2(token) ?: fetchMeLegacy(token) + } + + private fun fetchMeV2(token: String): MeResult? = runCatching { + val req = HttpRequest.newBuilder(URI.create("https://api-v2.soundcloud.com/me")) + .header("Authorization", "OAuth $token") + .header("User-Agent", SC_AUTH_UA) + .GET() + .timeout(Duration.ofSeconds(10)) + .build() + val resp = scAuthClient.send(req, HttpResponse.BodyHandlers.ofString()) + if (resp.statusCode() !in 200..299) return@runCatching null + parseMe(resp.body()) + }.onFailure { Log.w("SoundCloudAuth", "api-v2 /me failed", it) }.getOrNull() + + private fun fetchMeLegacy(token: String): MeResult? = runCatching { + val req = HttpRequest.newBuilder(URI.create("https://api.soundcloud.com/me?oauth_token=$token")) + .header("User-Agent", SC_AUTH_UA) + .GET() + .timeout(Duration.ofSeconds(10)) + .build() + val resp = scAuthClient.send(req, HttpResponse.BodyHandlers.ofString()) + if (resp.statusCode() !in 200..299) return@runCatching null + parseMe(resp.body()) + }.onFailure { Log.w("SoundCloudAuth", "legacy /me failed", it) }.getOrNull() + + internal fun parseMe(body: String): MeResult? = runCatching { + val obj = scAuthJson.parseToJsonElement(body).jsonObject + MeResult( + id = obj["id"]?.jsonPrimitive?.content?.toLongOrNull() ?: return null, + username = obj["username"]?.jsonPrimitive?.content ?: return null, + avatarUrl = obj["avatar_url"]?.jsonPrimitive?.contentOrNull, + permalink = obj["permalink_url"]?.jsonPrimitive?.contentOrNull + ) + }.getOrNull() +} diff --git a/desktop/src/main/kotlin/auth/SoundCloudOAuth.kt b/desktop/src/main/kotlin/auth/SoundCloudOAuth.kt new file mode 100644 index 0000000..ce31165 --- /dev/null +++ b/desktop/src/main/kotlin/auth/SoundCloudOAuth.kt @@ -0,0 +1,145 @@ +package auth + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.* +import util.Log +import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import java.util.Base64 +import java.util.UUID + +/** + * Native replica of the SoundCloud web player's sign-in: an OAuth 2 authorization-code + * flow with PKCE against `secure.soundcloud.com`, using the web player's public client id + * (scraped like the rest of the api-v2 calls) and its registered redirect URI. The + * resulting token is exactly what the web player stores, so api-v2 accepts it. + * + * Only the *authorization page* runs in the embedded browser; the code exchange happens + * here, so the full soundcloud.com web app is never loaded. + */ +object SoundCloudOAuth { + private const val TAG = "SoundCloudOAuth" + private const val AUTH_HOST = "https://secure.soundcloud.com" + private const val TOKEN_URL = "$AUTH_HOST/oauth/token" + /** Registered redirect of the web player; we intercept navigation to it instead of serving it. */ + const val REDIRECT_URI = "https://soundcloud.com/signin/callback" + /** The web player's application id, sent as `app_id` so the auth UI renders the sign-in view. */ + private const val WEB_APP_ID = 46941 + + private val http = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(10)) + .build() + private val json = Json { ignoreUnknownKeys = true } + + data class AuthRequest( + val url: String, + val clientId: String, + val codeVerifier: String, + val nonce: String + ) + + data class Tokens( + val accessToken: String, + val refreshToken: String?, + val expiresAt: Long? + ) + + fun buildAuthRequest(clientId: String): AuthRequest { + val verifier = generateCodeVerifier() + val challenge = computeCodeChallenge(verifier) + val nonce = UUID.randomUUID().toString().replace("-", "") + val state = base64Url("""{"client_id":"$clientId","nonce":"$nonce"}""") + val params = listOf( + "client_id" to clientId, + "device_id" to UUID.randomUUID().toString().replace("-", ""), + "theme" to "dark", + "ui_evo" to "true", + "app_id" to WEB_APP_ID.toString(), + "tracking" to "local", + "redirect_uri" to REDIRECT_URI, + "state" to state, + "code_challenge" to challenge, + "code_challenge_method" to "S256" + ).joinToString("&") { (k, v) -> "$k=${encode(v)}" } + return AuthRequest("$AUTH_HOST/web-auth?$params#start_view=sign_in", clientId, verifier, nonce) + } + + /** + * Parses the intercepted callback URL. Returns the code, or throws when SoundCloud + * reported an error or the state does not carry our nonce (CSRF / mixed-up attempt). + */ + fun parseCallback(url: String, request: AuthRequest): String { + val query = URI.create(url).rawQuery ?: throw IllegalStateException("Callback without parameters") + val params = query.split("&").associate { + URLDecoder.decode(it.substringBefore("="), "UTF-8") to URLDecoder.decode(it.substringAfter("=", ""), "UTF-8") + } + params["error"]?.let { throw IllegalStateException("SoundCloud returned: $it") } + val code = params["code"]?.takeIf { it.isNotBlank() } ?: throw IllegalStateException("No authorization code in callback") + val state = params["state"] ?: throw IllegalStateException("Missing state in callback") + val nonce = runCatching { + json.parseToJsonElement(String(Base64.getDecoder().decode(state))).jsonObject["nonce"]?.jsonPrimitive?.content + }.getOrNull() + if (nonce != request.nonce) throw IllegalStateException("State mismatch — please try signing in again") + return code + } + + suspend fun exchangeCode(code: String, request: AuthRequest): Tokens = withContext(Dispatchers.IO) { + postToken( + "grant_type=authorization_code" + + "&code=${encode(code)}" + + "&code_verifier=${encode(request.codeVerifier)}" + + "&redirect_uri=${encode(REDIRECT_URI)}" + + "&client_id=${encode(request.clientId)}", + "?grant_type=authorization_code" + ) + } + + suspend fun refresh(refreshToken: String, clientId: String): Tokens = withContext(Dispatchers.IO) { + postToken( + "grant_type=refresh_token" + + "&refresh_token=${encode(refreshToken)}" + + "&client_id=${encode(clientId)}", + "?grant_type=refresh_token" + ) + } + + private fun postToken(body: String, query: String): Tokens { + val req = HttpRequest.newBuilder(URI.create(TOKEN_URL + query)) + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .header("Accept", "application/json") + .header("Origin", "https://soundcloud.com") + .header("Referer", "https://soundcloud.com/") + .header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36") + .timeout(Duration.ofSeconds(15)) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build() + val resp = http.send(req, HttpResponse.BodyHandlers.ofString()) + if (resp.statusCode() !in 200..299) { + Log.w(TAG, "token endpoint returned ${resp.statusCode()}: ${resp.body().take(300)}") + throw IllegalStateException("SoundCloud token exchange failed (${resp.statusCode()})") + } + return parseTokens(resp.body()) + } + + internal fun parseTokens(body: String): Tokens { + val obj = json.parseToJsonElement(body).jsonObject + val access = obj["access_token"]?.jsonPrimitive?.contentOrNull + ?: throw IllegalStateException("Token response without access_token") + val expiresIn = obj["expires_in"]?.jsonPrimitive?.longOrNull + return Tokens( + accessToken = access, + refreshToken = obj["refresh_token"]?.jsonPrimitive?.contentOrNull, + expiresAt = expiresIn?.let { System.currentTimeMillis() / 1000 + it } + ) + } + + private fun encode(v: String) = URLEncoder.encode(v, "UTF-8") + private fun base64Url(v: String) = Base64.getEncoder().encodeToString(v.toByteArray()) +} diff --git a/desktop/src/main/kotlin/main.kt b/desktop/src/main/kotlin/main.kt index 5cb0790..8521797 100644 --- a/desktop/src/main/kotlin/main.kt +++ b/desktop/src/main/kotlin/main.kt @@ -1,4 +1,5 @@ import api.warmupStreamConnection +import auth.AuthStore import androidx.compose.ui.window.application import ui.AppWindow import util.Log @@ -8,8 +9,59 @@ fun main() { Log.e("Uncaught", "Uncaught exception on thread '${thread.name}'", throwable) } Log.i("Main", "Wren starting (pid=${ProcessHandle.current().pid()})") - warmupStreamConnection() - application { - AppWindow(onCloseRequest = ::exitApplication) + AuthStore.migrateLegacy() + applyUiScale().let { scale -> + warmupStreamConnection() + application { + AppWindow(uiScale = scale, onCloseRequest = ::exitApplication) + } } } + +/** + * AWT/Skia render through XWayland, and compositors like Hyprland rescale that buffer by + * the monitor scale — fractional scaling makes the UI pixelated. AWT on X11 doesn't pick + * up the compositor scale (and may ignore `sun.java2d.uiScale` set programmatically), so + * detect the compositor scale ourselves and hand it to Compose (see AppWindow), which + * sizes the window and lays out the UI at native resolution. Must run before the first + * AWT window exists (i.e. before the Compose `application` block). + */ +private fun applyUiScale(): Float { + val scale = detectUiScale() ?: 1f + if (scale > 1f) { + System.setProperty("sun.java2d.uiScale.enabled", "true") + System.setProperty("sun.java2d.uiScale", scale.toString()) + } + val awtDensity = runCatching { + java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment() + .defaultScreenDevice.defaultConfiguration.defaultTransform.scaleX.toFloat() + }.getOrNull()?.takeIf { it > 0f } ?: 1f + Log.i("Main", "Compositor scale $scale, AWT density $awtDensity") + return scale +} + +private fun detectUiScale(): Float? { + // 1. Hyprland (HYPRLAND_INSTANCE_SIGNATURE is set for session processes) + val hyprland = runCatching { + if (System.getenv("HYPRLAND_INSTANCE_SIGNATURE").isNullOrBlank()) return@runCatching null + val out = ProcessBuilder("hyprctl", "monitors") + .start().inputStream.bufferedReader().use { it.readText() } + Regex("""^\s*scale:\s*([\d.]+)""", RegexOption.MULTILINE) + .find(out)?.groupValues?.get(1)?.toFloatOrNull() + }.getOrNull() + if (hyprland != null && hyprland > 1f) return hyprland + + // 2. GDK_SCALE env (GTK convention, integer) + val gdk = System.getenv("GDK_SCALE")?.toFloatOrNull() + if (gdk != null && gdk > 1f) return gdk + + // 3. Xft.dpi from X resources (common in X11 dotfiles; 96 dpi == scale 1) + val xft = runCatching { + val out = ProcessBuilder("xrdb", "-query") + .start().inputStream.bufferedReader().use { it.readText() } + out.lineSequence() + .firstOrNull { it.startsWith("Xft.dpi") } + ?.substringAfter(':')?.trim()?.toFloatOrNull()?.div(96f) + }.getOrNull() + return xft?.takeIf { it > 1f } +} diff --git a/desktop/src/main/kotlin/ui/AuthDialog.kt b/desktop/src/main/kotlin/ui/AuthDialog.kt deleted file mode 100644 index 8dc9636..0000000 --- a/desktop/src/main/kotlin/ui/AuthDialog.kt +++ /dev/null @@ -1,164 +0,0 @@ -package ui - -import auth.AuthManager -import auth.buildAuthUrl -import auth.exchangeCode -import auth.loadCredentials -import auth.waitForAuthCode -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import kotlinx.coroutines.launch -import java.awt.Desktop -import java.net.URI - -@Composable -fun AuthDialog(onDismiss: () -> Unit, onSuccess: () -> Unit) { - val hasCredentials = remember { loadCredentials() != null } - - if (!hasCredentials) { - CredentialsSetupDialog(onDismiss) - return - } - - var authUrl by remember { mutableStateOf(null) } - var codeVerifier by remember { mutableStateOf(null) } - var error by remember { mutableStateOf(null) } - - LaunchedEffect(Unit) { - runCatching { - val creds = loadCredentials()!! - val authState = buildAuthUrl(creds.clientId) - authUrl = authState.authUrl - codeVerifier = authState.codeVerifier - openInBrowser(authState.authUrl) - val authCode = waitForAuthCode() - val tokens = exchangeCode(authCode, creds, codeVerifier!!) - AuthManager.saveTokens(tokens) - onSuccess() - }.onFailure { - error = it.message ?: it.toString() - } - } - - Dialog(onDismissRequest = { onDismiss() }) { - Column( - modifier = Modifier - .width(400.dp) - .background(Surface, RoundedCornerShape(0.dp)) - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Sign in", color = TextPrimary, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(24.dp)) - when { - error != null -> { - Text(error!!, color = PsSignalDanger, fontSize = 13.sp, textAlign = TextAlign.Center) - Spacer(Modifier.height(20.dp)) - Button( - onClick = { onDismiss() }, - colors = ButtonDefaults.buttonColors(backgroundColor = PsInset), - shape = RoundedCornerShape(0.dp) - ) { - Text("Close", color = TextSecondary, fontSize = 14.sp) - } - } - authUrl == null -> { - CircularProgressIndicator(color = PsInk900, modifier = Modifier.size(32.dp), strokeWidth = 3.dp) - Spacer(Modifier.height(16.dp)) - Text("Preparing authorization link...", color = TextSecondary, fontSize = 13.sp) - } - else -> { - Text( - "The browser has been opened. Authorize the app to continue.", - color = TextSecondary, fontSize = 13.sp, textAlign = TextAlign.Center - ) - Spacer(Modifier.height(16.dp)) - TextButton( - onClick = { openInBrowser(authUrl!!) }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Open link again", color = TextPrimary, fontSize = 13.sp) - } - Spacer(Modifier.height(16.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(color = PsInk900, modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - Spacer(Modifier.width(10.dp)) - Text("Waiting for authorization...", color = TextSecondary, fontSize = 13.sp) - } - Spacer(Modifier.height(20.dp)) - TextButton(onClick = { onDismiss() }) { - Text("Cancel", color = TextSecondary, fontSize = 12.sp) - } - } - } - } - } -} - -@Composable -private fun CredentialsSetupDialog(onDismiss: () -> Unit) { - Dialog(onDismissRequest = onDismiss) { - Column( - modifier = Modifier - .width(420.dp) - .background(Surface, RoundedCornerShape(0.dp)) - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Setup required", color = TextPrimary, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(16.dp)) - Text( - "To sign in you need to create OAuth credentials in Google Cloud and save them to:", - color = TextSecondary, fontSize = 13.sp, textAlign = TextAlign.Center - ) - Spacer(Modifier.height(12.dp)) - Text( - "~/.config/wren/oauth.json", - color = TextPrimary, fontSize = 12.sp, fontWeight = FontWeight.Medium, - fontFamily = FontMono - ) - Spacer(Modifier.height(16.dp)) - Text( - "1. Open Google Cloud Console\n" + - "2. Create a project and enable \"YouTube Data API v3\"\n" + - "3. Create credentials → OAuth 2.0 → Desktop app\n" + - "4. Save the file with this format:", - color = TextSecondary, fontSize = 12.sp - ) - Spacer(Modifier.height(12.dp)) - Text( - "{\n \"client_id\": \"…\",\n \"client_secret\": \"…\"\n}", - color = TextPrimary, - fontSize = 11.sp, - fontFamily = FontMono, - modifier = Modifier - .fillMaxWidth() - .background(PsInset, RoundedCornerShape(0.dp)) - .padding(12.dp) - ) - Spacer(Modifier.height(20.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = { openInBrowser("https://console.cloud.google.com/apis/credentials") }) { - Text("Open Cloud Console", color = TextPrimary, fontSize = 13.sp) - } - TextButton(onClick = onDismiss) { - Text("Close", color = TextSecondary, fontSize = 13.sp) - } - } - } - } -} - -private fun openInBrowser(url: String) { - runCatching { Desktop.getDesktop().browse(URI(url)) } -} diff --git a/desktop/src/main/kotlin/ui/ProfileDialog.kt b/desktop/src/main/kotlin/ui/ProfileDialog.kt new file mode 100644 index 0000000..22f3a13 --- /dev/null +++ b/desktop/src/main/kotlin/ui/ProfileDialog.kt @@ -0,0 +1,282 @@ +package ui + +import auth.AuthEvents +import auth.GoogleAuth +import auth.SoundCloudAuth +import auth.SoundCloudOAuth +import auth.loadCredentials +import auth.runGoogleLogin +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import kotlinx.coroutines.launch +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import util.openInBrowser + +@Composable +fun ProfileDialog(onDismiss: () -> Unit) { + val scope = rememberCoroutineScope() + var profileName by remember { mutableStateOf("") } + var isEditingName by remember { mutableStateOf(false) } + val authVersion by AuthEvents.version.collectAsState() + val googleConnected = remember(authVersion) { GoogleAuth.isAuthenticated } + val scConnected = remember(authVersion) { SoundCloudAuth.isAuthenticated } + var googleError by remember { mutableStateOf(null) } + var googleAuthUrl by remember { mutableStateOf(null) } // set while waiting for the browser + var googleBrowserOpened by remember { mutableStateOf(true) } + val clipboard = LocalClipboardManager.current + var scError by remember { mutableStateOf(null) } + var scTokenInput by remember { mutableStateOf("") } + var scConnecting by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + profileName = auth.AuthStore.profile().displayName + } + + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .width(440.dp) + .background(Surface, RoundedCornerShape(0.dp)) + .padding(32.dp) + ) { + // Header + Text("profile;", color = PsSteel400, fontFamily = FontMono, fontSize = 10.sp, letterSpacing = 1.7.sp) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + if (isEditingName) { + OutlinedTextField( + value = profileName, + onValueChange = { profileName = it }, + singleLine = true, + modifier = Modifier.weight(1f), + colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = TextPrimary, cursorColor = TextPrimary, + focusedBorderColor = TextPrimary, unfocusedBorderColor = PsPearl200, + backgroundColor = Surface + ) + ) + Spacer(Modifier.width(8.dp)) + TextButton(onClick = { + auth.AuthStore.updateDisplayName(profileName) + isEditingName = false + }) { Text("ok;", fontFamily = FontMono, fontSize = 12.sp) } + } else { + Text(profileName, color = TextPrimary, fontSize = 18.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.width(8.dp)) + TextButton(onClick = { isEditingName = true }) { Text("edit;", fontFamily = FontMono, fontSize = 11.sp) } + } + } + + Spacer(Modifier.height(24.dp)) + Text("connected sessions;", color = PsSteel400, fontFamily = FontMono, fontSize = 10.sp, letterSpacing = 1.7.sp) + Spacer(Modifier.height(12.dp)) + + // Google row + ProviderRow( + name = "Google", + code = "GOO", + connected = googleConnected, + accountLabel = GoogleAuth.accountName, + error = googleError, + onConnect = { + scope.launch { + googleError = null + val creds = loadCredentials() + if (creds == null) { + googleError = "No Google OAuth client: this build has none bundled and ~/.config/wren/oauth.json is missing" + return@launch + } + try { + val tokens = runGoogleLogin(creds) { url -> + googleAuthUrl = url + googleBrowserOpened = openInBrowser(url) + } + GoogleAuth.connect(tokens) + } catch (e: Exception) { + googleError = e.message?.take(100) ?: "Login failed" + } finally { + googleAuthUrl = null + } + } + }, + onDisconnect = { GoogleAuth.disconnect() }, + customContent = googleAuthUrl?.let { url -> + @Composable { + Column { + Text( + if (googleBrowserOpened) "// waiting_for_browser_authorization;" + else "// could_not_open_browser — copy the link and open it manually;", + color = if (googleBrowserOpened) PsSteel400 else PsSignalDanger, + fontFamily = FontMono, fontSize = 10.sp + ) + Spacer(Modifier.height(6.dp)) + Row { + TextButton(onClick = { clipboard.setText(AnnotatedString(url)) }) { + Text("copy_link;", color = TextPrimary, fontFamily = FontMono, fontSize = 11.sp) + } + TextButton(onClick = { googleBrowserOpened = openInBrowser(url) }) { + Text("open_again;", color = TextPrimary, fontFamily = FontMono, fontSize = 11.sp) + } + } + } + } + } + ) + + Spacer(Modifier.height(16.dp)) + + // SoundCloud row + val startWebLogin: () -> Unit = { + if (!scConnecting) { + scConnecting = true + scError = null + scope.launch { + try { + // Native PKCE flow: only the auth page runs in the WebView, + // the code exchange happens in SoundCloudOAuth. + val request = SoundCloudOAuth.buildAuthRequest(api.scClientId()) + val code = SoundCloudLoginWindow.open(request).await() + SoundCloudAuth.connect(SoundCloudOAuth.exchangeCode(code, request)) + scTokenInput = "" + } catch (e: Exception) { + scError = e.message?.take(100) ?: "Login failed" + } finally { + scConnecting = false + } + } + } + } + ProviderRow( + name = "SoundCloud", + code = "SC ", + connected = scConnected, + accountLabel = SoundCloudAuth.username, + error = scError, + onConnect = startWebLogin, + onDisconnect = { SoundCloudAuth.disconnect() }, + customContent = if (!scConnected) { + @Composable { + Column { + Button( + onClick = startWebLogin, + enabled = !scConnecting, + colors = ButtonDefaults.buttonColors(backgroundColor = PsInk900), + shape = RoundedCornerShape(0.dp), + modifier = Modifier.fillMaxWidth() + ) { + if (scConnecting && scTokenInput.isBlank()) { + CircularProgressIndicator(modifier = Modifier.size(14.dp), color = PsWhite, strokeWidth = 1.5.dp) + } else { + Text("sign_in_with_browser;", color = PsWhite, fontFamily = FontMono, fontSize = 12.sp) + } + } + Spacer(Modifier.height(10.dp)) + Text("// or_paste_token_manually;", color = PsSteel400, fontFamily = FontMono, fontSize = 10.sp) + Spacer(Modifier.height(6.dp)) + OutlinedTextField( + value = scTokenInput, + onValueChange = { scTokenInput = it }, + placeholder = { Text("_paste_oauth_token;", color = PsSteel400, fontFamily = FontMono, fontSize = 12.sp) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = TextPrimary, cursorColor = TextPrimary, + focusedBorderColor = TextPrimary, unfocusedBorderColor = PsPearl200, + backgroundColor = Surface, placeholderColor = PsSteel400 + ) + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { + if (scTokenInput.isBlank() || scConnecting) return@Button + scConnecting = true + scError = null + scope.launch { + runCatching { SoundCloudAuth.connect(scTokenInput.trim()) } + .onSuccess { scTokenInput = "" } + .onFailure { scError = it.message?.take(100) ?: "Invalid token" } + scConnecting = false + } + }, + enabled = scTokenInput.isNotBlank() && !scConnecting, + colors = ButtonDefaults.buttonColors(backgroundColor = PsInk900), + shape = RoundedCornerShape(0.dp) + ) { + if (scConnecting && scTokenInput.isNotBlank()) { + CircularProgressIndicator(modifier = Modifier.size(14.dp), color = PsWhite, strokeWidth = 1.5.dp) + } else { + Text("connect;", color = PsWhite, fontFamily = FontMono, fontSize = 12.sp) + } + } + } + } + } else null + ) + + Spacer(Modifier.height(20.dp)) + TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + Text("close;", color = PsSteel400, fontFamily = FontMono, fontSize = 12.sp) + } + } + } +} + +@Composable +private fun ProviderRow( + name: String, + code: String, + connected: Boolean, + accountLabel: String?, + error: String?, + onConnect: () -> Unit, + onDisconnect: () -> Unit, + customContent: @Composable (() -> Unit)? = null +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(PsInset, RoundedCornerShape(0.dp)) + .padding(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(code, color = PsSteel400, fontFamily = FontMono, fontWeight = FontWeight.SemiBold, fontSize = 11.sp) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text(name, color = TextPrimary, fontSize = 13.sp, fontWeight = FontWeight.Medium) + if (connected && accountLabel != null) { + Text(accountLabel, color = PsSteel400, fontSize = 11.sp, fontFamily = FontMono) + } + } + if (connected) { + Icon(Icons.Default.Check, contentDescription = null, tint = PsSignalOk, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + TextButton(onClick = onDisconnect) { Text("disconnect;", color = PsSignalDanger, fontFamily = FontMono, fontSize = 11.sp) } + } else { + TextButton(onClick = onConnect) { Text("connect;", color = TextPrimary, fontFamily = FontMono, fontSize = 11.sp) } + } + } + if (error != null) { + Spacer(Modifier.height(8.dp)) + Text(error, color = PsSignalDanger, fontSize = 11.sp) + } + if (!connected && customContent != null) { + Spacer(Modifier.height(10.dp)) + customContent() + } + } +} diff --git a/desktop/src/main/kotlin/ui/SoundCloudLoginWindow.kt b/desktop/src/main/kotlin/ui/SoundCloudLoginWindow.kt new file mode 100644 index 0000000..ed4c89c --- /dev/null +++ b/desktop/src/main/kotlin/ui/SoundCloudLoginWindow.kt @@ -0,0 +1,174 @@ +package ui + +import auth.SoundCloudOAuth +import javafx.application.Platform +import javafx.concurrent.Worker +import javafx.scene.Scene +import javafx.scene.web.WebEngine +import javafx.scene.web.WebView +import javafx.stage.Modality +import javafx.stage.Stage +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import util.Log +import java.net.CookieHandler +import java.net.CookieManager +import java.net.CookiePolicy +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Embedded browser for the SoundCloud authorization page only. + * + * Loads the PKCE authorization URL from [SoundCloudOAuth.buildAuthRequest] and watches + * the WebView's location. When SoundCloud redirects to its registered callback + * (`soundcloud.com/signin/callback?code=…`) we grab the code and close the window + * before the web player ever loads; the token exchange happens natively afterwards. + * + * Lifecycle notes: + * - The JavaFX toolkit can only be started once per JVM, so [ensureToolkit] + * guards `Platform.startup` and falls back to `runLater` afterwards. + * - The cookie jar is cleared on every open so a previous session can't be + * picked up silently (and the user can switch accounts). + */ +object SoundCloudLoginWindow { + + private const val TAG = "SoundCloudLogin" + /** + * Google refuses to sign in inside anything it identifies as an embedded WebView + * (403 disallowed_useragent), and JavaFX's default UA advertises exactly that. + * Present as a regular desktop Safari/Chrome build instead. + */ + private const val USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15" + + private val toolkitStarted = AtomicBoolean(false) + private val cookieManager = CookieManager(null, CookiePolicy.ACCEPT_ALL) + private var stage: Stage? = null + + /** + * Opens the authorization page. Completes with the authorization code on success, + * or exceptionally on cancel / error / toolkit failure. Cancelling the returned + * Deferred closes the window. + */ + fun open(request: SoundCloudOAuth.AuthRequest): Deferred { + val deferred = CompletableDeferred() + + // Fresh cookie jar per attempt: no stale session, no silent re-login. + cookieManager.cookieStore.removeAll() + CookieHandler.setDefault(cookieManager) + + runCatching { ensureToolkit { showStage(request, deferred) } } + .onFailure { e -> + Log.e(TAG, "JavaFX toolkit unavailable", e) + deferred.completeExceptionally( + IllegalStateException("Embedded browser unavailable — paste the token manually", e) + ) + } + return deferred + } + + fun close() { + if (!toolkitStarted.get()) return + runCatching { Platform.runLater { stage?.close() } } + } + + // ── Internals ──────────────────────────────────────────────────────────── + + private fun ensureToolkit(onReady: () -> Unit) { + if (toolkitStarted.compareAndSet(false, true)) { + try { + Platform.startup { + Platform.setImplicitExit(false) + onReady() + } + } catch (e: IllegalStateException) { + // Another component started the toolkit first — that's fine. + Platform.runLater(onReady) + } + } else { + Platform.runLater(onReady) + } + } + + /** Runs on the FX thread. */ + private fun showStage(request: SoundCloudOAuth.AuthRequest, deferred: CompletableDeferred) { + // Only one login window at a time. + stage?.close() + + val webView = WebView() + val engine = webView.engine + configureEngine(engine, "main", request, deferred) + + val s = Stage().apply { + title = "Sign in to SoundCloud" + scene = Scene(webView, 520.0, 720.0) + setOnHidden { + if (stage === this) stage = null + deferred.completeExceptionally(IllegalStateException("Login cancelled")) + } + } + stage = s + + // "Continue with Google/Apple/Facebook" opens a popup via window.open(). Without a + // handler JavaFX returns null and the main view goes blank, so host the popup in + // its own window whose engine is watched for the callback exactly like the main one. + engine.setCreatePopupHandler { features -> + Log.d(TAG, "popup requested (menu=${features.hasMenu()}, toolbar=${features.hasToolbar()})") + val popupView = WebView() + configureEngine(popupView.engine, "popup", request, deferred) + Stage().apply { + title = "Sign in" + initOwner(s) + initModality(Modality.NONE) + scene = Scene(popupView, 480.0, 680.0) + // The provider closes the popup itself when done; mirror that on the FX side. + popupView.engine.onVisibilityChanged = javafx.event.EventHandler { ev -> + if (!ev.data) close() + } + show() + } + popupView.engine + } + + s.show() + s.toFront() + engine.load(request.url) + + // Tear down *this* attempt only — a newer window may already own `stage`. + deferred.invokeOnCompletion { + Platform.runLater { if (s.isShowing) s.close() } + } + } + + /** Shared setup for the main view and any popup: UA, callback interception, diagnostics. */ + private fun configureEngine( + engine: WebEngine, + name: String, + request: SoundCloudOAuth.AuthRequest, + deferred: CompletableDeferred + ) { + engine.isJavaScriptEnabled = true + engine.userAgent = USER_AGENT + + // Intercept the redirect to the callback: complete *before* closing the stage, + // because closing fires onHidden, which would otherwise report "cancelled". + engine.locationProperty().addListener { _, _, location -> + Log.d(TAG, "[$name] navigate: $location") + if (location != null && location.startsWith(SoundCloudOAuth.REDIRECT_URI)) { + runCatching { SoundCloudOAuth.parseCallback(location, request) } + .onSuccess { deferred.complete(it) } + .onFailure { deferred.completeExceptionally(it) } + } + } + engine.loadWorker.stateProperty().addListener { _, _, state -> + when (state) { + Worker.State.SUCCEEDED -> Log.d(TAG, "[$name] loaded: ${engine.location} title=\"${engine.title}\"") + Worker.State.FAILED -> Log.w(TAG, "[$name] load failed for ${engine.location}", engine.loadWorker.exception) + else -> {} + } + } + engine.onError = javafx.event.EventHandler { ev -> Log.w(TAG, "[$name] webkit error: ${ev.message}") } + engine.onAlert = javafx.event.EventHandler { ev -> Log.d(TAG, "[$name] page alert: ${ev.data}") } + engine.onStatusChanged = javafx.event.EventHandler { ev -> if (!ev.data.isNullOrBlank()) Log.d(TAG, "[$name] status: ${ev.data}") } + } +} diff --git a/desktop/src/main/kotlin/util/Browser.kt b/desktop/src/main/kotlin/util/Browser.kt new file mode 100644 index 0000000..60c47c9 --- /dev/null +++ b/desktop/src/main/kotlin/util/Browser.kt @@ -0,0 +1,42 @@ +package util + +import java.awt.Desktop +import java.net.URI + +/** + * Opens [url] in the user's default browser. `java.awt.Desktop` is unsupported on many + * Linux desktops (Wayland compositors, no GNOME libs in the jlinked runtime), so fall + * back to the platform opener. Returns false when every strategy failed, so callers + * can show the link for manual copy instead of failing silently. + */ +fun openInBrowser(url: String): Boolean { + val os = System.getProperty("os.name").lowercase() + val openers: List<() -> Unit> = listOfNotNull( + { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI(url)) + } else { + error("Desktop.browse unsupported") + } + }, + System.getenv("BROWSER")?.takeIf { it.isNotBlank() }?.let { b -> { spawn(b, url) } }, + when { + os.contains("win") -> { { spawn("rundll32", "url.dll,FileProtocolHandler", url) } } + os.contains("mac") -> { { spawn("open", url) } } + else -> { { spawn("xdg-open", url) } } + } + ) + for (open in openers) { + val result = runCatching(open) + if (result.isSuccess) return true + Log.w("Browser", "Opener failed: ${result.exceptionOrNull()?.message}") + } + Log.e("Browser", "Could not open browser for $url") + return false +} + +private fun spawn(vararg cmd: String) { + val p = ProcessBuilder(*cmd).redirectErrorStream(true).start() + // xdg-open exits quickly; a non-zero code (e.g. 3 = no handler) means it did nothing. + if (p.waitFor() != 0) error("${cmd.first()} exited with ${p.exitValue()}") +} diff --git a/desktop/src/test/kotlin/auth/AuthStoreTest.kt b/desktop/src/test/kotlin/auth/AuthStoreTest.kt new file mode 100644 index 0000000..ebbe7ca --- /dev/null +++ b/desktop/src/test/kotlin/auth/AuthStoreTest.kt @@ -0,0 +1,106 @@ +package auth + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class AuthStoreTest { + + @TempDir + lateinit var tempDir: File + + private fun withTempConfig(block: () -> Unit) { + val old = System.getProperty("wren.config.dir") + System.setProperty("wren.config.dir", tempDir.absolutePath) + try { block() } finally { + if (old != null) System.setProperty("wren.config.dir", old) + else System.clearProperty("wren.config.dir") + } + } + + @Test + fun `should create and load profile`() = withTempConfig { + val p = AuthStore.profile() + assertNotNull(p.id) + assertEquals("wren", p.displayName) + assertTrue(p.createdAt > 0) + // Reload — same id + val p2 = AuthStore.profile() + assertEquals(p.id, p2.id) + } + + @Test + fun `should update display name`() = withTempConfig { + AuthStore.updateDisplayName("my_wren") + assertEquals("my_wren", AuthStore.profile().displayName) + } + + @Test + fun `should save and load google session`() = withTempConfig { + val session = GoogleSession("tok", "ref", 1000, "TestUser", "https://avatar.png") + AuthStore.saveGoogle(session) + val loaded = AuthStore.googleSession()!! + assertEquals("tok", loaded.accessToken) + assertEquals("TestUser", loaded.accountName) + assertEquals("https://avatar.png", loaded.avatarUrl) + } + + @Test + fun `should save and load soundcloud session`() = withTempConfig { + val session = SoundCloudSession("sc_tok", userId = 42, username = "nxssie", permalink = "https://soundcloud.com/nxssie") + AuthStore.saveSoundCloud(session) + val loaded = AuthStore.soundcloudSession()!! + assertEquals("sc_tok", loaded.accessToken) + assertEquals(42L, loaded.userId) + assertEquals("nxssie", loaded.username) + } + + @Test + fun `should disconnect google`() = withTempConfig { + AuthStore.saveGoogle(GoogleSession("tok", "ref", 1000)) + AuthStore.disconnectGoogle() + assertNull(AuthStore.googleSession()) + } + + @Test + fun `should disconnect soundcloud`() = withTempConfig { + AuthStore.saveSoundCloud(SoundCloudSession("tok")) + AuthStore.disconnectSoundCloud() + assertNull(AuthStore.soundcloudSession()) + } + + @Test + fun `should migrate legacy tokens`() = withTempConfig { + // Write legacy files + File(tempDir, "tokens.json").writeText("""{"access_token":"legacy_tok","refresh_token":"legacy_ref","expires_at":9999}""") + File(tempDir, "account_name").writeText("Legacy User") + File(tempDir, "avatar_url").writeText("https://legacy.png") + + AuthStore.migrateLegacy() + + // Legacy files deleted + assertFalse(File(tempDir, "tokens.json").exists()) + assertFalse(File(tempDir, "account_name").exists()) + assertFalse(File(tempDir, "avatar_url").exists()) + + // Session created + val session = AuthStore.googleSession()!! + assertEquals("legacy_tok", session.accessToken) + assertEquals("legacy_ref", session.refreshToken) + assertEquals("Legacy User", session.accountName) + assertEquals("https://legacy.png", session.avatarUrl) + } + + @Test + fun `should not migrate when session already exists`() = withTempConfig { + AuthStore.saveGoogle(GoogleSession("existing", "ref", 1000)) + File(tempDir, "tokens.json").writeText("""{"access_token":"should_not_migrate","refresh_token":"x","expires_at":1}""") + + AuthStore.migrateLegacy() + + assertEquals("existing", AuthStore.googleSession()!!.accessToken) + // Legacy file deleted anyway + assertFalse(File(tempDir, "tokens.json").exists()) + } +} diff --git a/desktop/src/test/kotlin/auth/SoundCloudAuthTest.kt b/desktop/src/test/kotlin/auth/SoundCloudAuthTest.kt new file mode 100644 index 0000000..653da9d --- /dev/null +++ b/desktop/src/test/kotlin/auth/SoundCloudAuthTest.kt @@ -0,0 +1,52 @@ +package auth + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class SoundCloudAuthTest { + + private val validMeJson = """ + { + "id": 12345678, + "username": "nxssie", + "avatar_url": "https://i1.sndcdn.com/u-00000-large.jpg", + "permalink_url": "https://soundcloud.com/nxssie", + "kind": "user" + } + """.trimIndent() + + private val minimalMeJson = """ + { + "id": 99, + "username": "testuser" + } + """.trimIndent() + + @Test + fun `should parse valid me response`() { + val result = SoundCloudAuth.parseMe(validMeJson)!! + assertEquals(12345678L, result.id) + assertEquals("nxssie", result.username) + assertEquals("https://i1.sndcdn.com/u-00000-large.jpg", result.avatarUrl) + assertEquals("https://soundcloud.com/nxssie", result.permalink) + } + + @Test + fun `should parse minimal me response`() { + val result = SoundCloudAuth.parseMe(minimalMeJson)!! + assertEquals(99L, result.id) + assertEquals("testuser", result.username) + assertNull(result.avatarUrl) + assertNull(result.permalink) + } + + @Test + fun `should return null for invalid JSON`() { + assertNull(SoundCloudAuth.parseMe("not json")) + } + + @Test + fun `should return null for missing required fields`() { + assertNull(SoundCloudAuth.parseMe("""{"avatar_url": "x"}""")) + } +} diff --git a/desktop/src/test/kotlin/auth/SoundCloudOAuthTest.kt b/desktop/src/test/kotlin/auth/SoundCloudOAuthTest.kt new file mode 100644 index 0000000..8393f7d --- /dev/null +++ b/desktop/src/test/kotlin/auth/SoundCloudOAuthTest.kt @@ -0,0 +1,59 @@ +package auth + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.Base64 + +class SoundCloudOAuthTest { + + private val request = SoundCloudOAuth.buildAuthRequest("abcdefghijklmnopqrstuvwxyz012345") + + private fun state(nonce: String) = + Base64.getEncoder().encodeToString("""{"client_id":"x","nonce":"$nonce"}""".toByteArray()) + + @Test + fun `auth url carries pkce, state and the web player redirect`() { + assertTrue(request.url.startsWith("https://secure.soundcloud.com/web-auth?")) + assertTrue(request.url.contains("client_id=abcdefghijklmnopqrstuvwxyz012345")) + assertTrue(request.url.contains("code_challenge_method=S256")) + assertTrue(request.url.contains("code_challenge=${computeCodeChallenge(request.codeVerifier)}")) + assertTrue(request.url.contains("redirect_uri=https%3A%2F%2Fsoundcloud.com%2Fsignin%2Fcallback")) + assertTrue(request.url.contains("state=")) + } + + @Test + fun `parseCallback returns the code when the nonce matches`() { + val url = "${SoundCloudOAuth.REDIRECT_URI}?code=the-code&state=${state(request.nonce)}" + assertEquals("the-code", SoundCloudOAuth.parseCallback(url, request)) + } + + @Test + fun `parseCallback rejects a foreign nonce`() { + val url = "${SoundCloudOAuth.REDIRECT_URI}?code=the-code&state=${state("other")}" + assertThrows { SoundCloudOAuth.parseCallback(url, request) } + } + + @Test + fun `parseCallback surfaces provider errors`() { + val url = "${SoundCloudOAuth.REDIRECT_URI}?error=access_denied&state=${state(request.nonce)}" + val e = assertThrows { SoundCloudOAuth.parseCallback(url, request) } + assertTrue(e.message!!.contains("access_denied")) + } + + @Test + fun `parseTokens reads access, refresh and expiry`() { + val t = SoundCloudOAuth.parseTokens("""{"access_token":"a","refresh_token":"r","expires_in":3600,"scope":"*"}""") + assertEquals("a", t.accessToken) + assertEquals("r", t.refreshToken) + assertNotNull(t.expiresAt) + assertTrue(t.expiresAt!! > System.currentTimeMillis() / 1000) + } + + @Test + fun `parseTokens tolerates a bare access token`() { + val t = SoundCloudOAuth.parseTokens("""{"access_token":"a"}""") + assertNull(t.refreshToken) + assertNull(t.expiresAt) + } +} From fc76e0c0a6b6ee2c3f49c17d4e635878c92a3fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Wed, 9 Sep 2026 15:51:10 +0100 Subject: [PATCH 3/6] fix(now-playing): queue jump, lyrics behaviour, seekable bar, dark hairlines - add FFmpegPlayer.jumpTo so clicking a queue row keeps the current order; loadQueue reshuffled and played a random track under shuffle - plain lyrics carry no timestamps and render static; the fake 4s cadence highlighted and auto-scrolled lines that were never synced - auto-scroll yields to the user for 6s after a manual scroll (lyrics and queue); constant lyric font size removes the jitter - progress bar seeks on click/drag with a live scrub label - explicit artwork accepted at any size, image fetch has timeouts, lyrics fetch keyed on the metadata it sends - theme-aware Hairline tokens replace hard-coded black separators --- desktop/src/main/kotlin/api/Lyrics.kt | 3 +- .../src/main/kotlin/player/FFmpegPlayer.kt | 14 ++ .../src/main/kotlin/ui/NowPlayingScreen.kt | 149 ++++++++++++++---- 3 files changed, 136 insertions(+), 30 deletions(-) diff --git a/desktop/src/main/kotlin/api/Lyrics.kt b/desktop/src/main/kotlin/api/Lyrics.kt index 608bf53..73305eb 100644 --- a/desktop/src/main/kotlin/api/Lyrics.kt +++ b/desktop/src/main/kotlin/api/Lyrics.kt @@ -45,9 +45,10 @@ suspend fun fetchLyrics(title: String, artist: String, durationSec: Double): Lyr val plain = obj["plainLyrics"]?.jsonPrimitive?.content if (!plain.isNullOrBlank()) { + // No timing info: timeMs = 0 so the UI never treats a line as "active" val lines = plain.lines() .filter { it.isNotBlank() } - .mapIndexed { i, text -> LyricLine(i * 4000L, text.trim()) } + .map { text -> LyricLine(0L, text.trim()) } return@runCatching LyricsResult(lines, synced = false) } diff --git a/desktop/src/main/kotlin/player/FFmpegPlayer.kt b/desktop/src/main/kotlin/player/FFmpegPlayer.kt index c87d735..4655fe8 100644 --- a/desktop/src/main/kotlin/player/FFmpegPlayer.kt +++ b/desktop/src/main/kotlin/player/FFmpegPlayer.kt @@ -122,6 +122,20 @@ class FFmpegPlayer { prefetchAt(shuffled, startIndex + 1, count = 4) } + /** Play the track at [index] of the *current* queue, preserving its order (unlike [loadQueue]). */ + fun jumpTo(index: Int) { + val q = queue.value + val item = q.getOrNull(index) ?: return + queueIndex.value = index + isEnqueuing.value = true + scope.launch { + val resolvedUrl = resolveStreamUrl(item.videoId) ?: item.url + isEnqueuing.value = false + loadInternal(item, resolvedUrl) + } + prefetchAt(q, index + 1, count = 4) + } + fun toggleShuffle() { shuffle.value = !shuffle.value val q = queue.value diff --git a/desktop/src/main/kotlin/ui/NowPlayingScreen.kt b/desktop/src/main/kotlin/ui/NowPlayingScreen.kt index a2f3d5f..5d8c3dd 100644 --- a/desktop/src/main/kotlin/ui/NowPlayingScreen.kt +++ b/desktop/src/main/kotlin/ui/NowPlayingScreen.kt @@ -9,6 +9,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -35,10 +38,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.withContext import player.FFmpegPlayer import models.QueueItem -import java.net.URL +import java.net.URI // Lyrics state machine private sealed class LyricsState { @@ -72,25 +76,30 @@ fun NowPlayingScreen(player: FFmpegPlayer) { var lyricsState by remember { mutableStateOf(LyricsState.Idle) } var queueHeight by remember { mutableStateOf(200.dp) } - LaunchedEffect(currentId) { + // Keyed on the metadata actually sent, so a fast track change never fetches + // the previous track's title/artist. + LaunchedEffect(currentId, displayTitle, artist) { + if (displayTitle.isBlank()) { + lyricsState = LyricsState.Loading + return@LaunchedEffect + } lyricsState = LyricsState.Loading - // Wait up to 5s for duration to arrive from mpv + // Wait up to 5s for duration (improves lrclib matching); proceed without it otherwise repeat(25) { if (player.duration.value > 0) return@repeat delay(200) } - val dur = player.duration.value - lyricsState = fetchLyrics(displayTitle, artist, dur) + lyricsState = fetchLyrics(displayTitle, artist, player.duration.value) ?.let { LyricsState.Loaded(it) } ?: LyricsState.NotFound } - // Active lyric line index + // Active lyric line index; -1 for plain (unsynced) lyrics so nothing is highlighted val posMs = (position * 1000).toLong() val activeLineIdx = remember(lyricsState, posMs) { - val lines = (lyricsState as? LyricsState.Loaded)?.result?.lines ?: return@remember 0 - val idx = lines.indexOfLast { it.timeMs <= posMs } - if (idx < 0) 0 else idx + val result = (lyricsState as? LyricsState.Loaded)?.result ?: return@remember -1 + if (!result.synced) return@remember -1 + result.lines.indexOfLast { it.timeMs <= posMs }.coerceAtLeast(0) } Column(Modifier.fillMaxSize().background(Background)) { @@ -104,7 +113,7 @@ fun NowPlayingScreen(player: FFmpegPlayer) { .fillMaxHeight() .background(Surface) .drawBehind { - drawLine(PsPearl200, Offset(size.width, 0f), Offset(size.width, size.height), 1f) + drawLine(Hairline, Offset(size.width, 0f), Offset(size.width, size.height), 1f) } .padding(horizontal = 20.dp, vertical = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) @@ -135,7 +144,8 @@ fun NowPlayingScreen(player: FFmpegPlayer) { progress = progress, position = position, duration = duration, - isLoading = isLoading + isLoading = isLoading, + onSeek = { fraction -> if (duration > 0.0) player.seek(fraction * duration) } ) } @@ -161,11 +171,10 @@ private fun NpLyrics( activeLineIdx: Int ) { val listState = rememberLazyListState() + val autoScroll = rememberUserAwareAutoScroll(listState) LaunchedEffect(activeLineIdx) { - if (activeLineIdx > 1) { - listState.animateScrollToItem((activeLineIdx - 2).coerceAtLeast(0)) - } + if (activeLineIdx > 1) autoScroll((activeLineIdx - 2).coerceAtLeast(0)) } Column( @@ -173,7 +182,7 @@ private fun NpLyrics( .drawBehind { // Top 1px hairline separator drawLine( - color = Color(0x1F000000), + color = Hairline, start = Offset(0f, 0f), end = Offset(size.width, 0f), strokeWidth = 1f @@ -241,12 +250,14 @@ private fun NpLyrics( ) { itemsIndexed(lines) { index, line -> val isActive = index == activeLineIdx + // Constant font size: changing it per-line makes item heights jump + // under the auto-scroll and the list jitters. Text( line.text, fontFamily = FontMono, - fontSize = if (isActive) 16.sp else 13.sp, + fontSize = 14.sp, fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, - color = if (isActive) TextPrimary else TextSecondary, + color = if (isActive || activeLineIdx < 0) TextPrimary else TextSecondary, modifier = Modifier .fillMaxWidth() .padding(horizontal = 28.dp, vertical = 10.dp) @@ -288,7 +299,7 @@ private fun NpHeader(queueIndex: Int, queueSize: Int) { .fillMaxWidth() .drawBehind { drawLine( - color = Color(0x1F000000), + color = Hairline, start = Offset(0f, size.height), end = Offset(size.width, size.height), strokeWidth = 1f @@ -354,7 +365,7 @@ private fun NpArtwork(videoId: String, artworkUrl: String?, sizeDp: Int) { Box( Modifier .size(sizeDp.dp) - .border(1.dp, Color(0x1A000000)) + .border(1.dp, Hairline) ) { NpThumbnail(videoId, artworkUrl, Modifier.fillMaxSize()) // Reticle corner brackets @@ -389,10 +400,13 @@ private fun NpThumbnail(videoId: String, artworkUrl: String?, modifier: Modifier "https://i.ytimg.com/vi/$videoId/hqdefault.jpg" ) } + // Explicit artwork is trusted at any size; YouTube fallbacks are filtered because + // i.ytimg.com serves a 120px placeholder for missing maxresdefault images. + val minWidth = if (!artworkUrl.isNullOrBlank()) 1 else 200 for (url in candidates) { runCatching { - val img = org.jetbrains.skia.Image.makeFromEncoded(URL(url).readBytes()) - if (img.width > 200) { + val img = org.jetbrains.skia.Image.makeFromEncoded(npFetchBytes(url)) + if (img.width >= minWidth) { bitmap = img.toComposeImageBitmap() return@withContext } @@ -418,16 +432,48 @@ private fun NpProgress( progress: Float, position: Double, duration: Double, - isLoading: Boolean + isLoading: Boolean, + onSeek: (Float) -> Unit ) { + // Local scrub state so the bar follows the pointer while dragging + var scrubbing by remember { mutableStateOf(false) } + var scrubFraction by remember { mutableStateOf(0f) } + val shown = if (scrubbing) scrubFraction else progress + val seekEnabled = !isLoading && duration > 0.0 + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Box( + Modifier + .fillMaxWidth() + // Tall hit target, thin visual bar drawn inside + .height(16.dp) + .pointerInput(seekEnabled) { + if (!seekEnabled) return@pointerInput + detectTapGestures { offset -> onSeek((offset.x / size.width).coerceIn(0f, 1f)) } + } + .pointerInput(seekEnabled) { + if (!seekEnabled) return@pointerInput + detectHorizontalDragGestures( + onDragStart = { offset -> + scrubbing = true + scrubFraction = (offset.x / size.width).coerceIn(0f, 1f) + }, + onDragEnd = { scrubbing = false; onSeek(scrubFraction) }, + onDragCancel = { scrubbing = false } + ) { change, _ -> + change.consume() + scrubFraction = (change.position.x / size.width).coerceIn(0f, 1f) + } + }, + contentAlignment = Alignment.Center + ) { + Box( Modifier .fillMaxWidth() .height(4.dp) .background(PsInset) - .border(1.dp, Color(0x1A000000)) - ) { + .border(1.dp, Hairline) + ) { if (isLoading) { LinearProgressIndicator( modifier = Modifier.fillMaxSize(), @@ -438,14 +484,18 @@ private fun NpProgress( // Progress fill — intentionally always PsInk900 Box( Modifier - .fillMaxWidth(progress) + .fillMaxWidth(shown) .fillMaxHeight() .background(PsInk900) ) } + } } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(npFormatTime(position), fontFamily = FontMono, fontSize = 11.sp, color = TextSecondary) + Text( + npFormatTime(if (scrubbing) scrubFraction * duration else position), + fontFamily = FontMono, fontSize = 11.sp, color = TextSecondary + ) Text(npFormatTime(duration), fontFamily = FontMono, fontSize = 11.sp, color = TextSecondary) } } @@ -454,12 +504,13 @@ private fun NpProgress( @Composable private fun NpQueue(queue: List, activeIndex: Int, player: FFmpegPlayer, height: Dp, onHeightChange: (Dp) -> Unit) { val listState = rememberLazyListState(initialFirstVisibleItemIndex = (activeIndex - 1).coerceAtLeast(0)) + val autoScroll = rememberUserAwareAutoScroll(listState) val density = LocalDensity.current val heightRef = rememberUpdatedState(height) val callbackRef = rememberUpdatedState(onHeightChange) LaunchedEffect(activeIndex) { - listState.animateScrollToItem((activeIndex - 1).coerceAtLeast(0)) + autoScroll((activeIndex - 1).coerceAtLeast(0)) } Column( @@ -530,7 +581,8 @@ private fun NpQueue(queue: List, activeIndex: Int, player: FFmpegPlay item = item, index = index, isActive = index == activeIndex, - onClick = { player.loadQueue(queue, index) } + // jumpTo keeps the current order; loadQueue would reshuffle under the user + onClick = { player.jumpTo(index) } ) } } @@ -553,7 +605,7 @@ private fun NpQueueRow(item: QueueItem, index: Int, isActive: Boolean, onClick: ) } drawLine( - color = Color(0x0D000000), + color = HairlineSoft, start = Offset(0f, size.height), end = Offset(size.width, size.height), strokeWidth = 1f @@ -595,6 +647,45 @@ private fun NpQueueRow(item: QueueItem, index: Int, isActive: Boolean, onClick: } } +/** + * Auto-scroll that yields to the user: after a manual scroll it stays out of the + * way for [USER_SCROLL_GRACE_MS] so people can read ahead or browse the queue. + */ +private const val USER_SCROLL_GRACE_MS = 6_000L + +@Composable +private fun rememberUserAwareAutoScroll(listState: LazyListState): suspend (Int) -> Unit { + var lastUserScrollAt by remember { mutableStateOf(0L) } + var programmatic by remember { mutableStateOf(false) } + + LaunchedEffect(listState) { + snapshotFlow { listState.isScrollInProgress }.collectLatest { scrolling -> + if (scrolling && !programmatic) lastUserScrollAt = System.currentTimeMillis() + } + } + + return remember(listState) { + val scrollTo: suspend (Int) -> Unit = { index -> + if (System.currentTimeMillis() - lastUserScrollAt > USER_SCROLL_GRACE_MS) { + programmatic = true + try { + listState.animateScrollToItem(index) + } finally { + programmatic = false + } + } + } + scrollTo + } +} + +private fun npFetchBytes(url: String): ByteArray = + URI.create(url).toURL().openConnection().run { + connectTimeout = 5_000 + readTimeout = 10_000 + getInputStream().use { it.readBytes() } + } + private fun npFormatTime(seconds: Double): String { val s = seconds.toLong().coerceAtLeast(0L) return "%d:%02d".format(s / 60, s % 60) From 365ac3e7511db047970bae819310c8d2c15fdce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Wed, 9 Sep 2026 15:51:10 +0100 Subject: [PATCH 4/6] feat(provider): scope browsing per platform with personalised discover - MusicProvider abstraction (search, artists, station, discover, library) with YouTubeProvider and SoundCloudProvider; a YT/SC switcher in the sidebar scopes Search, Discover and Library while the queue stays shared. Cross-platform search merging is gone - SoundCloud: api-v2 calls carry the session token; Discover shows mixed-selections ("Made for you", curated, trending) as collection cards plus the local weekly list; stations use the real station endpoint; Library lists likes and own playlists, hydrating id stubs through /tracks?ids - YouTube: per-track radio via the RDAMVM mix on the next endpoint; Discover offers one radio per recently played track - PlaylistTrack and ListeningHistory carry their source; sidebar profile row always opens the profile dialog so either session can be added --- .../src/main/kotlin/api/ListeningHistory.kt | 17 +- desktop/src/main/kotlin/api/SoundCloud.kt | 137 ++++++++- .../main/kotlin/api/SoundCloudDiscovery.kt | 4 +- desktop/src/main/kotlin/api/YoutubeMusic.kt | 17 +- desktop/src/main/kotlin/api/YtMusicRadio.kt | 105 +++++++ .../src/main/kotlin/provider/MusicProvider.kt | 81 ++++++ .../kotlin/provider/SoundCloudProvider.kt | 90 ++++++ .../main/kotlin/provider/YouTubeProvider.kt | 63 +++++ desktop/src/main/kotlin/ui/App.kt | 263 ++++++++++-------- desktop/src/main/kotlin/ui/DiscoverScreen.kt | 179 ++++++++---- desktop/src/main/kotlin/ui/LibraryScreen.kt | 24 +- desktop/src/main/kotlin/ui/SearchScreen.kt | 57 ++-- .../kotlin/api/SoundCloudCollectionTest.kt | 35 +++ .../src/test/kotlin/api/YtMusicRadioTest.kt | 47 ++++ shared/src/commonMain/kotlin/models/Models.kt | 19 +- 15 files changed, 900 insertions(+), 238 deletions(-) create mode 100644 desktop/src/main/kotlin/api/YtMusicRadio.kt create mode 100644 desktop/src/main/kotlin/provider/MusicProvider.kt create mode 100644 desktop/src/main/kotlin/provider/SoundCloudProvider.kt create mode 100644 desktop/src/main/kotlin/provider/YouTubeProvider.kt create mode 100644 desktop/src/test/kotlin/api/SoundCloudCollectionTest.kt create mode 100644 desktop/src/test/kotlin/api/YtMusicRadioTest.kt diff --git a/desktop/src/main/kotlin/api/ListeningHistory.kt b/desktop/src/main/kotlin/api/ListeningHistory.kt index 40c1571..4be7c9b 100644 --- a/desktop/src/main/kotlin/api/ListeningHistory.kt +++ b/desktop/src/main/kotlin/api/ListeningHistory.kt @@ -15,7 +15,9 @@ data class PlayRecord( val artist: String, val genre: String? = null, val artworkUrl: String? = null, - val playedAt: Long + val playedAt: Long, + // Legacy entries predate multi-platform history and were all SoundCloud + val source: Source = Source.SOUNDCLOUD ) object ListeningHistory { @@ -26,7 +28,6 @@ object ListeningHistory { @Synchronized fun record(item: QueueItem) { - if (item.source != Source.SOUNDCLOUD) return val entries = load() // Skip if the last 3 entries are the same track (avoid repeat spam) val recentIds = entries.take(3).map { it.trackId } @@ -38,18 +39,22 @@ object ListeningHistory { artist = item.artist, genre = item.genre, artworkUrl = item.artworkUrl, - playedAt = System.currentTimeMillis() + playedAt = System.currentTimeMillis(), + source = item.source ) val updated = (listOf(record) + entries).take(MAX_ENTRIES) save(updated) } - fun recent(n: Int): List { - return load().take(n) + /** Most recent plays, optionally restricted to the given sources. */ + fun recent(n: Int, sources: Set? = null): List { + val all = load() + return (if (sources == null) all else all.filter { it.source in sources }).take(n) } fun topGenres(limit: Int): List> { - val entries = load().take(100) + // Genres are only known for SoundCloud tracks + val entries = load().filter { it.source == Source.SOUNDCLOUD }.take(100) return entries.mapNotNull { it.genre } .groupingBy { it } .eachCount() diff --git a/desktop/src/main/kotlin/api/SoundCloud.kt b/desktop/src/main/kotlin/api/SoundCloud.kt index 1b7e2dc..6d3686f 100644 --- a/desktop/src/main/kotlin/api/SoundCloud.kt +++ b/desktop/src/main/kotlin/api/SoundCloud.kt @@ -2,7 +2,9 @@ package api import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import auth.SoundCloudAuth import kotlinx.serialization.json.* +import models.Playlist import models.SearchResult import models.Source import util.Log @@ -42,7 +44,7 @@ internal fun extractClientId(js: String): String? = private val ASSET_URL_REGEX = Regex("""https://a-v2\.sndcdn\.com/assets/[^"]+\.js""") private val CLIENT_ID_REGEX = Regex("""client_id\s*[:=]\s*"([a-zA-Z0-9]{32})""") -internal suspend fun scClientId(): String { +suspend fun scClientId(): String { cachedClientId?.let { if (System.currentTimeMillis() - clientIdFetchedAt < CLIENT_ID_TTL_MS) return it } return withContext(Dispatchers.IO) { // Config override @@ -99,27 +101,37 @@ private fun httpGet(url: String): String? { private const val SC_BASE = "https://api-v2.soundcloud.com" -internal suspend fun scGetJson(path: String): JsonObject? { +/** + * GET an api-v2 path as JSON. Sends the user's OAuth token when a session exists so + * personalised endpoints (`/me/...`, "Made for you" selections) resolve for that user; + * anonymous calls still work for public data. + */ +internal suspend fun scGetJson(path: String): JsonObject? = scGetJsonElement(path)?.let { it as? JsonObject } + +internal suspend fun scGetJsonElement(path: String): JsonElement? { + SoundCloudAuth.ensureValidToken() + val token = SoundCloudAuth.accessToken val clientId = scClientId() val separator = if ('?' in path) "&" else "?" val fullUrl = "$SC_BASE$path${separator}client_id=$clientId" - val resp = httpGetJson(fullUrl) + val resp = httpGetJson(fullUrl, token) if (resp == null || resp.status in 401..403) { // Possibly stale client_id — invalidate and retry once Log.w("SoundCloud", "API returned ${resp?.status} for $path — re-scraping client_id") invalidateClientId() val newClientId = runCatching { scClientId() }.getOrNull() ?: return null val retryUrl = "$SC_BASE$path${separator}client_id=$newClientId" - return httpGetJson(retryUrl)?.body?.jsonObject + return httpGetJson(retryUrl, token)?.takeIf { it.status in 200..299 }?.body } - return resp.body.jsonObject + return resp.takeIf { it.status in 200..299 }?.body } private data class JsonResp(val status: Int, val body: JsonElement) -private fun httpGetJson(url: String): JsonResp? = runCatching { +private fun httpGetJson(url: String, token: String? = null): JsonResp? = runCatching { val req = HttpRequest.newBuilder(URI.create(url)) .header("User-Agent", SC_USER_AGENT) + .apply { if (token != null) header("Authorization", "OAuth $token") } .GET() .timeout(Duration.ofSeconds(10)) .build() @@ -155,14 +167,117 @@ object SoundCloud { } } + /** + * Radio seeded by a track. Prefers SoundCloud's own station (what the web player + * plays for "Start station"); falls back to related tracks when it is empty. + */ suspend fun stationFor(seed: SearchResult): List = withContext(Dispatchers.IO) { val id = seed.soundcloudId ?: return@withContext listOf(seed) - val related = runCatching { relatedTracks(id, 25) }.getOrDefault(emptyList()) - val seedIds = setOf(seed.soundcloudId) - val relatedUnique = related - .filter { it.soundcloudId !in seedIds && it.videoId != seed.videoId } + val station = runCatching { stationTracks(id, 50) }.getOrDefault(emptyList()) + val candidates = if (station.isNotEmpty()) station else runCatching { relatedTracks(id, 25) }.getOrDefault(emptyList()) + val rest = candidates + .filter { it.soundcloudId != id && it.videoId != seed.videoId } .distinctBy { it.soundcloudId } - listOf(seed) + relatedUnique + listOf(seed) + rest + } + + private suspend fun stationTracks(trackId: Long, limit: Int): List { + val root = scGetJson("/stations/soundcloud:track-stations:$trackId/tracks?limit=$limit") ?: return emptyList() + return parseTrackCollection(root["collection"]?.jsonArray) + } + + // ── Discover: SoundCloud's own selections ("Made for you", curated, trending) ── + + data class Selection(val urn: String, val title: String, val items: List) + + /** A playlist-like item in a selection. [id] is what [collectionTracks] takes back. */ + data class Collection( + val id: String, + val title: String, + val subtitle: String?, + val artworkUrl: String?, + val trackCount: Int + ) + + suspend fun mixedSelections(limit: Int = 12): List = withContext(Dispatchers.IO) { + val root = scGetJson("/mixed-selections?limit=$limit") ?: return@withContext emptyList() + root["collection"]?.jsonArray?.mapNotNull { sel -> + val obj = sel.jsonObject + val title = obj["title"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val urn = obj["urn"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val items = obj["items"]?.jsonObject?.get("collection")?.jsonArray + ?.mapNotNull { parseCollection(it.jsonObject) } ?: emptyList() + if (items.isEmpty()) null else Selection(urn, title, items) + } ?: emptyList() + } + + /** + * Tracks of a collection returned by [mixedSelections] or [userPlaylists]. + * Playlist payloads embed only the first few full tracks; the rest are id stubs + * that must be hydrated through `/tracks?ids=`. + */ + suspend fun collectionTracks(collectionId: String): List = withContext(Dispatchers.IO) { + val (kind, id) = collectionId.split(":", limit = 2).let { it[0] to it.getOrElse(1) { "" } } + val path = when (kind) { + "playlist" -> "/playlists/$id?representation=full" + "system" -> "/system-playlists/$id?representation=full" + else -> return@withContext emptyList() + } + val root = scGetJson(path) ?: return@withContext emptyList() + hydrate(root["tracks"]?.jsonArray) + } + + // ── Library ────────────────────────────────────────────────────────────── + + suspend fun userLikes(userId: Long, limit: Int = 50): List = withContext(Dispatchers.IO) { + val root = scGetJson("/users/$userId/track_likes?limit=$limit") ?: return@withContext emptyList() + root["collection"]?.jsonArray?.mapNotNull { item -> + item.jsonObject["track"]?.jsonObject?.let { parseScTrack(it) } + } ?: emptyList() + } + + suspend fun userPlaylists(userId: Long, limit: Int = 50): List = withContext(Dispatchers.IO) { + val root = scGetJson("/users/$userId/playlists_without_albums?limit=$limit") ?: return@withContext emptyList() + root["collection"]?.jsonArray?.mapNotNull { parseCollection(it.jsonObject) }?.map { + Playlist(id = it.id, title = it.title, itemCount = it.trackCount, thumbnailUrl = it.artworkUrl ?: "") + } ?: emptyList() + } + + // ── Internals ──────────────────────────────────────────────────────────── + + private fun parseTrackCollection(arr: JsonArray?): List = + arr?.mapNotNull { parseScTrack(it.jsonObject) } ?: emptyList() + + /** Full track objects pass through; id-only stubs are fetched in batches, order preserved. */ + private suspend fun hydrate(tracks: JsonArray?): List { + if (tracks == null) return emptyList() + val full = tracks.mapNotNull { t -> t.jsonObject.takeIf { "title" in it }?.let(::parseScTrack) } + .associateBy { it.soundcloudId } + val stubIds = tracks.mapNotNull { t -> t.jsonObject.takeIf { "title" !in it }?.get("id")?.jsonPrimitive?.longOrNull } + val fetched = stubIds.chunked(50).flatMap { chunk -> + val arr = scGetJsonElement("/tracks?ids=${chunk.joinToString(",")}") as? JsonArray + parseTrackCollection(arr) + }.associateBy { it.soundcloudId } + return tracks.mapNotNull { t -> + val id = t.jsonObject["id"]?.jsonPrimitive?.longOrNull ?: return@mapNotNull null + full[id] ?: fetched[id] + } + } + + internal fun parseCollection(obj: JsonObject): Collection? { + val title = obj["title"]?.jsonPrimitive?.contentOrNull ?: return null + val kind = obj["kind"]?.jsonPrimitive?.contentOrNull ?: return null + val id = when (kind) { + "playlist" -> "playlist:" + (obj["id"]?.jsonPrimitive?.longOrNull ?: return null) + "system-playlist" -> "system:" + (obj["urn"]?.jsonPrimitive?.contentOrNull ?: return null) + else -> return null + } + val artwork = (obj["calculated_artwork_url"] ?: obj["artwork_url"])?.jsonPrimitive?.contentOrNull + ?.replace("-large.", "-t500x500.") + val count = obj["track_count"]?.jsonPrimitive?.intOrNull ?: obj["tracks"]?.jsonArray?.size ?: 0 + val subtitle = obj["description"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + ?: obj["user"]?.jsonObject?.get("username")?.jsonPrimitive?.contentOrNull + return Collection(id, title, subtitle, artwork, count) } } diff --git a/desktop/src/main/kotlin/api/SoundCloudDiscovery.kt b/desktop/src/main/kotlin/api/SoundCloudDiscovery.kt index 6b0325b..c8c6cb2 100644 --- a/desktop/src/main/kotlin/api/SoundCloudDiscovery.kt +++ b/desktop/src/main/kotlin/api/SoundCloudDiscovery.kt @@ -83,7 +83,7 @@ object SoundCloudDiscovery { } private fun buildSeeds(): List { - val recent = ListeningHistory.recent(6) + val recent = ListeningHistory.recent(6, setOf(models.Source.SOUNDCLOUD)) val byGenre = mutableMapOf() val byArtist = mutableMapOf() for (record in recent) { @@ -112,7 +112,7 @@ object SoundCloudDiscovery { artist = artist, artistId = null, duration = "", - thumbnailUrl = artworkUrl ?: "", + thumbnailUrl = artworkUrl?.replace("-large.", "-t500x500.") ?: "", source = models.Source.SOUNDCLOUD, soundcloudId = trackId.toLongOrNull(), genre = genre diff --git a/desktop/src/main/kotlin/api/YoutubeMusic.kt b/desktop/src/main/kotlin/api/YoutubeMusic.kt index 38d50d1..a58c353 100644 --- a/desktop/src/main/kotlin/api/YoutubeMusic.kt +++ b/desktop/src/main/kotlin/api/YoutubeMusic.kt @@ -1,15 +1,14 @@ package api -import auth.AuthManager +import auth.GoogleAuth import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import models.ArtistResult import models.SearchResult -import models.Source object YoutubeMusic { suspend fun searchArtists(query: String): List = coroutineScope { - if (AuthManager.isAuthenticated) AuthManager.ensureValidToken() + if (GoogleAuth.isAuthenticated) GoogleAuth.ensureValidToken() val filtered = async { searchYouTubeMusicArtists(query) } val general = async { searchYouTubeMusicArtistsFromGeneral(query) } // Merge: prefer general's entry (has listener count) over filtered's when both exist @@ -20,15 +19,13 @@ object YoutubeMusic { } suspend fun search(query: String, limit: Int = 20): List = coroutineScope { - if (AuthManager.isAuthenticated) AuthManager.ensureValidToken() + if (GoogleAuth.isAuthenticated) GoogleAuth.ensureValidToken() val music = async { searchYouTubeMusic(query, limit) } val video = async { searchYouTube(query, limit) } - val soundcloud = async { SoundCloud.searchTracks(query, limit) } - val combined = interleave(interleave(music.await(), video.await()), soundcloud.await()) - if (!AuthManager.isAuthenticated) return@coroutineScope combined - val ytIds = combined.filter { it.source != Source.SOUNDCLOUD }.map { it.videoId } - val counts = fetchViewCounts(ytIds) - combined.map { if (it.source != Source.SOUNDCLOUD) it.copy(viewCount = counts[it.videoId]) else it } + val combined = interleave(music.await(), video.await()) + if (!GoogleAuth.isAuthenticated) return@coroutineScope combined + val counts = fetchViewCounts(combined.map { it.videoId }) + combined.map { it.copy(viewCount = counts[it.videoId]) } } } diff --git a/desktop/src/main/kotlin/api/YtMusicRadio.kt b/desktop/src/main/kotlin/api/YtMusicRadio.kt new file mode 100644 index 0000000..9746fb2 --- /dev/null +++ b/desktop/src/main/kotlin/api/YtMusicRadio.kt @@ -0,0 +1,105 @@ +package api + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.* +import models.SearchResult +import models.Source +import util.Log +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +private val radioClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build() +private val radioJson = Json { ignoreUnknownKeys = true } +private const val RADIO_CLIENT_VERSION = "1.20240101.01.00" + +/** + * YouTube Music per-track radio: the `next` endpoint with the `RDAMVM` mix + * playlist returns the same queue the web player builds for "Start radio". + * Works unauthenticated; the seed is the first item. + */ +suspend fun youtubeRadio(videoId: String, limit: Int = 50): List = withContext(Dispatchers.IO) { + val body = buildJsonObject { + putJsonObject("context") { + putJsonObject("client") { + put("clientName", "WEB_REMIX") + put("clientVersion", RADIO_CLIENT_VERSION) + put("hl", "en") + } + } + put("videoId", videoId) + put("playlistId", "RDAMVM$videoId") + put("isAudioOnly", true) + }.toString() + + val req = HttpRequest.newBuilder(URI.create("https://music.youtube.com/youtubei/v1/next?prettyPrint=false")) + .header("Content-Type", "application/json") + .header("X-YouTube-Client-Name", "67") + .header("X-YouTube-Client-Version", RADIO_CLIENT_VERSION) + .header("Origin", "https://music.youtube.com") + .header("Referer", "https://music.youtube.com/") + .header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36") + .timeout(Duration.ofSeconds(12)) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build() + + runCatching { + val resp = radioClient.send(req, HttpResponse.BodyHandlers.ofString()) + if (resp.statusCode() !in 200..299) { + Log.w("YtMusicRadio", "next returned ${resp.statusCode()} for $videoId") + return@runCatching emptyList() + } + parseRadio(resp.body(), limit) + }.onFailure { Log.w("YtMusicRadio", "radio failed for $videoId", it) }.getOrDefault(emptyList()) +} + +internal fun parseRadio(body: String, limit: Int): List { + val root = runCatching { radioJson.parseToJsonElement(body) }.getOrNull() ?: return emptyList() + val out = mutableListOf() + collectRenderers(root, out, limit) + return out.distinctBy { it.videoId } +} + +/** Walks the response for every `playlistPanelVideoRenderer`, wherever the layout nests it. */ +private fun collectRenderers(el: JsonElement, out: MutableList, limit: Int) { + if (out.size >= limit) return + when (el) { + is JsonObject -> { + el["playlistPanelVideoRenderer"]?.jsonObject?.let { r -> parseRadioItem(r)?.let(out::add) } + el.values.forEach { collectRenderers(it, out, limit) } + } + is JsonArray -> el.forEach { collectRenderers(it, out, limit) } + else -> {} + } +} + +private fun parseRadioItem(r: JsonObject): SearchResult? { + val videoId = r["videoId"]?.jsonPrimitive?.contentOrNull ?: return null + val title = r["title"]?.jsonObject?.get("runs")?.jsonArray?.firstOrNull() + ?.jsonObject?.get("text")?.jsonPrimitive?.contentOrNull ?: return null + val byline = r["longBylineText"]?.jsonObject?.get("runs")?.jsonArray + val artistRun = byline?.firstOrNull()?.jsonObject + val artist = artistRun?.get("text")?.jsonPrimitive?.contentOrNull ?: "Unknown" + val artistId = artistRun?.get("navigationEndpoint")?.jsonObject + ?.get("browseEndpoint")?.jsonObject?.get("browseId")?.jsonPrimitive?.contentOrNull + val duration = r["lengthText"]?.jsonObject?.get("runs")?.jsonArray?.firstOrNull() + ?.jsonObject?.get("text")?.jsonPrimitive?.contentOrNull ?: "" + val views = byline?.mapNotNull { it.jsonObject["text"]?.jsonPrimitive?.contentOrNull } + ?.firstOrNull { it.endsWith(" views") }?.let(::parseCompactCount) + val thumb = r["thumbnail"]?.jsonObject?.get("thumbnails")?.jsonArray + ?.maxByOrNull { it.jsonObject["width"]?.jsonPrimitive?.intOrNull ?: 0 } + ?.jsonObject?.get("url")?.jsonPrimitive?.contentOrNull + ?: "https://i.ytimg.com/vi/$videoId/hqdefault.jpg" + return SearchResult(videoId, title, artist, artistId, duration, thumb, Source.YT_MUSIC, viewCount = views) +} + +/** "1.8B views" → 1_800_000_000; "205M views" → 205_000_000. */ +internal fun parseCompactCount(text: String): Long? { + val m = Regex("""([\d.,]+)\s*([KMB])?""").find(text) ?: return null + val num = m.groupValues[1].replace(",", "").toDoubleOrNull() ?: return null + val mult = when (m.groupValues[2]) { "K" -> 1e3; "M" -> 1e6; "B" -> 1e9; else -> 1.0 } + return (num * mult).toLong() +} diff --git a/desktop/src/main/kotlin/provider/MusicProvider.kt b/desktop/src/main/kotlin/provider/MusicProvider.kt new file mode 100644 index 0000000..ea83c4c --- /dev/null +++ b/desktop/src/main/kotlin/provider/MusicProvider.kt @@ -0,0 +1,81 @@ +package provider + +import models.ArtistResult +import models.Playlist +import models.PlaylistTrack +import models.SearchResult +import models.Source + +/** A streaming platform the user can browse. Sessions are per-platform, the queue is shared. */ +enum class Platform(val code: String, val label: String) { + YOUTUBE("YT", "youtube"), + SOUNDCLOUD("SC", "soundcloud"); + + companion object { + fun of(source: Source): Platform = when (source) { + Source.YT_MUSIC, Source.YOUTUBE -> YOUTUBE + Source.SOUNDCLOUD -> SOUNDCLOUD + } + } +} + +/** A playlist-like card (mix, station, curated playlist). Opened through [MusicProvider.collectionTracks]. */ +data class DiscoverCollection( + val id: String, + val title: String, + val subtitle: String? = null, + val artworkUrl: String? = null +) + +/** + * One block on the Discover screen. Either a flat track list, a row of collections, + * or both — the screen renders whatever is non-empty. + */ +data class DiscoverSection( + val title: String, + val caption: String? = null, + val tracks: List = emptyList(), + val collections: List = emptyList() +) + +/** + * Everything a screen needs from a platform. Browsing (search, discover, library) is + * scoped to one provider at a time; playback stays platform-agnostic through QueueItem. + * + * Capabilities differ per platform — screens must check the `supports*` flags instead + * of assuming, and the unsupported calls return empty results rather than throwing. + */ +interface MusicProvider { + val platform: Platform + val isAuthenticated: Boolean + + val supportsArtists: Boolean + val supportsStations: Boolean + val supportsLibrary: Boolean + + suspend fun search(query: String, limit: Int = 20): List + suspend fun searchArtists(query: String): List = emptyList() + + /** Radio seeded by [seed]; the seed itself comes first. Empty when unsupported. */ + suspend fun station(seed: SearchResult): List = emptyList() + + /** Personalised or curated sections. [forceRefresh] bypasses any weekly/daily cache. */ + suspend fun discover(forceRefresh: Boolean = false): List = emptyList() + + /** Tracks behind a [DiscoverCollection.id] from this provider's discover sections. */ + suspend fun collectionTracks(collectionId: String): List = emptyList() + + suspend fun playlists(): List = emptyList() + suspend fun playlistTracks(playlistId: String): List = emptyList() +} + +object Providers { + val all: List get() = listOf(YouTubeProvider, SoundCloudProvider) + + fun of(platform: Platform): MusicProvider = when (platform) { + Platform.YOUTUBE -> YouTubeProvider + Platform.SOUNDCLOUD -> SoundCloudProvider + } + + fun of(source: Source): MusicProvider = of(Platform.of(source)) +} diff --git a/desktop/src/main/kotlin/provider/SoundCloudProvider.kt b/desktop/src/main/kotlin/provider/SoundCloudProvider.kt new file mode 100644 index 0000000..fbfaa0c --- /dev/null +++ b/desktop/src/main/kotlin/provider/SoundCloudProvider.kt @@ -0,0 +1,90 @@ +package provider + +import api.SoundCloud +import api.SoundCloudDiscovery +import auth.SoundCloudAuth +import models.Playlist +import models.PlaylistTrack +import models.SearchResult +import models.Source +import util.Log +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +object SoundCloudProvider : MusicProvider { + private val dateFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy") + + override val platform = Platform.SOUNDCLOUD + override val isAuthenticated: Boolean get() = SoundCloudAuth.isAuthenticated + + override val supportsArtists = false + override val supportsStations = true + override val supportsLibrary = true + + private const val LIKES_ID = "likes" + + override suspend fun search(query: String, limit: Int): List = + SoundCloud.searchTracks(query, limit) + + override suspend fun station(seed: SearchResult): List = SoundCloud.stationFor(seed) + + /** + * SoundCloud's own selections first (personalised "Made for you" mixes when a session + * exists, curated and trending otherwise), then Wren's locally generated weekly list. + */ + override suspend fun discover(forceRefresh: Boolean): List { + val selections = runCatching { SoundCloud.mixedSelections() } + .onFailure { Log.w("SoundCloudProvider", "mixed-selections failed", it) } + .getOrDefault(emptyList()) + .sortedBy { if (it.urn.contains("personali") || it.urn.contains("made-for")) 0 else 1 } + .map { sel -> + DiscoverSection( + title = sel.title.lowercase(), + collections = sel.items.take(12).map { + DiscoverCollection(it.id, it.title, it.subtitle, it.artworkUrl) + } + ) + } + + val weekly = runCatching { if (forceRefresh) SoundCloudDiscovery.refresh() else SoundCloudDiscovery.current() } + .getOrNull() + val weeklySection = weekly?.let { + val generated = Instant.ofEpochMilli(it.generatedAt).atZone(ZoneId.systemDefault()).format(dateFormatter) + val basis = it.basisGenres.joinToString(", ") + DiscoverSection( + title = "weekly discovery", + caption = buildString { append("generated $generated"); if (basis.isNotEmpty()) append(" · based_on: $basis") }, + tracks = it.tracks + ) + } + return listOfNotNull(weeklySection) + selections + } + + override suspend fun collectionTracks(collectionId: String): List = + SoundCloud.collectionTracks(collectionId) + + // ── Library: likes + own playlists, needs the session's user id ───────── + + override suspend fun playlists(): List { + val userId = SoundCloudAuth.userId ?: return emptyList() + val likes = Playlist(id = LIKES_ID, title = "Liked tracks", itemCount = 0, thumbnailUrl = SoundCloudAuth.avatarUrl ?: "") + val own = runCatching { SoundCloud.userPlaylists(userId) }.getOrDefault(emptyList()) + return listOf(likes) + own + } + + override suspend fun playlistTracks(playlistId: String): List { + val userId = SoundCloudAuth.userId ?: return emptyList() + val tracks = if (playlistId == LIKES_ID) SoundCloud.userLikes(userId) else SoundCloud.collectionTracks(playlistId) + return tracks.map { + PlaylistTrack( + videoId = it.videoId, + title = it.title, + channelTitle = it.artist, + thumbnailUrl = it.thumbnailUrl, + duration = it.duration, + source = Source.SOUNDCLOUD + ) + } + } +} diff --git a/desktop/src/main/kotlin/provider/YouTubeProvider.kt b/desktop/src/main/kotlin/provider/YouTubeProvider.kt new file mode 100644 index 0000000..a91e9c2 --- /dev/null +++ b/desktop/src/main/kotlin/provider/YouTubeProvider.kt @@ -0,0 +1,63 @@ +package provider + +import api.ListeningHistory +import api.YoutubeMusic +import api.fetchPlaylistTracks +import api.fetchUserPlaylists +import api.youtubeRadio +import auth.GoogleAuth +import models.Source +import models.ArtistResult +import models.Playlist +import models.PlaylistTrack +import models.SearchResult + +/** + * YouTube + YouTube Music behind one provider: both are the same catalog and the same + * Google session, so they stay merged in search results (see [YoutubeMusic.search]). + */ +object YouTubeProvider : MusicProvider { + override val platform = Platform.YOUTUBE + override val isAuthenticated: Boolean get() = GoogleAuth.isAuthenticated + + override val supportsArtists = true + override val supportsStations = true // per-track radio via the RDAMVM mix + override val supportsLibrary = true + + override suspend fun search(query: String, limit: Int): List = + YoutubeMusic.search(query, limit) + + override suspend fun searchArtists(query: String): List = + YoutubeMusic.searchArtists(query) + + override suspend fun station(seed: SearchResult): List { + val radio = youtubeRadio(seed.videoId) + return if (radio.isEmpty()) listOf(seed) else listOf(seed) + radio.filter { it.videoId != seed.videoId } + } + + /** + * The Data API has no personalised feed, so Discover is built from what the user + * actually played here: one radio per recent distinct track, opened on demand. + */ + override suspend fun discover(forceRefresh: Boolean): List { + val recent = ListeningHistory.recent(40, setOf(Source.YT_MUSIC, Source.YOUTUBE)) + .distinctBy { it.trackId } + .take(10) + if (recent.isEmpty()) return emptyList() + val radios = recent.map { + DiscoverCollection( + id = it.trackId, + title = it.title, + subtitle = it.artist, + artworkUrl = it.artworkUrl ?: "https://i.ytimg.com/vi/${it.trackId}/hqdefault.jpg" + ) + } + return listOf(DiscoverSection(title = "radios from recent plays", caption = "seeded by your last plays", collections = radios)) + } + + override suspend fun collectionTracks(collectionId: String): List = youtubeRadio(collectionId) + + override suspend fun playlists(): List = fetchUserPlaylists() + + override suspend fun playlistTracks(playlistId: String): List = fetchPlaylistTracks(playlistId) +} diff --git a/desktop/src/main/kotlin/ui/App.kt b/desktop/src/main/kotlin/ui/App.kt index 6798ed8..2bd166b 100644 --- a/desktop/src/main/kotlin/ui/App.kt +++ b/desktop/src/main/kotlin/ui/App.kt @@ -1,6 +1,8 @@ package ui -import auth.AuthManager +import auth.AuthEvents +import auth.GoogleAuth +import auth.SoundCloudAuth import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -23,9 +25,11 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.graphics.painter.BitmapPainter @@ -35,6 +39,8 @@ import androidx.compose.ui.window.WindowState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import player.FFmpegPlayer +import provider.Platform +import provider.Providers import java.net.URL // PrintStream design tokens @@ -64,14 +70,24 @@ val Accent get() = if (globalDark) PsWhite else PsInk900 val TextPrimary get() = if (globalDark) PsWhite else PsInk900 val TextSecondary get() = if (globalDark) PsPearl200 else PsSteel500 val PsInset get() = if (globalDark) PsMidGraphite else PsPearl100 +val Hairline get() = if (globalDark) PsWhite.copy(alpha = 0.12f) else Color(0x1F000000) +val HairlineSoft get() = if (globalDark) PsWhite.copy(alpha = 0.06f) else Color(0x0D000000) val FontMono = FontFamily.Monospace +/** Density AWT actually applied (Xft.dpi / OS settings); 1f when it applied nothing. */ +private fun awtDensity(): Float = runCatching { + java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment() + .defaultScreenDevice.defaultConfiguration.defaultTransform.scaleX.toFloat() +}.getOrNull()?.takeIf { it > 0f } ?: 1f + @Composable -fun AppWindow(onCloseRequest: () -> Unit) { +fun AppWindow(uiScale: Float, onCloseRequest: () -> Unit) { val player = remember { FFmpegPlayer() } - var authenticated by remember { mutableStateOf(AuthManager.isAuthenticated) } - var showAuthDialog by remember { mutableStateOf(false) } + var showProfileDialog by remember { mutableStateOf(false) } var selectedTab by remember { mutableStateOf(0) } + // Browsing is scoped to one platform at a time; the queue/player stay shared. + var platform by remember { mutableStateOf(Platform.YOUTUBE) } + val provider = remember(platform) { Providers.of(platform) } var artistBrowseId by remember { mutableStateOf(null) } var artistName by remember { mutableStateOf("") } @@ -88,13 +104,20 @@ fun AppWindow(onCloseRequest: () -> Unit) { ) } + // Scale the window itself to native resolution: AWT may ignore the uiScale property + // on X11, so divide out whatever density AWT actually applied and multiply by the + // compositor scale. Content density is set explicitly (see below). + val awtDensity = remember { awtDensity() } + val sizeScale = if (awtDensity > 0f) uiScale / awtDensity else uiScale + Window( onCloseRequest = onCloseRequest, title = "Wren", icon = appIcon, - state = WindowState(width = 960.dp, height = 700.dp) + state = WindowState(width = (960 * sizeScale).dp, height = (700 * sizeScale).dp) ) { - MaterialTheme( + CompositionLocalProvider(LocalDensity provides Density(uiScale)) { + MaterialTheme( colors = if (globalDark) darkColors( background = PsInk900, surface = PsGraphite600, primary = PsWhite, onPrimary = PsInk900, @@ -108,11 +131,11 @@ fun AppWindow(onCloseRequest: () -> Unit) { Column(Modifier.fillMaxSize().background(Background)) { Row(Modifier.weight(1f)) { Sidebar( - authenticated = authenticated, + platform = platform, + onPlatformChange = { platform = it; artistBrowseId = null }, selectedTab = selectedTab, onTabChange = { selectedTab = it; artistBrowseId = null }, - onLoginRequest = { showAuthDialog = true }, - onLogout = { AuthManager.logout(); authenticated = false } + onOpenProfile = { showProfileDialog = true } ) Box(Modifier.weight(1f).fillMaxHeight()) { val browseId = artistBrowseId @@ -123,12 +146,16 @@ fun AppWindow(onCloseRequest: () -> Unit) { onBack = { artistBrowseId = null }, onArtistClick = { id, name -> artistBrowseId = id; artistName = name } ) - selectedTab == 0 -> SearchScreen( - player = player, - onArtistClick = { id, name -> artistBrowseId = id; artistName = name } - ) - selectedTab == 1 -> DiscoverScreen(player) - selectedTab == 2 -> LibraryScreen(player) + // key(platform): each platform keeps its own screen state + selectedTab == 0 -> key(platform) { + SearchScreen( + provider = provider, + player = player, + onArtistClick = { id, name -> artistBrowseId = id; artistName = name } + ) + } + selectedTab == 1 -> key(platform) { DiscoverScreen(provider, player) } + selectedTab == 2 -> key(platform) { LibraryScreen(provider, player) } selectedTab == 3 -> NowPlayingScreen(player) } } @@ -136,11 +163,9 @@ fun AppWindow(onCloseRequest: () -> Unit) { PlayerBar(player) } - if (showAuthDialog) { - AuthDialog( - onDismiss = { showAuthDialog = false }, - onSuccess = { authenticated = true; showAuthDialog = false } - ) + if (showProfileDialog) { + ProfileDialog(onDismiss = { showProfileDialog = false }) + } } } } @@ -148,11 +173,11 @@ fun AppWindow(onCloseRequest: () -> Unit) { @Composable private fun Sidebar( - authenticated: Boolean, + platform: Platform, + onPlatformChange: (Platform) -> Unit, selectedTab: Int, onTabChange: (Int) -> Unit, - onLoginRequest: () -> Unit, - onLogout: () -> Unit + onOpenProfile: () -> Unit ) { Column( modifier = Modifier @@ -197,6 +222,18 @@ private fun Sidebar( Spacer(Modifier.height(16.dp)) + Text( + "_platform;", + color = PsPearl300.copy(alpha = 0.6f), + fontFamily = FontMono, + fontSize = 9.sp, + letterSpacing = 1.7.sp, + modifier = Modifier.padding(start = 20.dp, bottom = 6.dp) + ) + PlatformSwitcher(platform, onPlatformChange) + + Spacer(Modifier.height(16.dp)) + // Section label Text( "_navigation;", @@ -269,16 +306,55 @@ private fun Sidebar( Divider(color = Color.White.copy(alpha = 0.08f), thickness = 1.dp) - if (authenticated) { - UserSection(onLogout = onLogout) - } else { - LoginButton(onClick = onLoginRequest) - } + UserSection(onOpenProfile = onOpenProfile) Spacer(Modifier.height(8.dp)) } } +/** Segmented control scoping Search / Discover / Library to one platform. */ +@Composable +private fun PlatformSwitcher(current: Platform, onChange: (Platform) -> Unit) { + val authVersion by AuthEvents.version.collectAsState() + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .background(PsGraphite700) + .padding(2.dp) + ) { + Platform.entries.forEach { p -> + val selected = p == current + val connected = remember(authVersion, p) { Providers.of(p).isAuthenticated } + Row( + Modifier + .weight(1f) + .background(if (selected) PsWhite else Color.Transparent) + .clickable { onChange(p) } + .padding(vertical = 6.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + p.code, + color = if (selected) PsInk900 else PsPearl300.copy(alpha = 0.7f), + fontFamily = FontMono, + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + letterSpacing = 1.4.sp + ) + Spacer(Modifier.width(6.dp)) + // Session dot: green when that platform has a connected account + Box( + Modifier.size(5.dp).background( + if (connected) PsSignalOk else (if (selected) PsPearl300 else PsPearl300.copy(alpha = 0.25f)) + ) + ) + } + } + } +} + @Composable private fun NavItem( code: String, @@ -331,13 +407,16 @@ private fun NavItem( } @Composable -private fun UserSection(onLogout: () -> Unit) { - var showLogout by remember { mutableStateOf(false) } - val avatarUrl = remember { AuthManager.avatarUrl } - val accountName = remember { AuthManager.accountName } +private fun UserSection(onOpenProfile: () -> Unit) { + val authVersion by AuthEvents.version.collectAsState() + val avatarUrl = remember(authVersion) { GoogleAuth.avatarUrl ?: SoundCloudAuth.avatarUrl } + val googleConnected = remember(authVersion) { GoogleAuth.isAuthenticated } + val scConnected = remember(authVersion) { SoundCloudAuth.isAuthenticated } + val profileName = remember(authVersion) { auth.AuthStore.profile().displayName } var avatarBitmap by remember { mutableStateOf(null) } LaunchedEffect(avatarUrl) { + avatarBitmap = null if (!avatarUrl.isNullOrEmpty()) { withContext(Dispatchers.IO) { runCatching { @@ -348,101 +427,45 @@ private fun UserSection(onLogout: () -> Unit) { } } - Column(Modifier.fillMaxWidth()) { - if (showLogout) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onLogout(); showLogout = false } - .padding(horizontal = 20.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.AutoMirrored.Filled.ExitToApp, - contentDescription = "Sign out", - tint = PsPearl300.copy(alpha = 0.7f), - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(10.dp)) - Text( - "_sign_out;", - color = PsPearl300.copy(alpha = 0.7f), - fontFamily = FontMono, - fontSize = 11.sp - ) + // Always visible, whatever is connected: the profile dialog is where sessions are + // connected/disconnected independently, so it must stay reachable at all times. + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onOpenProfile) + .padding(horizontal = 20.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box(Modifier.size(28.dp).background(PsGraphite600), contentAlignment = Alignment.Center) { + if (avatarBitmap != null) { + Image(bitmap = avatarBitmap!!, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop) + } else { + Icon(Icons.Default.AccountCircle, contentDescription = null, tint = PsPearl300, modifier = Modifier.size(20.dp)) } } - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { showLogout = !showLogout } - .padding(horizontal = 20.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Square avatar - Box( - Modifier.size(28.dp).background(PsGraphite600), - contentAlignment = Alignment.Center - ) { - if (avatarBitmap != null) { - Image( - bitmap = avatarBitmap!!, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - Icon( - Icons.Default.AccountCircle, - contentDescription = null, - tint = PsPearl300, - modifier = Modifier.size(20.dp) - ) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text(profileName, color = PsWhite, fontFamily = FontMono, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + ProviderBadge("GOO", googleConnected) + ProviderBadge("SC", scConnected) + if (!googleConnected && !scConnected) { + Text("_sign_in;", color = PsPearl300.copy(alpha = 0.5f), fontFamily = FontMono, fontSize = 9.sp) } } - Spacer(Modifier.width(10.dp)) - Column(Modifier.weight(1f)) { - Text( - accountName ?: "account", - color = PsWhite, - fontFamily = FontMono, - fontSize = 11.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Icon( - if (showLogout) Icons.Default.ExpandMore else Icons.Default.ExpandLess, - contentDescription = null, - tint = PsPearl300.copy(alpha = 0.5f), - modifier = Modifier.size(16.dp) - ) } + Icon( + Icons.Default.Settings, + contentDescription = "Profile and sessions", + tint = PsPearl300.copy(alpha = 0.5f), modifier = Modifier.size(14.dp) + ) } } @Composable -private fun LoginButton(onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.AccountCircle, - contentDescription = "Sign in", - tint = PsPearl300.copy(alpha = 0.5f), - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(10.dp)) - Text( - "_sign_in;", - color = PsPearl300.copy(alpha = 0.7f), - fontFamily = FontMono, - fontSize = 11.sp - ) - } +private fun ProviderBadge(code: String, connected: Boolean) { + Text( + code, + color = if (connected) PsSignalOk else PsPearl300.copy(alpha = 0.3f), + fontFamily = FontMono, + fontSize = 9.sp + ) } diff --git a/desktop/src/main/kotlin/ui/DiscoverScreen.kt b/desktop/src/main/kotlin/ui/DiscoverScreen.kt index 7bf500f..faa21bd 100644 --- a/desktop/src/main/kotlin/ui/DiscoverScreen.kt +++ b/desktop/src/main/kotlin/ui/DiscoverScreen.kt @@ -1,99 +1,158 @@ package ui +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Refresh import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import api.GeneratedDiscovery -import api.SoundCloudDiscovery +import api.resolveStreamUrl import kotlinx.coroutines.launch +import models.SearchResult +import models.toQueueItem import player.FFmpegPlayer -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter - -private val dateFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy") +import provider.DiscoverCollection +import provider.DiscoverSection +import provider.MusicProvider +/** + * Platform-scoped discovery. Sections come from the provider; a section may hold a flat + * track list, a row of collections (mixes, radios, curated playlists), or both. + * Tapping a collection opens it inline with its own back button. + */ @Composable -fun DiscoverScreen(player: FFmpegPlayer) { - var discovery by remember { mutableStateOf(null) } +fun DiscoverScreen(provider: MusicProvider, player: FFmpegPlayer) { + var sections by remember { mutableStateOf?>(null) } var loading by remember { mutableStateOf(false) } var error by remember { mutableStateOf(false) } + var openCollection by remember { mutableStateOf(null) } + var collectionTracks by remember { mutableStateOf>(emptyList()) } + var collectionLoading by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - LaunchedEffect(Unit) { - if (discovery == null && !loading) { - loading = true - error = false - runCatching { discovery = SoundCloudDiscovery.current() } + fun load(force: Boolean) { + if (loading) return + loading = true + error = false + scope.launch { + runCatching { sections = provider.discover(forceRefresh = force) } .onFailure { error = true } loading = false } } + fun open(collection: DiscoverCollection) { + openCollection = collection + collectionTracks = emptyList() + collectionLoading = true + scope.launch { + collectionTracks = runCatching { provider.collectionTracks(collection.id) }.getOrDefault(emptyList()) + collectionLoading = false + collectionTracks.take(6).forEach { launch { resolveStreamUrl(it.videoId) } } + } + } + + LaunchedEffect(Unit) { if (sections == null) load(force = false) } + Column(Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 24.dp)) { - // Header + val opened = openCollection Row(verticalAlignment = Alignment.CenterVertically) { - Text("weekly discovery;", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.weight(1f)) - IconButton(onClick = { - if (loading) return@IconButton - loading = true - error = false - scope.launch { - runCatching { discovery = SoundCloudDiscovery.refresh() } - .onFailure { error = true } - loading = false + if (opened != null) { + IconButton(onClick = { openCollection = null }, modifier = Modifier.size(32.dp)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = TextPrimary) + } + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Text(opened.title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + opened.subtitle?.let { Text("// $it;", color = PsSteel400, fontFamily = FontMono, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } + if (collectionTracks.isNotEmpty()) { + Text( + "play_all;", + color = TextPrimary, fontFamily = FontMono, fontSize = 11.sp, + modifier = Modifier + .background(PsInset) + .clickable { player.loadQueue(collectionTracks.map { it.toQueueItem() }, 0) } + .padding(horizontal = 10.dp, vertical = 6.dp) + ) + } + } else { + Text("${provider.platform.label} discover;", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.weight(1f)) + IconButton(onClick = { load(force = true) }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh", tint = TextPrimary) } - }) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh", tint = TextPrimary) } } - // Metadata - discovery?.let { d -> - val generatedDate = Instant.ofEpochMilli(d.generatedAt) - .atZone(ZoneId.systemDefault()) - .format(dateFormatter) - Text("// generated $generatedDate;", color = PsSteel400, fontFamily = FontMono, fontSize = 11.sp) - val basisText = d.basisGenres.joinToString(", ") - if (basisText.isNotEmpty()) { - Text("// based_on: $basisText;", color = PsSteel400, fontFamily = FontMono, fontSize = 11.sp) + Spacer(Modifier.height(12.dp)) + + if (opened != null) { + when { + collectionLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = PsInk900) + } + collectionTracks.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("// no_playable_tracks;", color = PsSteel400, fontSize = 14.sp, fontFamily = FontMono) + } + else -> LazyColumn { + items(collectionTracks.size, key = { collectionTracks[it].videoId }) { index -> + TrackRow(collectionTracks[index], index, collectionTracks, player, onArtistClick = null) + } + } } + return@Column } - Spacer(Modifier.height(12.dp)) - + val current = sections when { - loading && discovery == null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + loading && current == null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(color = PsInk900) } - error && discovery == null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("// soundcloud_unavailable;", color = PsSteel400, fontSize = 14.sp, fontFamily = FontMono) + error && current == null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("// ${provider.platform.label}_unavailable;", color = PsSteel400, fontSize = 14.sp, fontFamily = FontMono) + } + current.isNullOrEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("// play_something_first_to_seed_discover;", color = PsSteel400, fontSize = 14.sp, fontFamily = FontMono) } - else -> { - val tracks = discovery?.tracks ?: emptyList() - if (tracks.isEmpty()) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("// cold_start_creating_playlist;", color = PsSteel400, fontSize = 14.sp, fontFamily = FontMono) + else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(0.dp)) { + current.forEachIndexed { sIdx, section -> + item(key = "header-$sIdx") { + Column(Modifier.padding(top = if (sIdx == 0) 0.dp else 18.dp, bottom = 6.dp)) { + Text("${section.title};", color = PsSteel400, fontFamily = FontMono, fontSize = 10.sp, letterSpacing = 1.7.sp) + section.caption?.let { Text("// $it;", color = PsSteel400, fontFamily = FontMono, fontSize = 11.sp) } + } } - } else { - LazyColumn(verticalArrangement = Arrangement.spacedBy(0.dp)) { - items(tracks.size) { index -> - TrackRow(tracks[index], index, tracks, player, onArtistClick = null) + if (section.collections.isNotEmpty()) { + item(key = "collections-$sIdx") { + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(section.collections.size, key = { section.collections[it].id }) { i -> + CollectionCard(section.collections[i]) { open(section.collections[i]) } + } + } + } + } + if (section.tracks.isNotEmpty()) { + items(section.tracks.size, key = { "$sIdx-${section.tracks[it].videoId}" }) { index -> + TrackRow(section.tracks[index], index, section.tracks, player, onArtistClick = null) + } + } else if (section.collections.isEmpty()) { + item(key = "empty-$sIdx") { + Text("// cold_start_creating_playlist;", color = PsSteel400, fontSize = 12.sp, fontFamily = FontMono, + modifier = Modifier.padding(vertical = 12.dp)) } } } @@ -101,3 +160,19 @@ fun DiscoverScreen(player: FFmpegPlayer) { } } } + +@Composable +private fun CollectionCard(collection: DiscoverCollection, onClick: () -> Unit) { + Column( + Modifier + .width(140.dp) + .clickable(onClick = onClick) + ) { + Thumbnail(collection.artworkUrl ?: "", Modifier.size(140.dp)) + Spacer(Modifier.height(6.dp)) + Text(collection.title, color = TextPrimary, fontSize = 12.sp, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis) + collection.subtitle?.let { + Text(it, color = TextSecondary, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} diff --git a/desktop/src/main/kotlin/ui/LibraryScreen.kt b/desktop/src/main/kotlin/ui/LibraryScreen.kt index 0d48759..b440a85 100644 --- a/desktop/src/main/kotlin/ui/LibraryScreen.kt +++ b/desktop/src/main/kotlin/ui/LibraryScreen.kt @@ -21,32 +21,36 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import models.Playlist import models.PlaylistTrack -import api.fetchPlaylistTracks -import api.fetchUserPlaylists import api.resolveStreamUrl -import auth.AuthManager +import provider.MusicProvider import kotlinx.coroutines.launch import player.FFmpegPlayer -import models.QueueItem +import models.toQueueItem @Composable -fun LibraryScreen(player: FFmpegPlayer) { +fun LibraryScreen(provider: MusicProvider, player: FFmpegPlayer) { var playlists by remember { mutableStateOf?>(null) } var selectedPlaylist by remember { mutableStateOf(null) } var tracks by remember { mutableStateOf>(emptyList()) } var loading by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - if (!AuthManager.isAuthenticated) { + if (!provider.supportsLibrary) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("// sign_in_required;", color = PsSteel400, fontSize = 14.sp, fontFamily = FontMono) + Text("// ${provider.platform.label}_library_not_available_yet;", color = PsSteel400, fontSize = 14.sp, fontFamily = FontMono) + } + return + } + if (!provider.isAuthenticated) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("// ${provider.platform.label}_sign_in_required;", color = PsSteel400, fontSize = 14.sp, fontFamily = FontMono) } return } LaunchedEffect(Unit) { loading = true - playlists = fetchUserPlaylists() + playlists = runCatching { provider.playlists() }.getOrDefault(emptyList()) loading = false } @@ -103,7 +107,7 @@ fun LibraryScreen(player: FFmpegPlayer) { selectedPlaylist = playlist scope.launch { loading = true - tracks = fetchPlaylistTracks(playlist.id) + tracks = runCatching { provider.playlistTracks(playlist.id) }.getOrDefault(emptyList()) loading = false tracks.take(8).forEach { launch { resolveStreamUrl(it.videoId) } } } @@ -172,7 +176,7 @@ private fun PlaylistTrackRow(track: PlaylistTrack, index: Int, tracks: List.sorted(order: SortOrder): List = when (order) { SortOrder.POPULARITY -> sortedByDescending { it.viewCount ?: -1L } SortOrder.RELEVANCE -> this - SortOrder.YT_MUSIC_FIRST -> sortedBy { - when (it.source) { Source.YT_MUSIC -> 0; Source.YOUTUBE -> 1; Source.SOUNDCLOUD -> 2 } - } - SortOrder.YOUTUBE_FIRST -> sortedBy { - when (it.source) { Source.YOUTUBE -> 0; Source.YT_MUSIC -> 1; Source.SOUNDCLOUD -> 2 } - } - SortOrder.SOUNDCLOUD_FIRST -> sortedBy { - when (it.source) { Source.SOUNDCLOUD -> 0; Source.YT_MUSIC -> 1; Source.YOUTUBE -> 2 } - } + SortOrder.MUSIC_FIRST -> sortedBy { if (it.source == Source.YT_MUSIC) 0 else 1 } + SortOrder.VIDEOS_FIRST -> sortedBy { if (it.source == Source.YOUTUBE) 0 else 1 } SortOrder.DURATION -> sortedBy { parseDurationToSeconds(it.duration) } } @@ -82,12 +80,17 @@ private fun parseDurationToSeconds(duration: String): Int { } @Composable -fun SearchScreen(player: FFmpegPlayer, onArtistClick: (browseId: String, name: String) -> Unit) { +fun SearchScreen( + provider: MusicProvider, + player: FFmpegPlayer, + onArtistClick: (browseId: String, name: String) -> Unit +) { var query by remember { mutableStateOf("") } var rawResults by remember { mutableStateOf>(emptyList()) } var artistResults by remember { mutableStateOf>(emptyList()) } var showAllArtists by remember { mutableStateOf(false) } - var sortOrder by remember { mutableStateOf(SortOrder.POPULARITY) } + var sortOrder by remember { mutableStateOf(if (provider.platform == Platform.YOUTUBE) SortOrder.POPULARITY else SortOrder.RELEVANCE) } + val sortOptions = remember(provider.platform) { SortOrder.forPlatform(provider.platform) } var loading by remember { mutableStateOf(false) } var sortExpanded by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -100,8 +103,11 @@ fun SearchScreen(player: FFmpegPlayer, onArtistClick: (browseId: String, name: S loading = true showAllArtists = false coroutineScope { - val songs = async { YoutubeMusic.search(query) } - val artists = async { YoutubeMusic.searchArtists(query) } + val songs = async { runCatching { provider.search(query) }.getOrDefault(emptyList()) } + val artists = async { + if (provider.supportsArtists) runCatching { provider.searchArtists(query) }.getOrDefault(emptyList()) + else emptyList() + } rawResults = songs.await() artistResults = artists.await() } @@ -115,7 +121,7 @@ fun SearchScreen(player: FFmpegPlayer, onArtistClick: (browseId: String, name: S OutlinedTextField( value = query, onValueChange = { query = it }, - placeholder = { Text("_search_modules;", color = PsSteel400, fontFamily = FontMono, fontSize = 13.sp) }, + placeholder = { Text("_search_${provider.platform.label};", color = PsSteel400, fontFamily = FontMono, fontSize = 13.sp) }, singleLine = true, shape = RoundedCornerShape(0.dp), modifier = Modifier.weight(1f).onKeyEvent { e -> @@ -166,7 +172,7 @@ fun SearchScreen(player: FFmpegPlayer, onArtistClick: (browseId: String, name: S onDismissRequest = { sortExpanded = false }, modifier = Modifier.background(Surface) ) { - SortOrder.entries.forEach { option -> + sortOptions.forEach { option -> DropdownMenuItem(onClick = { sortOrder = option; sortExpanded = false }) { Text( option.label, @@ -246,6 +252,7 @@ fun TrackRow( val active = currentTitle == result.videoId val enqueuing = isEnqueuing && active val canNavigateArtist = onArtistClick != null && result.artistId != null + val rowProvider = remember(result.source) { Providers.of(result.source) } val scope = rememberCoroutineScope() var buildingStation by remember { mutableStateOf(false) } @@ -307,15 +314,15 @@ fun TrackRow( Spacer(Modifier.width(16.dp)) Text(result.duration, color = PsSteel400, fontSize = 12.sp) Spacer(Modifier.width(10.dp)) - if (result.source == Source.SOUNDCLOUD) { + if (rowProvider.supportsStations) { IconButton( onClick = { if (buildingStation) return@IconButton buildingStation = true scope.launch { runCatching { - val station = SoundCloud.stationFor(result) - player.loadQueue(station.map { it.toQueueItem() }, 0) + val station = rowProvider.station(result) + if (station.isNotEmpty()) player.loadQueue(station.map { it.toQueueItem() }, 0) } buildingStation = false } diff --git a/desktop/src/test/kotlin/api/SoundCloudCollectionTest.kt b/desktop/src/test/kotlin/api/SoundCloudCollectionTest.kt new file mode 100644 index 0000000..b9381f6 --- /dev/null +++ b/desktop/src/test/kotlin/api/SoundCloudCollectionTest.kt @@ -0,0 +1,35 @@ +package api + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class SoundCloudCollectionTest { + private fun obj(s: String) = Json.parseToJsonElement(s).jsonObject + + @Test + fun `playlist maps to playlist id with upscaled artwork`() { + val c = SoundCloud.parseCollection(obj("""{"kind":"playlist","id":42,"title":"Mix","track_count":30, + "artwork_url":"https://i1.sndcdn.com/artworks-x-large.jpg","user":{"username":"dj"}}"""))!! + assertEquals("playlist:42", c.id) + assertEquals("Mix", c.title) + assertEquals(30, c.trackCount) + assertEquals("dj", c.subtitle) + assertEquals("https://i1.sndcdn.com/artworks-x-t500x500.jpg", c.artworkUrl) + } + + @Test + fun `system playlist maps to its urn and counts embedded stubs`() { + val c = SoundCloud.parseCollection(obj("""{"kind":"system-playlist","urn":"soundcloud:system-playlists:trending:trap", + "title":"Trap","description":"Hot now","calculated_artwork_url":"https://x/a-large.jpg","tracks":[{"id":1},{"id":2}]}"""))!! + assertEquals("system:soundcloud:system-playlists:trending:trap", c.id) + assertEquals(2, c.trackCount) + assertEquals("Hot now", c.subtitle) + } + + @Test + fun `unknown kinds are ignored`() { + assertNull(SoundCloud.parseCollection(obj("""{"kind":"track","id":1,"title":"x"}"""))) + } +} diff --git a/desktop/src/test/kotlin/api/YtMusicRadioTest.kt b/desktop/src/test/kotlin/api/YtMusicRadioTest.kt new file mode 100644 index 0000000..08eb052 --- /dev/null +++ b/desktop/src/test/kotlin/api/YtMusicRadioTest.kt @@ -0,0 +1,47 @@ +package api + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class YtMusicRadioTest { + + private val body = """ + {"contents":{"x":{"playlistPanelRenderer":{"contents":[ + {"playlistPanelVideoRenderer":{"videoId":"abc","title":{"runs":[{"text":"Song A"}]}, + "longBylineText":{"runs":[{"text":"Artist A","navigationEndpoint":{"browseEndpoint":{"browseId":"UC1"}}},{"text":" • "},{"text":"1.8B views"}]}, + "lengthText":{"runs":[{"text":"3:24"}]}, + "thumbnail":{"thumbnails":[{"url":"small","width":100},{"url":"big","width":800}]}}}, + {"playlistPanelVideoRenderer":{"videoId":"abc","title":{"runs":[{"text":"Song A dup"}]}}}, + {"playlistPanelVideoRenderer":{"videoId":"def","title":{"runs":[{"text":"Song B"}]}}} + ]}}}} + """.trimIndent() + + @Test + fun `parses items anywhere in the tree, dedupes by videoId`() { + val r = parseRadio(body, 50) + assertEquals(listOf("abc", "def"), r.map { it.videoId }) + val a = r[0] + assertEquals("Song A", a.title) + assertEquals("Artist A", a.artist) + assertEquals("UC1", a.artistId) + assertEquals("3:24", a.duration) + assertEquals("big", a.thumbnailUrl) + assertEquals(1_800_000_000L, a.viewCount) + } + + @Test + fun `missing fields fall back sanely`() { + val b = parseRadio(body, 50)[1] + assertEquals("Unknown", b.artist) + assertEquals("", b.duration) + assertTrue(b.thumbnailUrl.contains("def")) + } + + @Test + fun `compact counts`() { + assertEquals(205_000_000L, parseCompactCount("205M views")) + assertEquals(12_300L, parseCompactCount("12.3K views")) + assertEquals(42L, parseCompactCount("42 views")) + assertNull(parseCompactCount("no number")) + } +} diff --git a/shared/src/commonMain/kotlin/models/Models.kt b/shared/src/commonMain/kotlin/models/Models.kt index e451a5a..1c04938 100644 --- a/shared/src/commonMain/kotlin/models/Models.kt +++ b/shared/src/commonMain/kotlin/models/Models.kt @@ -71,11 +71,26 @@ data class PlaylistTrack( val title: String, val channelTitle: String, val thumbnailUrl: String, - val duration: String = "" + val duration: String = "", + val source: Source = Source.YT_MUSIC ) { - val url get() = "https://music.youtube.com/watch?v=$videoId" + /** SoundCloud ids are already permalinks (see SearchResult.url). */ + val url get() = when (source) { + Source.YT_MUSIC -> "https://music.youtube.com/watch?v=$videoId" + Source.YOUTUBE -> "https://www.youtube.com/watch?v=$videoId" + Source.SOUNDCLOUD -> videoId + } } +fun PlaylistTrack.toQueueItem() = QueueItem( + url = url, + videoId = videoId, + title = title, + artist = channelTitle, + artworkUrl = if (source == Source.SOUNDCLOUD) thumbnailUrl else null, + source = source +) + fun SearchResult.toQueueItem() = QueueItem( url = url, videoId = videoId, From a10450efce1e17645227219957683f713ded9d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Wed, 9 Sep 2026 15:51:10 +0100 Subject: [PATCH 5/6] docs: describe bundled Google client, platform providers and discover --- README.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7a79d5c..98eb801 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - Search songs across YouTube Music, YouTube, and SoundCloud simultaneously, results interleaved and sortable by popularity, duration, or source - SoundCloud Station: start a radio of related tracks from any SoundCloud result -- Weekly Discovery: auto-generated playlist from your SoundCloud listening history (interest-based) +- Discover per platform: SoundCloud "Made for you" mixes, curated and trending selections plus a weekly list generated from your listening history; YouTube radios seeded by your recent plays - Artist pages with top songs, albums, singles, and EPs - Queue playback with automatic prefetching of upcoming tracks - Google OAuth login to access your YouTube Music playlists and library @@ -67,13 +67,19 @@ desktop/src/main/kotlin/ ├── player/ │ └── FFmpegPlayer.kt # in-process FFmpeg decoder + Java Sound API playback ├── ui/ -│ ├── App.kt # Window, sidebar, navigation +│ ├── App.kt # Window, sidebar, platform switcher, navigation │ ├── SearchScreen.kt # Search UI, sort dropdown, artist rows │ ├── ArtistScreen.kt # Artist page UI │ ├── LibraryScreen.kt # Playlist library │ ├── NowPlayingScreen.kt # Now Playing with lyrics + queue │ ├── PlayerBar.kt # Persistent playback controls -│ └── AuthDialog.kt # OAuth login dialog +│ ├── DiscoverScreen.kt # Platform-scoped discover: sections, collection cards, inline open +│ ├── ProfileDialog.kt # Local profile + Google/SoundCloud sessions +│ └── SoundCloudLoginWindow.kt # Embedded WebView sign-in for SoundCloud +├── provider/ +│ ├── MusicProvider.kt # Platform abstraction: search, discover, station, library +│ ├── YouTubeProvider.kt # YouTube + YouTube Music behind one provider (radio, library) +│ └── SoundCloudProvider.kt # SoundCloud provider (stations, selections, likes, playlists) └── util/ └── Log.kt # File logger (~/.local/state/wren/wren.log) for diagnostics ``` @@ -122,21 +128,69 @@ To use your own client ID, create `~/.config/wren/soundcloud.json`: Listening history is stored locally at `~/.config/wren/history.json` and used to generate the weekly discovery playlist. -## Authentication (optional) +## Fractional scaling on Wayland (Hyprland, etc.) -Wren works without a Google account — search and playback are fully available without login. +Wren renders through XWayland (AWT has no stable native Wayland toolkit yet). Wren detects your +monitor scale at startup and renders natively, but the compositor must be told not to rescale +XWayland buffers — otherwise the UI looks pixelated under fractional scaling. In `hyprland.conf`: -Logging in unlocks: -- Your YouTube Music playlists and library -- View count data for popularity sorting +```ini +xwayland { + force_zero_scaling = true +} +``` -To enable login, create an OAuth 2.0 client ID in the [Google Cloud Console](https://console.cloud.google.com/) (Desktop app type, YouTube Data API v3 scope) and place the downloaded `client_secret_*.json` at: +## Profile & Sessions (optional) -``` -~/.config/wren/oauth.json +Wren works without any login — search, playback, station, and weekly discovery are fully available. + +Logging in unlocks provider-specific features: + +| Provider | What it unlocks | +|----------|----------------| +| **Google** | YouTube Music playlists, library, view count data | +| **SoundCloud** | Liked tracks, your playlists, "Made for you" mixes in Discover | + +### Profile + +A local profile is created automatically at `~/.config/wren/profile.json`. You can rename it from the sidebar. + +### Google login + +Official builds bundle a Google OAuth client, so just click **GOO → connect** in the sidebar +and authorize in the browser. Until the app passes Google's OAuth verification you may see an +"unverified app" warning on the consent screen. + +If you build from source, or want to use your own Cloud project, either: + +- export `WREN_GOOGLE_CLIENT_ID` / `WREN_GOOGLE_CLIENT_SECRET` before running Gradle (baked in at build time), or +- create an OAuth 2.0 client ID in the [Google Cloud Console](https://console.cloud.google.com/) + (Desktop app type, YouTube Data API v3 scope) and save the downloaded `client_secret_*.json` + to `~/.config/wren/oauth.json`. The file always takes precedence over the bundled client. + +Tokens are stored at `~/.config/wren/sessions/google.json` and refreshed automatically. + +### SoundCloud login + +Two ways to connect: + +1. **Browser sign-in** (recommended): click **SC → connect**, then **sign_in_with_browser** — a window opens with the SoundCloud login page. Sign in normally; Wren captures the session cookie automatically. +2. **Manual token**: get your OAuth token from the browser (cookie `oauth_token` on soundcloud.com) and paste it. + +Session stored at `~/.config/wren/sessions/soundcloud.json`. + +### API Keys (optional) + +Wren ships with default API keys for YouTube Search and InnerTube. To use your own keys, create `~/.config/wren/api.json`: + +```json +{ + "youtubeApiKey": "YOUR_YOUTUBE_DATA_API_KEY", + "innerTubeApiKey": "YOUR_INNERTUBE_KEY" +} ``` -Tokens are stored at `~/.config/wren/tokens.json` and refreshed automatically. +Keys in this file override the built-in defaults. ### API Keys (optional) From 58785cc63f25d07c1693f29bdc9576de5f34331c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Wed, 9 Sep 2026 15:57:26 +0100 Subject: [PATCH 6/6] refactor(provider): name the YouTube discover tab "radios" - YouTube has no discovery feed reachable with the Data API scope; the tab is Wren's own construct (radios seeded by local plays), so the provider now owns the tab label and the empty-state hint and says so --- desktop/src/main/kotlin/provider/MusicProvider.kt | 5 +++++ .../src/main/kotlin/provider/SoundCloudProvider.kt | 2 ++ desktop/src/main/kotlin/provider/YouTubeProvider.kt | 11 ++++++++++- desktop/src/main/kotlin/ui/App.kt | 4 ++-- desktop/src/main/kotlin/ui/DiscoverScreen.kt | 5 +++-- 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/desktop/src/main/kotlin/provider/MusicProvider.kt b/desktop/src/main/kotlin/provider/MusicProvider.kt index ea83c4c..10db0ca 100644 --- a/desktop/src/main/kotlin/provider/MusicProvider.kt +++ b/desktop/src/main/kotlin/provider/MusicProvider.kt @@ -53,6 +53,11 @@ interface MusicProvider { val supportsStations: Boolean val supportsLibrary: Boolean + /** What the Discover tab is called for this platform — it must not overclaim. */ + val discoverLabel: String get() = "discover" + /** Shown when [discover] returns nothing; says honestly where the content comes from. */ + val discoverEmptyHint: String get() = "nothing_to_discover_yet" + suspend fun search(query: String, limit: Int = 20): List suspend fun searchArtists(query: String): List = emptyList() diff --git a/desktop/src/main/kotlin/provider/SoundCloudProvider.kt b/desktop/src/main/kotlin/provider/SoundCloudProvider.kt index fbfaa0c..b918341 100644 --- a/desktop/src/main/kotlin/provider/SoundCloudProvider.kt +++ b/desktop/src/main/kotlin/provider/SoundCloudProvider.kt @@ -22,6 +22,8 @@ object SoundCloudProvider : MusicProvider { override val supportsStations = true override val supportsLibrary = true + override val discoverEmptyHint = "sign_in_or_play_something_to_seed_discover" + private const val LIKES_ID = "likes" override suspend fun search(query: String, limit: Int): List = diff --git a/desktop/src/main/kotlin/provider/YouTubeProvider.kt b/desktop/src/main/kotlin/provider/YouTubeProvider.kt index a91e9c2..02c816b 100644 --- a/desktop/src/main/kotlin/provider/YouTubeProvider.kt +++ b/desktop/src/main/kotlin/provider/YouTubeProvider.kt @@ -24,6 +24,11 @@ object YouTubeProvider : MusicProvider { override val supportsStations = true // per-track radio via the RDAMVM mix override val supportsLibrary = true + // YouTube exposes no discovery feed through the Data API; this tab is Wren's own + // construct (radios seeded by local plays), so it is named for what it really is. + override val discoverLabel = "radios" + override val discoverEmptyHint = "youtube_has_no_discovery_feed — play_something_here_to_seed_radios" + override suspend fun search(query: String, limit: Int): List = YoutubeMusic.search(query, limit) @@ -52,7 +57,11 @@ object YouTubeProvider : MusicProvider { artworkUrl = it.artworkUrl ?: "https://i.ytimg.com/vi/${it.trackId}/hqdefault.jpg" ) } - return listOf(DiscoverSection(title = "radios from recent plays", caption = "seeded by your last plays", collections = radios)) + return listOf(DiscoverSection( + title = "radios from recent plays", + caption = "built by wren from your plays here — youtube offers no discovery feed", + collections = radios + )) } override suspend fun collectionTracks(collectionId: String): List = youtubeRadio(collectionId) diff --git a/desktop/src/main/kotlin/ui/App.kt b/desktop/src/main/kotlin/ui/App.kt index 2bd166b..36863bc 100644 --- a/desktop/src/main/kotlin/ui/App.kt +++ b/desktop/src/main/kotlin/ui/App.kt @@ -251,8 +251,8 @@ private fun Sidebar( onClick = { onTabChange(0) } ) NavItem( - code = "DSC", - label = "discover", + code = if (platform == Platform.YOUTUBE) "RAD" else "DSC", + label = Providers.of(platform).discoverLabel, selected = selectedTab == 1, onClick = { onTabChange(1) } ) diff --git a/desktop/src/main/kotlin/ui/DiscoverScreen.kt b/desktop/src/main/kotlin/ui/DiscoverScreen.kt index faa21bd..d80a66b 100644 --- a/desktop/src/main/kotlin/ui/DiscoverScreen.kt +++ b/desktop/src/main/kotlin/ui/DiscoverScreen.kt @@ -90,7 +90,7 @@ fun DiscoverScreen(provider: MusicProvider, player: FFmpegPlayer) { ) } } else { - Text("${provider.platform.label} discover;", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.Bold) + Text("${provider.platform.label} ${provider.discoverLabel};", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.weight(1f)) IconButton(onClick = { load(force = true) }) { Icon(Icons.Default.Refresh, contentDescription = "Refresh", tint = TextPrimary) @@ -126,7 +126,8 @@ fun DiscoverScreen(provider: MusicProvider, player: FFmpegPlayer) { Text("// ${provider.platform.label}_unavailable;", color = PsSteel400, fontSize = 14.sp, fontFamily = FontMono) } current.isNullOrEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("// play_something_first_to_seed_discover;", color = PsSteel400, fontSize = 14.sp, fontFamily = FontMono) + Text("// ${provider.discoverEmptyHint};", color = PsSteel400, fontSize = 14.sp, fontFamily = FontMono, + modifier = Modifier.padding(horizontal = 24.dp)) } else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(0.dp)) { current.forEachIndexed { sIdx, section ->