Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/gradle-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ gradle.properties
api.json
local.properties
dist-artifacts/
squashfs-root/
client_secret*.json
78 changes: 66 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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)

Expand Down
44 changes: 41 additions & 3 deletions build-appimage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
49 changes: 48 additions & 1 deletion desktop/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { dependsOn(generateBuildConfig) }

dependencies {
implementation(project(":shared"))
implementation(compose.desktop.currentOs)
Expand All @@ -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")
}
Expand All @@ -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"))
}
Expand Down
17 changes: 11 additions & 6 deletions desktop/src/main/kotlin/api/ListeningHistory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 }
Expand All @@ -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<PlayRecord> {
return load().take(n)
/** Most recent plays, optionally restricted to the given sources. */
fun recent(n: Int, sources: Set<Source>? = null): List<PlayRecord> {
val all = load()
return (if (sources == null) all else all.filter { it.source in sources }).take(n)
}

fun topGenres(limit: Int): List<Pair<String, Int>> {
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()
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/main/kotlin/api/Lyrics.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading
Loading