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/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) 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")) } 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/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/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/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/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/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/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/provider/MusicProvider.kt b/desktop/src/main/kotlin/provider/MusicProvider.kt new file mode 100644 index 0000000..10db0ca --- /dev/null +++ b/desktop/src/main/kotlin/provider/MusicProvider.kt @@ -0,0 +1,86 @@ +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 + + /** 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() + + /** 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..b918341 --- /dev/null +++ b/desktop/src/main/kotlin/provider/SoundCloudProvider.kt @@ -0,0 +1,92 @@ +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 + + 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 = + 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..02c816b --- /dev/null +++ b/desktop/src/main/kotlin/provider/YouTubeProvider.kt @@ -0,0 +1,72 @@ +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 + + // 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) + + 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 = "built by wren from your plays here — youtube offers no discovery feed", + 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..36863bc 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;", @@ -214,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) } ) @@ -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/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/DiscoverScreen.kt b/desktop/src/main/kotlin/ui/DiscoverScreen.kt index 7bf500f..d80a66b 100644 --- a/desktop/src/main/kotlin/ui/DiscoverScreen.kt +++ b/desktop/src/main/kotlin/ui/DiscoverScreen.kt @@ -1,99 +1,159 @@ 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} ${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) } - }) { - 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("// ${provider.discoverEmptyHint};", color = PsSteel400, fontSize = 14.sp, fontFamily = FontMono, + modifier = Modifier.padding(horizontal = 24.dp)) } - 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 +161,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(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) 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/SearchScreen.kt b/desktop/src/main/kotlin/ui/SearchScreen.kt index 2ae4cb5..908b68c 100644 --- a/desktop/src/main/kotlin/ui/SearchScreen.kt +++ b/desktop/src/main/kotlin/ui/SearchScreen.kt @@ -35,9 +35,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import models.SearchResult -import api.SoundCloud -import api.YoutubeMusic import api.resolveStreamUrl +import provider.MusicProvider +import provider.Platform +import provider.Providers import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -48,27 +49,24 @@ import models.QueueItem import models.toQueueItem import java.net.URL -enum class SortOrder(val label: String) { +/** [youtubeOnly] orders exist because YouTube merges the Music and video catalogs. */ +enum class SortOrder(val label: String, val youtubeOnly: Boolean = false) { POPULARITY("Popularity"), RELEVANCE("Relevance"), - YT_MUSIC_FIRST("YT Music first"), - YOUTUBE_FIRST("YouTube first"), - SOUNDCLOUD_FIRST("SoundCloud first"), - DURATION("Duration") + MUSIC_FIRST("Music first", youtubeOnly = true), + VIDEOS_FIRST("Videos first", youtubeOnly = true), + DURATION("Duration"); + + companion object { + fun forPlatform(platform: Platform) = entries.filter { !it.youtubeOnly || platform == Platform.YOUTUBE } + } } private fun 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/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/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/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) + } +} 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,