From f6631b5e8864e20a1e03531d0b22b63b503c7884 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 16:55:48 +0300 Subject: [PATCH 01/77] feat(soundcloud): add SoundCloud tab with feed, search, downloads, and CI release Add a production SoundCloud experience (session auth, feed/discover/likes, tiles/list, shuffle, pull-to-refresh, playlist back stack) plus commitlint enforcement and a Release APK workflow that publishes feature notes. Co-authored-by: Cursor --- .github/workflows/commitlint.yml | 60 ++ .github/workflows/release-apk.yml | 211 +++++ app/build.gradle.kts | 6 + app/proguard-rules.pro | 14 + app/src/main/AndroidManifest.xml | 7 + .../com/theveloper/pixelplay/MainActivity.kt | 3 +- .../presentation/navigation/AppNavigation.kt | 40 + .../presentation/navigation/MainRootRoutes.kt | 4 +- .../presentation/navigation/Screen.kt | 1 + .../screens/ExperimentalSettingsScreen.kt | 98 +++ .../presentation/screens/SoundCloudScreen.kt | 760 ++++++++++++++++++ .../auth/SoundCloudLoginActivity.kt | 210 +++++ .../pixelplay/soundcloud/SoundCloudClient.kt | 311 +++++++ .../soundcloud/SoundCloudDownloadService.kt | 246 ++++++ .../soundcloud/SoundCloudDownloader.kt | 78 ++ .../pixelplay/soundcloud/SoundCloudModels.kt | 33 + .../soundcloud/SoundCloudPrefsViewModel.kt | 67 ++ .../soundcloud/SoundCloudSessionApi.kt | 290 +++++++ .../soundcloud/SoundCloudSettings.kt | 102 +++ .../soundcloud/SoundCloudViewModel.kt | 502 ++++++++++++ .../res/drawable-hdpi/ic_soundcloud_24.png | Bin 0 -> 2539 bytes .../drawable-hdpi/ic_soundcloud_filled_24.png | Bin 0 -> 2539 bytes .../res/drawable-mdpi/ic_soundcloud_24.png | Bin 0 -> 1484 bytes .../drawable-mdpi/ic_soundcloud_filled_24.png | Bin 0 -> 1484 bytes .../res/drawable-xhdpi/ic_soundcloud_24.png | Bin 0 -> 3458 bytes .../ic_soundcloud_filled_24.png | Bin 0 -> 3458 bytes .../res/drawable-xxhdpi/ic_soundcloud_24.png | Bin 0 -> 6507 bytes .../ic_soundcloud_filled_24.png | Bin 0 -> 6507 bytes .../res/drawable-xxxhdpi/ic_soundcloud_24.png | Bin 0 -> 10222 bytes .../ic_soundcloud_filled_24.png | Bin 0 -> 10222 bytes app/src/main/res/values/strings.xml | 40 + commitlint.config.cjs | 24 + gradle/libs.versions.toml | 2 + settings.gradle.kts | 3 + 34 files changed, 3110 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/commitlint.yml create mode 100644 .github/workflows/release-apk.yml create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/soundcloud/auth/SoundCloudLoginActivity.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloader.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSettings.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt create mode 100644 app/src/main/res/drawable-hdpi/ic_soundcloud_24.png create mode 100644 app/src/main/res/drawable-hdpi/ic_soundcloud_filled_24.png create mode 100644 app/src/main/res/drawable-mdpi/ic_soundcloud_24.png create mode 100644 app/src/main/res/drawable-mdpi/ic_soundcloud_filled_24.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_soundcloud_24.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_soundcloud_filled_24.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_soundcloud_24.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_soundcloud_filled_24.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_soundcloud_24.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_soundcloud_filled_24.png create mode 100644 commitlint.config.cjs diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000000..2c30f95fc4 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,60 @@ +name: Commit Lint (Semantic) + +on: + push: + branches: [master, 'feat/**', 'fix/**', 'chore/**', 'ci/**'] + pull_request: + types: [opened, synchronize, reopened, edited] + branches: [master] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: commitlint-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + commitlint: + name: Enforce conventional commits + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Install commitlint + run: npm install --no-save @commitlint/cli@19 @commitlint/config-conventional@19 + + - name: Lint PR commits + if: github.event_name == 'pull_request' + run: | + npx commitlint \ + --from "${{ github.event.pull_request.base.sha }}" \ + --to "${{ github.event.pull_request.head.sha }}" \ + --verbose + + - name: Lint push commits + if: github.event_name == 'push' + run: | + # Validate every new commit on the push (range from before → after). + BEFORE="${{ github.event.before }}" + if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then + npx commitlint --last --verbose + else + npx commitlint --from "$BEFORE" --to "${{ github.sha }}" --verbose + fi + + - name: Lint PR title + if: github.event_name == 'pull_request' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + echo "$PR_TITLE" | npx commitlint --verbose diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml new file mode 100644 index 0000000000..3e2290153f --- /dev/null +++ b/.github/workflows/release-apk.yml @@ -0,0 +1,211 @@ +name: Release APK + +# Builds a signed (CI keystore) release APK and publishes a GitHub Release with +# conventional-commit feature notes. Triggered by version tags or manually. +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Release version without v prefix (e.g. 0.8.0-soundcloud)' + required: true + type: string + prerelease: + description: 'Mark as pre-release' + required: false + default: true + type: boolean + +permissions: + contents: write + +concurrency: + group: release-apk-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + if: github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + outputs: + version: ${{ steps.meta.outputs.version }} + tag: ${{ steps.meta.outputs.tag }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Resolve version metadata + id: meta + shell: bash + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ inputs.version }}" + VERSION="${VERSION#v}" + TAG="v${VERSION}" + else + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#v}" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "Resolved release ${TAG}" + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Cache keystore + id: cache-keystore + uses: actions/cache@v5 + with: + path: vz-pixelplay.jks + key: ${{ runner.os }}-keystore-vz-pixelplay + + - name: Generate keystore if not cached + if: steps.cache-keystore.outputs.cache-hit != 'true' + run: | + keytool -genkey -v -keystore vz-pixelplay.jks -alias pixelplay-key -keyalg RSA -keysize 4096 -validity 10000 \ + -storepass 994273 -keypass 994273 \ + -dname "CN=PixelPlay, OU=Dev, O=PixelPlay, L=World, S=World, C=US" + + - name: Create keystore.properties + run: | + echo "storePassword=994273" > keystore.properties + echo "keyAlias=pixelplay-key" >> keystore.properties + echo "keyPassword=994273" >> keystore.properties + + - name: Build Phone release APKs + run: gradle :app:assembleRelease -Ppixelplay.enableAbiSplits=true + + - name: Verify APKs + run: | + BUILD_TOOLS_VERSION="$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -n 1)" + for apk in \ + app/build/outputs/apk/release/app-arm64-v8a-release.apk \ + app/build/outputs/apk/release/app-armeabi-v7a-release.apk + do + "$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION/aapt2" dump badging "$apk" >/dev/null + "$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION/apksigner" verify --verbose "$apk" + done + + - name: Stage release assets + run: | + mkdir -p dist + cp app/build/outputs/apk/release/app-arm64-v8a-release.apk \ + "dist/PixelPlayer-${{ steps.meta.outputs.version }}-arm64-v8a.apk" + cp app/build/outputs/apk/release/app-armeabi-v7a-release.apk \ + "dist/PixelPlayer-${{ steps.meta.outputs.version }}-armeabi-v7a.apk" + + - name: Generate release notes from conventional commits + id: notes + uses: actions/github-script@v9 + with: + script: | + const { execSync } = require('child_process'); + const tag = '${{ steps.meta.outputs.tag }}'; + const version = '${{ steps.meta.outputs.version }}'; + + let range = ''; + try { + const tags = execSync('git tag --sort=-creatordate', { encoding: 'utf8' }) + .split('\n') + .map((t) => t.trim()) + .filter(Boolean) + .filter((t) => t !== tag); + if (tags.length > 0) { + range = `${tags[0]}..HEAD`; + } + } catch (_) {} + + const logCmd = range + ? `git log ${range} --pretty=format:%s` + : 'git log -30 --pretty=format:%s'; + const subjects = execSync(logCmd, { encoding: 'utf8' }) + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + + const buckets = { + feat: [], + fix: [], + perf: [], + refactor: [], + docs: [], + chore: [], + other: [], + }; + + const re = /^(feat|fix|perf|refactor|docs|chore|ci|build|test|style|revert)(\(.+\))?!?:\s*(.+)$/i; + for (const subject of subjects) { + const m = subject.match(re); + if (!m) { + buckets.other.push(subject); + continue; + } + const type = m[1].toLowerCase(); + const body = m[3]; + if (buckets[type]) { + buckets[type].push(body); + } else { + buckets.other.push(subject); + } + } + + const section = (title, items) => { + if (!items.length) return ''; + return `### ${title}\n\n${items.map((i) => `- ${i}`).join('\n')}\n\n`; + }; + + let body = `## PixelPlayer ${version}\n\n`; + body += section('Features', buckets.feat); + body += section('Fixes', buckets.fix); + body += section('Performance', buckets.perf); + body += section('Refactors', buckets.refactor); + body += section('Docs', buckets.docs); + body += section('Chores', buckets.chore); + body += section('Other', buckets.other); + body += [ + '### Downloads', + '', + '- `PixelPlayer-*-arm64-v8a.apk` — modern 64-bit devices (recommended)', + '- `PixelPlayer-*-armeabi-v7a.apk` — older 32-bit devices', + '', + '> CI builds use the repository CI keystore (not the store/Play signing key).', + '', + ].join('\n'); + + core.setOutput('body', body); + await core.summary.addHeading(`Release ${tag}`).addRaw(body).write(); + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.meta.outputs.tag }} + name: PixelPlayer ${{ steps.meta.outputs.version }} + body: ${{ steps.notes.outputs.body }} + draft: false + prerelease: ${{ github.event_name == 'workflow_dispatch' && inputs.prerelease || contains(steps.meta.outputs.version, '-') }} + files: | + dist/PixelPlayer-${{ steps.meta.outputs.version }}-arm64-v8a.apk + dist/PixelPlayer-${{ steps.meta.outputs.version }}-armeabi-v7a.apk + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload artifacts + uses: actions/upload-artifact@v7.0.1 + with: + name: PixelPlayer-${{ steps.meta.outputs.version }}-release-apks + path: dist/*.apk + if-no-files-found: error + compression-level: 0 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9f2583065f..64bb3938e4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -115,6 +115,11 @@ android { ?: "b18441a1ff607e10a989891a5462e627" buildConfigField("int", "TELEGRAM_API_ID", telegramApiId) buildConfigField("String", "TELEGRAM_API_HASH", "\"$telegramApiHash\"") + + val soundCloudClientId = localProperties.getProperty("soundcloud.clientId").orEmpty() + .replace("\\", "\\\\") + .replace("\"", "\\\"") + buildConfigField("String", "SOUNDCLOUD_CLIENT_ID", "\"$soundCloudClientId\"") } signingConfigs { @@ -322,6 +327,7 @@ dependencies { implementation(libs.androidx.security.crypto) implementation(libs.google.play.services.cast.framework) implementation(libs.tdlib) + implementation(libs.newpipe.extractor) // UI Utilities & Extra implementation(libs.timber) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 1d1420955c..6e2980b6dc 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -213,3 +213,17 @@ public static int d(...); public static int i(...); } + +# NewPipe Extractor (SoundCloud) +-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } +-keep class org.mozilla.javascript.** { *; } +-keep class org.mozilla.javascript.XMLLib.** { *; } +-dontwarn org.mozilla.javascript.XMLLib +-dontwarn org.schabi.newpipe.extractor.** + +# Rhino / NewPipe optional JDK APIs not on Android +-dontwarn org.mozilla.javascript.** +-dontwarn javax.script.** +-dontwarn java.beans.** +-dontwarn jdk.dynalink.** +-dontwarn org.mozilla.classfile.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1afb6f4044..67da09bace 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -120,6 +120,13 @@ android:theme="@style/Theme.PixelPlay" android:windowSoftInputMode="adjustResize" /> + + true + Screen.Library.route, + Screen.SoundCloud.route -> true else -> false } @@ -11,5 +12,6 @@ internal fun mainRootRouteIndex(route: String?): Int? = when (route) { Screen.Home.route -> 0 Screen.Search.route -> 1 Screen.Library.route -> 2 + Screen.SoundCloud.route -> 3 else -> null } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt index e079695893..af8ac6433b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt @@ -8,6 +8,7 @@ sealed class Screen(val route: String) { object Home : Screen("home") object Search : Screen("search") object Library : Screen("library") + object SoundCloud : Screen("soundcloud") object Settings : Screen("settings") object Accounts : Screen("settings_accounts") object SettingsCategory : Screen("settings_category/{categoryId}") { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt index 8198b71062..bb82e950af 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.LinearScale import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.Style +import androidx.compose.material.icons.rounded.Cloud import androidx.compose.material.icons.rounded.LinearScale import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.Rectangle @@ -44,8 +45,10 @@ import androidx.compose.material.icons.rounded.Title import androidx.compose.material.icons.rounded.ViewCarousel import androidx.compose.material.icons.rounded.BlurOn import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -66,11 +69,13 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.media3.common.util.UnstableApi import androidx.navigation.NavController +import android.content.Intent import com.theveloper.pixelplay.presentation.components.CollapsibleCommonTopBar import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel @@ -78,8 +83,10 @@ import com.theveloper.pixelplay.presentation.viewmodel.SettingsViewModel import kotlinx.coroutines.launch import kotlin.math.roundToInt import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.preferences.AlbumArtQuality +import com.theveloper.pixelplay.soundcloud.SoundCloudPrefsViewModel @OptIn(UnstableApi::class) @Composable @@ -180,6 +187,97 @@ fun ExperimentalSettingsScreen( contentPadding = PaddingValues(top = currentTopBarHeightDp + 8.dp), modifier = Modifier.fillMaxSize() ) { + item(key = "soundcloud_section") { + val scPrefsVm: SoundCloudPrefsViewModel = hiltViewModel() + val scClientId by scPrefsVm.clientId.collectAsStateWithLifecycle() + val scUsername by scPrefsVm.username.collectAsStateWithLifecycle() + val scSession by scPrefsVm.session.collectAsStateWithLifecycle() + var clientIdDraft by remember(scClientId) { mutableStateOf(scClientId) } + var usernameDraft by remember(scUsername) { mutableStateOf(scUsername) } + val context = LocalContext.current + + SettingsSection( + title = stringResource(R.string.soundcloud_section), + icon = { + Icon( + imageVector = Icons.Rounded.Cloud, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 0.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.soundcloud_settings_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), + ) + if (scSession.isSignedIn) { + Text( + text = stringResource( + R.string.soundcloud_signed_in_as, + scSession.displayName.ifBlank { scSession.permalink }.ifBlank { "account" }, + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 4.dp), + ) + FilledTonalButton( + onClick = { scPrefsVm.signOut() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.soundcloud_sign_out)) + } + } else { + FilledTonalButton( + onClick = { + context.startActivity( + Intent( + context, + com.theveloper.pixelplay.presentation.soundcloud.auth.SoundCloudLoginActivity::class.java, + ), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.soundcloud_sign_in)) + } + } + OutlinedTextField( + value = clientIdDraft, + onValueChange = { clientIdDraft = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringResource(R.string.soundcloud_client_id_label)) }, + placeholder = { Text(stringResource(R.string.soundcloud_client_id_hint)) }, + ) + OutlinedTextField( + value = usernameDraft, + onValueChange = { usernameDraft = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringResource(R.string.soundcloud_username_label)) }, + placeholder = { Text(stringResource(R.string.soundcloud_username_hint)) }, + ) + FilledTonalButton( + onClick = { + scPrefsVm.saveClientId(clientIdDraft) + scPrefsVm.saveUsername(usernameDraft) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.soundcloud_save_settings)) + } + } + } + } + item(key = "player_ui_tweaks_section") { SettingsSection( title = stringResource(R.string.settings_exp_player_ui_tweaks_section), diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt new file mode 100644 index 0000000000..6fee86810c --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -0,0 +1,760 @@ +package com.theveloper.pixelplay.presentation.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.ViewList +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Cloud +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.PlaylistPlay +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Shuffle +import androidx.compose.material.icons.rounded.ViewModule +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +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 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight +import com.theveloper.pixelplay.presentation.components.SmartImage +import com.theveloper.pixelplay.presentation.components.ToggleSegmentButton +import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem +import com.theveloper.pixelplay.presentation.navigation.Screen +import com.theveloper.pixelplay.presentation.navigation.navigateSafely +import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel +import com.theveloper.pixelplay.soundcloud.SoundCloudSearchHit +import com.theveloper.pixelplay.soundcloud.SoundCloudSection +import com.theveloper.pixelplay.soundcloud.SoundCloudViewModel +import com.theveloper.pixelplay.ui.theme.GoogleSansRounded +import kotlinx.coroutines.launch + +/** Material medium width — foldables / tablets / landscape. */ +private const val WIDE_SCREEN_DP = 600 + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SoundCloudScreen( + playerViewModel: PlayerViewModel, + navController: NavController, + paddingValues: PaddingValues = PaddingValues(), + viewModel: SoundCloudViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val stablePlayer by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val sections = remember { + SoundCloudSection.entries.filter { it != SoundCloudSection.SEARCH } + } + val selectedIndex = sections.indexOf(uiState.section).let { if (it < 0) -1 else it } + val configuration = LocalConfiguration.current + val isWideScreen = configuration.screenWidthDp >= WIDE_SCREEN_DP + val useTiles = uiState.tilesOverride ?: isWideScreen + val remoteTrackCount = remember(uiState.results) { + uiState.results.count { it.kind == SoundCloudSearchHit.Kind.TRACK } + } + val canShuffle = !uiState.isLoading && when (uiState.section) { + SoundCloudSection.DOWNLOADS -> uiState.downloads.isNotEmpty() + else -> remoteTrackCount > 0 + } + val gridColumns = when { + configuration.screenWidthDp >= 840 -> 4 + isWideScreen -> 3 + else -> 2 + } + val contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = MiniPlayerHeight + 36.dp, + ) + val currentSongId = stablePlayer.currentSong?.id + val isPlaying = stablePlayer.isPlaying + + val onHitPlay: (SoundCloudSearchHit) -> Unit = remember(viewModel, playerViewModel, scope) { + { hit -> + if (hit.kind == SoundCloudSearchHit.Kind.PLAYLIST) { + viewModel.openPlaylist(hit) + } else { + scope.launch { + viewModel.setBusy(hit.url) + try { + val song = viewModel.resolveToSong(hit) + playerViewModel.playSongs( + songsToPlay = listOf(song), + startSong = song, + queueName = "SoundCloud", + ) + viewModel.setIdle() + } catch (t: Throwable) { + viewModel.setIdle(error = t.message ?: t::class.java.simpleName) + } + } + } + } + } + + val onDownloadPlay: (Song) -> Unit = remember(uiState.downloads, playerViewModel) { + { song -> + playerViewModel.playSongs( + songsToPlay = uiState.downloads, + startSong = song, + queueName = "SoundCloud · Downloads", + ) + } + } + + val onShuffle: () -> Unit = { + scope.launch { + try { + if (uiState.section == SoundCloudSection.DOWNLOADS) { + val songs = uiState.downloads + if (songs.isEmpty()) return@launch + playerViewModel.playSongsShuffled( + songsToPlay = songs, + queueName = "SoundCloud · Downloads", + startAtZero = true, + ) + } else { + val songs = viewModel.resolveTracksForPlayback() + if (songs.isEmpty()) return@launch + val queueName = uiState.browsingPlaylistTitle + ?.let { "SoundCloud · $it" } + ?: "SoundCloud · ${uiState.section.name.lowercase().replaceFirstChar { it.titlecase() }}" + playerViewModel.playSongsShuffled( + songsToPlay = songs, + queueName = queueName, + startAtZero = true, + ) + viewModel.setIdle() + } + } catch (t: Throwable) { + viewModel.setIdle(error = t.message ?: t::class.java.simpleName) + } + } + } + + // Keep Back inside SoundCloud when browsing a playlist (don't jump to Library). + BackHandler(enabled = uiState.canNavigateBack) { + viewModel.navigateBack() + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + TopAppBar( + title = { + Text( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(R.string.soundcloud_tab_title), + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.primary, + fontSize = 40.sp, + letterSpacing = 1.sp, + ) + }, + actions = { + FilledIconButton( + modifier = Modifier.padding(end = 14.dp), + onClick = { navController.navigateSafely(Screen.Experimental.route) }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Icon( + painter = painterResource(R.drawable.rounded_settings_24), + contentDescription = stringResource(R.string.soundcloud_cd_open_settings), + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ), + ) + + PrimaryScrollableTabRow( + selectedTabIndex = selectedIndex.coerceAtLeast(0), + containerColor = Color.Transparent, + edgePadding = 12.dp, + indicator = {}, + divider = {}, + ) { + sections.forEachIndexed { index, section -> + TabAnimation( + index = index, + title = section.name, + selectedIndex = if (selectedIndex < 0) -1 else selectedIndex, + onClick = { viewModel.selectSection(section) }, + ) { + Text( + text = stringResource(section.labelRes).uppercase(), + style = MaterialTheme.typography.labelLarge, + fontFamily = GoogleSansRounded, + fontWeight = if (selectedIndex == index) FontWeight.Bold else FontWeight.Medium, + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.query, + onValueChange = viewModel::onQueryChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + singleLine = true, + label = { Text(stringResource(R.string.soundcloud_query_label)) }, + placeholder = { Text(stringResource(R.string.soundcloud_query_hint)) }, + leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, + trailingIcon = { + if (uiState.query.isNotEmpty()) { + IconButton(onClick = { viewModel.onQueryChange("") }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.common_clear_search), + ) + } + } + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(40.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ToggleSegmentButton( + modifier = Modifier.weight(1f), + active = useTiles, + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = MaterialTheme.colorScheme.surfaceVariant, + activeContentColor = MaterialTheme.colorScheme.onPrimary, + inactiveContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + activeCornerRadius = 32.dp, + onClick = { viewModel.setUseTiles(true) }, + text = stringResource(R.string.soundcloud_view_tiles), + imageVector = Icons.Rounded.ViewModule, + ) + ToggleSegmentButton( + modifier = Modifier.weight(1f), + active = !useTiles, + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = MaterialTheme.colorScheme.surfaceVariant, + activeContentColor = MaterialTheme.colorScheme.onPrimary, + inactiveContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + activeCornerRadius = 32.dp, + onClick = { viewModel.setUseTiles(false) }, + text = stringResource(R.string.soundcloud_view_list), + imageVector = Icons.AutoMirrored.Rounded.ViewList, + ) + FilledTonalButton( + onClick = onShuffle, + enabled = canShuffle, + modifier = Modifier.height(40.dp), + shape = RoundedCornerShape(32.dp), + contentPadding = PaddingValues(horizontal = 12.dp), + ) { + Icon( + imageVector = Icons.Rounded.Shuffle, + contentDescription = stringResource(R.string.soundcloud_cd_shuffle), + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.soundcloud_shuffle)) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + uiState.browsingPlaylistTitle?.let { title -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { viewModel.navigateBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.common_back), + ) + } + Text( + text = stringResource(R.string.soundcloud_playlist_browsing, title), + style = MaterialTheme.typography.titleSmall, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + + uiState.errorMessage?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + if (uiState.isLoading && uiState.resolvingKey == null && !uiState.isRefreshing) { + LoadingIndicator( + modifier = Modifier + .padding(24.dp) + .size(48.dp) + .align(Alignment.CenterHorizontally), + ) + } + + val pullToRefreshState = rememberPullToRefreshState() + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = viewModel::refresh, + state = pullToRefreshState, + modifier = Modifier.fillMaxSize(), + indicator = { + PullToRefreshDefaults.LoadingIndicator( + state = pullToRefreshState, + isRefreshing = uiState.isRefreshing, + modifier = Modifier.align(Alignment.TopCenter), + ) + }, + ) { + if (useTiles) { + LazyVerticalGrid( + columns = GridCells.Fixed(gridColumns), + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (uiState.section == SoundCloudSection.DOWNLOADS) { + items(uiState.downloads, key = { it.id }) { song -> + SoundCloudSongTile( + song = song, + isCurrent = currentSongId == song.id, + isPlaying = currentSongId == song.id && isPlaying, + onPlay = { onDownloadPlay(song) }, + ) + } + } else { + items(uiState.results, key = { it.url + it.kind.name }) { hit -> + SoundCloudResultTile( + hit = hit, + enabled = !uiState.isLoading, + isResolving = uiState.resolvingKey == hit.url, + onPlay = { onHitPlay(hit) }, + onDownload = { viewModel.download(hit) }, + ) + } + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (uiState.section == SoundCloudSection.DOWNLOADS) { + items(uiState.downloads, key = { it.id }) { song -> + EnhancedSongListItem( + song = song, + isPlaying = currentSongId == song.id && isPlaying, + isCurrentSong = currentSongId == song.id, + showMoreOptionsButton = false, + onMoreOptionsClick = {}, + onClick = { onDownloadPlay(song) }, + ) + } + } else { + items(uiState.results, key = { it.url + it.kind.name }) { hit -> + SoundCloudResultRow( + hit = hit, + enabled = !uiState.isLoading, + isResolving = uiState.resolvingKey == hit.url, + onPlay = { onHitPlay(hit) }, + onDownload = { viewModel.download(hit) }, + ) + } + } + } + } + } + } +} + +private val SoundCloudSection.labelRes: Int + get() = when (this) { + SoundCloudSection.FEED -> R.string.soundcloud_pill_feed + SoundCloudSection.DISCOVER -> R.string.soundcloud_pill_discover + SoundCloudSection.SEARCH -> R.string.soundcloud_pill_search + SoundCloudSection.LIKES -> R.string.soundcloud_pill_likes + SoundCloudSection.TRACKS -> R.string.soundcloud_pill_tracks + SoundCloudSection.PLAYLISTS -> R.string.soundcloud_pill_playlists + SoundCloudSection.DOWNLOADS -> R.string.soundcloud_pill_downloads + } + +@Composable +private fun SoundCloudSongTile( + song: Song, + isCurrent: Boolean, + isPlaying: Boolean, + onPlay: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onPlay), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)), + ) { + if (!song.albumArtUriString.isNullOrBlank()) { + SmartImage( + model = song.albumArtUriString, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + imageVector = Icons.Rounded.Cloud, + contentDescription = null, + modifier = Modifier + .padding(28.dp) + .align(Alignment.Center), + ) + } + } + if (isCurrent) { + Surface( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f), + modifier = Modifier.fillMaxSize(), + ) { + Icon( + imageVector = if (isPlaying) Icons.Rounded.PlayArrow else Icons.Rounded.Cloud, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .size(40.dp) + .align(Alignment.Center), + ) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = song.title, + style = MaterialTheme.typography.titleSmall, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + ) + Text( + text = song.displayArtist, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun SoundCloudResultTile( + hit: SoundCloudSearchHit, + enabled: Boolean, + isResolving: Boolean = false, + onPlay: () -> Unit, + onDownload: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(enabled = enabled && !isResolving, onClick = onPlay), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)), + ) { + if (!hit.thumbnailUrl.isNullOrBlank()) { + AsyncImage( + model = hit.thumbnailUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + imageVector = if (hit.kind == SoundCloudSearchHit.Kind.PLAYLIST) { + Icons.Rounded.PlaylistPlay + } else { + Icons.Rounded.Cloud + }, + contentDescription = null, + modifier = Modifier + .padding(28.dp) + .align(Alignment.Center), + ) + } + } + if (isResolving) { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f), + modifier = Modifier.fillMaxSize(), + ) { + LoadingIndicator( + modifier = Modifier + .size(40.dp) + .align(Alignment.Center), + ) + } + } else if (hit.kind == SoundCloudSearchHit.Kind.TRACK) { + IconButton( + onClick = onDownload, + enabled = enabled, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.72f), + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + Icons.Rounded.Download, + contentDescription = stringResource(R.string.soundcloud_download), + ) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = hit.title, + style = MaterialTheme.typography.titleSmall, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = hit.artist, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun SoundCloudResultRow( + hit: SoundCloudSearchHit, + enabled: Boolean, + isResolving: Boolean = false, + onPlay: () -> Unit, + onDownload: () -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(enabled = enabled && !isResolving, onClick = onPlay), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(modifier = Modifier.size(56.dp)) { + if (!hit.thumbnailUrl.isNullOrBlank()) { + AsyncImage( + model = hit.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop, + ) + } else { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + Icon( + imageVector = if (hit.kind == SoundCloudSearchHit.Kind.PLAYLIST) { + Icons.Rounded.PlaylistPlay + } else { + Icons.Rounded.Cloud + }, + contentDescription = null, + modifier = Modifier.padding(16.dp), + ) + } + } + if (isResolving) { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + LoadingIndicator( + modifier = Modifier + .size(28.dp) + .align(Alignment.Center), + ) + } + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = hit.title, + style = MaterialTheme.typography.titleMedium, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + ) + Text( + text = hit.artist, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + val meta = when { + hit.kind == SoundCloudSearchHit.Kind.PLAYLIST && hit.streamCount > 0 -> + stringResource(R.string.soundcloud_playlist_count, hit.streamCount) + hit.durationSec > 0 -> formatDuration(hit.durationSec) + else -> null + } + meta?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (hit.kind == SoundCloudSearchHit.Kind.TRACK && !isResolving) { + IconButton(onClick = onDownload, enabled = enabled) { + Icon( + Icons.Rounded.Download, + contentDescription = stringResource(R.string.soundcloud_download), + ) + } + } + if (isResolving) { + LoadingIndicator(modifier = Modifier.size(24.dp)) + } else { + Icon( + imageVector = if (hit.kind == SoundCloudSearchHit.Kind.PLAYLIST) { + Icons.Rounded.PlaylistPlay + } else { + Icons.Rounded.PlayArrow + }, + contentDescription = stringResource(R.string.soundcloud_play), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +private fun formatDuration(seconds: Long): String { + val m = seconds / 60 + val s = seconds % 60 + return "%d:%02d".format(m, s) +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/soundcloud/auth/SoundCloudLoginActivity.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/soundcloud/auth/SoundCloudLoginActivity.kt new file mode 100644 index 0000000000..efc889557c --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/soundcloud/auth/SoundCloudLoginActivity.kt @@ -0,0 +1,210 @@ +package com.theveloper.pixelplay.presentation.soundcloud.auth + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.webkit.CookieManager +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.soundcloud.SoundCloudPrefsViewModel +import com.theveloper.pixelplay.ui.theme.PixelPlayTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SoundCloudLoginActivity : ComponentActivity() { + + companion object { + const val TARGET_URL = "https://soundcloud.com/signin" + const val DESKTOP_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/124.0.0.0 Safari/537.36" + const val EXTRA_OAUTH_TOKEN = "oauth_token" + const val EXTRA_COOKIE_HEADER = "cookie_header" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + PixelPlayTheme { + SoundCloudWebLoginScreen( + onClose = { finish() }, + onSessionCaptured = { token, cookies -> + setResult( + RESULT_OK, + Intent().putExtra(EXTRA_OAUTH_TOKEN, token) + .putExtra(EXTRA_COOKIE_HEADER, cookies), + ) + finish() + }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SoundCloudWebLoginScreen( + onClose: () -> Unit, + onSessionCaptured: (oauthToken: String, cookieHeader: String) -> Unit, + prefsViewModel: SoundCloudPrefsViewModel = hiltViewModel(), +) { + var progress by remember { mutableIntStateOf(0) } + var webView by remember { mutableStateOf(null) } + + BackHandler { onClose() } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.soundcloud_sign_in)) }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null) + } + }, + actions = { + TextButton( + onClick = { + val result = extractSoundCloudSession() + if (result != null) { + prefsViewModel.saveSession(result.first, result.second) + onSessionCaptured(result.first, result.second) + } else { + Toast.makeText( + webView?.context, + R.string.soundcloud_sign_in_incomplete, + Toast.LENGTH_SHORT, + ).show() + } + }, + ) { + Text(stringResource(R.string.soundcloud_sign_in_done)) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + if (progress in 1..99) { + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + } + Box(modifier = Modifier.fillMaxSize()) { + SoundCloudWebView( + onProgress = { progress = it }, + onWebViewCreated = { webView = it }, + onCookiesMaybeReady = { + val result = extractSoundCloudSession() + if (result != null) { + prefsViewModel.saveSession(result.first, result.second) + onSessionCaptured(result.first, result.second) + } + }, + ) + } + } + } +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun SoundCloudWebView( + onProgress: (Int) -> Unit, + onWebViewCreated: (WebView) -> Unit, + onCookiesMaybeReady: () -> Unit, +) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.cacheMode = WebSettings.LOAD_DEFAULT + settings.userAgentString = SoundCloudLoginActivity.DESKTOP_UA + CookieManager.getInstance().setAcceptCookie(true) + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + onProgress(newProgress) + } + } + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + onCookiesMaybeReady() + } + } + loadUrl(SoundCloudLoginActivity.TARGET_URL) + onWebViewCreated(this) + } + }, + ) +} + +internal fun extractSoundCloudSession(): Pair? { + val cm = CookieManager.getInstance() + val main = cm.getCookie("https://soundcloud.com").orEmpty() + val api = cm.getCookie("https://api-v2.soundcloud.com").orEmpty() + val merged = listOf(main, api).filter { it.isNotBlank() }.joinToString("; ") + if (merged.isBlank()) return null + + val map = linkedMapOf() + merged.split(';') + .map { it.trim() } + .filter { it.contains('=') } + .forEach { part -> + val idx = part.indexOf('=') + val key = part.substring(0, idx).trim() + val value = part.substring(idx + 1).trim() + if (key.isNotEmpty()) map[key] = value + } + + val token = map["oauth_token"] + ?: map["oauth_token".uppercase()] + ?: return null + if (token.isBlank()) return null + + val cookieHeader = map.entries.joinToString("; ") { "${it.key}=${it.value}" } + return token to cookieHeader +} diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt new file mode 100644 index 0000000000..ffbb6b2357 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt @@ -0,0 +1,311 @@ +package com.theveloper.pixelplay.soundcloud + +import com.theveloper.pixelplay.data.model.Song +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs +import org.schabi.newpipe.extractor.kiosk.KioskInfo +import org.schabi.newpipe.extractor.playlist.PlaylistInfo +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem +import org.schabi.newpipe.extractor.services.soundcloud.SoundcloudParsingHelper +import org.schabi.newpipe.extractor.services.soundcloud.SoundcloudService +import org.schabi.newpipe.extractor.stream.AudioStream +import org.schabi.newpipe.extractor.stream.DeliveryMethod +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject +import javax.inject.Singleton + +/** + * SoundCloud access via NewPipe Extractor (web api-v2). + * + * Likes / Tracks / Playlists use the user's **public** profile URL + * (`soundcloud.com/{username}/…`) — not OAuth. Private likes won't show. + */ +@Singleton +class SoundCloudClient @Inject constructor( + private val sessionApi: SoundCloudSessionApi, +) { + private val downloader = SoundCloudDownloader() + private val runtimeClientId = AtomicReference(null) + + init { + ensureInitialized(downloader) + } + + fun setClientIdOverride(clientId: String?) { + val trimmed = clientId?.trim().orEmpty() + if (trimmed.isEmpty()) { + runtimeClientId.set(null) + return + } + runtimeClientId.set(trimmed) + injectClientId(trimmed) + sessionApi.updateSession( + token = downloader.oauthToken, + cookies = downloader.cookieHeader, + clientIdValue = trimmed, + ) + } + + fun setSession(oauthToken: String?, cookieHeader: String?) { + downloader.oauthToken = oauthToken?.trim()?.takeIf { it.isNotEmpty() } + downloader.cookieHeader = cookieHeader?.trim()?.takeIf { it.isNotEmpty() } + sessionApi.updateSession( + token = downloader.oauthToken, + cookies = downloader.cookieHeader, + clientIdValue = runtimeClientId.get(), + ) + } + + val hasSession: Boolean get() = sessionApi.hasSession + + @Throws(Exception::class) + fun loadFeed(limit: Int = 40): List = sessionApi.loadFeed(limit) + + @Throws(Exception::class) + fun loadMyLikes(limit: Int = 40): List = sessionApi.loadMyLikes(limit) + + @Throws(Exception::class) + fun loadMyTracks(limit: Int = 40): List = sessionApi.loadMyTracks(limit) + + @Throws(Exception::class) + fun loadMyPlaylists(limit: Int = 40): List = sessionApi.loadMyPlaylists(limit) + + @Throws(Exception::class) + fun loadDiscover(limit: Int = 30): List { + ensureClientIdReady() + val extractor = soundCloud().kioskList.defaultKioskExtractor + extractor.fetchPage() + val info = KioskInfo.getInfo(extractor) + return info.relatedItems + .asSequence() + .filterIsInstance() + .map { it.toHit() } + .filter { it.url.isNotBlank() } + .toList() + .let { filterOutDrmTracks(it, limit) } + } + + @Throws(Exception::class) + fun search(query: String, limit: Int = 30): List { + ensureClientIdReady() + val trimmed = query.trim() + require(trimmed.isNotEmpty()) { "Query is empty" } + val extractor = soundCloud().getSearchExtractor(trimmed) + extractor.fetchPage() + return extractor.initialPage.items + .asSequence() + .mapNotNull { it.toHitOrNull() } + .filter { it.url.isNotBlank() } + .toList() + .let { filterOutDrmTracks(it, limit) } + } + + @Throws(Exception::class) + fun loadUserLikes(username: String, limit: Int = 40): List = + loadChannelTab(username, ChannelTabs.LIKES, limit) + + @Throws(Exception::class) + fun loadUserTracks(username: String, limit: Int = 40): List = + loadChannelTab(username, ChannelTabs.TRACKS, limit) + + @Throws(Exception::class) + fun loadUserPlaylists(username: String, limit: Int = 40): List = + loadChannelTab(username, ChannelTabs.PLAYLISTS, limit) + + @Throws(Exception::class) + fun loadPlaylistTracks(playlistUrl: String, limit: Int = 100): List { + ensureClientIdReady() + val info = PlaylistInfo.getInfo(soundCloud(), playlistUrl.trim()) + return info.relatedItems + .asSequence() + .filterIsInstance() + .map { it.toHit() } + .filter { it.url.isNotBlank() } + .toList() + .let { filterOutDrmTracks(it, limit) } + } + + @Throws(Exception::class) + fun resolveTrack(url: String): SoundCloudResolvedTrack { + ensureClientIdReady() + val trimmedUrl = url.trim() + val info = try { + StreamInfo.getInfo(soundCloud(), trimmedUrl) + } catch (t: Throwable) { + throw IllegalStateException( + "No playable stream (often DRM encrypted-hls). Try another track. ${t.message}", + t, + ) + } + val stream = pickBestAudioStream(info.audioStreams) + ?: throw IllegalStateException( + "No progressive/plain stream available. DRM-protected tracks can't be played or downloaded.", + ) + return SoundCloudResolvedTrack( + url = info.url.orEmpty().ifBlank { trimmedUrl }, + title = info.name.orEmpty().ifBlank { "Unknown title" }, + artist = info.uploaderName.orEmpty().ifBlank { "Unknown artist" }, + durationMs = info.duration.coerceAtLeast(0L) * 1000L, + streamUrl = stream.content, + mimeType = stream.format?.mimeType, + artworkUrl = info.thumbnails.maxByOrNull { it.height }?.url, + ) + } + + fun toSong(track: SoundCloudResolvedTrack): Song { + val id = "sc_${track.url.hashCode().toUInt()}" + return Song( + id = id, + title = track.title, + artist = track.artist, + artistId = -1L, + artists = emptyList(), + album = "SoundCloud", + albumId = -1L, + albumArtist = track.artist, + path = track.streamUrl, + contentUriString = track.streamUrl, + albumArtUriString = track.artworkUrl, + duration = track.durationMs, + genre = "SoundCloud", + lyrics = null, + isFavorite = false, + trackNumber = 0, + discNumber = null, + year = 0, + dateAdded = System.currentTimeMillis(), + dateModified = System.currentTimeMillis(), + mimeType = track.mimeType, + bitrate = null, + sampleRate = null, + telegramFileId = null, + telegramChatId = null, + neteaseId = null, + gdriveFileId = null, + qqMusicMid = null, + navidromeId = null, + jellyfinId = null, + ) + } + + private fun loadChannelTab(username: String, tab: String, limit: Int): List { + ensureClientIdReady() + val user = username.trim().removePrefix("@").removePrefix("https://soundcloud.com/") + .substringBefore('/') + require(user.isNotEmpty()) { "Set your SoundCloud username in Settings → Experimental" } + + val url = "https://soundcloud.com/$user" + when (tab) { + ChannelTabs.LIKES -> "/likes" + ChannelTabs.TRACKS -> "/tracks" + ChannelTabs.PLAYLISTS -> "/sets" + ChannelTabs.ALBUMS -> "/albums" + else -> "" + } + val link = soundCloud().channelTabLHFactory.fromUrl(url) + val info = ChannelTabInfo.getInfo(soundCloud(), link) + return info.relatedItems + .asSequence() + .mapNotNull { it.toHitOrNull() } + .filter { it.url.isNotBlank() } + .toList() + .let { filterOutDrmTracks(it, limit) } + } + + /** + * Drop DRM-only tracks (encrypted-hls with no progressive/plain stream). + * Playlists are kept; unknown resolve failures keep the row (avoid over-hiding). + */ + private fun filterOutDrmTracks( + hits: List, + limit: Int, + ): List { + if (hits.isEmpty()) return hits + val out = ArrayList(minOf(hits.size, limit)) + for (hit in hits) { + if (out.size >= limit) break + if (hit.kind == SoundCloudSearchHit.Kind.PLAYLIST) { + out.add(hit) + continue + } + if (!sessionApi.isDrmOnlyUrl(hit.url)) { + out.add(hit) + } + } + return out + } + + private fun InfoItem.toHitOrNull(): SoundCloudSearchHit? = when (this) { + is StreamInfoItem -> toHit() + is PlaylistInfoItem -> SoundCloudSearchHit( + url = url.orEmpty(), + title = name.orEmpty().ifBlank { "Playlist" }, + artist = uploaderName.orEmpty().ifBlank { "SoundCloud" }, + durationSec = 0, + thumbnailUrl = thumbnails.maxByOrNull { it.height }?.url, + kind = SoundCloudSearchHit.Kind.PLAYLIST, + streamCount = streamCount.coerceAtLeast(0L), + ) + else -> null + } + + private fun StreamInfoItem.toHit() = SoundCloudSearchHit( + url = url.orEmpty(), + title = name.orEmpty().ifBlank { "Unknown title" }, + artist = uploaderName.orEmpty().ifBlank { "Unknown artist" }, + durationSec = duration.coerceAtLeast(0L), + thumbnailUrl = thumbnails.maxByOrNull { it.height }?.url, + kind = SoundCloudSearchHit.Kind.TRACK, + ) + + private fun ensureClientIdReady() { + val id = runtimeClientId.get()?.trim().orEmpty() + if (id.isEmpty()) { + throw IllegalStateException("Set SoundCloud client_id in Settings → Experimental") + } + injectClientId(id) + } + + private fun injectClientId(clientId: String) { + try { + val field = SoundcloudParsingHelper::class.java.getDeclaredField("clientId") + field.isAccessible = true + synchronized(SoundcloudParsingHelper::class.java) { + field.set(null, clientId) + } + } catch (t: Throwable) { + throw IllegalStateException("Could not inject SoundCloud client_id: ${t.message}", t) + } + } + + private fun pickBestAudioStream(streams: List): AudioStream? { + if (streams.isEmpty()) return null + val progressive = streams.filter { it.deliveryMethod == DeliveryMethod.PROGRESSIVE_HTTP } + val pool = progressive.ifEmpty { + streams.filter { it.deliveryMethod != DeliveryMethod.HLS || !it.content.contains("encrypted") } + .ifEmpty { streams } + } + return pool.maxWithOrNull( + compareBy { it.averageBitrate }.thenBy { it.bitrate }, + ) + } + + private fun soundCloud(): SoundcloudService = ServiceList.SoundCloud + + companion object { + private val initialized = AtomicBoolean(false) + + private fun ensureInitialized(downloader: SoundCloudDownloader) { + if (initialized.compareAndSet(false, true)) { + NewPipe.init(downloader) + Timber.i("SoundCloud: NewPipe Extractor initialized") + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt new file mode 100644 index 0000000000..88aaeefb58 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt @@ -0,0 +1,246 @@ +package com.theveloper.pixelplay.soundcloud + +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.utils.AlbumArtUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SoundCloudDownloadService @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val http = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .followRedirects(true) + .build() + + /** + * Saves a progressive/HTTP audio URL into public Music/PixelPlayer/SoundCloud + * so it can appear in the system library / PixelPlayer local scan. + */ + fun downloadToMusicFolder(track: SoundCloudResolvedTrack): File { + val safeTitle = track.title.replace(Regex("""[\\/:*?"<>|]"""), "_").take(80).ifBlank { "track" } + val safeArtist = track.artist.replace(Regex("""[\\/:*?"<>|]"""), "_").take(40).ifBlank { "SoundCloud" } + val ext = when { + track.mimeType?.contains("mpeg") == true -> "mp3" + track.mimeType?.contains("mp4") == true || track.mimeType?.contains("m4a") == true -> "m4a" + track.mimeType?.contains("ogg") == true || track.mimeType?.contains("opus") == true -> "ogg" + track.streamUrl.contains(".m3u8") -> throw IllegalStateException( + "This track is HLS-only — download needs a progressive stream. Try another track.", + ) + else -> "mp3" + } + val fileName = "$safeArtist - $safeTitle.$ext" + + val request = Request.Builder() + .url(track.streamUrl) + .header( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", + ) + .header("Referer", "https://soundcloud.com/") + .build() + + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IllegalStateException("Download HTTP ${response.code}") + } + val body = response.body ?: throw IllegalStateException("Empty download body") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val values = ContentValues().apply { + put(MediaStore.Audio.Media.DISPLAY_NAME, fileName) + put(MediaStore.Audio.Media.MIME_TYPE, track.mimeType ?: "audio/mpeg") + put(MediaStore.Audio.Media.RELATIVE_PATH, "Music/PixelPlayer/SoundCloud") + put(MediaStore.Audio.Media.IS_PENDING, 1) + put(MediaStore.Audio.Media.ARTIST, track.artist) + put(MediaStore.Audio.Media.TITLE, track.title) + put(MediaStore.Audio.Media.ALBUM, "SoundCloud") + } + val resolver = context.contentResolver + val collection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val uri = resolver.insert(collection, values) + ?: throw IllegalStateException("MediaStore insert failed") + resolver.openOutputStream(uri)?.use { out -> body.byteStream().copyTo(out) } + ?: throw IllegalStateException("Could not open MediaStore stream") + values.clear() + values.put(MediaStore.Audio.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + return File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), + "PixelPlayer/SoundCloud/$fileName", + ) + } + + val dir = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), + "PixelPlayer/SoundCloud", + ) + if (!dir.exists() && !dir.mkdirs()) { + throw IllegalStateException("Could not create ${dir.absolutePath}") + } + val outFile = File(dir, fileName) + outFile.outputStream().use { out -> body.byteStream().copyTo(out) } + return outFile + } + } + + /** Local SoundCloud downloads as playable [Song]s (MediaStore first, file fallback). */ + fun listDownloadedSongs(): List { + val fromStore = queryMediaStoreDownloads() + if (fromStore.isNotEmpty()) return fromStore + return listDownloadedFiles().map { fileToSong(it) } + } + + fun listDownloadedFiles(): List { + val dir = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), + "PixelPlayer/SoundCloud", + ) + if (!dir.isDirectory) return emptyList() + return dir.listFiles() + ?.filter { it.isFile && it.extension.lowercase() in AUDIO_EXTS } + ?.sortedByDescending { it.lastModified() } + .orEmpty() + } + + private fun queryMediaStoreDownloads(): List { + val songs = ArrayList() + val projection = arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.ALBUM_ID, + MediaStore.Audio.Media.DURATION, + MediaStore.Audio.Media.DATA, + MediaStore.Audio.Media.MIME_TYPE, + MediaStore.Audio.Media.DATE_ADDED, + MediaStore.Audio.Media.DATE_MODIFIED, + MediaStore.Audio.Media.DISPLAY_NAME, + ) + val (selection, args) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "${MediaStore.Audio.Media.RELATIVE_PATH} LIKE ?" to arrayOf("Music/PixelPlayer/SoundCloud%") + } else { + "${MediaStore.Audio.Media.DATA} LIKE ?" to arrayOf("%/Music/PixelPlayer/SoundCloud/%") + } + try { + context.contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + args, + "${MediaStore.Audio.Media.DATE_ADDED} DESC", + )?.use { cursor -> + val idCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) + val titleCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) + val artistCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) + val albumCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM) + val albumIdCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) + val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) + val dataCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA) + val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.MIME_TYPE) + val addedCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED) + val modifiedCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_MODIFIED) + val displayCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idCol) + val path = cursor.getString(dataCol).orEmpty() + val title = cursor.getString(titleCol) + ?.takeIf { it.isNotBlank() && it != "" } + ?: cursor.getString(displayCol)?.substringBeforeLast('.') + ?: "Unknown" + val artist = cursor.getString(artistCol) + ?.takeIf { it.isNotBlank() && it != "" } + ?: "SoundCloud" + val albumId = cursor.getLong(albumIdCol) + val albumArt = AlbumArtUtils.getAlbumArtUri( + appContext = context, + path = path, + songId = id, + forceRefresh = false, + ) + songs.add( + Song( + id = id.toString(), + title = title, + artist = artist, + artistId = -1L, + artists = emptyList(), + album = cursor.getString(albumCol)?.takeIf { it.isNotBlank() } ?: "SoundCloud", + albumId = albumId, + albumArtist = artist, + path = path, + contentUriString = ContentUris.withAppendedId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + id, + ).toString(), + albumArtUriString = albumArt, + duration = cursor.getLong(durationCol).coerceAtLeast(0L), + genre = "SoundCloud", + dateAdded = cursor.getLong(addedCol), + dateModified = cursor.getLong(modifiedCol), + mimeType = cursor.getString(mimeCol), + bitrate = null, + sampleRate = null, + ), + ) + } + } + } catch (_: SecurityException) { + // Fall back to filesystem listing. + } catch (_: Exception) { + // Fall back to filesystem listing. + } + return songs + } + + private fun fileToSong(file: File): Song { + val base = file.nameWithoutExtension + val parts = base.split(" - ", limit = 2) + val (artist, title) = if (parts.size == 2) parts[0] to parts[1] else "SoundCloud" to base + val id = "sc_dl_${file.absolutePath.hashCode().toUInt()}" + return Song( + id = id, + title = title, + artist = artist, + artistId = -1L, + artists = emptyList(), + album = "SoundCloud", + albumId = -1L, + albumArtist = artist, + path = file.absolutePath, + contentUriString = file.toURI().toString(), + albumArtUriString = null, + duration = 0L, + genre = "SoundCloud", + dateAdded = file.lastModified() / 1000L, + dateModified = file.lastModified() / 1000L, + mimeType = when (file.extension.lowercase()) { + "mp3" -> "audio/mpeg" + "m4a" -> "audio/mp4" + "ogg" -> "audio/ogg" + else -> "audio/*" + }, + bitrate = null, + sampleRate = null, + ) + } + + companion object { + private val AUDIO_EXTS = setOf("mp3", "m4a", "ogg", "aac", "wav", "flac") + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloader.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloader.kt new file mode 100644 index 0000000000..bde6252ad8 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloader.kt @@ -0,0 +1,78 @@ +package com.theveloper.pixelplay.soundcloud + +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import org.schabi.newpipe.extractor.downloader.Downloader +import org.schabi.newpipe.extractor.downloader.Request +import org.schabi.newpipe.extractor.downloader.Response +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Isolated OkHttp [Downloader] for NewPipe Extractor. + * + * Must not reuse the app OkHttpClient: AppModule forces a PixelPlayer User-Agent + * that breaks SoundCloud api-v2 / media URLs. + */ +internal class SoundCloudDownloader : Downloader() { + + @Volatile + var oauthToken: String? = null + + @Volatile + var cookieHeader: String? = null + + private val client: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .build() + + @Throws(IOException::class, ReCaptchaException::class) + override fun execute(request: Request): Response { + val bodyBytes = request.dataToSend() + val requestBody = bodyBytes?.toRequestBody(null) + + val builder = okhttp3.Request.Builder() + .method(request.httpMethod(), requestBody) + .url(request.url()) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Origin", "https://soundcloud.com") + .header("Referer", "https://soundcloud.com/") + + oauthToken?.trim()?.takeIf { it.isNotEmpty() }?.let { token -> + builder.header("Authorization", "OAuth $token") + } + cookieHeader?.trim()?.takeIf { it.isNotEmpty() }?.let { cookies -> + builder.header("Cookie", cookies) + } + + request.headers().forEach { (name, values) -> + builder.removeHeader(name) + values.forEach { value -> builder.addHeader(name, value) } + } + + client.newCall(builder.build()).execute().use { response -> + if (response.code == 429) { + throw ReCaptchaException("Rate limited / challenge requested", request.url()) + } + val responseBody = response.body.string() + return Response( + response.code, + response.message, + response.headers.toMultimap(), + responseBody, + response.request.url.toString(), + ) + } + } + + companion object { + private const val USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0" + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt new file mode 100644 index 0000000000..a7e6024706 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt @@ -0,0 +1,33 @@ +package com.theveloper.pixelplay.soundcloud + +data class SoundCloudSearchHit( + val url: String, + val title: String, + val artist: String, + val durationSec: Long, + val thumbnailUrl: String?, + val kind: Kind = Kind.TRACK, + val streamCount: Long = 0, +) { + enum class Kind { TRACK, PLAYLIST } +} + +data class SoundCloudResolvedTrack( + val url: String, + val title: String, + val artist: String, + val durationMs: Long, + val streamUrl: String, + val mimeType: String?, + val artworkUrl: String?, +) + +enum class SoundCloudSection { + FEED, + DISCOVER, + SEARCH, + LIKES, + TRACKS, + PLAYLISTS, + DOWNLOADS, +} diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt new file mode 100644 index 0000000000..0628c30c85 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt @@ -0,0 +1,67 @@ +package com.theveloper.pixelplay.soundcloud + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class SoundCloudPrefsViewModel @Inject constructor( + private val settings: SoundCloudSettings, + private val sessionApi: SoundCloudSessionApi, + private val client: SoundCloudClient, +) : ViewModel() { + val clientId: StateFlow = settings.clientId.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + "", + ) + val username: StateFlow = settings.username.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + "", + ) + val session: StateFlow = settings.session.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + SoundCloudSession(), + ) + + fun saveClientId(value: String) { + viewModelScope.launch { settings.setClientId(value) } + } + + fun saveUsername(value: String) { + viewModelScope.launch { settings.setUsername(value) } + } + + fun saveSession(oauthToken: String, cookieHeader: String) { + viewModelScope.launch { + client.setSession(oauthToken, cookieHeader) + sessionApi.updateSession(oauthToken, cookieHeader, clientId.value) + val profile = withContext(Dispatchers.IO) { + runCatching { sessionApi.fetchMe() }.getOrNull() + } + settings.setSession( + oauthToken = oauthToken, + cookieHeader = cookieHeader, + displayName = profile?.first.orEmpty(), + permalink = profile?.second.orEmpty(), + ) + } + } + + fun signOut() { + viewModelScope.launch { + settings.clearSession() + client.setSession(null, null) + sessionApi.updateSession(null, null, clientId.value) + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt new file mode 100644 index 0000000000..4bada610c7 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt @@ -0,0 +1,290 @@ +package com.theveloper.pixelplay.soundcloud + +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Authenticated SoundCloud api-v2 calls using a web-session OAuth token + cookies. + * + * The web client uses `/users/{id}/…` paths (not `/me/likes`, `/me/tracks`, … which 404). + */ +@Singleton +class SoundCloudSessionApi @Inject constructor() { + private val http = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .followRedirects(true) + .build() + + private val oauthToken = AtomicReference("") + private val cookieHeader = AtomicReference("") + private val clientId = AtomicReference("") + private val cachedUserId = AtomicReference(null) + private val cachedPermalink = AtomicReference("") + + fun updateSession(token: String?, cookies: String?, clientIdValue: String?) { + oauthToken.set(token?.trim().orEmpty()) + cookieHeader.set(cookies?.trim().orEmpty()) + clientId.set(clientIdValue?.trim().orEmpty()) + // Force re-resolve of /me when session credentials change. + cachedUserId.set(null) + cachedPermalink.set("") + } + + val hasSession: Boolean get() = oauthToken.get().isNotBlank() + + val permalink: String get() = cachedPermalink.get() + + @Throws(Exception::class) + fun fetchMe(): Triple { + val json = getJson("https://api-v2.soundcloud.com/me", requireAuth = true) + val userId = json.optLong("id", -1L) + require(userId > 0) { "SoundCloud /me did not return a user id" } + val displayName = json.optString("username") + .ifBlank { json.optString("full_name") } + .ifBlank { "SoundCloud" } + val permalink = json.optString("permalink").ifBlank { displayName } + cachedUserId.set(userId) + cachedPermalink.set(permalink) + return Triple(displayName, permalink, userId) + } + + @Throws(Exception::class) + fun loadFeed(limit: Int = 40): List { + val json = getJson( + "https://api-v2.soundcloud.com/stream?limit=${limit * 2}&linked_partitioning=1", + requireAuth = true, + ) + return parseCollection(json.optJSONArray("collection")).take(limit) + } + + @Throws(Exception::class) + fun loadMyLikes(limit: Int = 40): List { + val userId = requireUserId() + return getFirstCollection( + limit, + "https://api-v2.soundcloud.com/users/$userId/likes/tracks?limit=${limit * 2}&linked_partitioning=1", + "https://api-v2.soundcloud.com/users/$userId/track_likes?limit=${limit * 2}&linked_partitioning=1", + ) + } + + @Throws(Exception::class) + fun loadMyTracks(limit: Int = 40): List { + val userId = requireUserId() + val json = getJson( + "https://api-v2.soundcloud.com/users/$userId/tracks?limit=${limit * 2}&linked_partitioning=1", + requireAuth = true, + ) + return parseTrackArray(json.optJSONArray("collection")).take(limit) + } + + @Throws(Exception::class) + fun loadMyPlaylists(limit: Int = 40): List { + val userId = requireUserId() + return getFirstPlaylistCollection( + limit, + "https://api-v2.soundcloud.com/users/$userId/playlists_without_albums?limit=${limit * 2}&linked_partitioning=1", + "https://api-v2.soundcloud.com/users/$userId/playlists?limit=${limit * 2}&linked_partitioning=1", + ) + } + + private fun requireUserId(): Long { + cachedUserId.get()?.let { return it } + return fetchMe().third + } + + private fun getFirstCollection(limit: Int, vararg urls: String): List { + var lastError: Throwable? = null + for (url in urls) { + try { + val json = getJson(url, requireAuth = true) + val hits = parseCollection(json.optJSONArray("collection")) + if (hits.isNotEmpty() || json.has("collection")) return hits.take(limit) + } catch (t: Throwable) { + lastError = t + } + } + throw lastError ?: IllegalStateException("No SoundCloud likes endpoint succeeded") + } + + private fun getFirstPlaylistCollection(limit: Int, vararg urls: String): List { + var lastError: Throwable? = null + for (url in urls) { + try { + val json = getJson(url, requireAuth = true) + val hits = parsePlaylistCollection(json.optJSONArray("collection")) + if (hits.isNotEmpty() || json.has("collection")) return hits.take(limit) + } catch (t: Throwable) { + lastError = t + } + } + throw lastError ?: IllegalStateException("No SoundCloud playlists endpoint succeeded") + } + + private fun parseCollection(array: JSONArray?): List { + if (array == null) return emptyList() + val out = ArrayList(array.length()) + for (i in 0 until array.length()) { + val item = array.optJSONObject(i) ?: continue + val track = item.optJSONObject("track") + ?: item.takeIf { it.has("permalink_url") && it.has("title") } + if (track != null) { + trackToHit(track)?.let { out.add(it) } + continue + } + val playlist = item.optJSONObject("playlist") + if (playlist != null) { + playlistToHit(playlist)?.let { out.add(it) } + } + } + return out + } + + private fun parseTrackArray(array: JSONArray?): List { + if (array == null) return emptyList() + val out = ArrayList(array.length()) + for (i in 0 until array.length()) { + val item = array.optJSONObject(i) ?: continue + val track = item.optJSONObject("track") ?: item + trackToHit(track)?.let { out.add(it) } + } + return out + } + + private fun parsePlaylistCollection(array: JSONArray?): List { + if (array == null) return emptyList() + val out = ArrayList(array.length()) + for (i in 0 until array.length()) { + val item = array.optJSONObject(i) ?: continue + val playlist = item.optJSONObject("playlist") ?: item + playlistToHit(playlist)?.let { out.add(it) } + } + return out + } + + private fun trackToHit(track: JSONObject): SoundCloudSearchHit? { + if (isDrmOnlyTrack(track)) return null + val url = track.optString("permalink_url").ifBlank { return null } + val user = track.optJSONObject("user") + val durationMs = track.optLong("duration", 0L) + return SoundCloudSearchHit( + url = url, + title = track.optString("title").ifBlank { "Unknown title" }, + artist = user?.optString("username").orEmpty().ifBlank { "Unknown artist" }, + durationSec = (durationMs / 1000L).coerceAtLeast(0L), + thumbnailUrl = track.optString("artwork_url") + .ifBlank { user?.optString("avatar_url").orEmpty() } + .replace("-large", "-t500x500") + .takeIf { it.isNotBlank() }, + kind = SoundCloudSearchHit.Kind.TRACK, + ) + } + + private fun playlistToHit(playlist: JSONObject): SoundCloudSearchHit? { + val url = playlist.optString("permalink_url").ifBlank { return null } + val user = playlist.optJSONObject("user") + return SoundCloudSearchHit( + url = url, + title = playlist.optString("title").ifBlank { "Playlist" }, + artist = user?.optString("username").orEmpty().ifBlank { "SoundCloud" }, + durationSec = 0, + thumbnailUrl = playlist.optString("artwork_url") + .ifBlank { user?.optString("avatar_url").orEmpty() } + .replace("-large", "-t500x500") + .takeIf { it.isNotBlank() }, + kind = SoundCloudSearchHit.Kind.PLAYLIST, + streamCount = playlist.optLong("track_count", 0L).coerceAtLeast(0L), + ) + } + + /** + * Returns true when every known audio transcoding is DRM encrypted-hls + * (cbc-/ctr-encrypted-hls). Tracks with progressive or plain HLS stay visible. + */ + internal fun isDrmOnlyTrack(track: JSONObject): Boolean { + val transcodings = track.optJSONObject("media")?.optJSONArray("transcodings") + ?: return false + if (transcodings.length() == 0) return false + var sawAny = false + var sawUsable = false + for (i in 0 until transcodings.length()) { + val item = transcodings.optJSONObject(i) ?: continue + val protocol = item.optJSONObject("format")?.optString("protocol").orEmpty() + .ifBlank { item.optString("protocol") } + .lowercase() + if (protocol.isBlank()) continue + sawAny = true + if (!protocol.contains("encrypted")) { + sawUsable = true + break + } + } + return sawAny && !sawUsable + } + + /** Resolve a permalink and report whether it is DRM-only (for NewPipe list filtering). */ + fun isDrmOnlyUrl(trackUrl: String): Boolean { + return try { + val encoded = java.net.URLEncoder.encode(trackUrl.trim(), Charsets.UTF_8.name()) + val json = getJson( + "https://api-v2.soundcloud.com/resolve?url=$encoded", + requireAuth = false, + ) + val track = when { + json.has("media") || (json.has("permalink_url") && json.has("title")) -> json + else -> json.optJSONObject("track") + } ?: return false + isDrmOnlyTrack(track) + } catch (_: Throwable) { + false + } + } + + private fun getJson(url: String, requireAuth: Boolean): JSONObject = + JSONObject(getRaw(url, requireAuth)) + + private fun getRaw(url: String, requireAuth: Boolean): String { + val token = oauthToken.get() + if (requireAuth) { + require(token.isNotBlank()) { "Sign in to SoundCloud to load your personal library" } + } + val id = clientId.get() + val finalUrl = if (id.isNotBlank() && !url.contains("client_id=")) { + url + (if (url.contains("?")) "&" else "?") + "client_id=$id" + } else { + url + } + val builder = Request.Builder() + .url(finalUrl) + .get() + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Origin", "https://soundcloud.com") + .header("Referer", "https://soundcloud.com/") + if (token.isNotBlank()) { + builder.header("Authorization", "OAuth $token") + } + val cookies = cookieHeader.get() + if (cookies.isNotBlank()) { + builder.header("Cookie", cookies) + } + http.newCall(builder.build()).execute().use { response -> + val body = response.body.string() + if (!response.isSuccessful) { + throw IllegalStateException("SoundCloud API ${response.code}: ${body.take(180)}") + } + return body + } + } + + companion object { + private const val USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0" + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSettings.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSettings.kt new file mode 100644 index 0000000000..c78e41c327 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSettings.kt @@ -0,0 +1,102 @@ +package com.theveloper.pixelplay.soundcloud + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.theveloper.pixelplay.BuildConfig +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.soundCloudDataStore: DataStore by preferencesDataStore( + name = "soundcloud_settings", +) + +data class SoundCloudSession( + val oauthToken: String = "", + val cookieHeader: String = "", + val displayName: String = "", + val permalink: String = "", +) { + val isSignedIn: Boolean get() = oauthToken.isNotBlank() +} + +/** + * SoundCloud preferences: web client_id, optional public username, and web session (OAuth cookie). + */ +@Singleton +class SoundCloudSettings @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val dataStore = context.soundCloudDataStore + + val clientId: Flow = dataStore.data.map { prefs -> + prefs[Keys.CLIENT_ID] + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: BuildConfig.SOUNDCLOUD_CLIENT_ID.trim() + } + + val username: Flow = dataStore.data.map { prefs -> + prefs[Keys.USERNAME]?.trim().orEmpty() + } + + val session: Flow = dataStore.data.map { prefs -> + SoundCloudSession( + oauthToken = prefs[Keys.OAUTH_TOKEN]?.trim().orEmpty(), + cookieHeader = prefs[Keys.COOKIE_HEADER]?.trim().orEmpty(), + displayName = prefs[Keys.DISPLAY_NAME]?.trim().orEmpty(), + permalink = prefs[Keys.PERMALINK]?.trim().orEmpty(), + ) + } + + val isSignedIn: Flow = session.map { it.isSignedIn } + + suspend fun setClientId(value: String) { + dataStore.edit { it[Keys.CLIENT_ID] = value.trim() } + } + + suspend fun setUsername(value: String) { + dataStore.edit { it[Keys.USERNAME] = value.trim().removePrefix("@") } + } + + suspend fun setSession( + oauthToken: String, + cookieHeader: String, + displayName: String = "", + permalink: String = "", + ) { + dataStore.edit { prefs -> + prefs[Keys.OAUTH_TOKEN] = oauthToken.trim() + prefs[Keys.COOKIE_HEADER] = cookieHeader.trim() + prefs[Keys.DISPLAY_NAME] = displayName.trim() + prefs[Keys.PERMALINK] = permalink.trim().removePrefix("@") + if (permalink.isNotBlank()) { + prefs[Keys.USERNAME] = permalink.trim().removePrefix("@") + } + } + } + + suspend fun clearSession() { + dataStore.edit { prefs -> + prefs.remove(Keys.OAUTH_TOKEN) + prefs.remove(Keys.COOKIE_HEADER) + prefs.remove(Keys.DISPLAY_NAME) + prefs.remove(Keys.PERMALINK) + } + } + + private object Keys { + val CLIENT_ID = stringPreferencesKey("client_id") + val USERNAME = stringPreferencesKey("username") + val OAUTH_TOKEN = stringPreferencesKey("oauth_token") + val COOKIE_HEADER = stringPreferencesKey("cookie_header") + val DISPLAY_NAME = stringPreferencesKey("display_name") + val PERMALINK = stringPreferencesKey("permalink") + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt new file mode 100644 index 0000000000..b812a26bc2 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt @@ -0,0 +1,502 @@ +package com.theveloper.pixelplay.soundcloud + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import timber.log.Timber +import com.theveloper.pixelplay.data.model.Song +import javax.inject.Inject + +data class SoundCloudUiState( + val section: SoundCloudSection = SoundCloudSection.FEED, + val query: String = "", + val clientId: String = "", + val username: String = "", + val isSignedIn: Boolean = false, + val displayName: String = "", + val isLoading: Boolean = false, + val results: List = emptyList(), + val downloads: List = emptyList(), + val statusMessage: String? = null, + val errorMessage: String? = null, + val browsingPlaylistTitle: String? = null, + val browsingPlaylistUrl: String? = null, + /** null = follow device default (tiles on wide/foldable, list on phone). */ + val tilesOverride: Boolean? = null, + val resolvingKey: String? = null, + /** True while a playlist (or nested) browse can be popped with system Back. */ + val canNavigateBack: Boolean = false, + val isRefreshing: Boolean = false, +) + +private data class SoundCloudNavFrame( + val section: SoundCloudSection, + val results: List, + val browsingPlaylistTitle: String?, + val browsingPlaylistUrl: String?, + val query: String, +) + +@HiltViewModel +class SoundCloudViewModel @Inject constructor( + private val client: SoundCloudClient, + private val settings: SoundCloudSettings, + private val downloader: SoundCloudDownloadService, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SoundCloudUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var searchJob: Job? = null + private var loadJob: Job? = null + private val navStack = ArrayDeque() + + init { + viewModelScope.launch { + settings.clientId.collect { id -> + _uiState.update { it.copy(clientId = id) } + client.setClientIdOverride(id.ifBlank { null }) + } + } + viewModelScope.launch { + settings.username.collect { user -> + _uiState.update { it.copy(username = user) } + } + } + viewModelScope.launch { + settings.session.collect { session -> + client.setSession(session.oauthToken, session.cookieHeader) + _uiState.update { + it.copy( + isSignedIn = session.isSignedIn, + displayName = session.displayName.ifBlank { session.permalink }, + ) + } + } + } + viewModelScope.launch { + delay(350) + val state = _uiState.value + if (state.clientId.isNotBlank()) { + selectSection( + if (state.isSignedIn) SoundCloudSection.FEED else SoundCloudSection.DISCOVER, + ) + } + } + } + + fun onQueryChange(value: String) { + _uiState.update { it.copy(query = value, errorMessage = null) } + val trimmed = value.trim() + if (trimmed.length >= 2) { + if (_uiState.value.section != SoundCloudSection.SEARCH) { + searchJob?.cancel() + loadJob?.cancel() + _uiState.update { + it.copy( + section = SoundCloudSection.SEARCH, + browsingPlaylistTitle = null, + browsingPlaylistUrl = null, + statusMessage = null, + ) + } + } + scheduleSearch(value) + return + } + // Cleared / too short while searching — leave search mode empty (don't reload other tabs). + if (_uiState.value.section == SoundCloudSection.SEARCH) { + searchJob?.cancel() + _uiState.update { + it.copy( + results = emptyList(), + isLoading = false, + statusMessage = null, + ) + } + } + } + + fun setUseTiles(useTiles: Boolean) { + _uiState.update { it.copy(tilesOverride = useTiles) } + } + + fun selectSection(section: SoundCloudSection) { + searchJob?.cancel() + clearNavStack() + _uiState.update { + it.copy( + section = section, + browsingPlaylistTitle = null, + browsingPlaylistUrl = null, + errorMessage = null, + statusMessage = null, + canNavigateBack = false, + isRefreshing = false, + ) + } + when (section) { + SoundCloudSection.FEED -> loadFeed() + SoundCloudSection.DISCOVER -> loadDiscover() + SoundCloudSection.SEARCH -> { + val q = _uiState.value.query + if (q.trim().length >= 2) { + scheduleSearch(q) + } else { + _uiState.update { + it.copy(results = emptyList(), statusMessage = null) + } + } + } + SoundCloudSection.LIKES -> loadLikes() + SoundCloudSection.TRACKS -> loadTracks() + SoundCloudSection.PLAYLISTS -> loadPlaylists() + SoundCloudSection.DOWNLOADS -> refreshDownloads() + } + } + + fun openPlaylist(hit: SoundCloudSearchHit) { + if (hit.kind != SoundCloudSearchHit.Kind.PLAYLIST) return + pushNavFrame() + runLoad("Opening playlist…") { + _uiState.update { + it.copy( + browsingPlaylistTitle = hit.title, + browsingPlaylistUrl = hit.url, + canNavigateBack = navStack.isNotEmpty(), + ) + } + client.loadPlaylistTracks(hit.url) + } + } + + /** Pull-to-refresh: reload current section / open playlist without leaving the tab. */ + fun refresh() { + val state = _uiState.value + if (state.isRefreshing || state.isLoading) return + val playlistUrl = state.browsingPlaylistUrl + if (!playlistUrl.isNullOrBlank()) { + runLoad("Refreshing playlist…", clearResults = false, refreshing = true) { + client.loadPlaylistTracks(playlistUrl) + } + return + } + when (state.section) { + SoundCloudSection.FEED -> runLoad("Refreshing feed…", clearResults = false, refreshing = true) { + if (!client.hasSession) { + throw IllegalStateException("Sign in to SoundCloud to see your personal feed") + } + client.loadFeed() + } + SoundCloudSection.DISCOVER -> + runLoad("Refreshing discover…", clearResults = false, refreshing = true) { client.loadDiscover() } + SoundCloudSection.SEARCH -> { + val q = state.query.trim() + if (q.length >= 2) scheduleSearch(q, refreshing = true) + } + SoundCloudSection.LIKES -> loadLikes(refreshing = true) + SoundCloudSection.TRACKS -> loadTracks(refreshing = true) + SoundCloudSection.PLAYLISTS -> loadPlaylists(refreshing = true) + SoundCloudSection.DOWNLOADS -> { + _uiState.update { it.copy(isRefreshing = true) } + refreshDownloads() + _uiState.update { it.copy(isRefreshing = false) } + } + } + } + + /** Pops playlist browse (or nested) without leaving the SoundCloud tab. */ + fun navigateBack(): Boolean { + val frame = navStack.removeLastOrNull() ?: return false + loadJob?.cancel() + searchJob?.cancel() + _uiState.update { + it.copy( + section = frame.section, + results = frame.results, + browsingPlaylistTitle = frame.browsingPlaylistTitle, + browsingPlaylistUrl = frame.browsingPlaylistUrl, + query = frame.query, + isLoading = false, + isRefreshing = false, + errorMessage = null, + statusMessage = null, + canNavigateBack = navStack.isNotEmpty(), + ) + } + return true + } + + private fun pushNavFrame() { + val state = _uiState.value + navStack.addLast( + SoundCloudNavFrame( + section = state.section, + results = state.results, + browsingPlaylistTitle = state.browsingPlaylistTitle, + browsingPlaylistUrl = state.browsingPlaylistUrl, + query = state.query, + ), + ) + _uiState.update { it.copy(canNavigateBack = true) } + } + + private fun clearNavStack() { + navStack.clear() + } + + suspend fun resolveToSong(hit: SoundCloudSearchHit) = withContext(Dispatchers.IO) { + ensureClient() + client.toSong(client.resolveTrack(hit.url)) + } + + /** + * Resolves playable tracks from the current results list for shuffle/queue playback. + * Skips playlists and tracks that fail to resolve (e.g. DRM). + */ + suspend fun resolveTracksForPlayback(limit: Int = SHUFFLE_TRACK_LIMIT): List { + val tracks = _uiState.value.results + .asSequence() + .filter { it.kind == SoundCloudSearchHit.Kind.TRACK } + .take(limit) + .toList() + if (tracks.isEmpty()) { + throw IllegalStateException("No tracks to play — open a playlist or pick a feed with songs") + } + setBusy() + return withContext(Dispatchers.IO) { + ensureClient() + val gate = Semaphore(RESOLVE_PARALLELISM) + coroutineScope { + tracks.map { hit -> + async { + gate.withPermit { + try { + client.toSong(client.resolveTrack(hit.url)) + } catch (t: Throwable) { + Timber.w(t, "Skip unplayable SoundCloud track: %s", hit.url) + null + } + } + } + }.awaitAll().filterNotNull() + } + }.also { songs -> + if (songs.isEmpty()) { + setIdle(error = "No playable streams in this list") + } + } + } + + fun download(hit: SoundCloudSearchHit) { + if (hit.kind != SoundCloudSearchHit.Kind.TRACK) { + _uiState.update { it.copy(errorMessage = "Open the playlist, then download a track") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, statusMessage = null, errorMessage = null) } + try { + ensureClient() + val file = withContext(Dispatchers.IO) { + val track = client.resolveTrack(hit.url) + downloader.downloadToMusicFolder(track) + } + refreshDownloads() + _uiState.update { + it.copy( + isLoading = false, + statusMessage = null, + ) + } + } catch (t: Throwable) { + Timber.w(t, "SoundCloud download failed") + _uiState.update { + it.copy(isLoading = false, errorMessage = t.message ?: t::class.java.simpleName) + } + } + } + } + + fun setBusy(key: String? = null) { + _uiState.update { + it.copy(isLoading = true, statusMessage = null, errorMessage = null, resolvingKey = key) + } + } + + fun setIdle(error: String? = null) { + _uiState.update { + it.copy(isLoading = false, statusMessage = null, errorMessage = error, resolvingKey = null) + } + } + + private fun refreshDownloads() { + val songs = downloader.listDownloadedSongs() + _uiState.update { + it.copy( + downloads = songs, + results = emptyList(), + isLoading = false, + statusMessage = null, + ) + } + } + + private fun scheduleSearch(rawQuery: String, refreshing: Boolean = false) { + searchJob?.cancel() + val query = rawQuery.trim() + if (query.length < 2) { + _uiState.update { + it.copy( + results = emptyList(), + isLoading = false, + isRefreshing = false, + statusMessage = null, + errorMessage = null, + ) + } + return + } + searchJob = viewModelScope.launch { + if (!refreshing) delay(SEARCH_DEBOUNCE_MS) + runLoad("Searching…", cancelPrevious = false, clearResults = !refreshing, refreshing = refreshing) { + if (query.contains("soundcloud.com/", ignoreCase = true) && + !query.contains("/sets/", ignoreCase = true) && + !query.contains("/likes", ignoreCase = true) + ) { + val track = client.resolveTrack(query) + listOf( + SoundCloudSearchHit( + url = track.url, + title = track.title, + artist = track.artist, + durationSec = track.durationMs / 1000L, + thumbnailUrl = track.artworkUrl, + ), + ) + } else { + client.search(query) + } + } + } + } + + private fun loadFeed() = runLoad("Loading feed…") { + if (!client.hasSession) { + throw IllegalStateException("Sign in to SoundCloud to see your personal feed") + } + client.loadFeed() + } + + private fun loadDiscover() = runLoad("Loading discover…") { client.loadDiscover() } + + private fun loadLikes(refreshing: Boolean = false) = runLoad( + "Loading likes…", + clearResults = !refreshing, + refreshing = refreshing, + ) { + if (client.hasSession) { + client.loadMyLikes() + } else { + client.loadUserLikes(_uiState.value.username) + } + } + + private fun loadTracks(refreshing: Boolean = false) = runLoad( + "Loading tracks…", + clearResults = !refreshing, + refreshing = refreshing, + ) { + if (client.hasSession) { + client.loadMyTracks() + } else { + client.loadUserTracks(_uiState.value.username) + } + } + + private fun loadPlaylists(refreshing: Boolean = false) = runLoad( + "Loading playlists…", + clearResults = !refreshing, + refreshing = refreshing, + ) { + if (client.hasSession) { + client.loadMyPlaylists() + } else { + client.loadUserPlaylists(_uiState.value.username) + } + } + + private fun runLoad( + @Suppress("UNUSED_PARAMETER") status: String, + cancelPrevious: Boolean = true, + clearResults: Boolean = true, + refreshing: Boolean = false, + block: suspend () -> List, + ) { + if (cancelPrevious) { + loadJob?.cancel() + } + val job = viewModelScope.launch { + _uiState.update { + it.copy( + isLoading = !refreshing, + isRefreshing = refreshing, + errorMessage = null, + statusMessage = null, + results = if (clearResults) emptyList() else it.results, + ) + } + try { + ensureClient() + val hits = withContext(Dispatchers.IO) { block() } + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + results = hits, + statusMessage = null, + ) + } + } catch (t: Throwable) { + if (t is kotlinx.coroutines.CancellationException) throw t + Timber.w(t, "SoundCloud load failed") + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + errorMessage = t.message ?: t::class.java.simpleName, + statusMessage = null, + ) + } + } + } + if (cancelPrevious) { + loadJob = job + } + } + + private fun ensureClient() { + val id = _uiState.value.clientId + if (id.isBlank()) throw IllegalStateException("Set client_id in Settings → Experimental") + client.setClientIdOverride(id) + } + + companion object { + private const val SEARCH_DEBOUNCE_MS = 350L + private const val SHUFFLE_TRACK_LIMIT = 30 + private const val RESOLVE_PARALLELISM = 4 + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_soundcloud_24.png b/app/src/main/res/drawable-hdpi/ic_soundcloud_24.png new file mode 100644 index 0000000000000000000000000000000000000000..64d928892b8733c0e2f74979fb2da42379a0a4e8 GIT binary patch literal 2539 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_soundcloud_filled_24.png b/app/src/main/res/drawable-xxhdpi/ic_soundcloud_filled_24.png new file mode 100644 index 0000000000000000000000000000000000000000..0fd7bc38f0d751ffef714ba5be4916160d953041 GIT binary patch literal 6507 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_soundcloud_24.png b/app/src/main/res/drawable-xxxhdpi/ic_soundcloud_24.png new file mode 100644 index 0000000000000000000000000000000000000000..1d6e5194c5d1616684eb0e9da884b6211944845a GIT binary patch literal 10222 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 Home Search + SoundCloud Library + + SoundCloud + SoundCloud settings + Sign in for your personal feed and likes, or set a public username. Some tracks are DRM-only and won\'t play or download. + Signed in as @%1$s. Feed and likes use your SoundCloud session. + Search or paste URL + lofi hip hop — or https://soundcloud.com/… + Web client_id + From SoundCloud network requests + SoundCloud username + public profile handle (no @) + Sign in with your SoundCloud account for personalized feed and likes. client_id is still required for streaming. Optional public username is a fallback when signed out. + Save SoundCloud settings + Sign in to SoundCloud + Sign out + I\'m signed in + Couldn\'t find a SoundCloud session yet. Finish signing in, then tap again. + Signed in as @%1$s + Search / resolve + Play + Download + Shuffle + Shuffle play current list + Tiles + List + Tile view + List view + SoundCloud + Profile: @%1$s + Playlist: %1$s + %1$d tracks + Feed + Discover + Search + Likes + Tracks + Playlists + Downloads + Special Permission Required To edit song metadata (.mp3 files), PixelPlayer needs special access to all files. This allows us to modify track tags directly. Please grant this permission on the next screen to enable metadata editing. diff --git a/commitlint.config.cjs b/commitlint.config.cjs new file mode 100644 index 0000000000..41a452e438 --- /dev/null +++ b/commitlint.config.cjs @@ -0,0 +1,24 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [ + 2, + 'always', + [ + 'feat', + 'fix', + 'docs', + 'style', + 'refactor', + 'perf', + 'test', + 'build', + 'ci', + 'chore', + 'revert', + ], + ], + 'subject-case': [2, 'never', ['pascal-case', 'upper-case']], + 'header-max-length': [2, 'always', 100], + }, +}; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6196203923..bb6a183413 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -59,6 +59,7 @@ taglib = "1.0.6" jaudiotagger = "3.0.1" vorbisjava = "0.8" datastore = "1.2.1" +newpipeExtractor = "v0.26.5" credentials = "1.6.0" googleid = "1.2.0" androidxTestCore = "1.7.0" @@ -218,6 +219,7 @@ generativeai = { group = "com.google.ai.client.generativeai", name = "generative retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +newpipe-extractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeExtractor" } logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "mediarouterVersion" } play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCastFramework" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 15d093bfae..5b882dae13 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,6 +25,9 @@ dependencyResolutionManagement { includeGroup("com.github.philburk") includeGroup("com.github.racra") includeGroup("com.github.tdlibx") + includeGroup("com.github.teamnewpipe") + includeGroup("com.github.TeamNewPipe") + includeGroupByRegex("com\\.github\\.[Tt]eam[Nn]ew[Pp]ipe(\\..*)?") } } } From 65611a833e024f717398a42fd14c5a2ed2cafb6c Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 16:59:19 +0300 Subject: [PATCH 02/77] fix(soundcloud): resolve KSP crash from ViewModel default params Move nav frame into the ViewModel and inline shuffle defaults so Hilt/KSP no longer hits Unexpected owner function: null. Co-authored-by: Cursor --- .../soundcloud/SoundCloudViewModel.kt | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt index b812a26bc2..fe72abd1af 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt @@ -43,14 +43,6 @@ data class SoundCloudUiState( val isRefreshing: Boolean = false, ) -private data class SoundCloudNavFrame( - val section: SoundCloudSection, - val results: List, - val browsingPlaylistTitle: String?, - val browsingPlaylistUrl: String?, - val query: String, -) - @HiltViewModel class SoundCloudViewModel @Inject constructor( private val client: SoundCloudClient, @@ -58,12 +50,20 @@ class SoundCloudViewModel @Inject constructor( private val downloader: SoundCloudDownloadService, ) : ViewModel() { + private data class NavFrame( + val section: SoundCloudSection, + val results: List, + val browsingPlaylistTitle: String?, + val browsingPlaylistUrl: String?, + val query: String, + ) + private val _uiState = MutableStateFlow(SoundCloudUiState()) val uiState: StateFlow = _uiState.asStateFlow() private var searchJob: Job? = null private var loadJob: Job? = null - private val navStack = ArrayDeque() + private val navStack = ArrayDeque() init { viewModelScope.launch { @@ -244,7 +244,7 @@ class SoundCloudViewModel @Inject constructor( private fun pushNavFrame() { val state = _uiState.value navStack.addLast( - SoundCloudNavFrame( + NavFrame( section = state.section, results = state.results, browsingPlaylistTitle = state.browsingPlaylistTitle, @@ -268,7 +268,7 @@ class SoundCloudViewModel @Inject constructor( * Resolves playable tracks from the current results list for shuffle/queue playback. * Skips playlists and tracks that fail to resolve (e.g. DRM). */ - suspend fun resolveTracksForPlayback(limit: Int = SHUFFLE_TRACK_LIMIT): List { + suspend fun resolveTracksForPlayback(limit: Int = 30): List { val tracks = _uiState.value.results .asSequence() .filter { it.kind == SoundCloudSearchHit.Kind.TRACK } @@ -280,7 +280,7 @@ class SoundCloudViewModel @Inject constructor( setBusy() return withContext(Dispatchers.IO) { ensureClient() - val gate = Semaphore(RESOLVE_PARALLELISM) + val gate = Semaphore(4) coroutineScope { tracks.map { hit -> async { @@ -496,7 +496,5 @@ class SoundCloudViewModel @Inject constructor( companion object { private const val SEARCH_DEBOUNCE_MS = 350L - private const val SHUFFLE_TRACK_LIMIT = 30 - private const val RESOLVE_PARALLELISM = 4 } } From 66d8d61a2db8502efa10c727458b51e43c60bbf2 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 17:18:35 +0300 Subject: [PATCH 03/77] ci: drop Wear OS workflows and keep phone-only builds Remove wearos-apk CI, build only :app in CodeQL, and fix SoundCloud experimental OptIn so release compiles. Co-authored-by: Cursor --- .github/workflows/codeql.yml | 2 +- .github/workflows/wearos-apk.yml | 39 ------------------- .../presentation/screens/SoundCloudScreen.kt | 6 ++- 3 files changed, 6 insertions(+), 41 deletions(-) delete mode 100644 .github/workflows/wearos-apk.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b2793cfd88..e7e11f08a5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -49,7 +49,7 @@ jobs: build-mode: manual - name: Build with Gradle - run: ${{ github.workspace }}/gradlew :app:assembleDebug :wear:assembleDebug --no-daemon + run: ${{ github.workspace }}/gradlew :app:assembleDebug --no-daemon - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/wearos-apk.yml b/.github/workflows/wearos-apk.yml deleted file mode 100644 index 6e7b53b6da..0000000000 --- a/.github/workflows/wearos-apk.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Build Wear OS APK (Debug) - -on: - push: - branches: [ master ] - pull_request: - types: [opened, synchronize, reopened] - branches: [ master ] - workflow_dispatch: - -permissions: - contents: read - -jobs: - build-wear: - if: github.actor != 'dependabot[bot]' - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '21' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Build Wear OS debug APK - run: gradle :wear:assembleDebug - - - name: Upload Wear OS APK artifact - uses: actions/upload-artifact@v7.0.1 - with: - name: PixelPlay-wear-only-debug - path: wear/build/outputs/apk/debug/*.apk diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index 6fee86810c..4e61b8b965 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -1,3 +1,8 @@ +@file:OptIn( + androidx.compose.material3.ExperimentalMaterial3Api::class, + androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class, +) + package com.theveloper.pixelplay.presentation.screens import androidx.activity.compose.BackHandler @@ -89,7 +94,6 @@ import kotlinx.coroutines.launch /** Material medium width — foldables / tablets / landscape. */ private const val WIDE_SCREEN_DP = 600 -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun SoundCloudScreen( playerViewModel: PlayerViewModel, From efd1ba4d7de4ff24b1be0dc3050c84b96c524e63 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 17:34:38 +0300 Subject: [PATCH 04/77] docs(changelog): add 0.8.0-soundcloud release notes Co-authored-by: Cursor --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe99c2b68..648621ab08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0-soundcloud] - 2026-09-23 + +### Added +- **SoundCloud:** New main-tab SoundCloud experience with session sign-in, feed, discover, likes, tracks, playlists, and downloads. +- **SoundCloud:** Always-on search, tiles/list view (wide defaults to tiles), shuffle play, pull-to-refresh, and playlist back navigation. +- **SoundCloud:** Downloads shown as playable Library-style songs (MediaStore) with tile/list layouts. +- **CI:** Conventional-commit lint (`commitlint`) for pushes and PR titles. +- **CI:** `Release APK` workflow that builds phone release APKs and publishes a GitHub Release with feature notes from conventional commits. + +### Changed +- **CI:** Phone-only focus — removed Wear OS APK workflow; CodeQL builds `:app` only. + +### Fixed +- **SoundCloud:** Hilt/KSP crash from ViewModel default parameters (`Unexpected owner function: null`). +- **SoundCloud:** Release compile OptIn for experimental Material3 loading indicators. + ## [0.7.5-beta] - 2026-06-13 ### Added From cecae65b424232bcc980252405b089dfc182fd03 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 18:12:40 +0300 Subject: [PATCH 05/77] feat(soundcloud): refine feed and playback experience Co-authored-by: Cursor --- CHANGELOG.md | 17 + .../components/subcomps/LibraryActionRow.kt | 6 + .../presentation/screens/SoundCloudScreen.kt | 526 +++++++++++++----- .../pixelplay/soundcloud/SoundCloudClient.kt | 8 + .../soundcloud/SoundCloudDownloadService.kt | 24 +- .../pixelplay/soundcloud/SoundCloudModels.kt | 6 + .../soundcloud/SoundCloudSessionApi.kt | 53 ++ .../soundcloud/SoundCloudViewModel.kt | 288 +++++++++- app/src/main/res/values/strings.xml | 2 + 9 files changed, 765 insertions(+), 165 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 648621ab08..1a0a7c961c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.1-soundcloud] - 2026-09-23 + +### Added +- **SoundCloud:** Personalized feed shelves for likes, recent plays, mixes, curated tracks, playlists, and followed creators. +- **SoundCloud:** Automatic queue playback so the next playable track starts when the current track ends. +- **SoundCloud:** End-of-list loading that requests more results while scrolling. +- **SoundCloud:** Like and unlike controls on track tiles and list rows. + +### Changed +- **SoundCloud:** Reused the Library accent header, pill navigation, action row, and tiles/list controls across every SoundCloud page. +- **SoundCloud:** Compact screens use feed category pills while foldables and wide screens show the full multi-section feed. + +### Fixed +- **SoundCloud:** Playlist Back now restores the previous SoundCloud tab and feed state. +- **SoundCloud:** Downloaded tracks retain artwork, with metadata backfill for existing downloads. +- **SoundCloud:** Tile labels are no longer clipped by the card’s rounded bottom corners. + ## [0.8.0-soundcloud] - 2026-09-23 ### Added diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt index fb04d8a3d8..fc6ddce463 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -85,6 +86,7 @@ val genHeight = 42.dp @Composable fun LibraryActionRow( onMainActionClick: () -> Unit, + mainActionEnabled: Boolean = true, iconRotation: Float, onSortClick: () -> Unit, onLocateClick: () -> Unit = {}, @@ -102,6 +104,7 @@ fun LibraryActionRow( onFolderClick: (String) -> Unit, onNavigateBack: () -> Unit, isShuffleEnabled: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, // Storage Filter showStorageFilterButton: Boolean = false, currentStorageFilter: com.theveloper.pixelplay.data.model.StorageFilter = com.theveloper.pixelplay.data.model.StorageFilter.ALL, @@ -158,6 +161,7 @@ fun LibraryActionRow( FilledTonalButton( onClick = onMainActionClick, + enabled = mainActionEnabled, shape = RoundedCornerShape( topStart = 26.dp, bottomStart = 26.dp, topEnd = newButtonEndCorner, bottomEnd = newButtonEndCorner @@ -271,6 +275,8 @@ fun LibraryActionRow( Spacer(modifier = Modifier.width(8.dp)) + trailingContent() + if (showSortButton) { val outerCorner = 26.dp diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index 4e61b8b965..aeff002c8d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -6,6 +6,7 @@ package com.theveloper.pixelplay.presentation.screens import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -21,9 +22,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -32,16 +36,17 @@ import androidx.compose.material.icons.automirrored.rounded.ViewList import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Cloud import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.PlaylistPlay import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material.icons.rounded.ViewModule -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilterChip import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -49,6 +54,7 @@ import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -57,9 +63,13 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -82,14 +92,19 @@ import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight import com.theveloper.pixelplay.presentation.components.SmartImage import com.theveloper.pixelplay.presentation.components.ToggleSegmentButton import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem +import com.theveloper.pixelplay.presentation.components.subcomps.LibraryActionRow import com.theveloper.pixelplay.presentation.navigation.Screen import com.theveloper.pixelplay.presentation.navigation.navigateSafely import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.soundcloud.SoundCloudSearchHit +import com.theveloper.pixelplay.soundcloud.SoundCloudFeedShelf import com.theveloper.pixelplay.soundcloud.SoundCloudSection import com.theveloper.pixelplay.soundcloud.SoundCloudViewModel import com.theveloper.pixelplay.ui.theme.GoogleSansRounded import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape /** Material medium width — foldables / tablets / landscape. */ private const val WIDE_SCREEN_DP = 600 @@ -132,18 +147,18 @@ fun SoundCloudScreen( val currentSongId = stablePlayer.currentSong?.id val isPlaying = stablePlayer.isPlaying - val onHitPlay: (SoundCloudSearchHit) -> Unit = remember(viewModel, playerViewModel, scope) { - { hit -> + val onHitPlay: (SoundCloudSearchHit, List) -> Unit = + remember(viewModel, playerViewModel, scope) { + { hit, queueHits -> if (hit.kind == SoundCloudSearchHit.Kind.PLAYLIST) { viewModel.openPlaylist(hit) } else { scope.launch { - viewModel.setBusy(hit.url) try { - val song = viewModel.resolveToSong(hit) + val (songs, startSong) = viewModel.resolveQueueForPlayback(hit, queueHits) playerViewModel.playSongs( - songsToPlay = listOf(song), - startSong = song, + songsToPlay = songs, + startSong = startSong, queueName = "SoundCloud", ) viewModel.setIdle() @@ -196,148 +211,171 @@ fun SoundCloudScreen( } // Keep Back inside SoundCloud when browsing a playlist (don't jump to Library). - BackHandler(enabled = uiState.canNavigateBack) { + BackHandler( + enabled = uiState.canNavigateBack || uiState.browsingPlaylistUrl != null, + ) { viewModel.navigateBack() } - Column( + val headerContainerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) + Scaffold( modifier = Modifier .fillMaxSize() - .padding(paddingValues), - ) { - TopAppBar( - title = { - Text( - modifier = Modifier.padding(start = 8.dp), - text = stringResource(R.string.soundcloud_tab_title), - fontFamily = GoogleSansRounded, - fontWeight = FontWeight.ExtraBold, - color = MaterialTheme.colorScheme.primary, - fontSize = 40.sp, - letterSpacing = 1.sp, - ) - }, - actions = { - FilledIconButton( - modifier = Modifier.padding(end = 14.dp), - onClick = { navController.navigateSafely(Screen.Experimental.route) }, - colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + .padding(paddingValues) + .background(headerContainerColor), + topBar = { + Column(modifier = Modifier.background(headerContainerColor)) { + TopAppBar( + title = { + Text( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(R.string.soundcloud_tab_title), + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.primary, + fontSize = 40.sp, + letterSpacing = 1.sp, + ) + }, + actions = { + FilledIconButton( + modifier = Modifier.padding(end = 14.dp), + onClick = { navController.navigateSafely(Screen.Experimental.route) }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Icon( + painter = painterResource(R.drawable.rounded_settings_24), + contentDescription = stringResource(R.string.soundcloud_cd_open_settings), + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, ), - ) { - Icon( - painter = painterResource(R.drawable.rounded_settings_24), - contentDescription = stringResource(R.string.soundcloud_cd_open_settings), - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - scrolledContainerColor = Color.Transparent, - ), - ) + ) - PrimaryScrollableTabRow( - selectedTabIndex = selectedIndex.coerceAtLeast(0), - containerColor = Color.Transparent, - edgePadding = 12.dp, - indicator = {}, - divider = {}, - ) { - sections.forEachIndexed { index, section -> - TabAnimation( - index = index, - title = section.name, - selectedIndex = if (selectedIndex < 0) -1 else selectedIndex, - onClick = { viewModel.selectSection(section) }, + PrimaryScrollableTabRow( + selectedTabIndex = selectedIndex.coerceAtLeast(0), + containerColor = Color.Transparent, + edgePadding = 12.dp, + indicator = {}, + divider = {}, ) { - Text( - text = stringResource(section.labelRes).uppercase(), - style = MaterialTheme.typography.labelLarge, - fontFamily = GoogleSansRounded, - fontWeight = if (selectedIndex == index) FontWeight.Bold else FontWeight.Medium, - ) - } - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - OutlinedTextField( - value = uiState.query, - onValueChange = viewModel::onQueryChange, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - singleLine = true, - label = { Text(stringResource(R.string.soundcloud_query_label)) }, - placeholder = { Text(stringResource(R.string.soundcloud_query_hint)) }, - leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, - trailingIcon = { - if (uiState.query.isNotEmpty()) { - IconButton(onClick = { viewModel.onQueryChange("") }) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource(R.string.common_clear_search), - ) + sections.forEachIndexed { index, section -> + TabAnimation( + index = index, + title = section.name, + selectedIndex = if (selectedIndex < 0) -1 else selectedIndex, + onClick = { viewModel.selectSection(section) }, + ) { + Text( + text = stringResource(section.labelRes).uppercase(), + style = MaterialTheme.typography.labelLarge, + fontFamily = GoogleSansRounded, + fontWeight = if (selectedIndex == index) FontWeight.Bold else FontWeight.Medium, + ) + } } } - }, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Row( + } + }, + ) { scaffoldPadding -> + Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .height(40.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, + .fillMaxSize() + .padding(top = scaffoldPadding.calculateTopPadding()) + .background(headerContainerColor), ) { - ToggleSegmentButton( - modifier = Modifier.weight(1f), - active = useTiles, - activeColor = MaterialTheme.colorScheme.primary, - inactiveColor = MaterialTheme.colorScheme.surfaceVariant, - activeContentColor = MaterialTheme.colorScheme.onPrimary, - inactiveContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - activeCornerRadius = 32.dp, - onClick = { viewModel.setUseTiles(true) }, - text = stringResource(R.string.soundcloud_view_tiles), - imageVector = Icons.Rounded.ViewModule, - ) - ToggleSegmentButton( - modifier = Modifier.weight(1f), - active = !useTiles, - activeColor = MaterialTheme.colorScheme.primary, - inactiveColor = MaterialTheme.colorScheme.surfaceVariant, - activeContentColor = MaterialTheme.colorScheme.onPrimary, - inactiveContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - activeCornerRadius = 32.dp, - onClick = { viewModel.setUseTiles(false) }, - text = stringResource(R.string.soundcloud_view_list), - imageVector = Icons.AutoMirrored.Rounded.ViewList, - ) - FilledTonalButton( - onClick = onShuffle, - enabled = canShuffle, - modifier = Modifier.height(40.dp), - shape = RoundedCornerShape(32.dp), - contentPadding = PaddingValues(horizontal = 12.dp), + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surface, + shape = AbsoluteSmoothCornerShape( + cornerRadiusTL = 34.dp, + cornerRadiusTR = 34.dp, + cornerRadiusBL = 0.dp, + cornerRadiusBR = 0.dp, + smoothnessAsPercentTL = 60, + smoothnessAsPercentTR = 60, + smoothnessAsPercentBL = 60, + smoothnessAsPercentBR = 60, + ), ) { - Icon( - imageVector = Icons.Rounded.Shuffle, - contentDescription = stringResource(R.string.soundcloud_cd_shuffle), - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.soundcloud_shuffle)) - } - } + Column(modifier = Modifier.fillMaxSize()) { - Spacer(modifier = Modifier.height(8.dp)) + LibraryActionRow( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 10.dp, end = 12.dp), + onMainActionClick = onShuffle, + mainActionEnabled = canShuffle, + iconRotation = 0f, + onSortClick = {}, + showSortButton = false, + isPlaylistTab = false, + isFoldersTab = false, + currentFolder = null, + folderRootPath = "", + folderRootLabel = "", + onFolderClick = {}, + onNavigateBack = {}, + trailingContent = { + ToggleSegmentButton( + modifier = Modifier.size(42.dp), + active = useTiles, + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = MaterialTheme.colorScheme.surfaceVariant, + activeContentColor = MaterialTheme.colorScheme.onPrimary, + inactiveContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + activeCornerRadius = 26.dp, + onClick = { viewModel.setUseTiles(true) }, + imageVector = Icons.Rounded.ViewModule, + contentDesc = stringResource(R.string.soundcloud_cd_view_tiles), + ) + Spacer(modifier = Modifier.width(4.dp)) + ToggleSegmentButton( + modifier = Modifier.size(42.dp), + active = !useTiles, + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = MaterialTheme.colorScheme.surfaceVariant, + activeContentColor = MaterialTheme.colorScheme.onPrimary, + inactiveContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + activeCornerRadius = 26.dp, + onClick = { viewModel.setUseTiles(false) }, + imageVector = Icons.AutoMirrored.Rounded.ViewList, + contentDesc = stringResource(R.string.soundcloud_cd_view_list), + ) + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.query, + onValueChange = viewModel::onQueryChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + singleLine = true, + label = { Text(stringResource(R.string.soundcloud_query_label)) }, + placeholder = { Text(stringResource(R.string.soundcloud_query_hint)) }, + leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, + trailingIcon = { + if (uiState.query.isNotEmpty()) { + IconButton(onClick = { viewModel.onQueryChange("") }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.common_clear_search), + ) + } + } + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) uiState.browsingPlaylistTitle?.let { title -> Row( @@ -396,9 +434,40 @@ fun SoundCloudScreen( ) }, ) { - if (useTiles) { + if ( + uiState.section == SoundCloudSection.FEED && + uiState.browsingPlaylistUrl == null && + uiState.feedShelves.isNotEmpty() + ) { + SoundCloudFeedHome( + shelves = uiState.feedShelves, + useTiles = useTiles, + enabled = !uiState.isLoading, + resolvingKey = uiState.resolvingKey, + contentPadding = contentPadding, + isWideScreen = isWideScreen, + likedTrackUrls = uiState.likedTrackUrls, + likingUrl = uiState.likingUrl, + onPlay = onHitPlay, + onDownload = viewModel::download, + onToggleLike = viewModel::toggleLike, + onLoadMore = viewModel::loadMore, + ) + } else if (useTiles) { + val gridState = rememberLazyGridState() + LaunchedEffect(gridState, uiState.results.size, uiState.downloads.size) { + snapshotFlow { + val info = gridState.layoutInfo + val total = info.totalItemsCount + total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - gridColumns + } + .distinctUntilChanged() + .filter { it } + .collect { viewModel.loadMore() } + } LazyVerticalGrid( columns = GridCells.Fixed(gridColumns), + state = gridState, modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -419,14 +488,29 @@ fun SoundCloudScreen( hit = hit, enabled = !uiState.isLoading, isResolving = uiState.resolvingKey == hit.url, - onPlay = { onHitPlay(hit) }, + isLiked = hit.url in uiState.likedTrackUrls, + isLiking = uiState.likingUrl == hit.url, + onPlay = { onHitPlay(hit, uiState.results) }, onDownload = { viewModel.download(hit) }, + onToggleLike = { viewModel.toggleLike(hit) }, ) } } } } else { + val listState = rememberLazyListState() + LaunchedEffect(listState, uiState.results.size, uiState.downloads.size) { + snapshotFlow { + val info = listState.layoutInfo + val total = info.totalItemsCount + total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - 3 + } + .distinctUntilChanged() + .filter { it } + .collect { viewModel.loadMore() } + } LazyColumn( + state = listState, modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(10.dp), @@ -448,8 +532,129 @@ fun SoundCloudScreen( hit = hit, enabled = !uiState.isLoading, isResolving = uiState.resolvingKey == hit.url, - onPlay = { onHitPlay(hit) }, + isLiked = hit.url in uiState.likedTrackUrls, + isLiking = uiState.likingUrl == hit.url, + onPlay = { onHitPlay(hit, uiState.results) }, onDownload = { viewModel.download(hit) }, + onToggleLike = { viewModel.toggleLike(hit) }, + ) + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun SoundCloudFeedHome( + shelves: List, + useTiles: Boolean, + enabled: Boolean, + resolvingKey: String?, + contentPadding: PaddingValues, + isWideScreen: Boolean, + likedTrackUrls: Set, + likingUrl: String?, + onPlay: (SoundCloudSearchHit, List) -> Unit, + onDownload: (SoundCloudSearchHit) -> Unit, + onToggleLike: (SoundCloudSearchHit) -> Unit, + onLoadMore: () -> Unit, +) { + var selectedShelfId by remember(shelves.map { it.id }) { + mutableStateOf(shelves.firstOrNull()?.id.orEmpty()) + } + val visibleShelves = if (isWideScreen) { + shelves + } else { + listOfNotNull(shelves.firstOrNull { it.id == selectedShelfId } ?: shelves.firstOrNull()) + } + val listState = rememberLazyListState() + LaunchedEffect(listState, shelves.sumOf { it.items.size }) { + snapshotFlow { + val info = listState.layoutInfo + val total = info.totalItemsCount + total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - 2 + } + .distinctUntilChanged() + .filter { it } + .collect { onLoadMore() } + } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(22.dp), + ) { + if (!isWideScreen) { + item(key = "feed-submenu") { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(shelves, key = { it.id }) { shelf -> + FilterChip( + selected = shelf.id == selectedShelfId, + onClick = { selectedShelfId = shelf.id }, + label = { + Text( + text = shelf.title, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.SemiBold, + ) + }, + ) + } + } + } + } + items(visibleShelves, key = { it.id }) { shelf -> + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = shelf.title, + modifier = Modifier.padding(horizontal = 16.dp), + style = MaterialTheme.typography.headlineSmall, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.ExtraBold, + ) + if (useTiles) { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(shelf.items, key = { "${shelf.id}:${it.url}" }) { hit -> + Box(modifier = Modifier.width(176.dp)) { + SoundCloudResultTile( + hit = hit, + enabled = enabled, + isResolving = resolvingKey == hit.url, + isLiked = hit.url in likedTrackUrls, + isLiking = likingUrl == hit.url, + onPlay = { onPlay(hit, shelf.items) }, + onDownload = { onDownload(hit) }, + onToggleLike = { onToggleLike(hit) }, + ) + } + } + } + } else { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + shelf.items.take(6).forEach { hit -> + SoundCloudResultRow( + hit = hit, + enabled = enabled, + isResolving = resolvingKey == hit.url, + isLiked = hit.url in likedTrackUrls, + isLiking = likingUrl == hit.url, + onPlay = { onPlay(hit, shelf.items) }, + onDownload = { onDownload(hit) }, + onToggleLike = { onToggleLike(hit) }, ) } } @@ -480,7 +685,6 @@ private fun SoundCloudSongTile( Column( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) .clickable(onClick = onPlay), ) { Box( @@ -551,13 +755,15 @@ private fun SoundCloudResultTile( hit: SoundCloudSearchHit, enabled: Boolean, isResolving: Boolean = false, + isLiked: Boolean = false, + isLiking: Boolean = false, onPlay: () -> Unit, onDownload: () -> Unit, + onToggleLike: () -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) .clickable(enabled = enabled && !isResolving, onClick = onPlay), ) { Box( @@ -603,6 +809,28 @@ private fun SoundCloudResultTile( ) } } else if (hit.kind == SoundCloudSearchHit.Kind.TRACK) { + IconButton( + onClick = onToggleLike, + enabled = enabled && !isLiking, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(4.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.72f), + contentColor = MaterialTheme.colorScheme.primary, + ), + ) { + if (isLiking) { + LoadingIndicator(modifier = Modifier.size(22.dp)) + } else { + Icon( + imageVector = if (isLiked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, + contentDescription = stringResource( + if (isLiked) R.string.soundcloud_unlike else R.string.soundcloud_like, + ), + ) + } + } IconButton( onClick = onDownload, enabled = enabled, @@ -645,8 +873,11 @@ private fun SoundCloudResultRow( hit: SoundCloudSearchHit, enabled: Boolean, isResolving: Boolean = false, + isLiked: Boolean = false, + isLiking: Boolean = false, onPlay: () -> Unit, onDownload: () -> Unit, + onToggleLike: () -> Unit, ) { Surface( color = MaterialTheme.colorScheme.surfaceContainer, @@ -733,6 +964,19 @@ private fun SoundCloudResultRow( } if (hit.kind == SoundCloudSearchHit.Kind.TRACK && !isResolving) { + IconButton(onClick = onToggleLike, enabled = enabled && !isLiking) { + if (isLiking) { + LoadingIndicator(modifier = Modifier.size(22.dp)) + } else { + Icon( + imageVector = if (isLiked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, + contentDescription = stringResource( + if (isLiked) R.string.soundcloud_unlike else R.string.soundcloud_like, + ), + tint = MaterialTheme.colorScheme.primary, + ) + } + } IconButton(onClick = onDownload, enabled = enabled) { Icon( Icons.Rounded.Download, diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt index ffbb6b2357..f32e35a595 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt @@ -68,6 +68,10 @@ class SoundCloudClient @Inject constructor( @Throws(Exception::class) fun loadFeed(limit: Int = 40): List = sessionApi.loadFeed(limit) + @Throws(Exception::class) + fun loadRecentlyPlayed(limit: Int = 20): List = + sessionApi.loadRecentlyPlayed(limit) + @Throws(Exception::class) fun loadMyLikes(limit: Int = 40): List = sessionApi.loadMyLikes(limit) @@ -77,6 +81,10 @@ class SoundCloudClient @Inject constructor( @Throws(Exception::class) fun loadMyPlaylists(limit: Int = 40): List = sessionApi.loadMyPlaylists(limit) + @Throws(Exception::class) + fun setTrackLiked(trackUrl: String, liked: Boolean) = + sessionApi.setTrackLiked(trackUrl, liked) + @Throws(Exception::class) fun loadDiscover(limit: Int = 30): List { ensureClientIdReady() diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt index 88aaeefb58..1a370b3caa 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt @@ -20,6 +20,9 @@ import javax.inject.Singleton class SoundCloudDownloadService @Inject constructor( @ApplicationContext private val context: Context, ) { + private val artworkPreferences by lazy { + context.getSharedPreferences(ARTWORK_PREFERENCES, Context.MODE_PRIVATE) + } private val http = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) @@ -78,6 +81,7 @@ class SoundCloudDownloadService @Inject constructor( values.clear() values.put(MediaStore.Audio.Media.IS_PENDING, 0) resolver.update(uri, values, null, null) + rememberArtwork(fileName, track.artworkUrl) return File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), "PixelPlayer/SoundCloud/$fileName", @@ -93,10 +97,25 @@ class SoundCloudDownloadService @Inject constructor( } val outFile = File(dir, fileName) outFile.outputStream().use { out -> body.byteStream().copyTo(out) } + rememberArtwork(fileName, track.artworkUrl) return outFile } } + fun rememberArtwork(song: Song, artworkUrl: String?) { + val fileName = song.path.takeIf { it.isNotBlank() }?.let { File(it).name } + ?: return + rememberArtwork(fileName, artworkUrl) + } + + private fun rememberArtwork(fileName: String, artworkUrl: String?) { + val usableUrl = artworkUrl?.takeIf { it.isNotBlank() } ?: return + artworkPreferences.edit().putString(fileName, usableUrl).apply() + } + + private fun savedArtwork(fileName: String?): String? = + fileName?.let { artworkPreferences.getString(it, null) }?.takeIf { it.isNotBlank() } + /** Local SoundCloud downloads as playable [Song]s (MediaStore first, file fallback). */ fun listDownloadedSongs(): List { val fromStore = queryMediaStoreDownloads() @@ -172,7 +191,7 @@ class SoundCloudDownloadService @Inject constructor( path = path, songId = id, forceRefresh = false, - ) + ) ?: savedArtwork(cursor.getString(displayCol)) songs.add( Song( id = id.toString(), @@ -224,7 +243,7 @@ class SoundCloudDownloadService @Inject constructor( albumArtist = artist, path = file.absolutePath, contentUriString = file.toURI().toString(), - albumArtUriString = null, + albumArtUriString = savedArtwork(file.name), duration = 0L, genre = "SoundCloud", dateAdded = file.lastModified() / 1000L, @@ -241,6 +260,7 @@ class SoundCloudDownloadService @Inject constructor( } companion object { + private const val ARTWORK_PREFERENCES = "soundcloud_download_artwork" private val AUDIO_EXTS = setOf("mp3", "m4a", "ogg", "aac", "wav", "flac") } } diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt index a7e6024706..f94874552e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt @@ -12,6 +12,12 @@ data class SoundCloudSearchHit( enum class Kind { TRACK, PLAYLIST } } +data class SoundCloudFeedShelf( + val id: String, + val title: String, + val items: List, +) + data class SoundCloudResolvedTrack( val url: String, val title: String, diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt index 4bada610c7..ef2b5de0c6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt @@ -2,6 +2,7 @@ package com.theveloper.pixelplay.soundcloud import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import java.util.concurrent.TimeUnit @@ -64,6 +65,15 @@ class SoundCloudSessionApi @Inject constructor() { return parseCollection(json.optJSONArray("collection")).take(limit) } + @Throws(Exception::class) + fun loadRecentlyPlayed(limit: Int = 20): List { + return getFirstCollection( + limit, + "https://api-v2.soundcloud.com/me/play-history/tracks?limit=${limit * 2}&linked_partitioning=1", + "https://api-v2.soundcloud.com/me/play-history?limit=${limit * 2}&linked_partitioning=1", + ) + } + @Throws(Exception::class) fun loadMyLikes(limit: Int = 40): List { val userId = requireUserId() @@ -94,6 +104,22 @@ class SoundCloudSessionApi @Inject constructor() { ) } + @Throws(Exception::class) + fun setTrackLiked(trackUrl: String, liked: Boolean) { + val encoded = java.net.URLEncoder.encode(trackUrl.trim(), Charsets.UTF_8.name()) + val track = getJson( + "https://api-v2.soundcloud.com/resolve?url=$encoded", + requireAuth = true, + ) + val trackId = track.optLong("id", -1L) + require(trackId > 0) { "SoundCloud could not resolve this track for liking" } + val userId = requireUserId() + mutate( + url = "https://api-v2.soundcloud.com/users/$userId/track_likes/$trackId", + method = if (liked) "PUT" else "DELETE", + ) + } + private fun requireUserId(): Long { cachedUserId.get()?.let { return it } return fetchMe().third @@ -283,6 +309,33 @@ class SoundCloudSessionApi @Inject constructor() { } } + private fun mutate(url: String, method: String) { + val token = oauthToken.get() + require(token.isNotBlank()) { "Sign in to SoundCloud to like tracks" } + val id = clientId.get() + val finalUrl = if (id.isNotBlank() && !url.contains("client_id=")) { + "$url?client_id=$id" + } else { + url + } + val builder = Request.Builder() + .url(finalUrl) + .method(method, ByteArray(0).toRequestBody(null)) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Authorization", "OAuth $token") + .header("Origin", "https://soundcloud.com") + .header("Referer", "https://soundcloud.com/") + cookieHeader.get().takeIf { it.isNotBlank() }?.let { builder.header("Cookie", it) } + http.newCall(builder.build()).execute().use { response -> + if (!response.isSuccessful) { + throw IllegalStateException( + "SoundCloud like request failed (${response.code}): ${response.body.string().take(180)}", + ) + } + } + } + companion object { private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0" diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt index fe72abd1af..ea3ee0653e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt @@ -30,6 +30,7 @@ data class SoundCloudUiState( val displayName: String = "", val isLoading: Boolean = false, val results: List = emptyList(), + val feedShelves: List = emptyList(), val downloads: List = emptyList(), val statusMessage: String? = null, val errorMessage: String? = null, @@ -41,6 +42,10 @@ data class SoundCloudUiState( /** True while a playlist (or nested) browse can be popped with system Back. */ val canNavigateBack: Boolean = false, val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val endReached: Boolean = false, + val likedTrackUrls: Set = emptySet(), + val likingUrl: String? = null, ) @HiltViewModel @@ -63,6 +68,8 @@ class SoundCloudViewModel @Inject constructor( private var searchJob: Job? = null private var loadJob: Job? = null + private var artworkJob: Job? = null + private val artworkAttempts = mutableSetOf() private val navStack = ArrayDeque() init { @@ -143,10 +150,13 @@ class SoundCloudViewModel @Inject constructor( section = section, browsingPlaylistTitle = null, browsingPlaylistUrl = null, + feedShelves = if (section == SoundCloudSection.FEED) it.feedShelves else emptyList(), errorMessage = null, statusMessage = null, canNavigateBack = false, isRefreshing = false, + isLoadingMore = false, + endReached = false, ) } when (section) { @@ -196,12 +206,7 @@ class SoundCloudViewModel @Inject constructor( return } when (state.section) { - SoundCloudSection.FEED -> runLoad("Refreshing feed…", clearResults = false, refreshing = true) { - if (!client.hasSession) { - throw IllegalStateException("Sign in to SoundCloud to see your personal feed") - } - client.loadFeed() - } + SoundCloudSection.FEED -> loadFeed(refreshing = true) SoundCloudSection.DISCOVER -> runLoad("Refreshing discover…", clearResults = false, refreshing = true) { client.loadDiscover() } SoundCloudSection.SEARCH -> { @@ -236,6 +241,8 @@ class SoundCloudViewModel @Inject constructor( errorMessage = null, statusMessage = null, canNavigateBack = navStack.isNotEmpty(), + isLoadingMore = false, + endReached = false, ) } return true @@ -264,6 +271,52 @@ class SoundCloudViewModel @Inject constructor( client.toSong(client.resolveTrack(hit.url)) } + /** + * Builds an ordered queue around the selected track so normal player completion advances + * to the next playable SoundCloud track. Unplayable/DRM entries are skipped. + */ + suspend fun resolveQueueForPlayback( + startHit: SoundCloudSearchHit, + queueHits: List, + limit: Int = 40, + ): Pair, Song> { + val candidates = (listOf(startHit) + queueHits) + .asSequence() + .filter { it.kind == SoundCloudSearchHit.Kind.TRACK } + .distinctBy { it.url } + .take(limit) + .toList() + setBusy(startHit.url) + val resolved = withContext(Dispatchers.IO) { + ensureClient() + val gate = Semaphore(4) + coroutineScope { + candidates.map { hit -> + async { + gate.withPermit { + runCatching { hit to client.toSong(client.resolveTrack(hit.url)) } + .onFailure { Timber.w(it, "Skip unplayable SoundCloud track: %s", hit.url) } + .getOrNull() + } + } + }.awaitAll().filterNotNull() + } + } + val startSong = resolved.firstOrNull { it.first.url == startHit.url }?.second + ?: throw IllegalStateException("This SoundCloud track has no playable stream") + val songsByUrl = resolved.associate { it.first.url to it.second } + val orderedSongs = queueHits + .asSequence() + .filter { it.kind == SoundCloudSearchHit.Kind.TRACK } + .mapNotNull { songsByUrl[it.url] } + .distinctBy { it.id } + .toMutableList() + .apply { + if (none { it.id == startSong.id }) add(0, startSong) + } + return orderedSongs to startSong + } + /** * Resolves playable tracks from the current results list for shuffle/queue playback. * Skips playlists and tracks that fail to resolve (e.g. DRM). @@ -331,6 +384,42 @@ class SoundCloudViewModel @Inject constructor( } } + fun toggleLike(hit: SoundCloudSearchHit) { + if (hit.kind != SoundCloudSearchHit.Kind.TRACK || _uiState.value.likingUrl != null) return + if (!_uiState.value.isSignedIn) { + _uiState.update { it.copy(errorMessage = "Sign in to SoundCloud to like tracks") } + return + } + val shouldLike = hit.url !in _uiState.value.likedTrackUrls + viewModelScope.launch { + _uiState.update { it.copy(likingUrl = hit.url, errorMessage = null) } + try { + withContext(Dispatchers.IO) { client.setTrackLiked(hit.url, shouldLike) } + _uiState.update { state -> + val liked = if (shouldLike) { + state.likedTrackUrls + hit.url + } else { + state.likedTrackUrls - hit.url + } + state.copy( + likedTrackUrls = liked, + likingUrl = null, + results = if (!shouldLike && state.section == SoundCloudSection.LIKES) { + state.results.filterNot { it.url == hit.url } + } else { + state.results + }, + ) + } + } catch (t: Throwable) { + Timber.w(t, "SoundCloud like toggle failed") + _uiState.update { + it.copy(likingUrl = null, errorMessage = t.message ?: t::class.java.simpleName) + } + } + } + } + fun setBusy(key: String? = null) { _uiState.update { it.copy(isLoading = true, statusMessage = null, errorMessage = null, resolvingKey = key) @@ -353,8 +442,48 @@ class SoundCloudViewModel @Inject constructor( statusMessage = null, ) } + hydrateMissingDownloadArtwork(songs) } + private fun hydrateMissingDownloadArtwork(songs: List) { + if (_uiState.value.clientId.isBlank()) return + val missing = songs.filter { + it.albumArtUriString.isNullOrBlank() && artworkAttempts.add(it.id) + } + if (missing.isEmpty()) return + artworkJob?.cancel() + artworkJob = viewModelScope.launch(Dispatchers.IO) { + ensureClient() + val hydrated = missing.mapNotNull { song -> + val query = "${song.artist} ${song.title}".trim() + val candidates = runCatching { client.search(query, 5) }.getOrDefault(emptyList()) + val normalizedTitle = song.title.normalizedForArtworkMatch() + val match = candidates.firstOrNull { + it.kind == SoundCloudSearchHit.Kind.TRACK && + it.title.normalizedForArtworkMatch() == normalizedTitle && + !it.thumbnailUrl.isNullOrBlank() + } ?: candidates.firstOrNull { + it.kind == SoundCloudSearchHit.Kind.TRACK && !it.thumbnailUrl.isNullOrBlank() + } + val artwork = match?.thumbnailUrl ?: return@mapNotNull null + downloader.rememberArtwork(song, artwork) + song.id to artwork + }.toMap() + if (hydrated.isNotEmpty()) { + _uiState.update { state -> + state.copy( + downloads = state.downloads.map { song -> + hydrated[song.id]?.let { song.copy(albumArtUriString = it) } ?: song + }, + ) + } + } + } + } + + private fun String.normalizedForArtworkMatch(): String = + lowercase().replace(Regex("[^a-z0-9]+"), "") + private fun scheduleSearch(rawQuery: String, refreshing: Boolean = false) { searchJob?.cancel() val query = rawQuery.trim() @@ -394,48 +523,153 @@ class SoundCloudViewModel @Inject constructor( } } - private fun loadFeed() = runLoad("Loading feed…") { + /** Requests a larger result window when the current list reaches its end. */ + fun loadMore() { + val state = _uiState.value + if (state.isLoading || state.isRefreshing || state.isLoadingMore || state.endReached) return + val nextLimit = (state.results.size + PAGE_SIZE).coerceAtMost(MAX_RESULTS) + if (nextLimit <= state.results.size) { + _uiState.update { it.copy(endReached = true) } + return + } + val playlistUrl = state.browsingPlaylistUrl + if (!playlistUrl.isNullOrBlank()) { + runLoad("Loading more…", clearResults = false, loadingMore = true) { + client.loadPlaylistTracks(playlistUrl, nextLimit) + } + return + } + when (state.section) { + SoundCloudSection.FEED -> loadFeed(limit = nextLimit, loadingMore = true) + SoundCloudSection.DISCOVER -> + runLoad("Loading more…", clearResults = false, loadingMore = true) { + client.loadDiscover(nextLimit) + } + SoundCloudSection.SEARCH -> { + val query = state.query.trim() + if (query.length >= 2) { + runLoad("Loading more…", clearResults = false, loadingMore = true) { + client.search(query, nextLimit) + } + } + } + SoundCloudSection.LIKES -> loadLikes(limit = nextLimit, loadingMore = true) + SoundCloudSection.TRACKS -> loadTracks(limit = nextLimit, loadingMore = true) + SoundCloudSection.PLAYLISTS -> loadPlaylists(limit = nextLimit, loadingMore = true) + SoundCloudSection.DOWNLOADS -> _uiState.update { it.copy(endReached = true) } + } + } + + private fun loadFeed( + refreshing: Boolean = false, + limit: Int = INITIAL_FEED_SIZE, + loadingMore: Boolean = false, + ) = runLoad( + "Loading feed…", + clearResults = !refreshing && !loadingMore, + refreshing = refreshing, + loadingMore = loadingMore, + ) { if (!client.hasSession) { throw IllegalStateException("Sign in to SoundCloud to see your personal feed") } - client.loadFeed() + val shelfLimit = limit.coerceAtMost(40) + val feed = client.loadFeed(limit) + val likes = runCatching { client.loadMyLikes(shelfLimit) }.getOrDefault(emptyList()) + val history = runCatching { client.loadRecentlyPlayed(shelfLimit) }.getOrDefault(emptyList()) + val playlists = runCatching { client.loadMyPlaylists(shelfLimit) }.getOrDefault(emptyList()) + val discover = runCatching { client.loadDiscover(shelfLimit) }.getOrDefault(emptyList()) + val mixed = (likes.take(6) + feed.take(8) + discover.take(6)) + .distinctBy { it.url } + .take(16) + val user = _uiState.value.displayName.ifBlank { _uiState.value.username } + val shelves = buildList { + if (likes.isNotEmpty()) add( + SoundCloudFeedShelf("more_like", "More of what you like", likes), + ) + if (history.isNotEmpty()) add( + SoundCloudFeedShelf("recent", "Recently played", history), + ) + if (mixed.isNotEmpty()) add( + SoundCloudFeedShelf( + "mixed", + if (user.isBlank()) "Mixed for you" else "Mixed for $user", + mixed, + ), + ) + if (discover.isNotEmpty()) add( + SoundCloudFeedShelf("curated", "Curated by SoundCloud", discover), + ) + if (playlists.isNotEmpty()) add( + SoundCloudFeedShelf("playlists", "Playlists for you", playlists), + ) + if (feed.isNotEmpty()) add( + SoundCloudFeedShelf("following", "Latest from people you follow", feed), + ) + } + _uiState.update { it.copy(feedShelves = shelves) } + feed } private fun loadDiscover() = runLoad("Loading discover…") { client.loadDiscover() } - private fun loadLikes(refreshing: Boolean = false) = runLoad( + private fun loadLikes( + refreshing: Boolean = false, + limit: Int = INITIAL_LIST_SIZE, + loadingMore: Boolean = false, + ) = runLoad( "Loading likes…", - clearResults = !refreshing, + clearResults = !refreshing && !loadingMore, refreshing = refreshing, + loadingMore = loadingMore, ) { - if (client.hasSession) { - client.loadMyLikes() + val hits = if (client.hasSession) { + client.loadMyLikes(limit) } else { - client.loadUserLikes(_uiState.value.username) + client.loadUserLikes(_uiState.value.username, limit) + } + if (client.hasSession) { + _uiState.update { state -> + state.copy( + likedTrackUrls = state.likedTrackUrls + + hits.filter { it.kind == SoundCloudSearchHit.Kind.TRACK }.map { it.url }, + ) + } } + hits } - private fun loadTracks(refreshing: Boolean = false) = runLoad( + private fun loadTracks( + refreshing: Boolean = false, + limit: Int = INITIAL_LIST_SIZE, + loadingMore: Boolean = false, + ) = runLoad( "Loading tracks…", - clearResults = !refreshing, + clearResults = !refreshing && !loadingMore, refreshing = refreshing, + loadingMore = loadingMore, ) { if (client.hasSession) { - client.loadMyTracks() + client.loadMyTracks(limit) } else { - client.loadUserTracks(_uiState.value.username) + client.loadUserTracks(_uiState.value.username, limit) } } - private fun loadPlaylists(refreshing: Boolean = false) = runLoad( + private fun loadPlaylists( + refreshing: Boolean = false, + limit: Int = INITIAL_LIST_SIZE, + loadingMore: Boolean = false, + ) = runLoad( "Loading playlists…", - clearResults = !refreshing, + clearResults = !refreshing && !loadingMore, refreshing = refreshing, + loadingMore = loadingMore, ) { if (client.hasSession) { - client.loadMyPlaylists() + client.loadMyPlaylists(limit) } else { - client.loadUserPlaylists(_uiState.value.username) + client.loadUserPlaylists(_uiState.value.username, limit) } } @@ -444,16 +678,19 @@ class SoundCloudViewModel @Inject constructor( cancelPrevious: Boolean = true, clearResults: Boolean = true, refreshing: Boolean = false, + loadingMore: Boolean = false, block: suspend () -> List, ) { if (cancelPrevious) { loadJob?.cancel() } val job = viewModelScope.launch { + val previousCount = _uiState.value.results.size _uiState.update { it.copy( - isLoading = !refreshing, + isLoading = !refreshing && !loadingMore, isRefreshing = refreshing, + isLoadingMore = loadingMore, errorMessage = null, statusMessage = null, results = if (clearResults) emptyList() else it.results, @@ -466,7 +703,9 @@ class SoundCloudViewModel @Inject constructor( it.copy( isLoading = false, isRefreshing = false, + isLoadingMore = false, results = hits, + endReached = loadingMore && hits.size <= previousCount, statusMessage = null, ) } @@ -477,6 +716,7 @@ class SoundCloudViewModel @Inject constructor( it.copy( isLoading = false, isRefreshing = false, + isLoadingMore = false, errorMessage = t.message ?: t::class.java.simpleName, statusMessage = null, ) @@ -496,5 +736,9 @@ class SoundCloudViewModel @Inject constructor( companion object { private const val SEARCH_DEBOUNCE_MS = 350L + private const val INITIAL_FEED_SIZE = 40 + private const val INITIAL_LIST_SIZE = 40 + private const val PAGE_SIZE = 30 + private const val MAX_RESULTS = 200 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb01707ddf..3eafe62a50 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,8 @@ Search / resolve Play Download + Like track + Unlike track Shuffle Shuffle play current list Tiles From fd56e5868790fdac11c68289cff753583de0c2c8 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 18:22:06 +0300 Subject: [PATCH 06/77] feat(soundcloud): match library compact and tab-row navigation Co-authored-by: Cursor --- .../presentation/screens/LibraryScreen.kt | 2 +- .../presentation/screens/SoundCloudScreen.kt | 226 +++++++++++++++--- app/src/main/res/values/strings.xml | 2 + 3 files changed, 199 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index 5d37d029e9..eb6f5ab154 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt @@ -2175,7 +2175,7 @@ fun LibraryScreen( } @Composable -private fun CompactLibraryPagerIndicator( +internal fun CompactLibraryPagerIndicator( currentIndex: Int, pageCount: Int, modifier: Modifier = Modifier diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index aeff002c8d..3f4ac4569b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -105,6 +105,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape +import com.theveloper.pixelplay.data.preferences.LibraryNavigationMode +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.lazy.grid.itemsIndexed /** Material medium width — foldables / tablets / landscape. */ private const val WIDE_SCREEN_DP = 600 @@ -123,6 +128,10 @@ fun SoundCloudScreen( SoundCloudSection.entries.filter { it != SoundCloudSection.SEARCH } } val selectedIndex = sections.indexOf(uiState.section).let { if (it < 0) -1 else it } + val libraryNavigationMode by playerViewModel.libraryNavigationMode.collectAsStateWithLifecycle() + val isCompactNavigation = libraryNavigationMode == LibraryNavigationMode.COMPACT_PILL + var showSectionSwitcherSheet by remember { mutableStateOf(false) } + val currentSection = if (selectedIndex >= 0) sections[selectedIndex] else SoundCloudSection.FEED val configuration = LocalConfiguration.current val isWideScreen = configuration.screenWidthDp >= WIDE_SCREEN_DP val useTiles = uiState.tilesOverride ?: isWideScreen @@ -227,20 +236,34 @@ fun SoundCloudScreen( Column(modifier = Modifier.background(headerContainerColor)) { TopAppBar( title = { - Text( - modifier = Modifier.padding(start = 8.dp), - text = stringResource(R.string.soundcloud_tab_title), - fontFamily = GoogleSansRounded, - fontWeight = FontWeight.ExtraBold, - color = MaterialTheme.colorScheme.primary, - fontSize = 40.sp, - letterSpacing = 1.sp, - ) + if (isCompactNavigation) { + LibraryNavigationPill( + modifier = Modifier, + title = stringResource(currentSection.labelRes), + isExpanded = showSectionSwitcherSheet, + showIcon = true, + iconRes = currentSection.iconRes, + pageIndex = selectedIndex.coerceAtLeast(0), + compressForWatchTransfer = false, + onClick = { showSectionSwitcherSheet = true }, + onArrowClick = { showSectionSwitcherSheet = true }, + ) + } else { + Text( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(R.string.soundcloud_tab_title), + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.primary, + fontSize = 40.sp, + letterSpacing = 1.sp, + ) + } }, actions = { FilledIconButton( modifier = Modifier.padding(end = 14.dp), - onClick = { navController.navigateSafely(Screen.Experimental.route) }, + onClick = { navController.navigateSafely(Screen.Settings.route) }, colors = IconButtonDefaults.filledIconButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, @@ -258,28 +281,36 @@ fun SoundCloudScreen( ), ) - PrimaryScrollableTabRow( - selectedTabIndex = selectedIndex.coerceAtLeast(0), - containerColor = Color.Transparent, - edgePadding = 12.dp, - indicator = {}, - divider = {}, - ) { - sections.forEachIndexed { index, section -> - TabAnimation( - index = index, - title = section.name, - selectedIndex = if (selectedIndex < 0) -1 else selectedIndex, - onClick = { viewModel.selectSection(section) }, - ) { - Text( - text = stringResource(section.labelRes).uppercase(), - style = MaterialTheme.typography.labelLarge, - fontFamily = GoogleSansRounded, - fontWeight = if (selectedIndex == index) FontWeight.Bold else FontWeight.Medium, - ) + if (!isCompactNavigation) { + PrimaryScrollableTabRow( + selectedTabIndex = selectedIndex.coerceAtLeast(0), + containerColor = Color.Transparent, + edgePadding = 12.dp, + indicator = {}, + divider = {}, + ) { + sections.forEachIndexed { index, section -> + TabAnimation( + index = index, + title = section.name, + selectedIndex = if (selectedIndex < 0) -1 else selectedIndex, + onClick = { viewModel.selectSection(section) }, + ) { + Text( + text = stringResource(section.labelRes).uppercase(), + style = MaterialTheme.typography.labelLarge, + fontFamily = GoogleSansRounded, + fontWeight = if (selectedIndex == index) FontWeight.Bold else FontWeight.Medium, + ) + } } } + } else { + CompactLibraryPagerIndicator( + currentIndex = selectedIndex.coerceAtLeast(0), + pageCount = sections.size, + modifier = Modifier.padding(top = 8.dp, bottom = 10.dp), + ) } } }, @@ -547,6 +578,18 @@ fun SoundCloudScreen( } } } + + if (showSectionSwitcherSheet) { + SoundCloudSectionSwitcherSheet( + sections = sections, + currentIndex = selectedIndex.coerceAtLeast(0), + onSectionSelected = { index -> + sections.getOrNull(index)?.let(viewModel::selectSection) + showSectionSwitcherSheet = false + }, + onDismiss = { showSectionSwitcherSheet = false }, + ) + } } @Composable @@ -664,6 +707,129 @@ private fun SoundCloudFeedHome( } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SoundCloudSectionSwitcherSheet( + sections: List, + currentIndex: Int, + onSectionSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.soundcloud_tabs_sheet_title), + style = MaterialTheme.typography.headlineSmall, + fontFamily = GoogleSansRounded, + ) + Text( + text = stringResource(R.string.soundcloud_tabs_sheet_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 150.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(bottom = 24.dp, top = 8.dp), + ) { + itemsIndexed( + items = sections, + key = { index, section -> "${section.name}-$index" }, + ) { index, section -> + SoundCloudSectionGridItem( + section = section, + isSelected = index == currentIndex, + onClick = { onSectionSelected(index) }, + ) + } + } + } + } +} + +@Composable +private fun SoundCloudSectionGridItem( + section: SoundCloudSection, + isSelected: Boolean, + onClick: () -> Unit, +) { + val shape = RoundedCornerShape(20.dp) + val containerColor = + if (isSelected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerHigh + val iconContainer = + if (isSelected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.secondaryContainer + val textColor = + if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .clickable(onClick = onClick), + shape = shape, + color = containerColor, + tonalElevation = if (isSelected) 6.dp else 2.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 14.dp, horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(iconContainer.copy(alpha = 0.92f)), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = section.iconRes), + contentDescription = stringResource(section.labelRes), + tint = if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSecondaryContainer + }, + ) + } + Text( + text = stringResource(section.labelRes), + style = MaterialTheme.typography.titleMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = textColor, + ) + } + } +} + +private val SoundCloudSection.iconRes: Int + get() = when (this) { + SoundCloudSection.FEED -> R.drawable.rounded_home_24 + SoundCloudSection.DISCOVER -> R.drawable.rounded_instant_mix_24 + SoundCloudSection.SEARCH -> R.drawable.rounded_search_24 + SoundCloudSection.LIKES -> R.drawable.rounded_favorite_24 + SoundCloudSection.TRACKS -> R.drawable.rounded_music_note_24 + SoundCloudSection.PLAYLISTS -> R.drawable.rounded_playlist_play_24 + SoundCloudSection.DOWNLOADS -> R.drawable.rounded_drive_export_24 + } + private val SoundCloudSection.labelRes: Int get() = when (this) { SoundCloudSection.FEED -> R.string.soundcloud_pill_feed diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3eafe62a50..b9555b25e1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,8 @@ SoundCloud + SoundCloud tabs + Jump directly to any section. SoundCloud settings Sign in for your personal feed and likes, or set a public username. Some tracks are DRM-only and won\'t play or download. Signed in as @%1$s. Feed and likes use your SoundCloud session. From 642365f8935bc80544effe912a2db7cd42b350c4 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 18:28:44 +0300 Subject: [PATCH 07/77] fix(soundcloud): transparent loading container and Search-style bar Co-authored-by: Cursor --- .../presentation/screens/SoundCloudScreen.kt | 117 +++++++++++++----- 1 file changed, 88 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index 3f4ac4569b..8c9a30c0ee 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -52,7 +52,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.DockedSearchBar +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -76,6 +77,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -384,26 +386,77 @@ fun SoundCloudScreen( Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = uiState.query, - onValueChange = viewModel::onQueryChange, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - singleLine = true, - label = { Text(stringResource(R.string.soundcloud_query_label)) }, - placeholder = { Text(stringResource(R.string.soundcloud_query_hint)) }, - leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, - trailingIcon = { - if (uiState.query.isNotEmpty()) { - IconButton(onClick = { viewModel.onQueryChange("") }) { + val keyboardController = LocalSoftwareKeyboardController.current + val searchBarCornerRadius = 28.dp + val searchBarInputFieldColors = SearchBarDefaults.inputFieldColors( + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary, + ) + DockedSearchBar( + inputField = { + SearchBarDefaults.InputField( + query = uiState.query, + onQueryChange = viewModel::onQueryChange, + onSearch = { query -> + if (query.isNotBlank()) { + viewModel.onQueryChange(query) + } + keyboardController?.hide() + }, + expanded = false, + onExpandedChange = {}, + placeholder = { + Text( + stringResource(R.string.soundcloud_query_hint), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + ) + }, + leadingIcon = { Icon( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource(R.string.common_clear_search), + imageVector = Icons.Rounded.Search, + contentDescription = stringResource(R.string.search_cd_search_icon), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), ) - } - } + }, + trailingIcon = { + if (uiState.query.isNotBlank()) { + IconButton( + onClick = { viewModel.onQueryChange("") }, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f), + ), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.search_cd_clear_search_query), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + }, + colors = searchBarInputFieldColors, + ) }, + expanded = false, + onExpandedChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(searchBarCornerRadius)), + colors = SearchBarDefaults.colors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), + dividerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + inputFieldColors = searchBarInputFieldColors, + ), + content = {}, ) Spacer(modifier = Modifier.height(8.dp)) @@ -442,16 +495,11 @@ fun SoundCloudScreen( ) } - if (uiState.isLoading && uiState.resolvingKey == null && !uiState.isRefreshing) { - LoadingIndicator( - modifier = Modifier - .padding(24.dp) - .size(48.dp) - .align(Alignment.CenterHorizontally), - ) - } val pullToRefreshState = rememberPullToRefreshState() + val showContentLoading = + uiState.isLoading && uiState.resolvingKey == null && !uiState.isRefreshing + Box(modifier = Modifier.fillMaxSize()) { PullToRefreshBox( isRefreshing = uiState.isRefreshing, onRefresh = viewModel::refresh, @@ -462,6 +510,8 @@ fun SoundCloudScreen( state = pullToRefreshState, isRefreshing = uiState.isRefreshing, modifier = Modifier.align(Alignment.TopCenter), + containerColor = Color.Transparent, + elevation = 0.dp, ) }, ) { @@ -574,10 +624,19 @@ fun SoundCloudScreen( } } } - } - } + if (showContentLoading) { + LoadingIndicator( + modifier = Modifier + .size(48.dp) + .align(Alignment.Center), + color = MaterialTheme.colorScheme.primary, + ) } - } + } // Box (content + loading overlay) + } // Column (surface content) + } // Surface + } // header Column + } // Scaffold if (showSectionSwitcherSheet) { SoundCloudSectionSwitcherSheet( From 482f869a213b1955c3eeb5c744fafd10029a60a8 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 18:32:28 +0300 Subject: [PATCH 08/77] ci(release): publish continuous GitHub Release on every master push Co-authored-by: Cursor --- .github/workflows/release-apk.yml | 63 +++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index 3e2290153f..64f0f2c445 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -1,9 +1,15 @@ name: Release APK # Builds a signed (CI keystore) release APK and publishes a GitHub Release with -# conventional-commit feature notes. Triggered by version tags or manually. +# conventional-commit notes + downloadable APKs. +# +# Triggers: +# - Every push to master → continuous pre-release (auto tag + changelog) +# - Version tags (v*) → release for that tag +# - Manual workflow_dispatch → named version (e.g. 0.8.1-soundcloud) on: push: + branches: [ master ] tags: - 'v*' workflow_dispatch: @@ -22,8 +28,8 @@ permissions: contents: write concurrency: - group: release-apk-${{ github.ref }} - cancel-in-progress: false + group: release-apk-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: release: @@ -43,17 +49,35 @@ jobs: id: meta shell: bash run: | + SHORT_SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)" if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION="${{ inputs.version }}" VERSION="${VERSION#v}" TAG="v${VERSION}" - else + PRERELEASE="${{ inputs.prerelease }}" + TITLE="PixelPlayer ${VERSION}" + elif [ "${{ github.ref_type }}" = "tag" ]; then TAG="${GITHUB_REF_NAME}" VERSION="${TAG#v}" + # Tag releases with a dash (e.g. 0.8.1-soundcloud) stay pre-release. + if [[ "${VERSION}" == *-* ]]; then + PRERELEASE="true" + else + PRERELEASE="false" + fi + TITLE="PixelPlayer ${VERSION}" + else + # Continuous build on every master push — browseable on the Releases page. + VERSION="continuous-$(date -u +%Y%m%d)-${SHORT_SHA}" + TAG="v${VERSION}" + PRERELEASE="true" + TITLE="PixelPlayer continuous (${SHORT_SHA})" fi echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "Resolved release ${TAG}" + echo "prerelease=${PRERELEASE}" >> "$GITHUB_OUTPUT" + echo "title=${TITLE}" >> "$GITHUB_OUTPUT" + echo "Resolved release ${TAG} (prerelease=${PRERELEASE})" - name: Set up JDK 21 uses: actions/setup-java@v5 @@ -166,14 +190,19 @@ jobs: return `### ${title}\n\n${items.map((i) => `- ${i}`).join('\n')}\n\n`; }; - let body = `## PixelPlayer ${version}\n\n`; - body += section('Features', buckets.feat); - body += section('Fixes', buckets.fix); - body += section('Performance', buckets.perf); - body += section('Refactors', buckets.refactor); - body += section('Docs', buckets.docs); - body += section('Chores', buckets.chore); - body += section('Other', buckets.other); + let body = `## ${process.env.RELEASE_TITLE || `PixelPlayer ${version}`}\n\n`; + if (version.startsWith('continuous-')) { + body += '_Automatic build from `master`. Use a manual / tagged release for stable version numbers._\n\n'; + } + const typed = + section('Features', buckets.feat) + + section('Fixes', buckets.fix) + + section('Performance', buckets.perf) + + section('Refactors', buckets.refactor) + + section('Docs', buckets.docs) + + section('Chores', buckets.chore) + + section('Other', buckets.other); + body += typed || '_No conventional commits in this range._\n\n'; body += [ '### Downloads', '', @@ -186,15 +215,19 @@ jobs: core.setOutput('body', body); await core.summary.addHeading(`Release ${tag}`).addRaw(body).write(); + env: + RELEASE_TITLE: ${{ steps.meta.outputs.title }} - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.meta.outputs.tag }} - name: PixelPlayer ${{ steps.meta.outputs.version }} + name: ${{ steps.meta.outputs.title }} body: ${{ steps.notes.outputs.body }} + target_commitish: ${{ github.sha }} draft: false - prerelease: ${{ github.event_name == 'workflow_dispatch' && inputs.prerelease || contains(steps.meta.outputs.version, '-') }} + prerelease: ${{ steps.meta.outputs.prerelease == 'true' }} + make_latest: ${{ steps.meta.outputs.prerelease != 'true' }} files: | dist/PixelPlayer-${{ steps.meta.outputs.version }}-arm64-v8a.apk dist/PixelPlayer-${{ steps.meta.outputs.version }}-armeabi-v7a.apk From 4875802efb7554917f5e26ef040e37b06b83063c Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 18:41:40 +0300 Subject: [PATCH 09/77] ci: run only commitlint and Release APK on master pushes Co-authored-by: Cursor --- .github/workflows/codeql.yml | 5 +---- .github/workflows/commitlint.yml | 1 + .github/workflows/nightly-apk.yml | 4 +--- .github/workflows/phone-debug.yml | 3 +-- .github/workflows/phone-release.yml | 3 +-- 5 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e7e11f08a5..ece51db7f3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,10 +1,7 @@ name: "CodeQL Advanced" +# Weekly + manual only — avoid a full Gradle rebuild on every master push. on: - push: - branches: [ master ] - pull_request: - branches: [ master ] workflow_dispatch: schedule: - cron: '25 15 * * 0' diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 2c30f95fc4..3ec9c4e74a 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -1,5 +1,6 @@ name: Commit Lint (Semantic) +# Keep this — cheap and useful. Runs on push/PR; not an APK build. on: push: branches: [master, 'feat/**', 'fix/**', 'chore/**', 'ci/**'] diff --git a/.github/workflows/nightly-apk.yml b/.github/workflows/nightly-apk.yml index 2c6f7728f5..fd318847b6 100644 --- a/.github/workflows/nightly-apk.yml +++ b/.github/workflows/nightly-apk.yml @@ -1,9 +1,7 @@ name: Build Nightly APK +# Manual only — continuous Release APK on master replaces scheduled nightlies. on: - schedule: - # Runs at 02:17 America/Buenos_Aires. - - cron: '17 5 * * *' workflow_dispatch: permissions: diff --git a/.github/workflows/phone-debug.yml b/.github/workflows/phone-debug.yml index 3f7d70fb2e..5b97931414 100644 --- a/.github/workflows/phone-debug.yml +++ b/.github/workflows/phone-debug.yml @@ -1,8 +1,7 @@ name: Build Phone APK (Debug) +# PR / manual only — master publishes via Release APK instead. on: - push: - branches: [ master ] pull_request: types: [opened, synchronize, reopened] branches: [ master ] diff --git a/.github/workflows/phone-release.yml b/.github/workflows/phone-release.yml index 1c33965a3e..b538388ff7 100644 --- a/.github/workflows/phone-release.yml +++ b/.github/workflows/phone-release.yml @@ -1,8 +1,7 @@ name: Build Phone APK (Release) +# PR / manual only — master publishes via Release APK instead. on: - push: - branches: [ master ] pull_request: types: [opened, synchronize, reopened] branches: [ master ] From cac617b75ad90c19526fb49f0b909c096eef9fa0 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 18:53:32 +0300 Subject: [PATCH 10/77] ci(release): real download links and keep Latest pointing at newest build Co-authored-by: Cursor --- .github/workflows/release-apk.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index 64f0f2c445..4898c20466 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -70,7 +70,8 @@ jobs: # Continuous build on every master push — browseable on the Releases page. VERSION="continuous-$(date -u +%Y%m%d)-${SHORT_SHA}" TAG="v${VERSION}" - PRERELEASE="true" + # Keep as a full release so /releases/latest always points here. + PRERELEASE="false" TITLE="PixelPlayer continuous (${SHORT_SHA})" fi echo "version=${VERSION}" >> "$GITHUB_OUTPUT" @@ -203,14 +204,21 @@ jobs: section('Chores', buckets.chore) + section('Other', buckets.other); body += typed || '_No conventional commits in this range._\n\n'; + const repo = process.env.GITHUB_REPOSITORY; + const arm64Name = `PixelPlayer-${version}-arm64-v8a.apk`; + const armName = `PixelPlayer-${version}-armeabi-v7a.apk`; + const arm64Url = `https://github.com/${repo}/releases/download/${tag}/${arm64Name}`; + const armUrl = `https://github.com/${repo}/releases/download/${tag}/${armName}`; body += [ '### Downloads', '', - '- `PixelPlayer-*-arm64-v8a.apk` — modern 64-bit devices (recommended)', - '- `PixelPlayer-*-armeabi-v7a.apk` — older 32-bit devices', + `- [${arm64Name}](${arm64Url}) — modern 64-bit devices (recommended)`, + `- [${armName}](${armUrl}) — older 32-bit devices`, '', '> CI builds use the repository CI keystore (not the store/Play signing key).', '', + 'You can also expand **Assets** below on this release page.', + '', ].join('\n'); core.setOutput('body', body); From 16e710bd7f10c9ffd05b59ac318cd263de831581 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 18:57:39 +0300 Subject: [PATCH 11/77] ci(release): bump versionCode so APKs install as updates Co-authored-by: Cursor --- .github/workflows/release-apk.yml | 11 +++++++++-- gradle.properties | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index 4898c20466..7421586c9c 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -78,7 +78,10 @@ jobs: echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "prerelease=${PRERELEASE}" >> "$GITHUB_OUTPUT" echo "title=${TITLE}" >> "$GITHUB_OUTPUT" - echo "Resolved release ${TAG} (prerelease=${PRERELEASE})" + # Monotonic versionCode so sideload installs upgrade in place (keeps app data). + VERSION_CODE="$(date -u +%y%m%d%H%M)" + echo "version_code=${VERSION_CODE}" >> "$GITHUB_OUTPUT" + echo "Resolved release ${TAG} (prerelease=${PRERELEASE}, versionCode=${VERSION_CODE})" - name: Set up JDK 21 uses: actions/setup-java@v5 @@ -110,7 +113,11 @@ jobs: echo "keyPassword=994273" >> keystore.properties - name: Build Phone release APKs - run: gradle :app:assembleRelease -Ppixelplay.enableAbiSplits=true + run: > + gradle :app:assembleRelease + -Ppixelplay.enableAbiSplits=true + -PAPP_VERSION_NAME=${{ steps.meta.outputs.version }} + -PAPP_VERSION_CODE=${{ steps.meta.outputs.version_code }} - name: Verify APKs run: | diff --git a/gradle.properties b/gradle.properties index 83b824b752..e0c97d6555 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -APP_VERSION_NAME=0.7.5-beta -APP_VERSION_CODE=9 +APP_VERSION_NAME=0.8.1-soundcloud +APP_VERSION_CODE=10 org.gradle.configuration-cache=true # Adjusted to stay within 3GB limit and avoid thrashing # Increased heap to 4GB and Metaspace to 1024m to prevent memory exhaustion From b0db1642637b2929401c75dbe44a8512e582ebbb Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 19:00:01 +0300 Subject: [PATCH 12/77] fix(soundcloud): match Library header inset and open Experimental settings Co-authored-by: Cursor --- .../pixelplay/presentation/navigation/AppNavigation.kt | 1 - .../pixelplay/presentation/screens/SoundCloudScreen.kt | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt index 82519a61aa..a782db03ba 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt @@ -241,7 +241,6 @@ fun AppNavigation( SoundCloudScreen( playerViewModel = playerViewModel, navController = navController, - paddingValues = paddingValues, ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index 8c9a30c0ee..80e11da405 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -120,7 +120,6 @@ private const val WIDE_SCREEN_DP = 600 fun SoundCloudScreen( playerViewModel: PlayerViewModel, navController: NavController, - paddingValues: PaddingValues = PaddingValues(), viewModel: SoundCloudViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -232,7 +231,6 @@ fun SoundCloudScreen( Scaffold( modifier = Modifier .fillMaxSize() - .padding(paddingValues) .background(headerContainerColor), topBar = { Column(modifier = Modifier.background(headerContainerColor)) { @@ -265,7 +263,7 @@ fun SoundCloudScreen( actions = { FilledIconButton( modifier = Modifier.padding(end = 14.dp), - onClick = { navController.navigateSafely(Screen.Settings.route) }, + onClick = { navController.navigateSafely(Screen.Experimental.route) }, colors = IconButtonDefaults.filledIconButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, From 2531dd343df439d281e38d00583ffdaa817b542e Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 19:06:02 +0300 Subject: [PATCH 13/77] feat(soundcloud): multi-select downloads with Library batch actions Co-authored-by: Cursor --- .../presentation/screens/SoundCloudScreen.kt | 340 +++++++++++++++--- .../soundcloud/SoundCloudViewModel.kt | 2 +- 2 files changed, 297 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index 80e11da405..2e6531a16a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -6,6 +6,30 @@ package com.theveloper.pixelplay.presentation.screens import androidx.activity.compose.BackHandler +import com.theveloper.pixelplay.presentation.viewmodel.PlaylistViewModel +import com.theveloper.pixelplay.presentation.components.subcomps.SelectionCountPill +import com.theveloper.pixelplay.presentation.components.subcomps.SelectionActionRow +import com.theveloper.pixelplay.presentation.components.resolveNavBarOccupiedHeight +import com.theveloper.pixelplay.presentation.components.PlaylistBottomSheet +import com.theveloper.pixelplay.presentation.components.MultiSelectionBottomSheet +import com.theveloper.pixelplay.presentation.components.EditMultipleSongsSheet +import androidx.compose.ui.zIndex +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.animation.togetherWith +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.fadeOut +import androidx.compose.animation.fadeIn +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -125,6 +149,32 @@ fun SoundCloudScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val stablePlayer by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + val playlistViewModel: PlaylistViewModel = hiltViewModel() + val multiSelectionState = playerViewModel.multiSelectionStateHolder + val selectedSongs by multiSelectionState.selectedSongs.collectAsStateWithLifecycle() + val isSelectionMode by multiSelectionState.isSelectionMode.collectAsStateWithLifecycle() + val selectedSongIds by multiSelectionState.selectedSongIds.collectAsStateWithLifecycle() + val favoriteSongIds by playerViewModel.favoriteSongIds.collectAsStateWithLifecycle() + var showMultiSelectionSheet by remember { mutableStateOf(false) } + var showPlaylistBottomSheet by remember { mutableStateOf(false) } + var showBatchEditSheet by remember { mutableStateOf(false) } + var playlistSheetSongs by remember { mutableStateOf>(emptyList()) } + val downloadsSelectionActive = + isSelectionMode && uiState.section == SoundCloudSection.DOWNLOADS + val systemNavBarInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val navBarCompactMode by playerViewModel.navBarCompactMode.collectAsStateWithLifecycle() + val bottomBarHeightDp = resolveNavBarOccupiedHeight(systemNavBarInset, navBarCompactMode) + val onDownloadLongPress: (Song) -> Unit = remember(multiSelectionState, haptic) { + { song -> + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + multiSelectionState.toggleSelection(song) + } + } + val onDownloadSelectionToggle: (Song) -> Unit = remember(multiSelectionState) { + { song -> multiSelectionState.toggleSelection(song) } + } val sections = remember { SoundCloudSection.entries.filter { it != SoundCloudSection.SEARCH } } @@ -222,11 +272,32 @@ fun SoundCloudScreen( // Keep Back inside SoundCloud when browsing a playlist (don't jump to Library). BackHandler( - enabled = uiState.canNavigateBack || uiState.browsingPlaylistUrl != null, + enabled = downloadsSelectionActive, + ) { + multiSelectionState.clearSelection() + showMultiSelectionSheet = false + } + BackHandler( + enabled = !downloadsSelectionActive && + (uiState.canNavigateBack || uiState.browsingPlaylistUrl != null), ) { viewModel.navigateBack() } + LaunchedEffect(uiState.section) { + if (uiState.section != SoundCloudSection.DOWNLOADS) { + multiSelectionState.clearSelection() + showMultiSelectionSheet = false + showPlaylistBottomSheet = false + showBatchEditSheet = false + } + } + androidx.compose.runtime.DisposableEffect(Unit) { + onDispose { + multiSelectionState.clearSelection() + } + } + val headerContainerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) Scaffold( modifier = Modifier @@ -337,50 +408,72 @@ fun SoundCloudScreen( ) { Column(modifier = Modifier.fillMaxSize()) { - LibraryActionRow( + AnimatedContent( + targetState = downloadsSelectionActive, + label = "SoundCloudActionRowMode", + transitionSpec = { + (slideInHorizontally { -it } + fadeIn()) togetherWith + (slideOutHorizontally { it } + fadeOut()) + }, modifier = Modifier .fillMaxWidth() - .padding(start = 8.dp, top = 10.dp, end = 12.dp), - onMainActionClick = onShuffle, - mainActionEnabled = canShuffle, - iconRotation = 0f, - onSortClick = {}, - showSortButton = false, - isPlaylistTab = false, - isFoldersTab = false, - currentFolder = null, - folderRootPath = "", - folderRootLabel = "", - onFolderClick = {}, - onNavigateBack = {}, - trailingContent = { - ToggleSegmentButton( - modifier = Modifier.size(42.dp), - active = useTiles, - activeColor = MaterialTheme.colorScheme.primary, - inactiveColor = MaterialTheme.colorScheme.surfaceVariant, - activeContentColor = MaterialTheme.colorScheme.onPrimary, - inactiveContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - activeCornerRadius = 26.dp, - onClick = { viewModel.setUseTiles(true) }, - imageVector = Icons.Rounded.ViewModule, - contentDesc = stringResource(R.string.soundcloud_cd_view_tiles), + .padding(start = 8.dp, top = 10.dp, end = 12.dp) + .heightIn(min = 56.dp), + ) { inSelectionMode -> + if (inSelectionMode) { + SelectionActionRow( + selectedCount = selectedSongs.size, + onSelectAll = { + multiSelectionState.selectAll(uiState.downloads) + }, + onDeselect = { multiSelectionState.clearSelection() }, + onOptionsClick = { showMultiSelectionSheet = true }, ) - Spacer(modifier = Modifier.width(4.dp)) - ToggleSegmentButton( - modifier = Modifier.size(42.dp), - active = !useTiles, - activeColor = MaterialTheme.colorScheme.primary, - inactiveColor = MaterialTheme.colorScheme.surfaceVariant, - activeContentColor = MaterialTheme.colorScheme.onPrimary, - inactiveContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - activeCornerRadius = 26.dp, - onClick = { viewModel.setUseTiles(false) }, - imageVector = Icons.AutoMirrored.Rounded.ViewList, - contentDesc = stringResource(R.string.soundcloud_cd_view_list), + } else { + LibraryActionRow( + modifier = Modifier.fillMaxWidth(), + onMainActionClick = onShuffle, + mainActionEnabled = canShuffle, + iconRotation = 0f, + onSortClick = {}, + showSortButton = false, + isPlaylistTab = false, + isFoldersTab = false, + currentFolder = null, + folderRootPath = "", + folderRootLabel = "", + onFolderClick = {}, + onNavigateBack = {}, + trailingContent = { + ToggleSegmentButton( + modifier = Modifier.size(42.dp), + active = useTiles, + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = MaterialTheme.colorScheme.surfaceVariant, + activeContentColor = MaterialTheme.colorScheme.onPrimary, + inactiveContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + activeCornerRadius = 26.dp, + onClick = { viewModel.setUseTiles(true) }, + imageVector = Icons.Rounded.ViewModule, + contentDesc = stringResource(R.string.soundcloud_cd_view_tiles), + ) + Spacer(modifier = Modifier.width(4.dp)) + ToggleSegmentButton( + modifier = Modifier.size(42.dp), + active = !useTiles, + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = MaterialTheme.colorScheme.surfaceVariant, + activeContentColor = MaterialTheme.colorScheme.onPrimary, + inactiveContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + activeCornerRadius = 26.dp, + onClick = { viewModel.setUseTiles(false) }, + imageVector = Icons.AutoMirrored.Rounded.ViewList, + contentDesc = stringResource(R.string.soundcloud_cd_view_list), + ) + }, ) - }, - ) + } + } Spacer(modifier = Modifier.height(8.dp)) @@ -558,7 +651,16 @@ fun SoundCloudScreen( song = song, isCurrent = currentSongId == song.id, isPlaying = currentSongId == song.id && isPlaying, + isSelectionMode = downloadsSelectionActive, + isSelected = song.id in selectedSongIds, + selectionIndex = if (downloadsSelectionActive) { + multiSelectionState.getSelectionIndex(song.id) + } else { + null + }, onPlay = { onDownloadPlay(song) }, + onLongPress = { onDownloadLongPress(song) }, + onSelectionToggle = { onDownloadSelectionToggle(song) }, ) } } else { @@ -602,7 +704,21 @@ fun SoundCloudScreen( isCurrentSong = currentSongId == song.id, showMoreOptionsButton = false, onMoreOptionsClick = {}, - onClick = { onDownloadPlay(song) }, + isSelectionMode = downloadsSelectionActive, + isSelected = song.id in selectedSongIds, + selectionIndex = if (downloadsSelectionActive) { + multiSelectionState.getSelectionIndex(song.id) + } else { + null + }, + onLongPress = { onDownloadLongPress(song) }, + onClick = { + if (downloadsSelectionActive) { + onDownloadSelectionToggle(song) + } else { + onDownloadPlay(song) + } + }, ) } } else { @@ -630,12 +746,114 @@ fun SoundCloudScreen( color = MaterialTheme.colorScheme.primary, ) } + if (downloadsSelectionActive && selectedSongs.isNotEmpty()) { + SelectionCountPill( + selectedCount = selectedSongs.size, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = MiniPlayerHeight + 24.dp) + .zIndex(2f), + ) + } } // Box (content + loading overlay) } // Column (surface content) } // Surface } // header Column } // Scaffold + if (showMultiSelectionSheet && selectedSongs.isNotEmpty()) { + val activity = context as? android.app.Activity + MultiSelectionBottomSheet( + selectedSongs = selectedSongs, + favoriteSongIds = favoriteSongIds.toSet(), + onDismiss = { showMultiSelectionSheet = false }, + onPlayAll = { + playerViewModel.playSelectedSongs(selectedSongs) + showMultiSelectionSheet = false + }, + onAddToQueue = { + playerViewModel.addSelectedToQueue(selectedSongs) + showMultiSelectionSheet = false + }, + onPlayNext = { + playerViewModel.addSelectedAsNext(selectedSongs) + showMultiSelectionSheet = false + }, + onAddToPlaylist = { + playlistSheetSongs = selectedSongs + showMultiSelectionSheet = false + showPlaylistBottomSheet = true + }, + onToggleLikeAll = { shouldLike -> + if (shouldLike) { + playerViewModel.likeSelectedSongs(selectedSongs) + } else { + playerViewModel.unlikeSelectedSongs(selectedSongs) + } + showMultiSelectionSheet = false + }, + onShareAll = { + playerViewModel.shareSelectedAsZip(selectedSongs) + showMultiSelectionSheet = false + }, + onDeleteAll = { _, onComplete -> + activity?.let { + playerViewModel.deleteSelectedFromDevice(it, selectedSongs) { + showMultiSelectionSheet = false + viewModel.refreshDownloads() + onComplete(true) + } + } + }, + onBatchEdit = { + showMultiSelectionSheet = false + showBatchEditSheet = true + }, + ) + } + + if (showPlaylistBottomSheet && playlistSheetSongs.isNotEmpty()) { + val playlistUiState by playlistViewModel.uiState.collectAsStateWithLifecycle() + PlaylistBottomSheet( + playlistUiState = playlistUiState, + songs = playlistSheetSongs, + onDismiss = { + showPlaylistBottomSheet = false + playlistSheetSongs = emptyList() + }, + bottomBarHeight = bottomBarHeightDp, + playerViewModel = playerViewModel, + playlistViewModel = playlistViewModel, + ) + } + + if (showBatchEditSheet && selectedSongs.isNotEmpty()) { + EditMultipleSongsSheet( + visible = showBatchEditSheet, + songs = selectedSongs, + onDismiss = { showBatchEditSheet = false }, + onSave = { songs, title, artist, album, albumArtist, composer, genre, lyrics, trackNumber, discNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate -> + playerViewModel.saveBatchMetadata( + songs = songs, + title = title, + artist = artist, + album = album, + albumArtist = albumArtist, + composer = composer, + genre = genre, + lyrics = lyrics, + trackNumber = trackNumber, + discNumber = discNumber, + replayGainTrackGainDb = replayGainTrackGainDb, + replayGainAlbumGainDb = replayGainAlbumGainDb, + coverArtUpdate = coverArtUpdate, + ) + showBatchEditSheet = false + multiSelectionState.clearSelection() + }, + ) + } + if (showSectionSwitcherSheet) { SoundCloudSectionSwitcherSheet( sections = sections, @@ -904,11 +1122,23 @@ private fun SoundCloudSongTile( isCurrent: Boolean, isPlaying: Boolean, onPlay: () -> Unit, + isSelectionMode: Boolean = false, + isSelected: Boolean = false, + selectionIndex: Int? = null, + onLongPress: () -> Unit = {}, + onSelectionToggle: () -> Unit = {}, ) { Column( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onPlay), + .pointerInput(isSelectionMode) { + detectTapGestures( + onLongPress = { onLongPress() }, + onTap = { + if (isSelectionMode) onSelectionToggle() else onPlay() + }, + ) + }, ) { Box( modifier = Modifier @@ -937,7 +1167,29 @@ private fun SoundCloudSongTile( ) } } - if (isCurrent) { + if (isSelectionMode && isSelected) { + Surface( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f), + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + .size(28.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + text = (selectionIndex ?: 1).toString(), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + } + } + } else if (isCurrent && !isSelectionMode) { Surface( color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f), modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt index ea3ee0653e..778fa9b9e3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt @@ -432,7 +432,7 @@ class SoundCloudViewModel @Inject constructor( } } - private fun refreshDownloads() { + fun refreshDownloads() { val songs = downloader.listDownloadedSongs() _uiState.update { it.copy( From 58a9c362fdd21d8825ca7b77758d93e996fdab41 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 19:07:34 +0300 Subject: [PATCH 14/77] ci(release): use minutes-since-epoch versionCode within Int range Co-authored-by: Cursor --- .github/workflows/release-apk.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index 7421586c9c..a29254d0d4 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -78,8 +78,8 @@ jobs: echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "prerelease=${PRERELEASE}" >> "$GITHUB_OUTPUT" echo "title=${TITLE}" >> "$GITHUB_OUTPUT" - # Monotonic versionCode so sideload installs upgrade in place (keeps app data). - VERSION_CODE="$(date -u +%y%m%d%H%M)" + # Must fit in signed 32-bit Int (max 2147483647). yyMMddHHmm overflows. + VERSION_CODE="$(( $(date -u +%s) / 60 ))" echo "version_code=${VERSION_CODE}" >> "$GITHUB_OUTPUT" echo "Resolved release ${TAG} (prerelease=${PRERELEASE}, versionCode=${VERSION_CODE})" From 2a08fb8ff390a87afe68fa3342a01423b1f7c78c Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 19:25:45 +0300 Subject: [PATCH 15/77] fix(soundcloud): stop 429 DRM spam, auto-refresh client_id, vertical tiles Co-authored-by: Cursor --- app/proguard-rules.pro | 5 + .../presentation/screens/SoundCloudScreen.kt | 41 ++++-- .../pixelplay/soundcloud/SoundCloudClient.kt | 139 +++++++++++++----- .../soundcloud/SoundCloudDownloader.kt | 5 +- .../soundcloud/SoundCloudPrefsViewModel.kt | 4 +- .../soundcloud/SoundCloudSessionApi.kt | 29 +++- .../soundcloud/SoundCloudViewModel.kt | 22 +-- 7 files changed, 171 insertions(+), 74 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 6e2980b6dc..75b442a2e5 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -221,6 +221,11 @@ -dontwarn org.mozilla.javascript.XMLLib -dontwarn org.schabi.newpipe.extractor.** +# SoundCloud client_id is injected reflectively into NewPipe. +-keepclassmembers class org.schabi.newpipe.extractor.services.soundcloud.SoundcloudParsingHelper { + static java.lang.String clientId; +} + # Rhino / NewPipe optional JDK APIs not on Android -dontwarn org.mozilla.javascript.** -dontwarn javax.script.** diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index 2e6531a16a..5f2ab08340 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -939,22 +939,33 @@ private fun SoundCloudFeedHome( fontWeight = FontWeight.ExtraBold, ) if (useTiles) { - LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), + val tileColumns = if (isWideScreen) 3 else 2 + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - items(shelf.items, key = { "${shelf.id}:${it.url}" }) { hit -> - Box(modifier = Modifier.width(176.dp)) { - SoundCloudResultTile( - hit = hit, - enabled = enabled, - isResolving = resolvingKey == hit.url, - isLiked = hit.url in likedTrackUrls, - isLiking = likingUrl == hit.url, - onPlay = { onPlay(hit, shelf.items) }, - onDownload = { onDownload(hit) }, - onToggleLike = { onToggleLike(hit) }, - ) + shelf.items.chunked(tileColumns).forEach { rowHits -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + rowHits.forEach { hit -> + Box(modifier = Modifier.weight(1f)) { + SoundCloudResultTile( + hit = hit, + enabled = enabled, + isResolving = resolvingKey == hit.url, + isLiked = hit.url in likedTrackUrls, + isLiking = likingUrl == hit.url, + onPlay = { onPlay(hit, shelf.items) }, + onDownload = { onDownload(hit) }, + onToggleLike = { onToggleLike(hit) }, + ) + } + } + repeat(tileColumns - rowHits.size) { + Spacer(modifier = Modifier.weight(1f)) + } } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt index f32e35a595..b24817dab5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt @@ -96,8 +96,8 @@ class SoundCloudClient @Inject constructor( .filterIsInstance() .map { it.toHit() } .filter { it.url.isNotBlank() } + .take(limit) .toList() - .let { filterOutDrmTracks(it, limit) } } @Throws(Exception::class) @@ -111,8 +111,8 @@ class SoundCloudClient @Inject constructor( .asSequence() .mapNotNull { it.toHitOrNull() } .filter { it.url.isNotBlank() } + .take(limit) .toList() - .let { filterOutDrmTracks(it, limit) } } @Throws(Exception::class) @@ -130,28 +130,49 @@ class SoundCloudClient @Inject constructor( @Throws(Exception::class) fun loadPlaylistTracks(playlistUrl: String, limit: Int = 100): List { ensureClientIdReady() - val info = PlaylistInfo.getInfo(soundCloud(), playlistUrl.trim()) - return info.relatedItems - .asSequence() - .filterIsInstance() - .map { it.toHit() } - .filter { it.url.isNotBlank() } - .toList() - .let { filterOutDrmTracks(it, limit) } + try { + val info = PlaylistInfo.getInfo(soundCloud(), playlistUrl.trim()) + return info.relatedItems + .asSequence() + .filterIsInstance() + .map { it.toHit() } + .filter { it.url.isNotBlank() } + .take(limit) + .toList() + } catch (t: Throwable) { + if (looksLikeClientIdOrRateLimit(t)) { + refreshClientIdFromWeb() + val info = PlaylistInfo.getInfo(soundCloud(), playlistUrl.trim()) + return info.relatedItems + .asSequence() + .filterIsInstance() + .map { it.toHit() } + .filter { it.url.isNotBlank() } + .take(limit) + .toList() + } + throw wrapSoundCloudError(t, "Couldn't open this playlist") + } } @Throws(Exception::class) fun resolveTrack(url: String): SoundCloudResolvedTrack { - ensureClientIdReady() - val trimmedUrl = url.trim() - val info = try { - StreamInfo.getInfo(soundCloud(), trimmedUrl) + return try { + resolveTrackOnce(url) } catch (t: Throwable) { - throw IllegalStateException( - "No playable stream (often DRM encrypted-hls). Try another track. ${t.message}", - t, - ) + if (looksLikeClientIdOrRateLimit(t)) { + refreshClientIdFromWeb() + resolveTrackOnce(url) + } else { + throw wrapSoundCloudError(t, "No playable stream") + } } + } + + private fun resolveTrackOnce(url: String): SoundCloudResolvedTrack { + ensureClientIdReady() + val trimmedUrl = url.trim() + val info = StreamInfo.getInfo(soundCloud(), trimmedUrl) val stream = pickBestAudioStream(info.audioStreams) ?: throw IllegalStateException( "No progressive/plain stream available. DRM-protected tracks can't be played or downloaded.", @@ -222,31 +243,30 @@ class SoundCloudClient @Inject constructor( .asSequence() .mapNotNull { it.toHitOrNull() } .filter { it.url.isNotBlank() } + .take(limit) .toList() - .let { filterOutDrmTracks(it, limit) } } /** - * Drop DRM-only tracks (encrypted-hls with no progressive/plain stream). - * Playlists are kept; unknown resolve failures keep the row (avoid over-hiding). + * Scrapes a fresh web client_id via NewPipe and injects it. + * Call when prefs are empty or the cached id has stopped working. */ - private fun filterOutDrmTracks( - hits: List, - limit: Int, - ): List { - if (hits.isEmpty()) return hits - val out = ArrayList(minOf(hits.size, limit)) - for (hit in hits) { - if (out.size >= limit) break - if (hit.kind == SoundCloudSearchHit.Kind.PLAYLIST) { - out.add(hit) - continue - } - if (!sessionApi.isDrmOnlyUrl(hit.url)) { - out.add(hit) - } + @Throws(Exception::class) + fun refreshClientIdFromWeb(): String { + clearInjectedClientId() + val scraped = try { + SoundcloudParsingHelper.clientId() + } catch (t: Throwable) { + throw IllegalStateException( + "Couldn't auto-fetch SoundCloud client_id. Paste one in Settings → Experimental. (${t.message})", + t, + ) } - return out + val trimmed = scraped.trim() + require(trimmed.isNotEmpty()) { "SoundCloud returned an empty client_id" } + setClientIdOverride(trimmed) + Timber.i("SoundCloud: refreshed client_id from web") + return trimmed } private fun InfoItem.toHitOrNull(): SoundCloudSearchHit? = when (this) { @@ -273,13 +293,25 @@ class SoundCloudClient @Inject constructor( ) private fun ensureClientIdReady() { - val id = runtimeClientId.get()?.trim().orEmpty() + var id = runtimeClientId.get()?.trim().orEmpty() if (id.isEmpty()) { - throw IllegalStateException("Set SoundCloud client_id in Settings → Experimental") + id = refreshClientIdFromWeb() } injectClientId(id) } + private fun clearInjectedClientId() { + try { + val field = SoundcloudParsingHelper::class.java.getDeclaredField("clientId") + field.isAccessible = true + synchronized(SoundcloudParsingHelper::class.java) { + field.set(null, null) + } + } catch (t: Throwable) { + Timber.w(t, "SoundCloud: could not clear injected client_id") + } + } + private fun injectClientId(clientId: String) { try { val field = SoundcloudParsingHelper::class.java.getDeclaredField("clientId") @@ -292,6 +324,35 @@ class SoundCloudClient @Inject constructor( } } + private fun looksLikeClientIdOrRateLimit(t: Throwable): Boolean { + val msg = generateSequence(t) { it.cause } + .mapNotNull { it.message } + .joinToString(" ") + .lowercase() + return msg.contains("clientid") || + msg.contains("client_id") || + msg.contains("client id") || + msg.contains("rate limited") || + msg.contains("429") || + msg.contains("recaptcha") + } + + private fun wrapSoundCloudError(t: Throwable, prefix: String): IllegalStateException { + val msg = generateSequence(t) { it.cause } + .mapNotNull { it.message } + .firstOrNull { it.isNotBlank() } + .orEmpty() + val friendly = when { + msg.contains("429", ignoreCase = true) || + msg.contains("rate limited", ignoreCase = true) -> + "$prefix: SoundCloud rate-limited you. Wait a bit and try again." + msg.contains("client", ignoreCase = true) -> + "$prefix: client_id missing/expired. Open Settings → Experimental and save a fresh client_id (or retry)." + else -> "$prefix. $msg" + } + return IllegalStateException(friendly, t) + } + private fun pickBestAudioStream(streams: List): AudioStream? { if (streams.isEmpty()) return null val progressive = streams.filter { it.deliveryMethod == DeliveryMethod.PROGRESSIVE_HTTP } diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloader.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloader.kt index bde6252ad8..5c81e65f06 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloader.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloader.kt @@ -5,7 +5,6 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.schabi.newpipe.extractor.downloader.Downloader import org.schabi.newpipe.extractor.downloader.Request import org.schabi.newpipe.extractor.downloader.Response -import org.schabi.newpipe.extractor.exceptions.ReCaptchaException import java.io.IOException import java.util.concurrent.TimeUnit @@ -31,7 +30,7 @@ internal class SoundCloudDownloader : Downloader() { .followSslRedirects(true) .build() - @Throws(IOException::class, ReCaptchaException::class) + @Throws(IOException::class) override fun execute(request: Request): Response { val bodyBytes = request.dataToSend() val requestBody = bodyBytes?.toRequestBody(null) @@ -58,7 +57,7 @@ internal class SoundCloudDownloader : Downloader() { client.newCall(builder.build()).execute().use { response -> if (response.code == 429) { - throw ReCaptchaException("Rate limited / challenge requested", request.url()) + throw IOException("SoundCloud rate limited (HTTP 429). Wait a moment and try again.") } val responseBody = response.body.string() return Response( diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt index 0628c30c85..68b5755248 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt @@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -43,8 +44,9 @@ class SoundCloudPrefsViewModel @Inject constructor( fun saveSession(oauthToken: String, cookieHeader: String) { viewModelScope.launch { + val knownClientId = settings.clientId.first().ifBlank { clientId.value } client.setSession(oauthToken, cookieHeader) - sessionApi.updateSession(oauthToken, cookieHeader, clientId.value) + sessionApi.updateSession(oauthToken, cookieHeader, knownClientId.ifBlank { null }) val profile = withContext(Dispatchers.IO) { runCatching { sessionApi.fetchMe() }.getOrNull() } diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt index ef2b5de0c6..0e8ebf3136 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt @@ -206,8 +206,7 @@ class SoundCloudSessionApi @Inject constructor() { durationSec = (durationMs / 1000L).coerceAtLeast(0L), thumbnailUrl = track.optString("artwork_url") .ifBlank { user?.optString("avatar_url").orEmpty() } - .replace("-large", "-t500x500") - .takeIf { it.isNotBlank() }, + .let { if (it.isBlank()) null else upgradeArtworkUrl(it) }, kind = SoundCloudSearchHit.Kind.TRACK, ) } @@ -215,20 +214,38 @@ class SoundCloudSessionApi @Inject constructor() { private fun playlistToHit(playlist: JSONObject): SoundCloudSearchHit? { val url = playlist.optString("permalink_url").ifBlank { return null } val user = playlist.optJSONObject("user") + val firstTrackArt = playlist.optJSONArray("tracks") + ?.optJSONObject(0) + ?.optString("artwork_url") + .orEmpty() + val art = sequenceOf( + playlist.optString("artwork_url"), + playlist.optString("calculated_artwork_url"), + firstTrackArt, + user?.optString("avatar_url").orEmpty(), + ) + .map { it.trim() } + .firstOrNull { it.isNotBlank() } + ?.let { upgradeArtworkUrl(it) } return SoundCloudSearchHit( url = url, title = playlist.optString("title").ifBlank { "Playlist" }, artist = user?.optString("username").orEmpty().ifBlank { "SoundCloud" }, durationSec = 0, - thumbnailUrl = playlist.optString("artwork_url") - .ifBlank { user?.optString("avatar_url").orEmpty() } - .replace("-large", "-t500x500") - .takeIf { it.isNotBlank() }, + thumbnailUrl = art, kind = SoundCloudSearchHit.Kind.PLAYLIST, streamCount = playlist.optLong("track_count", 0L).coerceAtLeast(0L), ) } + private fun upgradeArtworkUrl(url: String): String { + return url + .replace("-large.", "-t500x500.") + .replace("-t200x200.", "-t500x500.") + .replace("-t67x67.", "-t500x500.") + .replace("-badge.", "-t500x500.") + } + /** * Returns true when every known audio transcoding is DRM encrypted-hls * (cbc-/ctr-encrypted-hls). Tracks with progressive or plain HLS stay visible. diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt index 778fa9b9e3..b347fa15b4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt @@ -98,11 +98,9 @@ class SoundCloudViewModel @Inject constructor( viewModelScope.launch { delay(350) val state = _uiState.value - if (state.clientId.isNotBlank()) { - selectSection( - if (state.isSignedIn) SoundCloudSection.FEED else SoundCloudSection.DISCOVER, - ) - } + selectSection( + if (state.isSignedIn) SoundCloudSection.FEED else SoundCloudSection.DISCOVER, + ) } } @@ -289,7 +287,7 @@ class SoundCloudViewModel @Inject constructor( setBusy(startHit.url) val resolved = withContext(Dispatchers.IO) { ensureClient() - val gate = Semaphore(4) + val gate = Semaphore(2) coroutineScope { candidates.map { hit -> async { @@ -333,7 +331,7 @@ class SoundCloudViewModel @Inject constructor( setBusy() return withContext(Dispatchers.IO) { ensureClient() - val gate = Semaphore(4) + val gate = Semaphore(2) coroutineScope { tracks.map { hit -> async { @@ -728,9 +726,13 @@ class SoundCloudViewModel @Inject constructor( } } - private fun ensureClient() { - val id = _uiState.value.clientId - if (id.isBlank()) throw IllegalStateException("Set client_id in Settings → Experimental") + private suspend fun ensureClient() { + var id = _uiState.value.clientId.trim() + if (id.isBlank()) { + id = withContext(Dispatchers.IO) { client.refreshClientIdFromWeb() } + settings.setClientId(id) + _uiState.update { it.copy(clientId = id) } + } client.setClientIdOverride(id) } From 0f3dc7e91ff2ea4186d5f5c42600adc454eafede Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 19:27:56 +0300 Subject: [PATCH 16/77] fix(playback): catch FGS start denial in MusicService.onStartCommand Co-authored-by: Cursor --- .../pixelplay/data/service/MusicService.kt | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index adab461ca8..7705ab13a6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -405,9 +405,10 @@ class MusicService : MediaLibraryService() { val existingHandler = Thread.currentThread().uncaughtExceptionHandler previousMainThreadExceptionHandler = existingHandler Thread.currentThread().setUncaughtExceptionHandler { thread, throwable -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - throwable is ForegroundServiceStartNotAllowedException - ) { + val isFgsDenied = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + generateSequence(throwable) { it.cause } + .any { it is ForegroundServiceStartNotAllowedException } + if (isFgsDenied) { Timber.tag(TAG).w(throwable, "Suppressed ForegroundServiceStartNotAllowedException from Media3/Cast internal path") } else { existingHandler?.uncaughtException(thread, throwable) @@ -1055,6 +1056,7 @@ class MusicService : MediaLibraryService() { ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK ) } catch (e: Exception) { + // Includes ForegroundServiceStartNotAllowedException on API 31+. Timber.tag(TAG).w(e, "Failed to promote service to foreground for external command") } } @@ -1175,10 +1177,31 @@ class MusicService : MediaLibraryService() { } } } - val startCommandResult = super.onStartCommand(intent, flags, startId) + val startCommandResult = try { + super.onStartCommand(intent, flags, startId) + } catch (e: Exception) { + // Android 12+: Media3 may call startForeground() from onStartCommand while the app + // is cached/backgrounded (widget/media-button/system restart). That throws + // ForegroundServiceStartNotAllowedException and kills the process if uncaught. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + e is ForegroundServiceStartNotAllowedException + ) { + Timber.tag(TAG).w( + e, + "super.onStartCommand startForeground denied; continuing without FGS promote" + ) + START_STICKY + } else { + throw e + } + } if (needsTemporaryForeground || startedTemporaryForegroundInOnCreate) { if (mediaSession?.player?.hasForegroundPlaybackIntent() != true) { - stopForeground(STOP_FOREGROUND_REMOVE) + try { + stopForeground(STOP_FOREGROUND_REMOVE) + } catch (e: Exception) { + Timber.tag(TAG).w(e, "stopForeground after temporary FGS failed") + } if (needsTemporaryForeground) { stopSelfResult(startId) } From 50ecaf0096b8a7c9ff19118e4f147f78917860a0 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 19:28:48 +0300 Subject: [PATCH 17/77] fix(soundcloud): pad list bottom for mini player and nav bar Co-authored-by: Cursor --- .../pixelplay/presentation/screens/SoundCloudScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index 5f2ab08340..f6611c60ef 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -202,7 +202,7 @@ fun SoundCloudScreen( start = 16.dp, end = 16.dp, top = 8.dp, - bottom = MiniPlayerHeight + 36.dp, + bottom = bottomBarHeightDp + MiniPlayerHeight + ListExtraBottomGap, ) val currentSongId = stablePlayer.currentSong?.id val isPlaying = stablePlayer.isPlaying @@ -751,7 +751,7 @@ fun SoundCloudScreen( selectedCount = selectedSongs.size, modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = MiniPlayerHeight + 24.dp) + .padding(bottom = bottomBarHeightDp + MiniPlayerHeight + 16.dp) .zIndex(2f), ) } From e464d1b8dd31668ee66849ae7848100e4005fcd1 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 19:32:13 +0300 Subject: [PATCH 18/77] feat(soundcloud): share tracks and playlists as links Co-authored-by: Cursor --- .../presentation/screens/SoundCloudScreen.kt | 52 ++++++++++++++++++- app/src/main/res/values/strings.xml | 1 + 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index f6611c60ef..042325196a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -5,6 +5,8 @@ package com.theveloper.pixelplay.presentation.screens +import android.content.Intent + import androidx.activity.compose.BackHandler import com.theveloper.pixelplay.presentation.viewmodel.PlaylistViewModel import com.theveloper.pixelplay.presentation.components.subcomps.SelectionCountPill @@ -65,6 +67,7 @@ import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.PlaylistPlay import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Share import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material.icons.rounded.ViewModule import androidx.compose.material3.ExperimentalMaterial3Api @@ -674,6 +677,7 @@ fun SoundCloudScreen( onPlay = { onHitPlay(hit, uiState.results) }, onDownload = { viewModel.download(hit) }, onToggleLike = { viewModel.toggleLike(hit) }, + onShare = { shareSoundCloudLink(context, hit) }, ) } } @@ -732,6 +736,7 @@ fun SoundCloudScreen( onPlay = { onHitPlay(hit, uiState.results) }, onDownload = { viewModel.download(hit) }, onToggleLike = { viewModel.toggleLike(hit) }, + onShare = { shareSoundCloudLink(context, hit) }, ) } } @@ -882,6 +887,7 @@ private fun SoundCloudFeedHome( onToggleLike: (SoundCloudSearchHit) -> Unit, onLoadMore: () -> Unit, ) { + val context = LocalContext.current var selectedShelfId by remember(shelves.map { it.id }) { mutableStateOf(shelves.firstOrNull()?.id.orEmpty()) } @@ -960,6 +966,7 @@ private fun SoundCloudFeedHome( onPlay = { onPlay(hit, shelf.items) }, onDownload = { onDownload(hit) }, onToggleLike = { onToggleLike(hit) }, + onShare = { shareSoundCloudLink(context, hit) }, ) } } @@ -984,6 +991,7 @@ private fun SoundCloudFeedHome( onPlay = { onPlay(hit, shelf.items) }, onDownload = { onDownload(hit) }, onToggleLike = { onToggleLike(hit) }, + onShare = { shareSoundCloudLink(context, hit) }, ) } } @@ -1246,6 +1254,7 @@ private fun SoundCloudResultTile( onPlay: () -> Unit, onDownload: () -> Unit, onToggleLike: () -> Unit, + onShare: () -> Unit, ) { Column( modifier = Modifier @@ -1294,7 +1303,25 @@ private fun SoundCloudResultTile( .align(Alignment.Center), ) } - } else if (hit.kind == SoundCloudSearchHit.Kind.TRACK) { + } else { + IconButton( + onClick = onShare, + enabled = enabled && hit.url.isNotBlank(), + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.72f), + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + imageVector = Icons.Rounded.Share, + contentDescription = stringResource(R.string.soundcloud_share_link), + ) + } + } + if (!isResolving && hit.kind == SoundCloudSearchHit.Kind.TRACK) { IconButton( onClick = onToggleLike, enabled = enabled && !isLiking, @@ -1364,6 +1391,7 @@ private fun SoundCloudResultRow( onPlay: () -> Unit, onDownload: () -> Unit, onToggleLike: () -> Unit, + onShare: () -> Unit, ) { Surface( color = MaterialTheme.colorScheme.surfaceContainer, @@ -1449,6 +1477,14 @@ private fun SoundCloudResultRow( } } + if (!isResolving && hit.url.isNotBlank()) { + IconButton(onClick = onShare, enabled = enabled) { + Icon( + imageVector = Icons.Rounded.Share, + contentDescription = stringResource(R.string.soundcloud_share_link), + ) + } + } if (hit.kind == SoundCloudSearchHit.Kind.TRACK && !isResolving) { IconButton(onClick = onToggleLike, enabled = enabled && !isLiking) { if (isLiking) { @@ -1487,6 +1523,20 @@ private fun SoundCloudResultRow( } } + +private fun shareSoundCloudLink(context: android.content.Context, hit: SoundCloudSearchHit) { + val url = hit.url.trim() + if (url.isBlank()) return + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, hit.title) + putExtra(Intent.EXTRA_TEXT, url) + } + context.startActivity( + Intent.createChooser(send, context.getString(R.string.soundcloud_share_link)), + ) +} + private fun formatDuration(seconds: Long): String { val m = seconds / 60 val s = seconds % 60 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9555b25e1..5b1d61ad96 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,6 +37,7 @@ Download Like track Unlike track + Share link Shuffle Shuffle play current list Tiles From dee14e9cb31a11a915aaa48522ca5b300117bd93 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 19:34:35 +0300 Subject: [PATCH 19/77] docs: rewrite README as user-focused download and SoundCloud guide Co-authored-by: Cursor --- README.md | 251 ++++++++++++++++++------------------------------------ 1 file changed, 84 insertions(+), 167 deletions(-) diff --git a/README.md b/README.md index 10fddba929..88f7eab69f 100644 --- a/README.md +++ b/README.md @@ -1,215 +1,132 @@ -# PixelPlayer 🎵 +# PixelPlayer

- App Icon -

-

-PixelPlayerHQ%2FPixelPlayer | Trendshift + App Icon

+

- A beautiful, feature-rich music player for Android
- Built with Jetpack Compose and Material Design 3 + A beautiful music player for Android
+ Local library, lyrics, playlists — plus SoundCloud streaming & downloads

- Screenshot 1 - Screenshot 2 - Screenshot 3 - Screenshot 4 + Home + Player + Library + Search

- - Latest Release - - - Total Downloads - - Android 11+ - Kotlin + + Latest Release + + Android 11+

--- -## ‼️ DISCLAIMER -- No fork of this project will recieve support, if you use a fork, ask the forker to support you. +## Download & install ---- - -## ✨ Features - -### 🎨 Modern UI/UX -- **Material You** - Dynamic color theming that adapts to your wallpaper -- **Smooth Animations** - Fluid transitions and micro-interactions -- **Customizable UI** - Adjustable corner radius and navigation bar settings -- **Dark/Light Theme** - Automatic or manual theme switching -- **Album Art Colors** - Dynamic color extraction from album artwork - -### 🎵 Powerful Playback -- **Media3 ExoPlayer** - Industry-leading audio engine with FFmpeg support -- **Background Playback** - Full media session integration -- **Queue Management** - Drag-and-drop reordering -- **Shuffle & Repeat** - All playback modes supported -- **Gapless Playback** - Seamless transitions between tracks -- **Custom Transitions** - Configure crossfades between songs - -### 📚 Library Management -- **Multi-format Support** - MP3, FLAC, AAC, OGG, WAV, and more -- **Browse By** - Songs, Albums, Artists, Genres, Folders -- **Smart Artist Parsing** - Configurable delimiters for multi-artist tracks -- **Album Artist Grouping** - Proper album organization -- **Folder Filtering** - Choose which directories to scan - -### 🔍 Discovery & Organization -- **Full-text Search** - Search across your entire library -- **Daily Mix** - AI-powered personalized playlist based on listening habits -- **Playlists** - Create and manage custom playlists -- **Statistics** - Track your listening history and habits - -### 🎤 Lyrics -- **Synchronized Lyrics** - LRC format via LRCLIB API -- **Lyrics Editing** - Modify or add lyrics to your tracks -- **Scrolling Display** - Follow along as you listen - -### 🖼️ Artist Artwork -- **Deezer Integration** - Automatic artist images from Deezer API -- **Smart Caching** - Memory (LRU) + database caching for offline access -- **Fallback Icons** - Beautiful placeholders when images unavailable - -### 📲 Connectivity -- **Chromecast** - Stream to your TV or smart speakers -- **Android Auto** - Full Android Auto support for in-car playback (Soon) -- **Widgets** - Home screen control with Glance widgets - -### ⚙️ Advanced Features -- **Tag Editor** - Edit metadata with TagLib (MP3, FLAC, M4A support) -- **AI Playlists** - Generate playlists with AI (Supports Gemini, Deepseek, OpenAI, etc.) +1. Open **[Latest Release](https://github.com/kibetmasi/PixelPlayer/releases/latest)**. +2. Under **Assets**, download the APK that matches your phone: + - **`…-arm64-v8a.apk`** — most phones from ~2017 onward (**recommended**) + - **`…-armeabi-v7a.apk`** — older 32-bit devices +3. On your phone, open the downloaded file and install. + - If Android blocks it, allow **Install unknown apps** for your browser / Files app. +4. Open PixelPlayer and grant **music / files** access when asked so your local library can load. ---- +> Tip: each new release can update the app **in place** (same signing key). You usually don’t need to uninstall first — that keeps login and settings. -## 🛠️ Tech Stack - -| Category | Technology | -|----------|------------| -| **Language** | [Kotlin](https://kotlinlang.org/) 100% | -| **UI Framework** | [Jetpack Compose](https://developer.android.com/jetpack/compose) | -| **Design System** | [Material Design 3](https://m3.material.io/) | -| **Audio Engine** | [Media3 ExoPlayer](https://developer.android.com/guide/topics/media/media3) + FFmpeg | -| **Architecture** | MVVM with StateFlow/SharedFlow | -| **DI** | [Hilt](https://dagger.dev/hilt/) | -| **Database** | [Room](https://developer.android.com/training/data-storage/room) | -| **Networking** | [Retrofit](https://square.github.io/retrofit/) + OkHttp | -| **Image Loading** | [Coil](https://coil-kt.github.io/coil/) | -| **Async** | Kotlin Coroutines & Flow | -| **Background Tasks** | WorkManager | -| **Metadata** | [TagLib](https://github.com/nicholaus/taglib-android) | -| **Widgets** | [Glance](https://developer.android.com/jetpack/compose/glance) | +Optional: use [Obtainium](https://github.com/ImranR98/Obtainium) pointed at this repo to get update notifications. --- -## 📱 Requirements +## SoundCloud setup -- **Android 11** (API 30) or higher -- **6GB RAM** recommended for smooth performance +SoundCloud in PixelPlayer needs a **web `client_id`** (from SoundCloud’s website). Signing in alone is not enough for streaming. ---- +### 1. Open SoundCloud settings in the app -## 🚀 Getting Started +1. Go to the **SoundCloud** tab. +2. Tap the **gear** (top right) → opens **Experimental**. +3. You’ll see fields for **client_id**, optional username, and **Sign in**. -### Prerequisites +### 2. Get a `client_id` from your browser -- Android Studio Ladybug | 2024.2.1 or newer -- Android SDK 29+ -- JDK 11+ +Do this on a computer (Chrome / Edge / Firefox): -### Installation +1. Open [https://soundcloud.com](https://soundcloud.com) and play any track (signed in or not). +2. Press **F12** (or right‑click → **Inspect**) to open Developer Tools. +3. Open the **Network** tab. +4. In the filter box, type: `client_id` +5. Click around or pause/play so new requests appear. +6. Click a request whose URL looks like: + `https://api-v2.soundcloud.com/…?client_id=xxxxxxxx…` +7. Copy the long `client_id` value (letters and numbers after `client_id=`). -1. **Clone the repository** - ```sh - git clone https://github.com/theovilardo/PixelPlayer.git - ``` +**Screenshots help here** — if you send them, drop them into `assets/` as: -2. **Open in Android Studio** - - Open Android Studio - - Select "Open an Existing Project" - - Navigate to the cloned directory +| File | What to show | +|------|----------------| +| `assets/soundcloud-devtools-network.jpg` | Network tab with `client_id` in the filter | +| `assets/soundcloud-devtools-request.jpg` | A request URL with `client_id=…` highlighted | +| `assets/soundcloud-app-settings.jpg` | PixelPlayer Experimental screen with the client_id field | -3. **Sync and Build** - - Wait for Gradle to sync dependencies - - Build the project (Build → Make Project) - -4. **Run** - - Connect a device or start an emulator - - Click Run (▶️) - ---- - -## ⬇️ Download +*(Those images are optional placeholders until you add them.)* + ---- +### 3. Paste it into PixelPlayer -## 📂 Project Structure - -``` -app/src/main/java/com/theveloper/pixelplay/ -├── data/ -│ ├── database/ # Room entities, DAOs, migrations -│ ├── model/ # Domain models (Song, Album, Artist, etc.) -│ ├── network/ # API services (LRCLIB, Deezer) -│ ├── preferences/ # DataStore preferences -│ ├── repository/ # Data repositories -│ ├── service/ # MusicService, HTTP server -│ └── worker/ # WorkManager sync workers -├── di/ # Hilt dependency injection modules -├── presentation/ -│ ├── components/ # Reusable Compose components -│ ├── navigation/ # Navigation graph -│ ├── screens/ # Screen composables -│ └── viewmodel/ # ViewModels -├── ui/ -│ ├── glancewidget/ # Home screen widgets -│ └── theme/ # Colors, typography, theming -└── utils/ # Extensions and utilities -``` +1. Back in **Experimental**, paste the value into **Web client_id**. +2. Tap **Save SoundCloud settings**. +3. (Recommended) Tap **Sign in to SoundCloud**, log in in the browser window, then confirm you’re signed in. +4. Return to the SoundCloud tab — Feed / Likes / playlists should load after a moment. ---- +### If something fails -## 🤝 Contributing +| Message / symptom | What to try | +|-------------------|-------------| +| “Set client_id in Settings…” | Paste a fresh `client_id` from the steps above and Save. | +| “Rate limited” / API 429 | Wait a minute, then try again. Don’t spam refresh. | +| “No playable stream” | Track may be DRM-only, or `client_id` expired — grab a new one. | +| Empty Feed while signed in | Sign out/in again; confirm Save after pasting `client_id`. | -Contributions are welcome! Please feel free to submit a Pull Request. - -1. Fork the Project -2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) -3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) -4. Push to the Branch (`git push origin feature/AmazingFeature`) -5. Open a Pull Request +> `client_id` can expire when SoundCloud updates their site. If streaming suddenly stops, repeat step 2 and Save again. --- -## 📄 License +## Everyday use (quick) + +- **Library** — your local music (songs, albums, artists, playlists, folders). +- **SoundCloud** — Feed, Discover, Likes, Tracks, Playlists, Downloads; search or paste a SoundCloud URL. +- **Downloads** — long-press to multi-select (play, queue, next, playlist, delete, share ZIP). +- Share a track/playlist from SoundCloud with the **share** icon — sends the **web link**. + +--- -This project is licensed under a Proprietary License - see the [LICENSE](LICENSE) file for details. +## Requirements -Portions contributed before 2026-05-12 remain available under the MIT License; see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). +- Android **11** or newer +- Storage / music permission for local files +- Internet for SoundCloud --- -

- Made with ❤️ by theovilardo -

-

- Logo designed by Aureal. -

+## Support & credits + +This build is maintained at **[kibetmasi/PixelPlayer](https://github.com/kibetmasi/PixelPlayer)** (SoundCloud-focused fork). + +Upstream project by [theovilardo](https://github.com/theovilardo/PixelPlayer). Logo by [Aureal](https://github.com/NPSummers). +See [LICENSE](LICENSE) and [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). From f66b6908ff3d22e80bf52eb7782bbbe12d34f580 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 19:44:40 +0300 Subject: [PATCH 20/77] docs: add client_id screenshot and drop Obtainium tip Co-authored-by: Cursor --- README.md | 31 ++++++------------------- assets/soundcloud-devtools-request.png | Bin 0 -> 20287 bytes 2 files changed, 7 insertions(+), 24 deletions(-) create mode 100644 assets/soundcloud-devtools-request.png diff --git a/README.md b/README.md index 88f7eab69f..6098d94333 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,6 @@ > Tip: each new release can update the app **in place** (same signing key). You usually don’t need to uninstall first — that keeps login and settings. -Optional: use [Obtainium](https://github.com/ImranR98/Obtainium) pointed at this repo to get update notifications. - --- ## SoundCloud setup @@ -60,31 +58,16 @@ Do this on a computer (Chrome / Edge / Firefox): 3. Open the **Network** tab. 4. In the filter box, type: `client_id` 5. Click around or pause/play so new requests appear. -6. Click a request whose URL looks like: - `https://api-v2.soundcloud.com/…?client_id=xxxxxxxx…` -7. Copy the long `client_id` value (letters and numbers after `client_id=`). - -**Screenshots help here** — if you send them, drop them into `assets/` as: - -| File | What to show | -|------|----------------| -| `assets/soundcloud-devtools-network.jpg` | Network tab with `client_id` in the filter | -| `assets/soundcloud-devtools-request.jpg` | A request URL with `client_id=…` highlighted | -| `assets/soundcloud-app-settings.jpg` | PixelPlayer Experimental screen with the client_id field | +6. Click a request to SoundCloud’s API — for example: + `https://api-v2.soundcloud.com/me?client_id=Pb72ranhoyt6gw7hM7TkzUItXlMWSNSo` +7. Copy the long value after `client_id=` (in the example above: `Pb72ranhoyt6gw7hM7TkzUItXlMWSNSo`). + Yours will look similar but may be different, and it can change over time. -*(Those images are optional placeholders until you add them.)* - - + +

Network → filter client_id → open a request → copy client_id from the Request URL

### 3. Paste it into PixelPlayer diff --git a/assets/soundcloud-devtools-request.png b/assets/soundcloud-devtools-request.png new file mode 100644 index 0000000000000000000000000000000000000000..2a54a506d70609919f02322f7958dd11f27e5ee2 GIT binary patch literal 20287 zcmb5V1yEdJw=7C^@V?tmqJ0rM}&ZYK=~vst^@%AH4Xk72oDSXEFUDC1pj*Hq$DK@ zQ9ed^0A7GL6Oj{vfT)T=dNP0kFC*AXYdAqbp!U4|yc@79GKPSVpZO#%qU^4FvWlXu ztg;4op7|j^%n*^rGAuAD_H$Kn-mjed>dlmhk1aD1H>9-rbz16{R_&rE1@(E|ObZ}V$RItvTZ3fg~@5QkRrlT!mw4xdECZ+uLR&h2YZ8j?wo{HoyOYs+JA41`Hj71 zSWL|3_EezzTF=3bp+zN@%+fNgMItS}ifqGn6yXD-{NeaxZ++TnC+(u{Ui-J6JF47Y z?daED2_paJVu|3{tIlCZ5w{y6b^Unrp(EoFTOAzWZ%n2aclA=kno~@R{|jX{lx7!u zBW5{qan+lPThYm4LlyG3p!k}?^=})%EOL;=-~W;4Xd1q)`4-AD$OYkHf5S4oi~ZlK ziVQ4CRR2~5et`Y2W7PHPr-X$ajv7J1ZrmxLs^tTXQ&&R3o9?UT1|Gbq*V+$VTeJ(>a#pG|{$`48vYVH;9lS9u$sE#V9F{Ba2FO=bMP zF{qYlGM|L}+N?0B1Co*F@JW%_F=3|Ds^(i^T=#ylxqgT&)2cFgHsd@x%n({je!x8i zapEcXrBxjIhmT7b313;4K7q{cV}LEhez4Tunwe1M_rA=c=A)UDrbXNxUv#U2if&&OHMhg_r80+`o+1NX?kX|_ zzL=usRLOiRDVX7C+W|9~ISSAZU~?|s>9~)^MH1-DTE#5=YMTt+RVbwF?4(;!#cU2W zhypHWbL=ke(k2+#@*p5!253zw3Y3q#U)FW1Z$7FkdjyF)gQ}&k##XBd7=pOpgGavdz@>ZrQ>1=3=wB`xtAmdtuLwO(eI4_ZtQ8`O#N$VtUod7F`& zyUQg;B<=dQ@AK`ZS0WoP0l54*>6Y0khYJsG`wRJeyq5W=@ace{eTOwogz`%P(gZjy zykSmfLSBSPJGpLsmG()_a&H+gz0w6I*>(?;k`5N;tJo@Yz%ceZ1UMJ_Mugn5>QlQ#K6Ow`U)~m$L5Da z#sA(O1Z#0r9qmgY^x`iD*BiQde1~^ec=@99@j*QEi`4;{96Mod9wW;;-?? z)TE8^6|^y->NJyoREEaV!gi^Quz>SsvKMX0y6^b_K^*Sf?Bo}}9dcAV4-&scA7b-K z6ua*T;%bRr0hwbP_Ma%6%}r&m>L)X|(OX}s?UV`m0;z14wr6hDOP@YMGu_?^V?5x2 z5-Ap#tvFsjs2-{AIIG(63z+e*Ad2&pVUfRFL!a7N;ug1Qr-eizarL+iTVJ4;kYdsX_Kp~{_Golc+(5eSk1-0}Bl%TJLQs4!Zt)41D_7$cM#2pV=9EFv zdcK{LWk_+Ao8NZ?g)+J)r}`T^G@c7QwAyZeuUmXPN{rsisVc!4D+sq*D&gZwe%~&H zFtTnHBGirrx#tbwW?H3wG!NNO>wE4pZT-0&j6-Y}u!3f~@mvuAg39bVf9GqT(6BxA z(YQj-zsyFj7wILj@~RGI!M&JBa?~AfH8upR=BL9T7v`kj>9++Ov!S15Mu$sRKVoZd za0#2EyU*9kzT6KXm)^H_U%D{s90{&)8S}s3?lV9NP4+Q0-9Is%86N#)GOTYL%S|4z z*W;R`(w!a&{ZVz8X`#tm(dRPK?kL#R?043!c%$AKoTg*G(v-O48`Z{QIn1_Su*PSS zd`5oTrk%Q{QedN!NWV@H^x6#H*4Kg1luxwb(!pKtZg;8&!k#-W{$hi-CE)qqr&q9G z<{>gi{)q^gL*S8As!VK?A<$+t*xI|Dp14n>hGlr*=|hNYzV1^vQLB`=zPTOKN&8?& zTu%B(rMJyH#uAXvqd834Swqiv95YrBB?qb>6*Q4nn4jC>^EyP%quGu32_oHRhXssh^nl&vUfzNmq7x@pS{S zfS8;TbG}3n%$gR>p&4Uu2*zEG*qIayv>da8IC}FENl(bP;QNtbJ8^Fl-}}num%LrK zbm5g&_14%qD5FsKtgYqvBxW{jio*}F<)tjGqOjRUKV`V%C;~@F|kYzc+!C zDOMuHHQ_mwD+xa=w?}RRHb<@4`dFDg&#R2}^QR;63(5VO>~qVss3Zid|-FH!gyD^f$ty9h(%txQtC6Q zzl9N~sNmeac=NT+O8%oY@)@=0q=O#gSPqu9SD1~qUnr>WqN27@Z-uLKu>^{c5gYfm zf;x&{3_$$ADtTl5{Wy;cIe^_-$4Us9Xg@0A+0LN6D3c7zLX1n4jhkzvzj^WN#fz!K zD{qLtdzkx)FHVFI>W$cTpo;oai21T7Zj_HGip{VO1CxQ(F>>h;e~=^*Tuw}d``x#Y z9IeRQ`zI6!LJw5d*m@7$5Z^n*ep*vM0ab8t;yr|)#14fsE;T-`jT3sy) z{!8WWOYWx6?Su!s(ANC@pu!GA8tJ-hJdXl;aqPs=PnNkD4EQ zxUNt@1UOg5X5!+`d_R6h4Q*)1bJqK8!&)!j`z{beQ-)|!Z?2*#h=pGrQF=BO9oVknhbD{e( zKAMe;le#(KJC5s!o`$evMl^U&@1AThNC7MU7^;beG|Dd~e1qEZIXPaFIiWsv7^5Df zd3gt_LA2K(EG$N{-(O(Sw%89|`#G>ZG_Z`f<~7?2+YufK7gx23E5BzWmEDMDK2@DC z{;De~cDL#7sfql;x&5gzLg*l5YaQ9@iMJ`&;DFIuma+4|Nr;rDoBKl^uklPc^Zu z5djrb!p}Gq5g8S>0voFfCd2H{chTZ%3O4!Je`xB0ZUA=V zFQ+B)()mp^himm!3$F?2V{?1@B?_z>P(^2$?G|=QyJkFIJBRI}-qnP029x*!ca6&H zUX*_>I7h7aUb+w(Xc4iUM znCSIgl%_{^g_l+u<2`0*M0w|;9JCf=;+K1!kfo*jkF)QXS27H6+kqm}Eus=<13!nwz# z$QBXQI2*+MC32a&NzC6_MewBT+V zCQYTJE@$<>#_Jh-jhvdoiTPQq+H(n(@PaU3sg~qN@@gG60gD2`1*g2QX;SI;urOOS zEH&Ti2k%-3rD{4X?1!F?;#!KrTC*&v8@pgs^RV*tM}rr1v@8pXi>foTUx^uFO+j2w ze7Ikm&@3p1%bHQUUTZA^%TI`V{Kt0L?NXmLoOZ}xe%|8k`X)q1y48ud+ydJL%`p_y zyDfCk7w&a0x^Ab?xP7w8S~CvI^UVlOPhe$JfC zNO#_U@4o@3wK+T~Ys_9JWyDjgcz~zva1oT*!V<-{%zXKvPXp_00lQbOkY!f5PTDs; zT&EV79JM9m+SoD9w*OJeIJKe2|5LW*?GB_dW||1aJoV=49J8Ui#%(}PIeuRSe`jo|GL4V?QjUwF_+I>)dGn(ppYHcLb9~tgBj|ZyO<*HSB((L$Qd-BMeX3{ zGEcjPhL>5^eK~%cdt9z+&xtZxFS?g~x%B8PrOUWK8}cG!KS+`6)ZTUt**81DzD2FI;daFNZg~;qk;}h`xLDtl zfC{aG7oct}?gyPjMWPkK{MGZ;{aZMvSVVm(NsPJ;fx(5l4LK{tVkl3%7V$vZ76XXo zL<(H<5G9Z4tcn{|%h@{RdbijRyo}`y&n}2@9IY;&DX;@%?~v58H~&%byR?4KQ>r2Lm0! zM0&@f8c2Za(bT9-)Ixfx)atoy;ZJPr&0IK(M`b9VOpQi|73?urneX#)#ifvgWENfX z=oh1>^(v(L6*pd}4Vi5|Gimdfti0k1f{ghh@IALL!WP{Pl=;%)EpoYneWXACIG9*P ze>z-e@#@FC=XiKWt!#B#Zk=W`iFlVj(tlc^lD{@~SH&m?Et;O$g>;2YrU^YIHzJR> zBseZP8Q*X2%z^`o^dD(K+qTAVuu~xFwe7*~2(Y$(T0-wc!Ar8))&!Alkwh|kb&<=0 zzH)7RVHITi@bk%E<~E4gK^HmW)-JeaoqmA!!3FC(Y@Nr2-8+KjMD0?Y_A8lWuH4dzH8EBjit(Io<337zPLsg^K7|`z1W{!Lrp}w zV~H)$!v3qIu;M+f9N}_gL5&qK_3%cEzgHxpZ81h`co%Yg`{m+Hwl6>-_WGTeMY9Xb zXlhuPwc%eWCeDR0nTk9|EBdDqTdl1_ChaQTigwyWX?PngSbdp{>W>4fG}Q;DgvqG; zhfyrgkDWKDNCo+HLa%}MzvLF`-8#VP2zd@c@SFJKEaTzuXM&G4HUWRl2ZsuUAm=k! zU2&%1NV@NZh?d0wOfqb*B5YcojB!{DA=XKPzvM!kZVe9`q)OiXD*>tem3mT*nV(Hu z%iqob5C_NvpWxs4ALOgA{lt(Cik@OmBMuUlcRU^k@o)gm9*yxw9QbS$1`< zRE**Hyk5!s)o5HHYJYBt~QVD8nvH9OG@LJa9EG4Zm zA{vkUCrOSS`U)x@@gP$qse*9nn15PYy@tFnRj!4V^G#0(YS{qb@Z~%dUKI)yX+r#; z;`D%4Re`|OC%}1v-&&mu%}9ANp>irp%e zGkV}rP>=b&JEZ9urR6^oTYAZ)^;#$@&c?P&ruoQbAcgtK)aSaHI-4YZIg-+Z&EFz* zo_t!ZI8uaKs(3EtV@XY76Rgh^HtbrkOFvyGwxr|}i#fpJ#P44BynNefhKn#&Ij+Fp zv|EWW;G5~pKp%7fi!u|D-vh&%q+7@kiiEnnG8P*_FNm8gNI!)3>AA{0{c*yWJ-iI?5Cx2Gw^l{mD_1$f@-=0V-No{-Srfk#8{{=ht!7cgcuz# zOZ{MHt~r6%S~61Y!9`?l^yKy?JnmyFp>?pGgzK-j9f~PgK^L||&?k_j>~0cVi&tV7 z4eQUVGbM5ehw%s}kTgDKWE6VYszWDMJd(P#J9zb6_06fQHw3-L0{3cmIFOS&2u$|e ztVk|_f)(1y{QWewvRnnKXA&~oPHR6THtck*;SR*W!cWCP%2Fu*H@iAt5XFwO!c)^_ z(NJ&U`MJf_Ew*?i;c+X$$7U<@Gn7{9$El1xU4Gs48uh>QI?j@gJSRo#I;LY;du3T` zs0Q%L__D}{bcX7OQlb)H>$&89m00lhKsUsC!WUe{=48<6`aI>)*nT1TRlnJ5QKq^7 zEg>4!{a(c=>m&7?r44?%`5E$`(}}2Nd2eax)PCRcg2(oesdbig1y5Hz&HA>4Ye5I( z<9@G#xXpa&PxJYzBO5EYm510Pq^lTRw1XLxLi&dpeS$x%U#dx6M{PS>I{NmVYwfJO zYYGl)mK`y9<$U854|)y(4uZSYwljjg81}9$!!=9d;wohi?hM4f``NTIs`99a(S(Xx z$x#PKP{wvO_YK8}KD*x;>7+&~lZ!?cK27)_m{V94_iOOHUEF*Tao|D9lw4;%!|t!s z1$Z6L24--f{42eDv}d{=RE{BHOPTzer!Y{Gxt4q#S*3KcMoS672@9yeMUx~L#bal zjRNW1FFS++VfIdky53r>{G*9Zg!s8jo`>4p+WZq4lc%!6$Rr`pXTRnN)_d%Mqeu=8 zX@$hx+ucvAf28A1OT)t~O&+`w0&3{>nj9tK>4(ArH_5_PfRIkk5941F^isN=ejTjK zJYa;Qj-?|<1@ah~eQY0f5|&wQL za_7-jz*pKd8)bk1jd0$ zj-NljQoY#ysDF_&rJA!_C@RyUAOSfPT@QXXxpcc?cv&qSQUDCE&(5D2aZHE)9c1M@ zeCMN$g6FW>uOry*PgMiP@${AP&AD70RpEzgY}nZ;2dwV3uOoC4BXHpLT1irib2xKM`8;y;fQzys;HPM+>TiG zc(kz|SR2P-V9cfZDMe-jaUh!|AJ0%er&=(4E7{!ZcQ^X>TFSQ(M)=AsXSxK(;ImXu zDkW=sho%MgFQhA|x@s|>KxEPn9VkF$0fwwwWtdOV^d%e3mmO_VKTx_VNA)!mc53EW zZM0Zkv~)a3eAZ1oJp8ZlS_j@tjj(d4APZIluqoonf%4Z6ITh!!=LB=Sx5&}L_;2UL z^89-HMfQqnRIMp=1|I-8FH)0YcQNl~|GP3BH|zhgu_7-lNtNdRJ?#2BM(i6XD5_w0 zIY&yO09J+oHmQpSJzZCW^zC`Gpl0@dbVa}G(9BBRnasAQ8dZe`9Iz{sXNFub-9E0y z@%BC%8v|rJ7N%iP&Mf^~r%0;qlz9-xp7Ul~xH{m_>Z+v*vG2AnCrQrunL7= zMyml%nxsSrd*2&HY{7C3$WD<*b7{S-z8piD2;#hHUratTPtDR)Mx2 z>B%d90;4;z*#8q@N3b_8N(c$mgNpl4>P(R2!vtSSV9s(kv>)xhF7Ls2PZbe|tiGX2 zSXj}RMVIrHq+71ch@`pa_-do`4o;Z}uR}IuQ+}cf!u`c?g+2Q6pv{h;iyfNb)^(R9 z&lr(PNkdHx>IN9=;uZG}k;4z8c0rt0xr?k5(UVbOnKTS)R&Tc(Y;BR$vY)lv$^lN7 z-w!Z7ebcaz(4=kPV1;=aU)9`V;esn2w@ z4K+vomf?r)6E&y#@@=QGQJcMCq=}_dyt5gPBY!y^GQvhk?LJ2QRO_h4+#3kjV89TI zBm0OW5rkGLSn;Asq~etK72+b zlkVh;75ncI0MoU-;)l-DQ&{^}wJ7NlAE0B;HX7l?RMby#|A+gI7kF>V>6q6jYv;ag z&82!|*r=P3UnbCU12s!8Az3$ZQ+`K~ngHwQE8#B3?c2E^>7YYU{n=W};X~dlM%+k; zL=&=hpWqy%h0G(h4Jn z{@vRUO+IrL7<_Afs*iT&&@cQox|_FaaeZQ`0Om_M3um+wp>RDUv#wYO%q&NL= zJi;X9lxXEquTBWHzV&sQ<{IC-T_I7EA;`(76R1TW06NxfHV}k+IFBRPSc#t;9Dzx@e&Qw&eNzL!0 zDRdm_Ux~zu`6-pFZt3TC41otn`FsPk7Av!O!SZ)IMtb>2ECsG=LLw>1d6{BY&Mw+K z_!XM}xrMvG<7(_r2k7o~2)vNbR&@snXrya;gGX;yyrTN`|o7~X452?pI=^-2C1`2>@D-2WfKSk zUR0hG%13y4#@)6W@|Wu8&Cm>WR|1_@Guhx$XP?=B$)+ZZ-=!!ujE)Pdsf@HoDf<)B z)^~VL&_!QNHkcC|^Jk86s~xf1Ez4)s9ITMwh*bM3v4O$#j<5UWg>+hQ$?zt{gqK(L z&Otr${Tr=%3mqsMPP;mJx5f%bG;k?SKK7STDHFj?s}3&qirrW&i#9!J6yrZ1&z41k zt@VR{rh0W$7ke@@*k5l;ft&71>TrZ!Q9;0x7}RWmaN_(`6tSK{&~4TRS&>H5nvw_n zP8`))9mY5)=MJdSD{h{!kj95DCE>#f*Ysk#@_xVbu+V-iM`^5_b@vM<>~c_K8NErLp1B|0*CNb4s-S|b+TSidcVhoF zJZ075Cy-K!tM_P{Gky=PqNw518p3tyqv$q}a8^d+2PZ}*)Rf|7{vbGW>$Ir;k83GE zrxr!G>XGffj!P0N&9wdWLqan}6<>HS7OAo0gZ%x>t4Xgi6xcfK)3U10&jyA@XPZk2 zW?s~6j6wsaI1F#eZ$^!l4<{Uq_D-=%DoMrI)xXb>tLi?^Tt?5(e5?r@PK<8Y*KASKj3-^WqRyc+-!E-_R z-3=GgHzDy#pzbtsVJv<}L5bP-#&!r+b5u@Wez^Z8ZY6hT1H$Jj(I}LpzZ=`?DRYId z4et5A`&&7DFJ^!tTj17u@>wARlUab`>PM#Th&0j8Q)2cwlNjOOnwT+p@gs4y?uI^1_m#@u|LJL%Zoo`F}drDSP!*{Mwpo2_xiA{zXoox z*14LQPm~1_Ot*e=y10z7j0%G{pW4^GzPJS%q`0{m53wI$+lIMY;6HMF7CY7x>JhtI z|E4e|+u9CJb+ ziez*RE;wRF()`ME-QZYo;kv$Mt{@vr-a$`nhToBOS` zMPAeRyF@|}xsZ~JNBVNDO7~~0nu_?l)YF@a%Zy5HJuyw^fYUbphxW?JhGsv$>NY^> zD%Z_Y&sNFKyq=mQhaa`+<)i2q_9;)zwwE=Fqdu>VKkHDj z70==5M`zDkU=KGutO(V81A)iz=~njo$pm9-_X9b`yK~37ppo3^N@oi~(`yGv!n6&F zz}0%g8}~U)<9IbvItP;I5?$+>NwqmZTDiSg$H7;0md-j`U%n0tCh>494Nf7*u?K{( zXDM1g$=Ja_>OhlN>ShG5QR~!gRZYI_RV&o8f^)LVn+qM^XI2rWQz?9 z@mO{8K)rZcONOM_E@{0&>%1dDZv~WtfFH6(>z9PcAc84pIIX;-mY84fg0cnj^Wge* zIj~f&8&tG2?Y_~DD1O=K#7THJlC-F}wZf%}fQX#y)_<+$LyF=QVPnn7E5+2W+`~BQ zcuqnO>FQ#$AD|x}$j_k*ZCl`l1LsZ%ia9(;@5>|;rdK70K zu;Vx7OhmV7<6=C0cv0agd*wwb4;|j6wS$A@$TxD7K(FUlOa;R4c$y$tf5IPhN*JF= zT@NB(sO8o>63G*}@#(38dyL4MxA*ne3v;)#NsjKS^7=vrrKV_i(`MI&{ySw|E7{z_ zdra%co!gPAw}wSkd^O{G;pszifmQS2diZUF<=R@HyB;*(iAcmL{z~8a@6VHow@XaP zMPnNDB$@2muME*|tyr_~9)4fD|NgWVlevW2oh2CJY`ejK%NW_s7T0e=W_kY;q^f>U z!jDxU)e$@zEH}aC%(yFsZzf!|>CH1NKDa90yCo@*CcfEi;Urukz(y3@SSz|R??UtF zQS3>sbQx=i9Bq|O4qV1TkOsVy*c<2#y=hhY<)Q33rv!`FtXRbo54S zqa3D0^PE%)mS|#%P%US}$O#?uXR8!+dO*T>xF$3iEyR!cy-h21G(>T;$Se(5j?}tI z(W^ul(-MU=%y*SLH8Vy_we{R+9#MRk0vB0P!crCV0`f$BSO<$H_LdZ9Dx})!*LO4L zF#Z8#_v6y#p0-pO3=m&Cew=a?OYR)03m8!#&NL@SZR_16?HGGtn4Ik;{ z`Up%awQ@b>L{-!g2HT*hVf}7tQGV<;4y=IZEb-Ma)Vc8)iCc?5M2ec#GtT`zbV55$ zt3?t0&PgZ)R47xH;K$YPuhO6ndyoOIdUiu|X5D+R)}aRCOFmp)I`sM+EozmF882Cl zME_!n;>Whs7Dq9&^=Rkv);U)ctxQU1LiV0*WYS`v4&Ev;XiG_o9~TpXB$8}fG_3`q z_Jmb*X1R;Hly3=${YzI|V*2*{z!1)B@f3JyP{NeW4i}EJ-W$4}87f0}MQ!HgrPFKu zD?U0nO>L38_GqGQsN4F0s!xezW7`@GP*H2>0?ZEeW|UUs4ax?v>)_J5Z-z`e4b}=t3QHQNp^4G@kt zh)#f9_=5KW;5lcz$~uRDR(4!uk77+zFXo$3xGWRS&N=Fbg$#0`nbU#9^~BB(8*-G- zHn9e77>f#sh!Ze96lR8=?xd%5pZX9>idXjHk{S^PPN2?zIrM7f`&<3YI-vSYk&`kN z(>)Ba=!p3xwMxApdSu3uN69+Pq!AV)W+)5pLoh8fI>{s52Vl~49ZCR;C2rWzBoFm^ zANVd}h)(cm2{i-v$2H<=f=7Po@?6qN$*kU3bQpr#i9JG?5NanqI1m|SZ)QT1MOIWkZ%1115 zIgvp*N#cywTV=62_M{>n=}EqN^fLVf8}f*Zz!PZg(LP(;e#Tq%0Sk_PYIWzi<(B$LJbrkz zQps5cP?J)C3sVJQ*S0^CRF2gv^N#PSJQ(|jnh`Ji9>}MiNp3JQH4<*XI9!wD|J(kw zf2gt)Y~wndxm1K_3C*)Q2-?$f$O#t+sYN0T8vC)X&hK9I@htP3=HDes=rzxOmX|lX zxvA)CFWAOCOV0B0`QxhM>$bAxjdG^2j1jxIuXX1;7pNFn{UA7N4$0b7VJljDlJ9EiP z`(ig(=(_f80e_w%&*RF@zk{82E}4i~!%y}Tjke`X7?&L}U&7aUuFMt#`!bU>-*rZnP)F8Cq{=p;PG_ zUc-C>2RHfSLN?4#_YSoH)x&?4mHs>6)tJO0TzCvcFr*?t!^? zU!`7hY}|m?Y^haZaIqnvQkaG-G2wRlQnogRebxsUNKlbXFJUu1MX!~8l_nTX2)H#~ z4ZDwyz*T3T}MDA4+|~c(8S6iYLouo~24p#2DLk&1=J>ZNISV8lwE? ztz}h8^N_PD%;kC@$Hh^H9y_<9*|SQSI3QZy9fhF*d0LL01&v^)KE#({h~4VSy!56W zWm#NRfMoHmL|}7c0pq9hvYM#}vPW6JQE!jl>>P^Dx~fzuh`^M&Hcn}6ZSkF1G{KVb zquZ8tPuuj^>h;p#&eyBo-?qRO$Xh6m&h#i4d5|x-RV3Ix*?RuR@i-W`I!Ey&S4UDd zskAEP%A=>~B_timsyjRIXx*{GU3sTfF9EC%8*Z*B=FY5OKyHf@DG`H$;~p!$3m~I@%XRx z)PljG3aJeZq=7s7d#louI$~aS8v@{3g#T{q;xMi6hxdE5moNxX>%W|^qqJmVl}mg*{05F&4SdA zQ9bP5@tM5sHISM$T3?r4^^E-~U^-?a#ntxc z^VFJ&x9K-#6=JENERqE)qUofzRgnH&`s@k{VgcILZx4m*y8n_jD%Rp)?mP5-+Z#0h zKaGVnPa**zb!^)xq4kL3<8*LBR|B`MUg>{}8U+Sm|H=d2%rbq~W*NY5%zLK~pgi(xSvsWgDNL|GCp1}-(kj)hk(CNP5PVsD zpnRhaZ&5B1WJ|PCOA_a5xd<5}bemNAqtEgwVupy%{`QzhrC_W&5-=>OK^fjk9%iy${H)A8pa z_^jM#H;S@ZqZ^yYWa@u+i1i9hV5Ql@;SL-htjNy@*!vv zo<7pP|F+60&U_(j&Qn3dzX*F7{n0Z5NYmhRb#t#Mf<{d87(TMeqM+YSvNDtw z9)?8grT;2v(F$Pll;+}#wk0VKckRM!a=?PiPoWYIZUkcFvWKiroj#|c^&XMad^7S#JNLC64rH{8duCJ=E{nNgLSyB@eB%wMO(7ZZA#`!TU}@S$ZGD2E}Q#eJtDqi-Aos%tzqRe_Q777khuq zVf1ZD5D&)jhwAK@a7vi1DGI-~mV+@kDZ4vjCIEpCgU)Wocv5C!g=7}!;`R)~!~sb+ z-R91Ym;qKE09UF}acxbQH<_WoCTN3kYbwGhxN%_&0OKg$*tT#tIUVLD_>NCP&7dYB zSMNZSzr|h%tTsr2)rLsiXMeimQ&>b!`ranNuN%wC8DU=<>!-phHno{{8@mgKB>Cx< zh9%0*^0+RPBByC?&4Z>bjh6&Tk!wVuCRO{#_U?>U`HJ_M^Oe>Kmi0Bny$Hwaq^Oys zHXH@#IzE(Zt+B>HhzoJyx0mMxWzv0l@*&?^``jb4kiCB81lZi8Je2wJ#Tf2R4{VV% zAo8#h!O(L=OCAkpL|*LpE7{~NUaEa2k8PaASw~3<3tY5f!FHI19@@1rQbqwxjz+xM z^U7|Tjbz;eC^S5QXB%Pm0NEw8-@P?P1DiJ$v=c@P=J%Z_+oEvUHWrAxvV<`UXLJ+d zC$_0k?ZkKy`k9Epc<8REXSY}O0}c&8q9L`m{QTXm;lM}{)r_-+QjK0 z`7+zgxiJ_U8%brsseRb+5TS4QLsD^NU(Ol5)xwN<`dUXmX( z@S=fsZFA%^DlYH?oQg>d&pvkMw~=^yf!k_>39S{dP{7)w({XDyL5|ksH}rclf&(oG zHypTMX_cH?X%RW^;cBljOSB4%&i#$`|Co9=x!9oi-Tn&O-~Bg@$lu%4lYf`;Y(T*V z&fp8l_SN8Cm74};MmReo(nXwQu>hSgKTy=L;D_`Dl>d8qrp^IPP~EfZHAMw6m4vLHF|jR&OZA!*0;J55r<+5Vq`@B zn7&vCvN0WqC09JM!;=k+IiiB5j?YXvITl{`&Q(Ee)@~{{m9vRoQi6Je?Y4T(#(VPP z2_IQ28{Cwv=7$1*)@Uvsi8aIO(BQ5c>k3ZbT34^(qg{bld0uMRP&H#dmFSb)~p0dNBH zInrF)4W=W`rLu6mLZ);O)a1=g|NkT_GLgcNlgpx{(cQ07Ri?gSXJJ`t~T+4=D{%Ra?;OqV1gP;w$CO)+e zi|5)<;Ei#+s|L{rs(MHQve7E^fvoPfGcuS69`&$eG{~y6Lwq8yqO^X{1m1`&Jup6e z<EQh^AXTUnR&+PutI&=+x&CRVoXh8UjezpSMrf(K7IEoV9MBcwK`~JTz z-~WrraxY_G-Da_O9o#^rbqrhAvl`R5QC=@sd&xAww6*t&oohJFQYI_z+_F9Rcdp&6 zI@F$|Is3E0La=kM&Iyr!2uiI4*vVL=Y9J$TUVQ_GFl*rAV66bGd`ie9T1)qft(ct1 zFn4Rpap=B8?!|Oyg?^V>h-V3`xc<-?UF`i7;<2*IJkX;85Qa(w?Iy)nrma|uP*vd@ zMqlRa(pE?>tQ=_r?;{{Fi>_$Gsy-LEZ~5eyhn%?Qo%=cAKLGlNI`_>2sI;cLcPH&m zz?}oI!x4a`PrkzkfiMCdqLR80ApIi_{i6u<`-aD@mUkEcBihi;j{>kkW_gxiKboWO zt%DijuzWj$CDm`Rb$5yIT2WroV9nAbm9{`_YydUB4-w2#bwM- zjXj@}{jHJW-(eHkrec^}I@n|@Y(9w{_|8Js19k!ghl zGZ-Y74evTF{us*6YKnIfoJ2uYS!Vtq#x1!mmPN{o@-de|9z$NR<_*agrhnvyM(^T; zvLJ89ep7GuAK5J+`F?F5XNwXtskv-zA3w!YY@kZrOVSTie_Vyg1SEBE6Mv!uzd4JL zvadlD=)5SOg82Ol+R*D9-{p|RgN9y#w#FbKiV3aKB~TRSSqBZF0J0WMH10D<*W8gF zhQ+S5@3vIb_+%*<#_j{YWQzbKx_}T^_acteMHQyD5#qb{dXZCkr3y^1{S5WyDC9*q zytxVGLL1c0vqNTiR>P0=2uY<#+DH*2Gn%ANZ2{knIX<_U@Vfr-+Ui0u^a*5Ix#BXs z#1{K1dr92Mhi-%{QG*cKw|-9Cdmz~ype#$A{QdnaI#yDuTvTSn*dj}_q7`}0(i>G3 zpWmw)uPmHF5Fp3dIAem#bwkof4pbW`mv}cu!Dc|1q0>mhof(a0CS^e}U*V^MnmG{C zO>My_%C!T9<4R_G92r(dMdU4hPTEK)-&te$Ix$2?Dky(+9he9%0G`dbi!6T=0$#Ma zy?y2T(!#fyH-nH@wlShF#>4al-QRaVAIH{xPMg2Fps}Sqlm&ec@#&X6aR(nLfVfAp zN2>1qYVr4!b1pn+Fm*N+n+Ft{WX3B)PE%aus47)b&Fqx&ibQxxDirg zNlsk7jw>+!`iwt~XC1#>1W3~eRJ4zl#+ba(|9ioO-a^=|n8A(XR};NP&^fzuVo9eS z5GXC1IZo=qpZnSWra=kS#m+1P? z=)MTBX>ROZTf^GKUREKsItq378KWX>u!3@nG}t2azK(~V5CB_azl5)E9fA~aq_EWY zHr&JEYked;rIgvxkfmZ=v1k?&L)r?(#ikkeF=+5L@3R%)j8fcCBV>p!^uPGmqXF;+ z*W=1RYAp{7KX&~Nz^-xElIi1&X0H%*c#q0hU|>Qzh$6oyqnH&Tx#T*RkuM2Nd|fF^ zHt-`nOvWy1BW6#1F(a(V^ex3t-XJ6gs*EJO0pg1w%a>H>;~TY91DHE@CGDoi+@o=3H|jLj$F$Z<#HmOvho7=zrAAu_ zdAV>n5;WOhi8xK*45?B*f0pp%+`xHnKr?*8e|et1V*7~-kssD54zac{fS`>M z4{wV}kXH95o8{$NrTXzBea$SJq&ZuVmaT%gveQ%g8z2^&c7PpPw;!-$QTAuz>AH zK`M`w{>9>3rDq-2LBZhnGR$q&;4P_H;HpIwM@8O@^7gcor~ZRmjRCX&@X)aT zvpMMhw^&^q5NcJTH_Hp|Z4L&P4iZau4?trX=G_XycWx@85`gMC#?Ca955OG3dos^S z^QY@gI`#8-AP^w+ZCuYkp!~za!yH}Mh|+BQbw)LWB11jd^$n(q5oJZ4(AbC8umw$` z#C=ylm;{MJaAt0{asqJ>*l9-zrUla(4(PuSnlw`2M~7)d@`hNyI)Z>Oh_WCR)fQhC z1D1Be1B)X54mbyjLZiBDW2Gx2nfMsHf}8F1vS`IrFA4Dm^0$ryMWVs~H$Q#cjM$BK zZIp_)EY-6X)OLs4&+h5v>^g>(?37J$)AQEQAv*X>o(W+NLl-3CTu$N zHw#em9g{GzYZ1isYM%&_VbsZ7;G*Uo0T}6}>Ln#hvl-r8xiM5_Q1a}b1xX`QwDR|j z1mdLXII;8Bx`4^QEe(L#^h@&6!H>|#ry>{{xA48C5*N~%O@yB!_sD5G<(KKL=N#!% zuUN4eux{wKS%hklx?ix(7EVv>uWRNxgWA^Ne{-F7(4$`U2$5-kF*1Bw}$Iet1x(kZ8Ie7H4&D)%;^xW7ZK#VHW5ae z%JtAratbM519-rhe8^_I>Q9}p^N&;yX#L0a*`~6z5N1V_GcL{R;zR7<+|ZTi{e)P5 z+^<>5{%4m&6G|wLhkf4@-~AEyE*zd4%$oTqfX4zv{PQA7{M17833RirKzsr5lY&fB-qs z2qB5tm4DZoNpyKYuU97Eqr1zoCguh|D@On3Vz3lYAUc^K1k{+reTHN$zZ|oLturx> zD+i|_+L%dHttvrw{l-F-JdzDFix4O#>e*qOsG@t0?fe3LS3WhLl@sB%bUDrH5G3We< zqFy&U>5Y7oj1-703t2%)=H^#Pvd_?Br& zQ#FyBh4k=yeCYu_T~UMYdxZv9hTRDy?rn5!yl0`(;noPNO$X=TuNfmxH+~%Qdqnkh zw-c~{w;8L^7d|K6iFyOwM1{n$`rgC9e#pYeT}3Dh!u48J;3CkW`9f1N^^>ew)Sa&3 zdr{Yq8jdA}fW$!oYSdG7r{UW}hm0)625o`Yb~pOz(Xx|5%z1V(d|l?$N^3o>G?DCa zXcoR``RK5QBF?ey32o8+y#X+#JgF$ZT5kv6`*gU!m9Qdr>1LJl0hfMVk39_&YG2E$ z!!Cl;s-RnqnS2t(uB>px!MNYUC3=P5su#R6>06M~%1#b6;O6~#F{v;FpAr~rDvjz_ zAcqzEAx(^|W10LR@nf4lkwe6)W3BX9Mepc7aG&tD)IkBJ9OxWwKOU|cITp4qb zWeJw+xl<>`fv@UgQ4Ies3&Vbi9PP7k{ZjCRKD*xTXE*4M(h!y%Oj44909rz4MZI4Q zEa)SvO=~oVRS&9U2z%>1vN#`E$8LUy-z9yk}!y9LQ!bBGnj5rcPz0h zD8RZ|YP-x@eX^dhbY}QoA7C0;(o(=2x)XF;=N{S`XE%EInSeAj_QAPqqls5&4jvVv z;h)d!+HMe8LR;Zo!&MW(iT*wIkWkfAc{a>%wV8LMdlP~9NcnoqTxdD3Bs_&-?OsC5 zHm4C$OrzG^Epog*+R(u>=r6mm=CX(5g~&I#8NMT0uw?TJ;LGH&;&N0fxn95NjZ<3giQo_M z&d+AZw~Q$W2ep14#-~XW#L;nnlG?z59Bn|%o)7|}+0rhEZF}Fi2gGZ2Rh7?wPbwW3 z6xO6o4BLzGI4FZ2|H^}YZdd_ofP$d*A`o@LW<*uWTBgKQF?p?4Zaq%hk*Nga?Q~q1 zi_!Y-{Ff)U{pZX1igEItu|E_IjOsK+Nb|;&OvO3W%$CKUO*f9mLN6VpA_GCxVQ)nG zpQ~)9Pt#ccYY*{XU5@kHLkZbEt~7&m#WqyJzIB_5c>xt3_@TgYuS0$k@V3u6C#b}c zSvqRZ$fagu!f97vvR1ryt?Wa)`NlZ>1Sk0^``69EBh>R=TdkRIlDA{qXwZaW|24Hg zuiO)akIVFvF_Ah1$v;AlpCJ_pP)M6u#$tN!@G0XaIM`270M0Fanl;nP&f4?DON1_| z!IbVMj3a|N>=o@84{@svM-D3R`5*xkp_-5wU+HOn&A|h|Ra8sh`^X1C01kyN_)718 zR(vK@8%P?lQnv*oRnM}Z5ysQASd&~s&Bxj-l5V_I5$>M5nwBQfx1wJ=$6K;tLB4#! zfMhKT_bSJj`rSdp_-z!K`qGHhF zb|~eylyONZVWHEX3LEstMAAp$1v)3s7 z{bQx2UXf|$)wS8LCK{8X192QSB3c7mP4kNaTpObZZk8QQ1jsc{YUxXPf0$ayabnve z%P2khA#73v_o`D#a!-cjg;=nV@CmpmQTUcJ+@Q}#>Vjo})06JD;6vgUe5yS@ZHI40 zMUNLtHeY*(vm$w9XUSL=41k{n>gk6x!2zMDCU1xpB?|bRodRjzjl6sQXxWkM-T=SS zrrFl<{I4NRYo0ju$5Gk_V%A}#XFsV>kgA)OYkfe-*eMxLl2j*l^39Ni`A8^3{~H3Q zQkFSxvJze}foBa4PRJtSmaX(co7$6Stb;rVlZ-3}%{(NGKByke^|UtCEz|gK^7{oH z_n^f;+;^6m)+sE9v$p+cGk%=6?f#T%=?1v^%qH}Dzv0}>DJG82<#!(*6SkRcPqu%O zbLsI6lkmwGHkcC1GVEWoAsxa>B6Mz9F5AA&i1}iub0sOA<3&XeTs&O-2S>K)#S4?d zXX#7U(lxB~=yKX$%3a&K+|?TT5S;-6PflBW|TE&aSqP{FPA9B?`qsCD~itz zE!**Y1$(Ts*|fjeGCM92R}s~B?)8X9<8)g#+$~s^}4Sqda&m-wtehkFIf|MC(4WUovlNq zY=r{)m60R71c-Pph@Q3##Y8NO@uG6Hn_Fab_sM|t*>-mP?l2&;UKCA}%vF-%wL-r Date: Wed, 23 Sep 2026 19:46:18 +0300 Subject: [PATCH 21/77] docs: redact client_id from README and screenshot Co-authored-by: Cursor --- README.md | 6 +++--- assets/soundcloud-devtools-request.png | Bin 20287 -> 19470 bytes 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6098d94333..75eb382b47 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,9 @@ Do this on a computer (Chrome / Edge / Firefox): 4. In the filter box, type: `client_id` 5. Click around or pause/play so new requests appear. 6. Click a request to SoundCloud’s API — for example: - `https://api-v2.soundcloud.com/me?client_id=Pb72ranhoyt6gw7hM7TkzUItXlMWSNSo` -7. Copy the long value after `client_id=` (in the example above: `Pb72ranhoyt6gw7hM7TkzUItXlMWSNSo`). - Yours will look similar but may be different, and it can change over time. + `https://api-v2.soundcloud.com/me?client_id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` +7. Copy the long value after `client_id=` (a string of letters and numbers). + Yours will look similar but different, and it can change over time.

Chrome DevTools Network tab showing api-v2.soundcloud.com/me?client_id=… diff --git a/assets/soundcloud-devtools-request.png b/assets/soundcloud-devtools-request.png index 2a54a506d70609919f02322f7958dd11f27e5ee2..c4346238fed37f0c6ea58739323efd402bc36268 100644 GIT binary patch literal 19470 zcma%?WmH^Em$sWE5F|kG009!*3GNaCA-KB*5AF^Pgy8PdxVuYmcWK<+-J$XRn&+LF zZ)Vo4S@VZ;=&q_$b*gLcUDv%UNKQr!6`24T005wh{}53C0A5eR?u!s#!Tx?O3h@U3 zB&WqizACw-95172jXu2YOY1Y%dU$zw0- zOBP8FFEAx^Q?~8(<``K<diJF38Leip1^(r%LRC|UN;Jr)M zb70P4dJ&5*fW6AK?TbD_DIKqW-Lw1#R>ddwS7xJzsZ)*fUvstDy0?He(HzRha@kV> zEK3VwL12o*6(YvG3`n43;zG&J&a4xvbDx&%S#PzG;iWIgtwyc}c^;nqE?^Cr6$83I zHwq+fZ{~Sk_k?>QIjwx2RC*I4Yc^C35qVBgolV8h-qqLLgIZSHDSNE=&~r4 zeESI5l2c`C#m9-6XT7qb?9S26l_8&T*-+NywKeP;Z`L??PCO)tCB(QjNb6pfh)MZa zQ$_`K*wKg?IZ$jq%9-#AhX#mgAH;ZDx=l#e#Xh%gwQC1iT0y7b_+;zEzfPq4T8@KJ z0n5r0<7D`4=_$Vq47iPou45ro3v-5#K!x{yzCzl1!)0d-0nk>~J)#I#rxMPx^RB+V zurK3;U$v)iUk4@pN?gVkb98(Cj^ysd=83s0sfU@-+}DHuU7~0lvm06OUKj!CUF1HB zXFYw&1kDI}w9PEO7`-kCa_d9q`GR2P$Nqz^RVTzzB z1i%^c)9Kk~(yA(xXD1WbI4Y3Ch*#P?&Uv)?%zo|_W-|Z&VP|a=Vd>$f^pnTYbaS#- z>dV-NFOe$%ukZIyG+G~7e&~5C(Rfu}?K$gyv^;*F^!b}n4lQrK>$czQP|%OIv^y<_ z^LwMOhXjjD&&97@*84%!iS|Do4mK?7Xe6Tv>T>CmXb@9N>xnd)M>ta2BxgHrz5U|y z?bpgt6!FrR4K1U(-|V5S)NqC(HHn#MYT6K>VU(i+G6JHK&GxUx-$PP$=kS(cT19)=<8@30hl@ zNQqZJ`~jhGvt!qLS|Q6gra74*F!<@i0?8oPxn=SMN%fuWThKKu4WUrG} zl$^Ppz!1~fA0^wcrFMx1EvBZyQGO_QS9a4>%C2uWVwC{#=-`jbB`7 zAaw_>L%MKX|cMSG^hrp+I4ll z$*V`gRf${F&q0hPTJpra-~CT?1zM{eVrSoUO!rvvojDtbD~r#{ByTS5a-*vLJokEo z@Q(2I0!el38<};M&FozLq_KOHOUV&YRIh_IYgzcG=dwSEHaa#zV9DVffK_i9&t8C`_3r( zJ$gipbJ*Q=suAekRj{t5E@R$aI#y!e_(;6IM6;OtW_)S9$UWk~1JypSiO2)VJ*QPX zxmLkGU1%6~7<>p{?fvR&zUe!Ak;j;7X<=fO;`B^}N9JeV=2gpomzGf!Z^GAB{JIS; zQYAqG{pqA0gOy)^jfGP%(p9*IjM)O8-S|%%uXUr%L+Vg|XAEn|(1W^L`Q2Ez2Gea{ z?bW>hyno#OkZ?E#jWi_5GE*gu7&A_*ka>EtqjPAxZk6$wKWh6sJQJ^M`I3;)-KBho zx}pc)Ro?x({<-^QYXdF5<9EXhd#5!V<^1M@7WwN>I^z+5s~VWyvw+TjE6#2~M^tSg zo3J>wsJ~bm0)NJUW=}RARD+B&(g^`PNyjz !X5;6j|^gj4K69M=(1Dn&%N=42i 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 Date: Wed, 23 Sep 2026 19:46:58 +0300 Subject: [PATCH 22/77] docs: fully mask client_id in SoundCloud DevTools screenshot Co-authored-by: Cursor --- assets/soundcloud-devtools-request.png | Bin 19470 -> 19762 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/soundcloud-devtools-request.png b/assets/soundcloud-devtools-request.png index c4346238fed37f0c6ea58739323efd402bc36268..2f3d00e0d934b6b5baf65ed7366e27b6f7ccae50 100644 GIT binary patch literal 19762 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 !X5;6j|^gj4K69M=(1Dn&%N=42i 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 x3q+RxZ{42Qfa8=BK%9C{R(=l Date: Wed, 23 Sep 2026 19:50:26 +0300 Subject: [PATCH 23/77] docs: scrub real client_id from DevTools screenshot Co-authored-by: Cursor --- assets/soundcloud-devtools-request.png | Bin 19762 -> 18182 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/soundcloud-devtools-request.png b/assets/soundcloud-devtools-request.png index 2f3d00e0d934b6b5baf65ed7366e27b6f7ccae50..39218d1b4d26ea2a54517cbc1b349bb007cd0919 100644 GIT binary patch literal 18182 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 N;vyfR1@E-K{txq{Ne}=4 literal 19762 zcmbT7by%BU)92IDqQ#*&6fa(&xD|IO?ocT1?%Gn^-QC??ic4^JcZcATZ1_Fd{jhyVZpSyDno5deUhhJG%Ae+zvjEeQ<(0AwB{ zMFf>y(@s~Bw8Iu5a2J@!DT{hN}3TM=iwagq(#{!`R^v z%2a6swU4XXF;mu3r8!7L`LnVLm{iYt<0Hz`Un%oa$Dise=QFs*-Nqa;Okmf!Kwe|y zUo~ap(XNEFb&bpD%-+|2-PRb(BonF}D(o`M)0`^rt{@h@`*ZyJ`1d#}*mLtd^9;d9 zVEb_cG(8IOL~sBPl(;`1bnme*-C#fdg?R(L1Ayn0|4N~j;|=cX9{_g&QXQ523St-l zK;Wm2rIJo`VL5c6V37Z{>|ju4)W`V3B3Pu z$Te&%13f>)Ko$5n-kHR<_;wr#2HAZ8w_akKAUR(EAi;tR-pJs7x0%Gp##$92Z-ZvdVm zKIz<&@XWOZ;8VF~$7m2nN}H?u{w<9CQ&>YpX-o_(=`RyHkJF9V94Q)!=fZxqx}z+WgCKcq#~smkGhUeT|{FC zDgFOE%=5uiG@r!nT+GDxnpE_W7G=`v{GpiKP@B{KXo}apmNz!RV{Nc!e*QEN(`z)} zn4H?H@P@l5oe-g*`)o3KkBTw8_2L#5Fad;@XRUvllF(32iYokLLN0g8P~Mn&$nH@0 zPDM%2gX3%hQlWxLvr2L@Dh9lzW}-VwXzZx$trONWt0i8+j92sa?1KE2ZjIkF&v}7& z^EyA-BlqC(JRz6S=}6(X{KV$--1hZwWlX&pTZ`(txe9ovj7Q4iFwa1s=XJPKTW~v! z*$KWvlGQ+We!33rB$4S5r}C_w6mq%n<@ay%b{FtASy&7yFlHiNhq&5d@gNtR>1PC` z?ZxKpcHtqzdFiMnVB>*P?-O}W3=w<0gssuY#?)Jl-UhG6mo{65D6efI2ZBj^M)t;Q zCKAy}^UmzXl6_tO!~;JZ$IQP^D^*IX)vsriT=oMP`z*HVsMtBxL6_l~(8>AGc>bMPjI5 zVG$QkL$k~E{ky3QlhRNQ{6H?td@Ei`;il*6IO%Z9M$y>JGaK^cQMu86^qCs zBk54Y*Y)>+johc_P>lek0;&vw`9OHt`UdN?_vdIixu-eepf=+`2I2l?EYQGP;Bq2kToy6h6$Ay@Rs3@3MwGt0Ru-#<%hy+m%ZhSi{>5F!Qn&iLW{N^ zP^oau2!_(RRdP6;dZYILm~81qB>!DiSk}+}O(iLC2f8FM$Q-q+s(q*-vsq52es3a| zF*;0x-Jo7?6p|f(yT~f#aPn0Y#Po+qhBDLZL8V6kmWbc=3XjzAM`n)jTI=~t@yO!F zCkW??&oZ(>-H+z1b?RSq#iK`!!7KodW)0g&T=3>Im#TB^iS-F>3V8Q}SB%G(d-btzJrA{I zgvN`zCkByr6B{kK5acp5y1fGpykp8$-2E&%C&(0P>vk!pPCf5d_zTfJl5bItt^GuW z0Glzxz0mx^z3bAS0OaZ}I!Pt5dlQg}?J?b%HcGHO9qqBrQ3n{w%Rv^R#}Or}wRfOQ zRr!HaJZR)YBo`H;(a@XC%Xq12Z9FP#l*snm+RCAC#!Zl6f*q(2i*G|e%e3CE{^pPp zC^0;y;}ca?(DmubeTLSHNamapLwiQyP!-YTs01%Vtbp$$63T`O>mU52NkR zA3aQIR3Qp~7IWjxUV?(3ATpc;<`l{ZZePAFn)xj@8oZdj_)CU=2|ox^#;SPaF+0!2 z@z?Hjbcrc3FMYxgBHq!J%khTsxz9?IA>=Xf>b$-O`9L&;RlKs^K$q>?qNnE!ZPZ8^ zDiMDg)_$iUnaej{(e;HgZYYgR6wl?W2&~EEtYrwPRP3l9VbSq=RyAzGroPR+)WXFU zdkn`ISf|jl&dgAo)I3>?*}fP=6b0G(;1}m3V7_c3)=|%o_wLW3$HZwGvdQfBz%iO$ zC~wFPUyZF4lZ%YoHesR1YHmofWd#jJSwQwQ!oJTwhpl{{i7W2(I3x@GzTWqF3A6vh z5jp?hm<$kTqrwFo7q5LVz!V%m9Bm%up6%VEyZTQ$1T z%O#kpsZ1o?i`5#boYMWHDFK>?aiqiiGy*ibD}&gp5_qmuWy`#BisGX3QU=vb9FhcG zA&H^I!R76SgHjoalfhQ{J7sqo(nV3$Ye%@@7#>vOnaDYI%`;l(OPS|KG%_EeKK$h$WzoDaV^6$8jfK5g8ghiWJ4z-mP@JNlKgIj*? z0WHihkM42``yh@BmR_kD{4E)}s#4Zi0<(mddbi9+Cs)K;cnG!ohs)e|kvHN1ce5Q? zj0CvUOs7mS33k$6UH#8Az#k?yt>LQF6rge<#n|-^=5uK&Sa5&?LI#`SFdl~#G0Xy0 z9=Deg@&pdj9V4qLpOrz6uQs^aZhSw~w5KT+z5_3kmzZM6+qQ@!3lf+~{jc*eyz~%5 zKHlHXCNUS9E%%&~3PMawjk5)Y%;LueFZ3D(?k8AXb7Fd zfkS6D9Cp#3L+L?7){RB%d7zv{zMH>0w$pe zE!^>UxDGGM9XADvT_AV7hg#J7pT4U(inVHWE^T>_lDuJ1_l0Xm`w?hM?YyH`Df{W7 z_!e!n*Z|Q;RM15|Qi7p0b(Cwu#Lzt5g)O~F$f)1GI7caiRd@3>9V&&xi4J5@bb zZ+2c!I*+$y8GY?hyt=a-PZ+l?yJVza^|{@=X|D^P9zQCTId!P>iE#`INN!L*az0Kk z>PauNJkzPADwJJckjU+pc%N>UP*hDGyJsjxlTL6vXO5mq|H&^B4<9-AWtCkv`~%@P^2(%m6&YqLVU@8KBNyVhSkl}= zFFGb}O}m;t_(|2fT_wFIlPRa+s)T za=1qI{lAt{bk3f`vNazT3cZ~o!O+L++x*3(uwPW(*n(jE;U4@{pIO+i#eo z*mLAfDSr%)j#nh-%jvCaKFGfFo0ABSpcR!r`lf04{=S0rFpZE`fGSS z3E>Yh$v2l8Gym)(26{l&Dk*11jUs)XSYKtIx!nS|!rv%!U zZ`8=Q5mslT#4)(l{#;6s#;>t#+OqCm+twCS=&wnC)^(_aV@N1d*kqY}MNeILU!1mq zy~8~hJ~+7W-a!V>;s&o$gDsNj`-7POPW~S6BrBeBJU&Psv^Nwehk~B0|J7%4?jf%u z=j8lz$(5oUg0P4BselIM&O?2xd_mC%_oSK0W3`>Mr$WMUz4>?@Q~?REesc1_ppj(m^%sw$j-Hyji;HRbR0v~N z{>zP6pxw!GBk_Y`V*LUJ!(u4^>A zAGXw-PcvierkxU>KgTxl-YXAs1;>93pi9IAJYZ#KVp=-*`V zbmu{V!8QLN2-es7NJ>uLP5mpBB(#1SCwL_ZS*)L~U|&DnQ`E}R(wVoo!viMz@?b-q zZqT6r<=5O{VC0|ZU2y#ddE=RSzTTO6i%LxP;q@C6f{Rp;fgds{oY6MRwkq-1zyvu6 zA+YzA+5j9U((ZiUz?`ixaNAu`oU=QyPFK6T>n$#a28R&y5#MwTkll9p&@&rCg0yW{ zdMQPI{QkiKLKS#DDc~DNN6*zL0XQ6)jn!o2ASl_!LD+t-cVwm4^4KYv3;m2_h2)=c z=S@8^d`R9QXe`zz3)wZo{P*ezz>?+^P^84k`Yvd zav09n6?CpZe_#pI4GH1Ws#Qzbv9Tx!jm0#|5LoWtB>FF@UF5TgXfP5nl=&*dqmC?E zP0D$M*uJ3d%%Isb4X-dKwWgXZv~=0+McAF-BxsFSr2m!t7R>C>KL7ab4PYsCwjjNI zc_jX&5v}?TnXQh(AB2PA_?cc5%3dmvoV_+mejR;i-=;FIm=SOa23FXxas=n*SOxHK zVOSEo+T$Uxb7X_N96}{N+cXsYc^cjx6SwSO&PBPHz$BqtKPlv+QU=Jh#%diUOz+c?x z7*;9uE39-7O{J`aCeTbnsVl=-F@e-=fWSM!iKJB9Hi72>88A_>O`X&DV7Xb&b^UZc z+HEa#Qg{@VrX#*2CunxBGsa4<@3?1Yz#*P|*Kock(EH$!60bvG$?2D8nSN_)Eleyu zdSk^(VDbvCTAn4j-%ioJ=oqsnoMH#e#gVQO0G4)#XG?LIbTYV&SoV#~%*^in*6)`- z1A|ly?K_t}0w}-C5C|EDPf)afz+_dkHg2eVKzBB7s99aWWpxlga%nd6OCSXf&DEam zaIiYiI=i6CB07Lf0=I{k3^@v8D3WZwYaCEzQ?|vn-jz+hgBtFGeB^j#k!t1FD667NLd|)a%S~ktz;!H%0$dD;T-V)oSd<;Xor3kYDnqT>SY(} z-mTH`vzjnRKp%A>#Vx|K<6qk|h|+cyt=M4on54g-o{!TSz{1)KliJaE0L5e7fr`~W__d3g#A znb1mDUzR~SZq6Jx)c{8cB7U{1In-T0ExbKJy1C@Q6Yz6v`9k6Yqr>Ny8i$Pqfk%Ky zq`Snqc)8pc(Um^eISXH(wP*8#W3@WL;fY)6Yb#<_K2%!AwR~#l^W{7(JCvS^@{w== zfazXhxK_jYYOJ1PV&(H%zxgpC7-R^vAHZkU9$mCP3-Bd!Im3yZC9b!=oC<`*uQcmD zGe^v7c@4IbPx<`4lUD23mbd`B4d60ZG0juvV`Dd-RgowxXLIkc4w*bEJ20@_>@(@o zLyR}mpZY5GI?vJ)3o+jdBmr7J{&VBJF2)HhL<3`tR>T?uSpWi>@#CL-!gB zcTai#I3zOc#ZA|+usYNV5afprKE13J-(~SA9^V{uh>t%@!b^^}d7p{}bm6u)ke}an zHn%N-WjgdSFV$j24jp@{9}^)RA3b(4Nb{z3g*f~N2>>lLQ1D$r{I+g)Jnz`1{ab_T zw6Vt0hl6#+q?`oO|W$#rs|@3u(FfJ(~5vjd%!dxK&&#!$Kp-bI&Zc{nZG}qS=9#8)U#_6A&>rXI8nh0;H&s7@GO2Ef8806jpbssz&maA#$@0N2}Cba zl>ffcuyy}&fi96DT;}oi5YuD#%I{#?KPuRL`{P)3A~A&%|Dj?g|H`{b1$^?4qYfL_ zc;3&0^#pirv9uTn~gOP zW!EUMu5F_&bG&z^lz|D}CX=Bu1N?cFfv#iQN>5YaY^V8%GBm%uqPp87k@@^SUt!u;j~EnUQZE(6>{*&++AOoDU)4}tFrAOT04&p zuIjVX=4FCOQoP78vmmA*rFV||3?lRDYR<4c2Btu=( zm>5gk%}Q8oOYEnGnFM^haASBO%iI3AuJ7ruQax6V)y1!&RnXPhX?$xb*wT&;oAnN> zI!{$CP+3EQ5@Tr@Zbjbn`Kgw^I0_`QIX?C@t!6kROBfCezJonyG;ma4=6>nCV(GB& z^nPj6UKuzH>$tlR^qyRORIBv&_kTy6Q%x6HnJ$oNFpO9Af_Mj=xB^@m&-*6B5%_Z7 zi}vu*9izbeGEGe99owrLpZk6$+d61;fW#{ZZ@Fsg78I6G_lvqJjv^Ck+}sEW3H|P+ z1ZFDRo3FLGDXA(5!sAge>Z(d&zcYuRzjt11^YewNpY(A;#ujLEe@Q`8?8&PDrE=Ij zR#ozzw}R&SSrmT<4h?}AN$qJE>Z^H|A3;&;p-q1p%Ez;P1%^4RYB5EILyFcwiXMy>)p-`dW{v+&^`o^mMRH9y z)1Q}aTQhiSi2rL%+MP?)t9|F2oxZ+)hkYi$S4a9?d=b1?*=^AfEGC9hrCg?YheeMu z9!)X6v6YpMj*77Vyeaf(|BU{c{d$MjPkk>?@6mJfcpJ~Wl)TXAP378d?e12%qNOdwJxu|IJAxAANDt_;wl9tZ4=?1aU`@Vji zf3NsYelMM;d^WddZ7SydPDXC;1B#m^AaBg2hCRF*Q64V5 zsg^J1O5rB(oiiHIZf0xi8ak?-3krH!U^L{C=hP%}eAfGoM`>j7+&sXIjL{r@wmPEllJRbhoC{^1;f0EV@!ZPY1(pp z&&r{YxyURxT9d^!VDt1YKh3oj_6wsq*0_s_C z3-+K(Z@!5hd_4a<4f1i+DT@l&d1v=ygu)-5(8|bZw*M~)19kEt|e^7i&Fm;jPdJ_?Bm%kK7LqV*#SI?zrZ%UtW^IceB>~^T=N^&h^hb215dgBfxCM{*s%0z@{?a zQ|YI%94Fg&yY04Ia#H|;O-4Isf!m38ZIymMA%I_{;HUTpEX=+os@0WPmNzc7?IeDs zPflfWobLd>-+miXr0lw>@%gCSJkL(!R33J(CrTfrKD~?AdgQkuHxT})c4U1tuT!k{ZL{g_pYrSdGPu(!{;8BSyQ}c5BWimR&%g9- zRW=^#+(1)N-#|_4iK!~%$WL(4%fWzZw=8N}-tD@-U0z<6!F3Vkpt4BUPm+s*o~Y;+ z)3r073g4rYI-AtY$Pn$--(=ZB0oV19F#>=)z3~uu#ego~JI5c5t#%Z+XC^TLUrWJd zuk;-wv_in9N5}%3Tc7W9g-YCKM6JWhHhz0WJd?*oJBKE--LXGBwVEfNnZ0g?-N<)M zy(A|W(^dNEu3<3@h=_}^Ppg;<+F?p%I_(=Z<--{t?8c#(${lx;pl@c|9!ftV3HngO z>zgBsEkfgBLL)F}u(r`v|3vD!At3-0F17{r8?ODGL>sSQipz z5g&Q@3z;3&aa0)=#xG5hh+%=gf!nIm!e;ioRa>qMh!&tQ{YM32&8pn&&v`3as>H@iBQrMMj*2}?TD!FuPfr49C)6meQV#|?ht6^bID zazZ^oQ?{kkYIw)ApM1F=r5TR0OYNE^T&Jh$=g-84u?QX2eZJg)gggz(^I#?verl3F zzTJK~##B4GQ;E{T#gmqLS4kqZZ;fp~Eja%aV6IF*KZ0&)vg|_18z$NsjoE%xVvF}Y zTxC3=uSxldZ?+r!LRtMK4HQOtcC6-c4Cc(n>T$R>W_Yj|c`NUV9AT2Un7%M_OSOtH z$Nt8bnBHw2JhE@DIyM}LlF6jvc#DD($%}@6P#`KX$21UQj*Yw4B}gpAlxeG(85z`0 zr+;@P8gSH zc8&7+V=2!S9oo^hU(aX>ST2kT-4tt$aGGG5X0NFa!S6=2CI6B#I6yaDe75hyPLwt! zsG&Qhwg|NgNYuzO=prhA`ABfqT@d=a^80GTf!>cso5_>MPFX=wr)@L#OT=)#`R{H! z_9)}HYuT}P=!veWxL;EEoeX4c>^x7fGazJ5R_`#M4>ijQyI(%rKcz|KXnIduWbv!# z6y#(rddw5;xApbGrQi~v9til_fL@;Nroj(|bgkim)hq<#tp3b0+L;; zUYVs%&S2Kvkp<^(89JXM@4Luz-1J7Sb4Oi9bWUb0bGY5^QWB)?Sf_L63&cV5y2x6t z^~{`+;4>|`-|^~01pE=bafGO-;|p}o3jG!OyhgM+WbV~!Tg76c)MZ6KTLdl=-LK?|p z%_9>g-FCXiMm7-1gGwu)JTh@C#mkXX;2gR2kt_C6xtN>5)R~MlSnz6+__F##Nl423 z!VZF7H@&)0qbj;k#6>?9x@YZNu|5YB<=70xZ-!j)-uBdO3x&ewiqDqpUs0x(#rW<+ zOir-nGk~Jq28xzN$A^;Uh)Cv~40lZf2Yv3n1Mc7X^03j1_uzJqp)+M=?)|b(W%pot z1Ak^_U!fI>aA;hpiuQ9;_nJ8UqAh-gXX*qlc2DO|QaUKCoh6l9)m;jigNSKtd9R#W zZtd!@CXqx_X&4kyDji&mXUpicUFG(OcD?3AE!0vX<&OrXxOHL?Z5fmF)OJlJ!|HtP zJ>n5*566~;$VD|d1iF-f1Lc1K&RFZqXA6iR?lMY}8XNyTY*wO&2l3m){Bf>U0|GfP zESnBK1J0~Dt%NW^S1{$p=90rpHqOSF8!GOXjj`1nOltWnYgP_cqSC^<`1basqJzn8 zEzZO@W-SKI%Tuqy#Xb z5n)+nMP5t7?`Va2OMg!zG}u)AGjJG%l!R9Q;n6?9URD$gcL!ylfah*P2L3tGONaXI z8`y)aK`n_Oo8{GMoywnFSZR{x{pk`R! zf|`f!Wfj1W0Ihg`sJ_8|dHNjEjBE@8T+pgSgtGlyRI zDuQDtttnE4krSBye8x@o0_WN3Xf*QY-Fc=<*yPl$y^f-$g59y47Tw48Og(n%#SRyF zsOU#Iari&s-_}Q~*Szt-jQ@z0zW5zRN3x|)Vsf~V$DeFABc05%@_*Ca?~$2u|C#QJ z=SK?YeTC?tQqVB2ZF=?V$ztSBBM%(ufuA%_;-&qgG(A$DvN>v3oI{sdV8u3o(n(>u9E#=e(qiBkOn%xlbazJFv~5- zYg=`A3C^loP4&ES62la#SRK4K9z`5eg^Fe6-I?}s6x)PHL*x*cA2#!& zFZ&9lR++@!=XgWbIE}N_d8y6iRq~M2j8dFD-*=GHQ937*{&f8GH&e_k?808BW@Y~g z`osxb^f(8+vx?L$r;MONIW?g?R3 zQN?QdN+oehu@kgx|C|`*B=FG5wrp4wcB07<2?af75aY3$x@eP1+E{N^>5Z^^D3)PA z%@Mi?_Y<%;=RT!;ox?Wy9WQPwjI1!vSLi1!7qw&LvQ#-{`P%RQrb zbHQ$QJVP{{M;QL$X`Migb&4TO1|?&MpJH%ou-72w(p(^OiMs&G?60Wf3%ABFg%nw) zOq!X<{3SjR4!Usq5%p73qYI(D*ySV)$&7>VCbtKmHJmg4ru8wUFN8dd=V$4mW;Fg$ z>++c??0wfTmH$|#WA0#UTTV=^qYKk_9Z*3dDm)jbp z2xERedxD9U5Al^p?+Zk#=nqz3ACQ)A&Ap7B+fz|zl&0$F_|Lntp@kB`t(aYTTCQCv z=!sML-UHLB>;K_*moi?{{Uq{Yk#}YPdxJ#`^n&f{T2>Biz;ZiY~U~h9|-b#p=GJ9=`XUa)p_EY z{2o3kff9uC=LYzdk}r;?a-??=K06Itx!W)ud&v0F@0|>;F}n{ z#@t{%`kJ5Yx~(ycEYbU|e|&T$yG`SLGFuD%%~$8}i@vRxV!V&vFTs%OZt^!H`lUMu zLy^~;ENS?8ejh=8ykfk0oTWupnNiFNDj+J})^5v=^2!4%PID>Yn5~_)WwJLb@TK%2 zQ=Y9g%3j53q3S%bLbGg@Ns(=`~JpJodPR3}rvE5-1s!<|mLah0Z zwbRr-73P}AMn>pk72w*+q+!?C{SCY{xwcA+e(*f`uFtk-eS`IcAk z&P~_VIM7Cfl+>ImBXsX@XmW^7^{_lBi%9ap-?(LFUQ{oojh2a))c+9~)t7%(DP)?!*!Dq2jFXM?*)TRM&;eGG|aEM{MOqj}_=y*8Jx{12=@9bzMjAZY{)gVbLX4&A1vp5Mqe5M zO5xONR{9Oimw%W27Yyj3Di-#jJugn%F;3#4JyoRJZIajhvEYv!61%Pu-bh-ZaNI}E ze>84W>*KnCLU!{H24_Nn-`PDaRqlSjr+?LpLvz=&5l^v@F(%XG6NPIRMy11Aw-9A+ zT;afs#DwLlgGewf+)K%4R5 zJ8^nlVu`s%S^Y5l0f$=d;Wk~!Z;?vgBI7dOVWmZ>%KjL&4GN+8lMWkvD?tfFSLGw8 zm%lj*tIqGEy#^lUUC^86!?n0VIqXRU-(4V>57^lK*MfYLaY^HU733Rwn{|G{RO=Co z8bm+g|?bg!){<%XK&XzrYV}y_k)XJTD@aBXw&6VTVFP`Z-M4+ zFx`m(x@m4vM_jpiOA=DSXq2+e0ohcVE$6)UW85Ge(1-D z)R>#=FN`gQ(^oM$(ZaiM`3WOdD|)}&-AK;0r?JJkJ~z{)Ccf(8CZY9{GeP5d+Z7bA zcvQc*{4@CDIJZb&wRPLw_oD`nU^1!%V4!Nyp+~>&r6E%{P;R4vsXUq1Mhsr>LPx2~ z3r#yIR}`0rhL=0)(>?3~BY(MN?AbSA(fIRZ&NK*pI6jW?JKx(MMgu2dgNEiK>f1$0 zTADP>!&4%Khy49pB_<+*K-ECGX*C2I8 zB?&}1W5rq1`R^k(^k)P_VW{zo;__Y;7L-#pE~4_ku8B(5xNXmIlTzI2b16(z&t_vv zvYNn%s!oRIg$?W)W+hbPrAr(Vl#(sK<8)Ei#M4s8<5(ea+nMw+STepHtq2Exu7^mu znlx>~IP_TT9U`tb#4 z+Qcz8!0NorG^pyHQ*nDQ&sjb$-Yd8Ab(MAIT0@oJ<#4QfqGzAu;w*{ZZX5%X*3|f; z?n0sj{PA$PJ!tJK*9T~G zL#N6H!sS}}Sfii+C2kOO%?Pr@zaa5$3JSfW2(PQj;K2UlFyF=$#=sQbxe8% zZDE6ZZM(acf(9mET_)plOkw~utm~k<{0ln%KW~|X<~iR?{<^>Jv*Uvq)vP{uUF1j1 z@sFBEwa|Of8Rmv$mBPIToC99Z`pt!!f;FqN)S1Mnx<7?~*uX2U?=d3L3q1u?aN?F2 zfdW`Uf{wf9bS$Mj-Vcw>z023d>Z4aD$Un#9Jl~yUd!zQAtr!b39F5mtMRQVE0` zt~?haWcf+;@#4P@p%QcceahCLL;TRn_4qk-vJ5+QzIibI@zu(;)cep{9kfQ^NpH1s z@F+q`uH|Dhh)(*fKW-L#EoZKlt^5sM4HG!oI}v)>SJOua9)<*JY#{L1cO&X^eB;r7 z1+(}H3i$f`%}UQvLvEuB&VN<^gphUL;hkvX7Is(#*CVEj>8hAOs)xj9$fq>J(bytk zH2d`tVi@;S^yywIF}A9ppjHElh(EkW?G%O6(%82N=knXc!Uk*mn4%=TmJg)dneG~C z=_yh#dZWD#le~Of+-`le?bzsbyP}l-ZN}&2#tm+ZyvPXn`Uw=` z)`(IDPP6&H*0Q1Y(a3I^aY5AJ3Dc;otl0zZOwMMPMAet~9(V;3MD0ibU`#=OqZGJ62vbC{LikiX}8r^JNa{Fh?Cd^I1g>w?L zNi$>b`mj#Q45rbSM2$5p51y3SB0VQm!pV5y7nD9rI1NuS5TpDPNRMPPvM~>jZ9AfP zrIGCo;8qwGSjvpf0~j1NHzu&0F@>nihoh&o3K#WV$PxBM$G9soyjF1N`P0_q2Ns<= z-s#iZ%@rDvT-&PSYjS_f$zJ>?M$h1?!?NxldY&M?Z714K7{0T}BBN6Ue~w1G>B4}- z_p<%jp1Lx=y%M&NLTXa6>)XXpoZ(o2otY!d_?D*lU4c(|Eapilf-g8?(c0JL7qs2S z9p6~>FKuL&^!pt124bywo{~$ecn;^wRUC{K{TRYSc^`|A!O?-;>*$D7HQQhJVkawS zYMu_WR4Mj5`p%B*vC5RhqpmWB@i*Z!@n+-pOcX2#wO>vJorGCaPV#O@Nd*bQ@`IDU zlGz-voVXn)-KE4@!!No~Cd7pztM13m-XKf5D{qH!0_u=n$#QVg5S$C6ZoNQe5uR5? zU#tEcMq+i@G=W<4SR|9Bym&I{u@{+AvJ@-tv)XtLhbm5jD&~79R`+9=ANBD3E8Ch3AsNU$7oHCd;lTOc z3cWIOELlP6jJ3ozLpuYMl+PaM7z+5OyR}!;GG^iolo<_)G*5d(rVe8y8%+UJ9Xqlw_WQSfj&TXA>^+Yr zrJD_6J?~y>#On2rSI&FLQT@fwstSz62cB0QqXNYn;rjM5WW^(AQ``7pgr13zCG;mU zf^z)k*19%5I;P5HS5NP#L-Fw3_ZVeuyuar~mLIUnHAw0oY=ZJb#@4!KKEbS}$PU4@ z!OcCuRi4{f$6;;UwBv*K57i%KC>456Xa0^?(&UwcBF8SuS~$Iy45kXblZqwp8g+>0+bG9t*3YXm8l~u3 zVE`o1t=$u+(B5k_cxW5o!C_U#Yk(R5%MI}rsD1}<$J+V-+ynkkK>OdfiSzbg0Ne@x zr%Ifqo48}?|A`)ucro`luJF|!<3YItsboTqWZejj7ya& zOQEjMgJjd}qG9aeaeq;oCvbIEOCBvN@%2JqD@oMr@GWVz^Hg+N`x+)LX(zt?yA+hB zDyL>erWNFBx+*l~`Z*#sp*$Od0Gn)G3>%}7@L;~T448=nY*rw|aHAq@ENpgFe=bdb zwm6;FqD>8q2DV1Es=?On&5uI|>)Q!OpVD1BWsgDzV{zB5ZFcVcs&#{0Aqj{Wk$fe@GsIae+4gCUyRaVy>Dgl^kn5 zwCYE6aVDos6Haqq=gFT~i@8${74>ku%&tn>ilAx7iL-f_6{J=r8*^^8o7%tXr@Icc z)Hf(VLztI$d(mc5)=M>TX@A{OR??O$U?V%C#(JrHQbaDh?O7Q#m)WSFoeT|>CI5uJ z;}q!+t0GAx97V{j8m1gl>1Y{$XU1oTBZ>GT&8t(P)rrHnPXcG9DIssW+8ayZoM@`M z9nyRkm}sp^Qj_^4cam?d>mAr}0-WXCv%5C-_+sPg@J|~vKS6gE@Iw7)_5VfR^{mN} zsC${mTYPh`54k~XZ|y)*I~^}fRUgg5kXB_d9OQ@BI@vjL$QIEb9#%2_&mry{;iTMe zik_%>>KCwDQX^kcv%vj?yC1{EOY>F`H84LrA7()y8Iv-yuK_Whng0x_;rS`%iIsOb zi@zCshBU|TY&YZP=y>xM?rv4o9@GIAFt}(gM>&2rfR=>iI?(<9BS`vI5u`MQDbh79 zKF-)X7zZptbauvMQimi%U!_QJWIM*u)EV8<%m&pv%QP$YE7(#ViJ2w+odq`CcImT7Sw9(giS8kjY zg2asG2*;b;GJ}|OMP(X3^Ag7R#0D-xOS&;6=~~0nZwjX* z)Y9eVomTK2FHCTV@TG-x|63#18P!y_M&n2mX%Yn~QF;&|h@c3BDo6>5f`HNoDH=&o z5T!{|Ktu@&Qj*9JI?^;iDAJ`F0+Avpy_(Pl1tN%uyc=9IYu^sr{zfJ}%8C5*MB$c*jPje^)Av-{p-T#+{c~G<6Aa zC@cf(iE;D9Or^rG0X~n0YS(Yh`k~?pe~8v%^zDfov{^@OSII6Zt8TDW*KcDLHy&uv z4u)rdIp!kS4_f~7o_K66nS8^uX#%MtIWlr6X+SwO>V9L@x!`jSiN;3nrh} zC|OF%p>%s2u5O>xd@nD&Y+kX$`9@t1>j414m`%uz#3e(@)n$@isJX{yU-3cbwkjgP zkl?L5-O7P)6MHoJQsp!t9b(#hX=@j?G%fm!<*!9m^b$+InxVZIC~hUFM`Q~y-f)7h;=ZV70KKKNuo|_vE%4@?KPJ&IJw9tlrx?h&N8FHqqIYpO@^;|Qv z$gQg$dVKphaP!$J#|JN@&m?nDx%)@L#N49ncc8DNWMY|P_Ej92Ne z)ew@)S2J6fh(mzvWVdFn*XVjrO|Ptp=2mDSVqKEw>*-|J_gf)Cw}~Fs>&}Gsx*lD` zR&?bc5&w|Uv`|-}yiDt&1tt4;uYmDSGp{c9u^8MY-#^Cb&3F(!w*8b}*hNl3Bxf^O ztL8Lmu#{6~2y`{tNC) z8Fm-bMLedKp>rI2g5^DkAcF*O`QQsT3dSpCr;qdb^e`*CE?(v|EOzo)X^cu;rz_Pm z?i=i3ZN1^`R>4w!+nT%ezVgd1AD5ut-$dAsW}E()Z2kaINAFDMY)10eh!7VOf*O13PUzbH9{KIbwo|^wz*?nm{ zgh|)`0eqF>U(DjT)Qm7l@7Uke!odZY7()^@sN6NK|JU%Be>VOIfOP|lRMi9oLH=3z zH8|$3lYc1KR2}W)2?7bV%Y!2Ri1~Ma8b(Ua!fz}DX$xz3G1#FOy+Kf#JU)N#4^Dw5 zx6s*~GE%Nv*&wbzH}RyfY?+R~5U8%s$UQ&hJD@c0CX|4B(7=EigY!0l?NrTsFy<14 z+Bq0BI0S(@o>o(PKc7UI2n|qP%jP$*p!!k?^CkSGW@DNSmKc z*2#Yxw8BR3yH2YT^Q8@CyAo&LA`RobWl9Ekr0LC5c}!7E&}8*cvt=a8?DinlSto(t zcZiwg6nx3Sg!0YuG9K+dgx)$^g~ZK#nhUkJ4KfWMC#2~=%+F1_!7%|+dL+whkyk|Z zMrDcdxlUmU<%d#*yr!Z!@~Hed-C?e0&?1{=lsFqIxZF&`l-C=z?H zmmXNks?h=@w-jB4fkUs|B{eDkCE?Ssc3W!HBf%r`mGt7H)Xw7+bgqTIm7@F1^AL9f)(7%B1o1M~i`X9lHA3lqN@yF7ZfMwvYd3W?J#whPzUwqK4m zR*>%6rq<*MbP}|4OlGk_A3f3zT+tet1rIytl^5h5?&*dO)U=67X#aHM> zMP3Eyg8*q{SDs#8ZP>=Y z8z_FBEC|taEu33|NFE82(&#}J*~P*uh!?fP51V-=U(R(s% z8Ry(=jzSAEGITcbPue!}#__FF65s09KxHl(6ulwo9x&N!2@ms;Jm-dSp%iN@GTLy? zETQ1^Ul|%|(A1j~a#gDPelMV8`63W4S}i zpdKwYE>*3W8;`#ajt0-Gq)2hhorx{k^BTf_W!H0Pw+A%z@=wmN^?`LDuKt1`$X^ur z(Sal%e`;)?&T^dm0NSv8 Date: Wed, 23 Sep 2026 20:40:00 +0300 Subject: [PATCH 24/77] fix: play SoundCloud HLS and highlight now-playing rows Add media3-exoplayer-hls so HLS streams no longer crash/spin forever, and show the active track in SoundCloud lists/tiles. Co-authored-by: Cursor --- app/build.gradle.kts | 1 + app/proguard-rules.pro | 3 +- .../presentation/screens/SoundCloudScreen.kt | 126 ++++++++++++++++-- .../pixelplay/soundcloud/SoundCloudClient.kt | 8 +- .../soundcloud/SoundCloudViewModel.kt | 6 +- gradle/libs.versions.toml | 1 + 6 files changed, 127 insertions(+), 18 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 64bb3938e4..6af8596c5e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -292,6 +292,7 @@ dependencies { // Media & Files implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.exoplayer.hls) implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.session) implementation(libs.androidx.media3.exoplayer.ffmpeg) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 75b442a2e5..cb99e77533 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -46,7 +46,8 @@ # [NUEVO] Regla general para mantener metadatos de Kotlin, puede ayudar a R8 -keep class kotlin.Metadata { *; } -# ExoPlayer FFmpeg extension +# ExoPlayer extensions loaded via reflection (DefaultMediaSourceFactory) +-keep class androidx.media3.exoplayer.hls.** { *; } -keep class androidx.media3.decoder.ffmpeg.** { *; } -keep class androidx.media3.exoplayer.ffmpeg.** { *; } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index 042325196a..06a8be6e62 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -122,9 +122,11 @@ import com.theveloper.pixelplay.presentation.components.SmartImage import com.theveloper.pixelplay.presentation.components.ToggleSegmentButton import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem import com.theveloper.pixelplay.presentation.components.subcomps.LibraryActionRow +import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIcon import com.theveloper.pixelplay.presentation.navigation.Screen import com.theveloper.pixelplay.presentation.navigation.navigateSafely import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel +import com.theveloper.pixelplay.soundcloud.SoundCloudClient import com.theveloper.pixelplay.soundcloud.SoundCloudSearchHit import com.theveloper.pixelplay.soundcloud.SoundCloudFeedShelf import com.theveloper.pixelplay.soundcloud.SoundCloudSection @@ -207,9 +209,13 @@ fun SoundCloudScreen( top = 8.dp, bottom = bottomBarHeightDp + MiniPlayerHeight + ListExtraBottomGap, ) - val currentSongId = stablePlayer.currentSong?.id + val currentSong = stablePlayer.currentSong + val currentSongId = currentSong?.id val isPlaying = stablePlayer.isPlaying + fun hitIsCurrent(hit: SoundCloudSearchHit): Boolean = + isSoundCloudHitCurrent(hit, currentSong) + val onHitPlay: (SoundCloudSearchHit, List) -> Unit = remember(viewModel, playerViewModel, scope) { { hit, queueHits -> @@ -623,6 +629,8 @@ fun SoundCloudScreen( isWideScreen = isWideScreen, likedTrackUrls = uiState.likedTrackUrls, likingUrl = uiState.likingUrl, + currentSong = currentSong, + isPlaying = isPlaying, onPlay = onHitPlay, onDownload = viewModel::download, onToggleLike = viewModel::toggleLike, @@ -668,12 +676,15 @@ fun SoundCloudScreen( } } else { items(uiState.results, key = { it.url + it.kind.name }) { hit -> + val isCurrent = hitIsCurrent(hit) SoundCloudResultTile( hit = hit, enabled = !uiState.isLoading, isResolving = uiState.resolvingKey == hit.url, isLiked = hit.url in uiState.likedTrackUrls, isLiking = uiState.likingUrl == hit.url, + isCurrent = isCurrent, + isPlaying = isCurrent && isPlaying, onPlay = { onHitPlay(hit, uiState.results) }, onDownload = { viewModel.download(hit) }, onToggleLike = { viewModel.toggleLike(hit) }, @@ -727,12 +738,15 @@ fun SoundCloudScreen( } } else { items(uiState.results, key = { it.url + it.kind.name }) { hit -> + val isCurrent = hitIsCurrent(hit) SoundCloudResultRow( hit = hit, enabled = !uiState.isLoading, isResolving = uiState.resolvingKey == hit.url, isLiked = hit.url in uiState.likedTrackUrls, isLiking = uiState.likingUrl == hit.url, + isCurrent = isCurrent, + isPlaying = isCurrent && isPlaying, onPlay = { onHitPlay(hit, uiState.results) }, onDownload = { viewModel.download(hit) }, onToggleLike = { viewModel.toggleLike(hit) }, @@ -882,6 +896,8 @@ private fun SoundCloudFeedHome( isWideScreen: Boolean, likedTrackUrls: Set, likingUrl: String?, + currentSong: Song?, + isPlaying: Boolean, onPlay: (SoundCloudSearchHit, List) -> Unit, onDownload: (SoundCloudSearchHit) -> Unit, onToggleLike: (SoundCloudSearchHit) -> Unit, @@ -956,6 +972,7 @@ private fun SoundCloudFeedHome( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { rowHits.forEach { hit -> + val isCurrent = isSoundCloudHitCurrent(hit, currentSong) Box(modifier = Modifier.weight(1f)) { SoundCloudResultTile( hit = hit, @@ -963,6 +980,8 @@ private fun SoundCloudFeedHome( isResolving = resolvingKey == hit.url, isLiked = hit.url in likedTrackUrls, isLiking = likingUrl == hit.url, + isCurrent = isCurrent, + isPlaying = isCurrent && isPlaying, onPlay = { onPlay(hit, shelf.items) }, onDownload = { onDownload(hit) }, onToggleLike = { onToggleLike(hit) }, @@ -982,12 +1001,15 @@ private fun SoundCloudFeedHome( verticalArrangement = Arrangement.spacedBy(8.dp), ) { shelf.items.take(6).forEach { hit -> + val isCurrent = isSoundCloudHitCurrent(hit, currentSong) SoundCloudResultRow( hit = hit, enabled = enabled, isResolving = resolvingKey == hit.url, isLiked = hit.url in likedTrackUrls, isLiking = likingUrl == hit.url, + isCurrent = isCurrent, + isPlaying = isCurrent && isPlaying, onPlay = { onPlay(hit, shelf.items) }, onDownload = { onDownload(hit) }, onToggleLike = { onToggleLike(hit) }, @@ -1213,14 +1235,23 @@ private fun SoundCloudSongTile( color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f), modifier = Modifier.fillMaxSize(), ) { - Icon( - imageVector = if (isPlaying) Icons.Rounded.PlayArrow else Icons.Rounded.Cloud, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier - .size(40.dp) - .align(Alignment.Center), - ) + if (isPlaying) { + PlayingEqIcon( + modifier = Modifier + .size(width = 28.dp, height = 24.dp) + .align(Alignment.Center), + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .size(40.dp) + .align(Alignment.Center), + ) + } } } } @@ -1251,6 +1282,8 @@ private fun SoundCloudResultTile( isResolving: Boolean = false, isLiked: Boolean = false, isLiking: Boolean = false, + isCurrent: Boolean = false, + isPlaying: Boolean = false, onPlay: () -> Unit, onDownload: () -> Unit, onToggleLike: () -> Unit, @@ -1292,6 +1325,30 @@ private fun SoundCloudResultTile( ) } } + if (isCurrent && !isResolving) { + Surface( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f), + modifier = Modifier.fillMaxSize(), + ) { + if (isPlaying) { + PlayingEqIcon( + modifier = Modifier + .size(width = 28.dp, height = 24.dp) + .align(Alignment.Center), + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .size(40.dp) + .align(Alignment.Center), + ) + } + } + } if (isResolving) { Surface( color = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f), @@ -1303,7 +1360,7 @@ private fun SoundCloudResultTile( .align(Alignment.Center), ) } - } else { + } else if (!isCurrent) { IconButton( onClick = onShare, enabled = enabled && hit.url.isNotBlank(), @@ -1321,7 +1378,7 @@ private fun SoundCloudResultTile( ) } } - if (!isResolving && hit.kind == SoundCloudSearchHit.Kind.TRACK) { + if (!isResolving && hit.kind == SoundCloudSearchHit.Kind.TRACK && !isCurrent) { IconButton( onClick = onToggleLike, enabled = enabled && !isLiking, @@ -1370,6 +1427,7 @@ private fun SoundCloudResultTile( fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, + color = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, ) Text( text = hit.artist, @@ -1388,13 +1446,16 @@ private fun SoundCloudResultRow( isResolving: Boolean = false, isLiked: Boolean = false, isLiking: Boolean = false, + isCurrent: Boolean = false, + isPlaying: Boolean = false, onPlay: () -> Unit, onDownload: () -> Unit, onToggleLike: () -> Unit, onShare: () -> Unit, ) { + val colors = MaterialTheme.colorScheme Surface( - color = MaterialTheme.colorScheme.surfaceContainer, + color = if (isCurrent) colors.primaryContainer.copy(alpha = 0.55f) else colors.surfaceContainer, shape = RoundedCornerShape(12.dp), modifier = Modifier .fillMaxWidth() @@ -1433,6 +1494,31 @@ private fun SoundCloudResultRow( ) } } + if (isCurrent && !isResolving) { + Surface( + color = colors.primary.copy(alpha = 0.55f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + if (isPlaying) { + PlayingEqIcon( + modifier = Modifier + .size(width = 18.dp, height = 16.dp) + .align(Alignment.Center), + color = colors.onPrimary, + ) + } else { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + tint = colors.onPrimary, + modifier = Modifier + .size(28.dp) + .align(Alignment.Center), + ) + } + } + } if (isResolving) { Surface( color = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f), @@ -1455,6 +1541,7 @@ private fun SoundCloudResultRow( fontFamily = GoogleSansRounded, fontWeight = FontWeight.SemiBold, maxLines = 2, + color = if (isCurrent) colors.primary else colors.onSurface, ) Text( text = hit.artist, @@ -1508,6 +1595,12 @@ private fun SoundCloudResultRow( } if (isResolving) { LoadingIndicator(modifier = Modifier.size(24.dp)) + } else if (isCurrent) { + PlayingEqIcon( + modifier = Modifier.size(width = 18.dp, height = 16.dp), + color = colors.primary, + isPlaying = isPlaying, + ) } else { Icon( imageVector = if (hit.kind == SoundCloudSearchHit.Kind.PLAYLIST) { @@ -1524,6 +1617,15 @@ private fun SoundCloudResultRow( } +private fun isSoundCloudHitCurrent(hit: SoundCloudSearchHit, currentSong: Song?): Boolean { + if (currentSong == null || hit.kind != SoundCloudSearchHit.Kind.TRACK) return false + if (currentSong.id == SoundCloudClient.songIdForUrl(hit.url)) return true + // Fallback when an older queue used a resolved URL hash instead of the list permalink. + return currentSong.album == "SoundCloud" && + currentSong.title == hit.title && + currentSong.artist == hit.artist +} + private fun shareSoundCloudLink(context: android.content.Context, hit: SoundCloudSearchHit) { val url = hit.url.trim() if (url.isBlank()) return diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt index b24817dab5..1911776a2a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt @@ -188,8 +188,9 @@ class SoundCloudClient @Inject constructor( ) } - fun toSong(track: SoundCloudResolvedTrack): Song { - val id = "sc_${track.url.hashCode().toUInt()}" + fun toSong(track: SoundCloudResolvedTrack, permalinkUrl: String = track.url): Song { + // Prefer the list permalink so UI can match the now-playing row without resolving again. + val id = songIdForUrl(permalinkUrl.ifBlank { track.url }) return Song( id = id, title = track.title, @@ -370,6 +371,9 @@ class SoundCloudClient @Inject constructor( companion object { private val initialized = AtomicBoolean(false) + fun songIdForUrl(url: String): String = + "sc_${url.trim().hashCode().toUInt()}" + private fun ensureInitialized(downloader: SoundCloudDownloader) { if (initialized.compareAndSet(false, true)) { NewPipe.init(downloader) diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt index b347fa15b4..8ef4960809 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt @@ -266,7 +266,7 @@ class SoundCloudViewModel @Inject constructor( suspend fun resolveToSong(hit: SoundCloudSearchHit) = withContext(Dispatchers.IO) { ensureClient() - client.toSong(client.resolveTrack(hit.url)) + client.toSong(client.resolveTrack(hit.url), permalinkUrl = hit.url) } /** @@ -292,7 +292,7 @@ class SoundCloudViewModel @Inject constructor( candidates.map { hit -> async { gate.withPermit { - runCatching { hit to client.toSong(client.resolveTrack(hit.url)) } + runCatching { hit to client.toSong(client.resolveTrack(hit.url), permalinkUrl = hit.url) } .onFailure { Timber.w(it, "Skip unplayable SoundCloud track: %s", hit.url) } .getOrNull() } @@ -337,7 +337,7 @@ class SoundCloudViewModel @Inject constructor( async { gate.withPermit { try { - client.toSong(client.resolveTrack(hit.url)) + client.toSong(client.resolveTrack(hit.url), permalinkUrl = hit.url) } catch (t: Throwable) { Timber.w(t, "Skip unplayable SoundCloud track: %s", hit.url) null diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb6a183413..f3701381aa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -147,6 +147,7 @@ androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltNa androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "composeMaterialIcons" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "composeMaterialIcons" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Session" } +androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3Session" } androidx-media3-exoplayer-midi = { module = "androidx.media3:media3-exoplayer-midi", version.ref = "media3Session" } androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3Session" } androidx-media3-transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3Transformer" } From ebb1fa1e2909b4b4671d844ee12dca1a6aa2e789 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 20:46:28 +0300 Subject: [PATCH 25/77] fix: start SoundCloud playback after resolving only the tapped track Previously up to 40 NewPipe StreamInfo calls blocked play. HLS enables playback but does not fix that latency; play immediately then prefetch the rest of the queue. Co-authored-by: Cursor --- .../presentation/screens/SoundCloudScreen.kt | 16 ++- .../pixelplay/soundcloud/SoundCloudClient.kt | 45 ++++-- .../soundcloud/SoundCloudViewModel.kt | 132 ++++++++++-------- 3 files changed, 117 insertions(+), 76 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index 06a8be6e62..25d5d0c22e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -224,13 +224,17 @@ fun SoundCloudScreen( } else { scope.launch { try { - val (songs, startSong) = viewModel.resolveQueueForPlayback(hit, queueHits) + // Resolve only the tapped track — don't wait on the whole list. + val startSong = viewModel.resolveStartForPlayback(hit) playerViewModel.playSongs( - songsToPlay = songs, + songsToPlay = listOf(startSong), startSong = startSong, queueName = "SoundCloud", ) viewModel.setIdle() + viewModel.prefetchQueueAfter(hit, queueHits) { song -> + playerViewModel.addSongToQueue(song) + } } catch (t: Throwable) { viewModel.setIdle(error = t.message ?: t::class.java.simpleName) } @@ -261,17 +265,19 @@ fun SoundCloudScreen( startAtZero = true, ) } else { - val songs = viewModel.resolveTracksForPlayback() - if (songs.isEmpty()) return@launch + val (startSong, remaining) = viewModel.resolveShuffleStart() val queueName = uiState.browsingPlaylistTitle ?.let { "SoundCloud · $it" } ?: "SoundCloud · ${uiState.section.name.lowercase().replaceFirstChar { it.titlecase() }}" playerViewModel.playSongsShuffled( - songsToPlay = songs, + songsToPlay = listOf(startSong), queueName = queueName, startAtZero = true, ) viewModel.setIdle() + viewModel.prefetchHits(remaining) { song -> + playerViewModel.addSongToQueue(song) + } } } catch (t: Throwable) { viewModel.setIdle(error = t.message ?: t::class.java.simpleName) diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt index 1911776a2a..19a1898dc1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt @@ -16,6 +16,7 @@ import org.schabi.newpipe.extractor.stream.DeliveryMethod import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject @@ -33,6 +34,12 @@ class SoundCloudClient @Inject constructor( ) { private val downloader = SoundCloudDownloader() private val runtimeClientId = AtomicReference(null) + private val resolveCache = ConcurrentHashMap() + + private data class CachedResolve( + val track: SoundCloudResolvedTrack, + val cachedAtMs: Long, + ) init { ensureInitialized(downloader) @@ -157,16 +164,23 @@ class SoundCloudClient @Inject constructor( @Throws(Exception::class) fun resolveTrack(url: String): SoundCloudResolvedTrack { - return try { - resolveTrackOnce(url) + val key = url.trim() + resolveCache[key]?.takeIf { + System.currentTimeMillis() - it.cachedAtMs < RESOLVE_CACHE_TTL_MS + }?.let { return it.track } + + val resolved = try { + resolveTrackOnce(key) } catch (t: Throwable) { if (looksLikeClientIdOrRateLimit(t)) { refreshClientIdFromWeb() - resolveTrackOnce(url) + resolveTrackOnce(key) } else { throw wrapSoundCloudError(t, "No playable stream") } } + resolveCache[key] = CachedResolve(resolved, System.currentTimeMillis()) + return resolved } private fun resolveTrackOnce(url: String): SoundCloudResolvedTrack { @@ -356,20 +370,33 @@ class SoundCloudClient @Inject constructor( private fun pickBestAudioStream(streams: List): AudioStream? { if (streams.isEmpty()) return null + // Progressive HTTP starts fastest (single file). Prefer it over HLS when available. val progressive = streams.filter { it.deliveryMethod == DeliveryMethod.PROGRESSIVE_HTTP } - val pool = progressive.ifEmpty { - streams.filter { it.deliveryMethod != DeliveryMethod.HLS || !it.content.contains("encrypted") } - .ifEmpty { streams } + if (progressive.isNotEmpty()) { + return progressive.maxWithOrNull( + compareBy { it.averageBitrate }.thenBy { it.bitrate }, + ) } - return pool.maxWithOrNull( - compareBy { it.averageBitrate }.thenBy { it.bitrate }, - ) + // Plain HLS only — skip encrypted/DRM. Prefer a mid/low bitrate for quicker first buffer. + val hls = streams.filter { + it.deliveryMethod == DeliveryMethod.HLS && !it.content.contains("encrypted") + } + if (hls.isNotEmpty()) { + return hls.minWithOrNull( + compareBy { + val br = it.averageBitrate.takeIf { b -> b > 0 } ?: it.bitrate + if (br > 0) br else Int.MAX_VALUE + }, + ) ?: hls.first() + } + return streams.firstOrNull { !it.content.contains("encrypted") } ?: streams.firstOrNull() } private fun soundCloud(): SoundcloudService = ServiceList.SoundCloud companion object { private val initialized = AtomicBoolean(false) + private const val RESOLVE_CACHE_TTL_MS = 15 * 60 * 1000L fun songIdForUrl(url: String): String = "sc_${url.trim().hashCode().toUInt()}" diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt index 8ef4960809..cf55599bc8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt @@ -5,17 +5,13 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import timber.log.Timber import com.theveloper.pixelplay.data.model.Song @@ -69,6 +65,7 @@ class SoundCloudViewModel @Inject constructor( private var searchJob: Job? = null private var loadJob: Job? = null private var artworkJob: Job? = null + private var queuePrefetchJob: Job? = null private val artworkAttempts = mutableSetOf() private val navStack = ArrayDeque() @@ -270,56 +267,46 @@ class SoundCloudViewModel @Inject constructor( } /** - * Builds an ordered queue around the selected track so normal player completion advances - * to the next playable SoundCloud track. Unplayable/DRM entries are skipped. + * Resolves only the tapped track so playback can start immediately. + * Call [prefetchQueueAfter] afterward to fill upcoming songs without blocking. */ - suspend fun resolveQueueForPlayback( + suspend fun resolveStartForPlayback(hit: SoundCloudSearchHit): Song { + setBusy(hit.url) + return withContext(Dispatchers.IO) { + ensureClient() + client.toSong(client.resolveTrack(hit.url), permalinkUrl = hit.url) + } + } + + /** + * Background-resolves tracks after [startHit] in list order and invokes [onSong] + * for each success (typically to append to the player queue). + */ + fun prefetchQueueAfter( startHit: SoundCloudSearchHit, queueHits: List, - limit: Int = 40, - ): Pair, Song> { - val candidates = (listOf(startHit) + queueHits) + limit: Int = 24, + onSong: (Song) -> Unit, + ) { + val tracks = queueHits .asSequence() .filter { it.kind == SoundCloudSearchHit.Kind.TRACK } .distinctBy { it.url } - .take(limit) .toList() - setBusy(startHit.url) - val resolved = withContext(Dispatchers.IO) { - ensureClient() - val gate = Semaphore(2) - coroutineScope { - candidates.map { hit -> - async { - gate.withPermit { - runCatching { hit to client.toSong(client.resolveTrack(hit.url), permalinkUrl = hit.url) } - .onFailure { Timber.w(it, "Skip unplayable SoundCloud track: %s", hit.url) } - .getOrNull() - } - } - }.awaitAll().filterNotNull() - } + val startIndex = tracks.indexOfFirst { it.url == startHit.url } + val following = if (startIndex >= 0) { + tracks.drop(startIndex + 1).take(limit) + } else { + tracks.filter { it.url != startHit.url }.take(limit) } - val startSong = resolved.firstOrNull { it.first.url == startHit.url }?.second - ?: throw IllegalStateException("This SoundCloud track has no playable stream") - val songsByUrl = resolved.associate { it.first.url to it.second } - val orderedSongs = queueHits - .asSequence() - .filter { it.kind == SoundCloudSearchHit.Kind.TRACK } - .mapNotNull { songsByUrl[it.url] } - .distinctBy { it.id } - .toMutableList() - .apply { - if (none { it.id == startSong.id }) add(0, startSong) - } - return orderedSongs to startSong + prefetchHits(following, onSong) } /** - * Resolves playable tracks from the current results list for shuffle/queue playback. - * Skips playlists and tracks that fail to resolve (e.g. DRM). + * Resolves the first playable track quickly so shuffle can start immediately. + * Prefetch the rest with [prefetchHits] after calling play. */ - suspend fun resolveTracksForPlayback(limit: Int = 30): List { + suspend fun resolveShuffleStart(limit: Int = 30): Pair> { val tracks = _uiState.value.results .asSequence() .filter { it.kind == SoundCloudSearchHit.Kind.TRACK } @@ -328,27 +315,48 @@ class SoundCloudViewModel @Inject constructor( if (tracks.isEmpty()) { throw IllegalStateException("No tracks to play — open a playlist or pick a feed with songs") } - setBusy() - return withContext(Dispatchers.IO) { + setBusy(tracks.first().url) + val startSong = withContext(Dispatchers.IO) { ensureClient() - val gate = Semaphore(2) - coroutineScope { - tracks.map { hit -> - async { - gate.withPermit { - try { - client.toSong(client.resolveTrack(hit.url), permalinkUrl = hit.url) - } catch (t: Throwable) { - Timber.w(t, "Skip unplayable SoundCloud track: %s", hit.url) - null - } - } - } - }.awaitAll().filterNotNull() + var first: Song? = null + for (hit in tracks) { + runCatching { + client.toSong(client.resolveTrack(hit.url), permalinkUrl = hit.url) + }.onSuccess { song -> + first = song + }.onFailure { Timber.w(it, "Skip unplayable SoundCloud track: %s", hit.url) } + if (first != null) break } - }.also { songs -> - if (songs.isEmpty()) { - setIdle(error = "No playable streams in this list") + first ?: throw IllegalStateException("No playable streams in this list") + } + val remaining = tracks.filter { SoundCloudClient.songIdForUrl(it.url) != startSong.id } + return startSong to remaining + } + + /** + * Background-resolves [hits] and invokes [onSong] for each success. + * Call after playback has started so songs append to the new queue. + */ + fun prefetchHits( + hits: List, + onSong: (Song) -> Unit, + ) { + if (hits.isEmpty()) return + queuePrefetchJob?.cancel() + queuePrefetchJob = viewModelScope.launch { + withContext(Dispatchers.IO) { + ensureClient() + for (hit in hits) { + ensureActive() + if (hit.kind != SoundCloudSearchHit.Kind.TRACK) continue + runCatching { + client.toSong(client.resolveTrack(hit.url), permalinkUrl = hit.url) + }.onSuccess { song -> + withContext(Dispatchers.Main.immediate) { onSong(song) } + }.onFailure { + Timber.w(it, "Skip unplayable SoundCloud track: %s", hit.url) + } + } } } } From 2d50ef0efd628068dc8ab43ef07eec3bf29f1110 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 21:01:52 +0300 Subject: [PATCH 26/77] ci: tolerate force-push when commitlint resolves the push range Fall back to linting HEAD when github.event.before is no longer a reachable commit after history rewrite. Co-authored-by: Cursor --- .github/workflows/commitlint.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 3ec9c4e74a..4aad782b7d 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -46,8 +46,15 @@ jobs: if: github.event_name == 'push' run: | # Validate every new commit on the push (range from before → after). + # Force-pushes rewrite `before` so that SHA may no longer exist — fall back + # to linting only HEAD instead of failing with "Invalid revision range". BEFORE="${{ github.event.before }}" - if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then + ZERO="0000000000000000000000000000000000000000" + if [ -z "$BEFORE" ] || [ "$BEFORE" = "$ZERO" ]; then + echo "No previous SHA (new branch); linting HEAD only." + npx commitlint --last --verbose + elif ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + echo "Previous SHA $BEFORE is unreachable (likely force-push); linting HEAD only." npx commitlint --last --verbose else npx commitlint --from "$BEFORE" --to "${{ github.sha }}" --verbose From 2ac33476f7ff1b3b6898a98491e77302c7a57e99 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 21:15:23 +0300 Subject: [PATCH 27/77] fix(soundcloud): polish now-playing UI and add section/shelf swipe Keep album art clear with a small EQ badge, shrink row actions, and use horizontal pagers for main tabs and Feed shelves. Co-authored-by: Cursor --- .../presentation/screens/SoundCloudScreen.kt | 863 +++++++++++------- 1 file changed, 555 insertions(+), 308 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index 25d5d0c22e..b2140330d5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -26,6 +26,8 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.animation.togetherWith import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideInHorizontally @@ -130,6 +132,7 @@ import com.theveloper.pixelplay.soundcloud.SoundCloudClient import com.theveloper.pixelplay.soundcloud.SoundCloudSearchHit import com.theveloper.pixelplay.soundcloud.SoundCloudFeedShelf import com.theveloper.pixelplay.soundcloud.SoundCloudSection +import com.theveloper.pixelplay.soundcloud.SoundCloudUiState import com.theveloper.pixelplay.soundcloud.SoundCloudViewModel import com.theveloper.pixelplay.ui.theme.GoogleSansRounded import kotlinx.coroutines.launch @@ -216,6 +219,27 @@ fun SoundCloudScreen( fun hitIsCurrent(hit: SoundCloudSearchHit): Boolean = isSoundCloudHitCurrent(hit, currentSong) + val sectionPagerState = rememberPagerState( + initialPage = selectedIndex.coerceAtLeast(0), + ) { sections.size } + LaunchedEffect(selectedIndex) { + if (selectedIndex < 0) return@LaunchedEffect + if (sectionPagerState.currentPage != selectedIndex && !sectionPagerState.isScrollInProgress) { + sectionPagerState.animateScrollToPage(selectedIndex) + } + } + LaunchedEffect(sectionPagerState, sections) { + snapshotFlow { sectionPagerState.settledPage } + .distinctUntilChanged() + .collect { page -> + val section = sections.getOrNull(page) ?: return@collect + val current = viewModel.uiState.value.section + if (current != section && current != SoundCloudSection.SEARCH) { + viewModel.selectSection(section) + } + } + } + val onHitPlay: (SoundCloudSearchHit, List) -> Unit = remember(viewModel, playerViewModel, scope) { { hit, queueHits -> @@ -380,7 +404,10 @@ fun SoundCloudScreen( index = index, title = section.name, selectedIndex = if (selectedIndex < 0) -1 else selectedIndex, - onClick = { viewModel.selectSection(section) }, + onClick = { + viewModel.selectSection(section) + scope.launch { sectionPagerState.animateScrollToPage(index) } + }, ) { Text( text = stringResource(section.labelRes).uppercase(), @@ -605,7 +632,96 @@ fun SoundCloudScreen( val pullToRefreshState = rememberPullToRefreshState() val showContentLoading = uiState.isLoading && uiState.resolvingKey == null && !uiState.isRefreshing + val sectionSwipeEnabled = + uiState.section != SoundCloudSection.SEARCH && uiState.browsingPlaylistUrl == null Box(modifier = Modifier.fillMaxSize()) { + if (sectionSwipeEnabled) { + HorizontalPager( + state = sectionPagerState, + modifier = Modifier.fillMaxSize(), + beyondViewportPageCount = 0, + key = { sections[it].name }, + ) { page -> + val pageSection = sections[page] + PullToRefreshBox( + isRefreshing = uiState.isRefreshing && pageSection == uiState.section, + onRefresh = viewModel::refresh, + state = pullToRefreshState, + modifier = Modifier.fillMaxSize(), + indicator = { + PullToRefreshDefaults.LoadingIndicator( + state = pullToRefreshState, + isRefreshing = uiState.isRefreshing && pageSection == uiState.section, + modifier = Modifier.align(Alignment.TopCenter), + containerColor = Color.Transparent, + elevation = 0.dp, + ) + }, + ) { + if (pageSection != uiState.section) { + Box(modifier = Modifier.fillMaxSize()) + } else if ( + uiState.section == SoundCloudSection.FEED && + uiState.browsingPlaylistUrl == null && + uiState.feedShelves.isNotEmpty() + ) { + SoundCloudFeedHome( + shelves = uiState.feedShelves, + useTiles = useTiles, + enabled = !uiState.isLoading, + resolvingKey = uiState.resolvingKey, + contentPadding = contentPadding, + isWideScreen = isWideScreen, + likedTrackUrls = uiState.likedTrackUrls, + likingUrl = uiState.likingUrl, + currentSong = currentSong, + isPlaying = isPlaying, + onPlay = onHitPlay, + onDownload = viewModel::download, + onToggleLike = viewModel::toggleLike, + onLoadMore = viewModel::loadMore, + ) + } else if (useTiles) { + SoundCloudResultsGrid( + uiState = uiState, + gridColumns = gridColumns, + contentPadding = contentPadding, + downloadsSelectionActive = downloadsSelectionActive, + selectedSongIds = selectedSongIds, + getSelectionIndex = multiSelectionState::getSelectionIndex, + currentSongId = currentSongId, + isPlaying = isPlaying, + hitIsCurrent = { hitIsCurrent(it) }, + onHitPlay = onHitPlay, + onDownloadPlay = onDownloadPlay, + onDownloadLongPress = onDownloadLongPress, + onDownloadSelectionToggle = onDownloadSelectionToggle, + onDownload = viewModel::download, + onToggleLike = viewModel::toggleLike, + onLoadMore = viewModel::loadMore, + ) + } else { + SoundCloudResultsList( + uiState = uiState, + contentPadding = contentPadding, + downloadsSelectionActive = downloadsSelectionActive, + selectedSongIds = selectedSongIds, + getSelectionIndex = multiSelectionState::getSelectionIndex, + currentSongId = currentSongId, + isPlaying = isPlaying, + hitIsCurrent = { hitIsCurrent(it) }, + onHitPlay = onHitPlay, + onDownloadPlay = onDownloadPlay, + onDownloadLongPress = onDownloadLongPress, + onDownloadSelectionToggle = onDownloadSelectionToggle, + onDownload = viewModel::download, + onToggleLike = viewModel::toggleLike, + onLoadMore = viewModel::loadMore, + ) + } + } + } + } else { PullToRefreshBox( isRefreshing = uiState.isRefreshing, onRefresh = viewModel::refresh, @@ -621,148 +737,46 @@ fun SoundCloudScreen( ) }, ) { - if ( - uiState.section == SoundCloudSection.FEED && - uiState.browsingPlaylistUrl == null && - uiState.feedShelves.isNotEmpty() - ) { - SoundCloudFeedHome( - shelves = uiState.feedShelves, - useTiles = useTiles, - enabled = !uiState.isLoading, - resolvingKey = uiState.resolvingKey, + if (useTiles) { + SoundCloudResultsGrid( + uiState = uiState, + gridColumns = gridColumns, contentPadding = contentPadding, - isWideScreen = isWideScreen, - likedTrackUrls = uiState.likedTrackUrls, - likingUrl = uiState.likingUrl, - currentSong = currentSong, + downloadsSelectionActive = downloadsSelectionActive, + selectedSongIds = selectedSongIds, + getSelectionIndex = multiSelectionState::getSelectionIndex, + currentSongId = currentSongId, isPlaying = isPlaying, - onPlay = onHitPlay, + hitIsCurrent = { hitIsCurrent(it) }, + onHitPlay = onHitPlay, + onDownloadPlay = onDownloadPlay, + onDownloadLongPress = onDownloadLongPress, + onDownloadSelectionToggle = onDownloadSelectionToggle, onDownload = viewModel::download, onToggleLike = viewModel::toggleLike, onLoadMore = viewModel::loadMore, ) - } else if (useTiles) { - val gridState = rememberLazyGridState() - LaunchedEffect(gridState, uiState.results.size, uiState.downloads.size) { - snapshotFlow { - val info = gridState.layoutInfo - val total = info.totalItemsCount - total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - gridColumns - } - .distinctUntilChanged() - .filter { it } - .collect { viewModel.loadMore() } - } - LazyVerticalGrid( - columns = GridCells.Fixed(gridColumns), - state = gridState, - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (uiState.section == SoundCloudSection.DOWNLOADS) { - items(uiState.downloads, key = { it.id }) { song -> - SoundCloudSongTile( - song = song, - isCurrent = currentSongId == song.id, - isPlaying = currentSongId == song.id && isPlaying, - isSelectionMode = downloadsSelectionActive, - isSelected = song.id in selectedSongIds, - selectionIndex = if (downloadsSelectionActive) { - multiSelectionState.getSelectionIndex(song.id) - } else { - null - }, - onPlay = { onDownloadPlay(song) }, - onLongPress = { onDownloadLongPress(song) }, - onSelectionToggle = { onDownloadSelectionToggle(song) }, - ) - } - } else { - items(uiState.results, key = { it.url + it.kind.name }) { hit -> - val isCurrent = hitIsCurrent(hit) - SoundCloudResultTile( - hit = hit, - enabled = !uiState.isLoading, - isResolving = uiState.resolvingKey == hit.url, - isLiked = hit.url in uiState.likedTrackUrls, - isLiking = uiState.likingUrl == hit.url, - isCurrent = isCurrent, - isPlaying = isCurrent && isPlaying, - onPlay = { onHitPlay(hit, uiState.results) }, - onDownload = { viewModel.download(hit) }, - onToggleLike = { viewModel.toggleLike(hit) }, - onShare = { shareSoundCloudLink(context, hit) }, - ) - } - } - } } else { - val listState = rememberLazyListState() - LaunchedEffect(listState, uiState.results.size, uiState.downloads.size) { - snapshotFlow { - val info = listState.layoutInfo - val total = info.totalItemsCount - total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - 3 - } - .distinctUntilChanged() - .filter { it } - .collect { viewModel.loadMore() } - } - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), + SoundCloudResultsList( + uiState = uiState, contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (uiState.section == SoundCloudSection.DOWNLOADS) { - items(uiState.downloads, key = { it.id }) { song -> - EnhancedSongListItem( - song = song, - isPlaying = currentSongId == song.id && isPlaying, - isCurrentSong = currentSongId == song.id, - showMoreOptionsButton = false, - onMoreOptionsClick = {}, - isSelectionMode = downloadsSelectionActive, - isSelected = song.id in selectedSongIds, - selectionIndex = if (downloadsSelectionActive) { - multiSelectionState.getSelectionIndex(song.id) - } else { - null - }, - onLongPress = { onDownloadLongPress(song) }, - onClick = { - if (downloadsSelectionActive) { - onDownloadSelectionToggle(song) - } else { - onDownloadPlay(song) - } - }, - ) - } - } else { - items(uiState.results, key = { it.url + it.kind.name }) { hit -> - val isCurrent = hitIsCurrent(hit) - SoundCloudResultRow( - hit = hit, - enabled = !uiState.isLoading, - isResolving = uiState.resolvingKey == hit.url, - isLiked = hit.url in uiState.likedTrackUrls, - isLiking = uiState.likingUrl == hit.url, - isCurrent = isCurrent, - isPlaying = isCurrent && isPlaying, - onPlay = { onHitPlay(hit, uiState.results) }, - onDownload = { viewModel.download(hit) }, - onToggleLike = { viewModel.toggleLike(hit) }, - onShare = { shareSoundCloudLink(context, hit) }, - ) - } - } - } + downloadsSelectionActive = downloadsSelectionActive, + selectedSongIds = selectedSongIds, + getSelectionIndex = multiSelectionState::getSelectionIndex, + currentSongId = currentSongId, + isPlaying = isPlaying, + hitIsCurrent = { hitIsCurrent(it) }, + onHitPlay = onHitPlay, + onDownloadPlay = onDownloadPlay, + onDownloadLongPress = onDownloadLongPress, + onDownloadSelectionToggle = onDownloadSelectionToggle, + onDownload = viewModel::download, + onToggleLike = viewModel::toggleLike, + onLoadMore = viewModel::loadMore, + ) } } + } // sectionSwipeEnabled else if (showContentLoading) { LoadingIndicator( modifier = Modifier @@ -884,7 +898,10 @@ fun SoundCloudScreen( sections = sections, currentIndex = selectedIndex.coerceAtLeast(0), onSectionSelected = { index -> - sections.getOrNull(index)?.let(viewModel::selectSection) + sections.getOrNull(index)?.let { section -> + viewModel.selectSection(section) + scope.launch { sectionPagerState.animateScrollToPage(index) } + } showSectionSwitcherSheet = false }, onDismiss = { showSectionSwitcherSheet = false }, @@ -893,37 +910,104 @@ fun SoundCloudScreen( } @Composable -private fun SoundCloudFeedHome( - shelves: List, - useTiles: Boolean, - enabled: Boolean, - resolvingKey: String?, +private fun SoundCloudResultsGrid( + uiState: SoundCloudUiState, + gridColumns: Int, contentPadding: PaddingValues, - isWideScreen: Boolean, - likedTrackUrls: Set, - likingUrl: String?, - currentSong: Song?, + downloadsSelectionActive: Boolean, + selectedSongIds: Set, + getSelectionIndex: (String) -> Int?, + currentSongId: String?, isPlaying: Boolean, - onPlay: (SoundCloudSearchHit, List) -> Unit, + hitIsCurrent: (SoundCloudSearchHit) -> Boolean, + onHitPlay: (SoundCloudSearchHit, List) -> Unit, + onDownloadPlay: (Song) -> Unit, + onDownloadLongPress: (Song) -> Unit, + onDownloadSelectionToggle: (Song) -> Unit, onDownload: (SoundCloudSearchHit) -> Unit, onToggleLike: (SoundCloudSearchHit) -> Unit, onLoadMore: () -> Unit, ) { val context = LocalContext.current - var selectedShelfId by remember(shelves.map { it.id }) { - mutableStateOf(shelves.firstOrNull()?.id.orEmpty()) + val gridState = rememberLazyGridState() + LaunchedEffect(gridState, uiState.results.size, uiState.downloads.size) { + snapshotFlow { + val info = gridState.layoutInfo + val total = info.totalItemsCount + total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - gridColumns + } + .distinctUntilChanged() + .filter { it } + .collect { onLoadMore() } } - val visibleShelves = if (isWideScreen) { - shelves - } else { - listOfNotNull(shelves.firstOrNull { it.id == selectedShelfId } ?: shelves.firstOrNull()) + LazyVerticalGrid( + columns = GridCells.Fixed(gridColumns), + state = gridState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (uiState.section == SoundCloudSection.DOWNLOADS) { + items(uiState.downloads, key = { it.id }) { song -> + SoundCloudSongTile( + song = song, + isCurrent = currentSongId == song.id, + isPlaying = currentSongId == song.id && isPlaying, + isSelectionMode = downloadsSelectionActive, + isSelected = song.id in selectedSongIds, + selectionIndex = if (downloadsSelectionActive) getSelectionIndex(song.id) else null, + onPlay = { onDownloadPlay(song) }, + onLongPress = { onDownloadLongPress(song) }, + onSelectionToggle = { onDownloadSelectionToggle(song) }, + ) + } + } else { + items(uiState.results, key = { it.url + it.kind.name }) { hit -> + val isCurrent = hitIsCurrent(hit) + SoundCloudResultTile( + hit = hit, + enabled = !uiState.isLoading, + isResolving = uiState.resolvingKey == hit.url, + isLiked = hit.url in uiState.likedTrackUrls, + isLiking = uiState.likingUrl == hit.url, + isCurrent = isCurrent, + isPlaying = isCurrent && isPlaying, + onPlay = { onHitPlay(hit, uiState.results) }, + onDownload = { onDownload(hit) }, + onToggleLike = { onToggleLike(hit) }, + onShare = { shareSoundCloudLink(context, hit) }, + ) + } + } } +} + +@Composable +private fun SoundCloudResultsList( + uiState: SoundCloudUiState, + contentPadding: PaddingValues, + downloadsSelectionActive: Boolean, + selectedSongIds: Set, + getSelectionIndex: (String) -> Int?, + currentSongId: String?, + isPlaying: Boolean, + hitIsCurrent: (SoundCloudSearchHit) -> Boolean, + onHitPlay: (SoundCloudSearchHit, List) -> Unit, + onDownloadPlay: (Song) -> Unit, + onDownloadLongPress: (Song) -> Unit, + onDownloadSelectionToggle: (Song) -> Unit, + onDownload: (SoundCloudSearchHit) -> Unit, + onToggleLike: (SoundCloudSearchHit) -> Unit, + onLoadMore: () -> Unit, +) { + val context = LocalContext.current val listState = rememberLazyListState() - LaunchedEffect(listState, shelves.sumOf { it.items.size }) { + LaunchedEffect(listState, uiState.results.size, uiState.downloads.size) { snapshotFlow { val info = listState.layoutInfo val total = info.totalItemsCount - total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - 2 + total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - 3 } .distinctUntilChanged() .filter { it } @@ -933,98 +1017,278 @@ private fun SoundCloudFeedHome( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(22.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - if (!isWideScreen) { - item(key = "feed-submenu") { - LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(shelves, key = { it.id }) { shelf -> - FilterChip( - selected = shelf.id == selectedShelfId, - onClick = { selectedShelfId = shelf.id }, - label = { - Text( - text = shelf.title, - fontFamily = GoogleSansRounded, - fontWeight = FontWeight.SemiBold, - ) - }, - ) - } - } + if (uiState.section == SoundCloudSection.DOWNLOADS) { + items(uiState.downloads, key = { it.id }) { song -> + EnhancedSongListItem( + song = song, + isPlaying = currentSongId == song.id && isPlaying, + isCurrentSong = currentSongId == song.id, + showMoreOptionsButton = false, + onMoreOptionsClick = {}, + isSelectionMode = downloadsSelectionActive, + isSelected = song.id in selectedSongIds, + selectionIndex = if (downloadsSelectionActive) getSelectionIndex(song.id) else null, + onLongPress = { onDownloadLongPress(song) }, + onClick = { + if (downloadsSelectionActive) { + onDownloadSelectionToggle(song) + } else { + onDownloadPlay(song) + } + }, + ) + } + } else { + items(uiState.results, key = { it.url + it.kind.name }) { hit -> + val isCurrent = hitIsCurrent(hit) + SoundCloudResultRow( + hit = hit, + enabled = !uiState.isLoading, + isResolving = uiState.resolvingKey == hit.url, + isLiked = hit.url in uiState.likedTrackUrls, + isLiking = uiState.likingUrl == hit.url, + isCurrent = isCurrent, + isPlaying = isCurrent && isPlaying, + onPlay = { onHitPlay(hit, uiState.results) }, + onDownload = { onDownload(hit) }, + onToggleLike = { onToggleLike(hit) }, + onShare = { shareSoundCloudLink(context, hit) }, + ) } } - items(visibleShelves, key = { it.id }) { shelf -> - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text( - text = shelf.title, - modifier = Modifier.padding(horizontal = 16.dp), - style = MaterialTheme.typography.headlineSmall, - fontFamily = GoogleSansRounded, - fontWeight = FontWeight.ExtraBold, + } +} + +@Composable +private fun SoundCloudFeedHome( + shelves: List, + useTiles: Boolean, + enabled: Boolean, + resolvingKey: String?, + contentPadding: PaddingValues, + isWideScreen: Boolean, + likedTrackUrls: Set, + likingUrl: String?, + currentSong: Song?, + isPlaying: Boolean, + onPlay: (SoundCloudSearchHit, List) -> Unit, + onDownload: (SoundCloudSearchHit) -> Unit, + onToggleLike: (SoundCloudSearchHit) -> Unit, + onLoadMore: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + if (isWideScreen) { + val listState = rememberLazyListState() + LaunchedEffect(listState, shelves.sumOf { it.items.size }) { + snapshotFlow { + val info = listState.layoutInfo + val total = info.totalItemsCount + total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - 2 + } + .distinctUntilChanged() + .filter { it } + .collect { onLoadMore() } + } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(22.dp), + ) { + items(shelves, key = { it.id }) { shelf -> + SoundCloudFeedShelfBody( + shelf = shelf, + useTiles = useTiles, + tileColumns = 3, + enabled = enabled, + resolvingKey = resolvingKey, + likedTrackUrls = likedTrackUrls, + likingUrl = likingUrl, + currentSong = currentSong, + isPlaying = isPlaying, + showTitle = true, + onPlay = onPlay, + onDownload = onDownload, + onToggleLike = onToggleLike, + ) + } + } + return + } + + val shelfPagerState = rememberPagerState(initialPage = 0) { shelves.size.coerceAtLeast(1) } + LaunchedEffect(shelves.map { it.id }) { + if (shelfPagerState.currentPage >= shelves.size && shelves.isNotEmpty()) { + shelfPagerState.scrollToPage(0) + } + } + Column(modifier = Modifier.fillMaxSize()) { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = 10.dp), + ) { + items(shelves, key = { it.id }) { shelf -> + val index = shelves.indexOfFirst { it.id == shelf.id } + FilterChip( + selected = shelfPagerState.currentPage == index, + onClick = { + scope.launch { shelfPagerState.animateScrollToPage(index.coerceAtLeast(0)) } + }, + label = { + Text( + text = shelf.title, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.SemiBold, + ) + }, ) - if (useTiles) { - val tileColumns = if (isWideScreen) 3 else 2 - Column( + } + } + HorizontalPager( + state = shelfPagerState, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + key = { shelves.getOrNull(it)?.id ?: it }, + ) { page -> + val shelf = shelves.getOrNull(page) ?: return@HorizontalPager + val listState = rememberLazyListState() + LaunchedEffect(listState, shelf.items.size, page) { + if (page != shelfPagerState.settledPage) return@LaunchedEffect + snapshotFlow { + val info = listState.layoutInfo + val total = info.totalItemsCount + total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - 2 + } + .distinctUntilChanged() + .filter { it } + .collect { onLoadMore() } + } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(key = "shelf-title-${shelf.id}") { + Text( + text = shelf.title, modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + style = MaterialTheme.typography.headlineSmall, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.ExtraBold, + ) + } + item(key = "shelf-body-${shelf.id}") { + SoundCloudFeedShelfBody( + shelf = shelf, + useTiles = useTiles, + tileColumns = 2, + enabled = enabled, + resolvingKey = resolvingKey, + likedTrackUrls = likedTrackUrls, + likingUrl = likingUrl, + currentSong = currentSong, + isPlaying = isPlaying, + showTitle = false, + onPlay = onPlay, + onDownload = onDownload, + onToggleLike = onToggleLike, + ) + } + } + } + } +} + +@Composable +private fun SoundCloudFeedShelfBody( + shelf: SoundCloudFeedShelf, + useTiles: Boolean, + tileColumns: Int, + enabled: Boolean, + resolvingKey: String?, + likedTrackUrls: Set, + likingUrl: String?, + currentSong: Song?, + isPlaying: Boolean, + showTitle: Boolean, + onPlay: (SoundCloudSearchHit, List) -> Unit, + onDownload: (SoundCloudSearchHit) -> Unit, + onToggleLike: (SoundCloudSearchHit) -> Unit, +) { + val context = LocalContext.current + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (showTitle) { + Text( + text = shelf.title, + modifier = Modifier.padding(horizontal = 16.dp), + style = MaterialTheme.typography.headlineSmall, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.ExtraBold, + ) + } + if (useTiles) { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + shelf.items.chunked(tileColumns).forEach { rowHits -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - shelf.items.chunked(tileColumns).forEach { rowHits -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - rowHits.forEach { hit -> - val isCurrent = isSoundCloudHitCurrent(hit, currentSong) - Box(modifier = Modifier.weight(1f)) { - SoundCloudResultTile( - hit = hit, - enabled = enabled, - isResolving = resolvingKey == hit.url, - isLiked = hit.url in likedTrackUrls, - isLiking = likingUrl == hit.url, - isCurrent = isCurrent, - isPlaying = isCurrent && isPlaying, - onPlay = { onPlay(hit, shelf.items) }, - onDownload = { onDownload(hit) }, - onToggleLike = { onToggleLike(hit) }, - onShare = { shareSoundCloudLink(context, hit) }, - ) - } - } - repeat(tileColumns - rowHits.size) { - Spacer(modifier = Modifier.weight(1f)) - } + rowHits.forEach { hit -> + val isCurrent = isSoundCloudHitCurrent(hit, currentSong) + Box(modifier = Modifier.weight(1f)) { + SoundCloudResultTile( + hit = hit, + enabled = enabled, + isResolving = resolvingKey == hit.url, + isLiked = hit.url in likedTrackUrls, + isLiking = likingUrl == hit.url, + isCurrent = isCurrent, + isPlaying = isCurrent && isPlaying, + onPlay = { onPlay(hit, shelf.items) }, + onDownload = { onDownload(hit) }, + onToggleLike = { onToggleLike(hit) }, + onShare = { shareSoundCloudLink(context, hit) }, + ) } } - } - } else { - Column( - modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - shelf.items.take(6).forEach { hit -> - val isCurrent = isSoundCloudHitCurrent(hit, currentSong) - SoundCloudResultRow( - hit = hit, - enabled = enabled, - isResolving = resolvingKey == hit.url, - isLiked = hit.url in likedTrackUrls, - isLiking = likingUrl == hit.url, - isCurrent = isCurrent, - isPlaying = isCurrent && isPlaying, - onPlay = { onPlay(hit, shelf.items) }, - onDownload = { onDownload(hit) }, - onToggleLike = { onToggleLike(hit) }, - onShare = { shareSoundCloudLink(context, hit) }, - ) + repeat(tileColumns - rowHits.size) { + Spacer(modifier = Modifier.weight(1f)) } } } } + } else { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val rows = if (showTitle) shelf.items else shelf.items + rows.forEach { hit -> + val isCurrent = isSoundCloudHitCurrent(hit, currentSong) + SoundCloudResultRow( + hit = hit, + enabled = enabled, + isResolving = resolvingKey == hit.url, + isLiked = hit.url in likedTrackUrls, + isLiking = likingUrl == hit.url, + isCurrent = isCurrent, + isPlaying = isCurrent && isPlaying, + onPlay = { onPlay(hit, shelf.items) }, + onDownload = { onDownload(hit) }, + onToggleLike = { onToggleLike(hit) }, + onShare = { shareSoundCloudLink(context, hit) }, + ) + } + } } } } @@ -1238,26 +1502,19 @@ private fun SoundCloudSongTile( } } else if (isCurrent && !isSelectionMode) { Surface( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f), - modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.92f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp), ) { - if (isPlaying) { - PlayingEqIcon( - modifier = Modifier - .size(width = 28.dp, height = 24.dp) - .align(Alignment.Center), - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Icon( - imageVector = Icons.Rounded.PlayArrow, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier - .size(40.dp) - .align(Alignment.Center), - ) - } + PlayingEqIcon( + modifier = Modifier + .padding(horizontal = 7.dp, vertical = 6.dp) + .size(width = 16.dp, height = 14.dp), + color = MaterialTheme.colorScheme.onPrimary, + isPlaying = isPlaying, + ) } } } @@ -1333,26 +1590,19 @@ private fun SoundCloudResultTile( } if (isCurrent && !isResolving) { Surface( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f), - modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.92f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp), ) { - if (isPlaying) { - PlayingEqIcon( - modifier = Modifier - .size(width = 28.dp, height = 24.dp) - .align(Alignment.Center), - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Icon( - imageVector = Icons.Rounded.PlayArrow, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier - .size(40.dp) - .align(Alignment.Center), - ) - } + PlayingEqIcon( + modifier = Modifier + .padding(horizontal = 7.dp, vertical = 6.dp) + .size(width = 16.dp, height = 14.dp), + color = MaterialTheme.colorScheme.onPrimary, + isPlaying = isPlaying, + ) } } if (isResolving) { @@ -1366,13 +1616,14 @@ private fun SoundCloudResultTile( .align(Alignment.Center), ) } - } else if (!isCurrent) { + } else { IconButton( onClick = onShare, enabled = enabled && hit.url.isNotBlank(), modifier = Modifier .align(Alignment.TopEnd) - .padding(4.dp), + .padding(2.dp) + .size(30.dp), colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.72f), contentColor = MaterialTheme.colorScheme.onSurface, @@ -1381,29 +1632,32 @@ private fun SoundCloudResultTile( Icon( imageVector = Icons.Rounded.Share, contentDescription = stringResource(R.string.soundcloud_share_link), + modifier = Modifier.size(16.dp), ) } } - if (!isResolving && hit.kind == SoundCloudSearchHit.Kind.TRACK && !isCurrent) { + if (!isResolving && hit.kind == SoundCloudSearchHit.Kind.TRACK) { IconButton( onClick = onToggleLike, enabled = enabled && !isLiking, modifier = Modifier - .align(Alignment.BottomStart) - .padding(4.dp), + .align(if (isCurrent) Alignment.BottomCenter else Alignment.BottomStart) + .padding(2.dp) + .size(30.dp), colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.72f), contentColor = MaterialTheme.colorScheme.primary, ), ) { if (isLiking) { - LoadingIndicator(modifier = Modifier.size(22.dp)) + LoadingIndicator(modifier = Modifier.size(16.dp)) } else { Icon( imageVector = if (isLiked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, contentDescription = stringResource( if (isLiked) R.string.soundcloud_unlike else R.string.soundcloud_like, ), + modifier = Modifier.size(16.dp), ) } } @@ -1412,7 +1666,8 @@ private fun SoundCloudResultTile( enabled = enabled, modifier = Modifier .align(Alignment.BottomEnd) - .padding(4.dp), + .padding(2.dp) + .size(30.dp), colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.72f), contentColor = MaterialTheme.colorScheme.onSurface, @@ -1421,6 +1676,7 @@ private fun SoundCloudResultTile( Icon( Icons.Rounded.Download, contentDescription = stringResource(R.string.soundcloud_download), + modifier = Modifier.size(16.dp), ) } } @@ -1500,31 +1756,6 @@ private fun SoundCloudResultRow( ) } } - if (isCurrent && !isResolving) { - Surface( - color = colors.primary.copy(alpha = 0.55f), - shape = RoundedCornerShape(8.dp), - modifier = Modifier.fillMaxSize(), - ) { - if (isPlaying) { - PlayingEqIcon( - modifier = Modifier - .size(width = 18.dp, height = 16.dp) - .align(Alignment.Center), - color = colors.onPrimary, - ) - } else { - Icon( - imageVector = Icons.Rounded.PlayArrow, - contentDescription = null, - tint = colors.onPrimary, - modifier = Modifier - .size(28.dp) - .align(Alignment.Center), - ) - } - } - } if (isResolving) { Surface( color = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f), @@ -1571,17 +1802,26 @@ private fun SoundCloudResultRow( } if (!isResolving && hit.url.isNotBlank()) { - IconButton(onClick = onShare, enabled = enabled) { + IconButton( + onClick = onShare, + enabled = enabled, + modifier = Modifier.size(32.dp), + ) { Icon( imageVector = Icons.Rounded.Share, contentDescription = stringResource(R.string.soundcloud_share_link), + modifier = Modifier.size(18.dp), ) } } if (hit.kind == SoundCloudSearchHit.Kind.TRACK && !isResolving) { - IconButton(onClick = onToggleLike, enabled = enabled && !isLiking) { + IconButton( + onClick = onToggleLike, + enabled = enabled && !isLiking, + modifier = Modifier.size(32.dp), + ) { if (isLiking) { - LoadingIndicator(modifier = Modifier.size(22.dp)) + LoadingIndicator(modifier = Modifier.size(16.dp)) } else { Icon( imageVector = if (isLiked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, @@ -1589,21 +1829,27 @@ private fun SoundCloudResultRow( if (isLiked) R.string.soundcloud_unlike else R.string.soundcloud_like, ), tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), ) } } - IconButton(onClick = onDownload, enabled = enabled) { + IconButton( + onClick = onDownload, + enabled = enabled, + modifier = Modifier.size(32.dp), + ) { Icon( Icons.Rounded.Download, contentDescription = stringResource(R.string.soundcloud_download), + modifier = Modifier.size(18.dp), ) } } if (isResolving) { - LoadingIndicator(modifier = Modifier.size(24.dp)) + LoadingIndicator(modifier = Modifier.size(22.dp)) } else if (isCurrent) { PlayingEqIcon( - modifier = Modifier.size(width = 18.dp, height = 16.dp), + modifier = Modifier.size(width = 16.dp, height = 14.dp), color = colors.primary, isPlaying = isPlaying, ) @@ -1616,6 +1862,7 @@ private fun SoundCloudResultRow( }, contentDescription = stringResource(R.string.soundcloud_play), tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), ) } } From 75cb8d4a407207fb6c85b029dd53403f25f33b2c Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Fri, 25 Sep 2026 10:07:47 +0300 Subject: [PATCH 28/77] fix(soundcloud): skip unplayable tracks and tag downloads correctly Gray out tracks with no stream and advance the queue instead of stopping playback. Embed real metadata and cover art on download, and add a setting to keep those files off the main library. Co-authored-by: Cursor --- .../pixelplay/data/service/MusicService.kt | 21 ++ .../screens/ExperimentalSettingsScreen.kt | 7 + .../presentation/screens/SoundCloudScreen.kt | 35 +- .../pixelplay/soundcloud/SoundCloudClient.kt | 62 +++ .../soundcloud/SoundCloudDownloadService.kt | 355 +++++++++++++----- .../soundcloud/SoundCloudLibraryGate.kt | 36 ++ .../pixelplay/soundcloud/SoundCloudModels.kt | 22 ++ .../soundcloud/SoundCloudPlayabilityStore.kt | 55 +++ .../soundcloud/SoundCloudPrefsViewModel.kt | 13 + .../soundcloud/SoundCloudSessionApi.kt | 80 +++- .../soundcloud/SoundCloudSettings.kt | 11 + .../soundcloud/SoundCloudViewModel.kt | 234 +++++++++--- app/src/main/res/values/strings.xml | 3 + 13 files changed, 777 insertions(+), 157 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudLibraryGate.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPlayabilityStore.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index 7705ab13a6..f084bf7826 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -198,6 +198,7 @@ class MusicService : MediaLibraryService() { private var countedPlayCount = 0 private var countedOriginalId: String? = null private var countedPlayListener: Player.Listener? = null + private var consecutivePlaybackErrorSkips = 0 private val alarmManager by lazy { getSystemService(Context.ALARM_SERVICE) as AlarmManager } @@ -267,6 +268,7 @@ class MusicService : MediaLibraryService() { private const val PLAYBACK_SNAPSHOT_DEBOUNCE_MS = 1500L private const val MEDIA_SESSION_BUTTON_DEBOUNCE_MS = 250L private const val DEFERRED_SERVICE_STARTUP_WORK_DELAY_MS = 1_000L + private const val MAX_PLAYBACK_ERROR_SKIPS = 8 private const val PAUSED_RESTORE_PREPARE_QUEUE_LIMIT = 50 private val pendingMediaButtonForegroundStarts = AtomicInteger(0) @@ -1336,6 +1338,9 @@ class MusicService : MediaLibraryService() { override fun onPlaybackStateChanged(playbackState: Int) { Timber.tag(TAG).d("Playback state changed: $playbackState") + if (playbackState == Player.STATE_READY) { + consecutivePlaybackErrorSkips = 0 + } if (playbackState == Player.STATE_ENDED) { listeningStatsTracker.finalizeCurrentSession() val mediaItem = (mediaSession?.player ?: engine.masterPlayer).currentMediaItem @@ -1478,6 +1483,22 @@ class MusicService : MediaLibraryService() { } override fun onPlayerError(error: PlaybackException) { + val player = mediaSession?.player ?: engine.masterPlayer + val nextIndex = player.nextMediaItemIndex + val canSkipToAnother = nextIndex != C.INDEX_UNSET && nextIndex != player.currentMediaItemIndex + if (canSkipToAnother && consecutivePlaybackErrorSkips < MAX_PLAYBACK_ERROR_SKIPS) { + consecutivePlaybackErrorSkips++ + Timber.tag(TAG).w( + error, + "Skipping unplayable item (%d)", + consecutivePlaybackErrorSkips, + ) + player.seekToNextMediaItem() + player.prepare() + player.play() + return + } + consecutivePlaybackErrorSkips = 0 Timber.tag(TAG).e(error, "Error en el reproductor: ") serviceScope.launch { val currentMediaItem = mediaSession?.player?.currentMediaItem diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt index bb82e950af..e7b604da34 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt @@ -192,6 +192,7 @@ fun ExperimentalSettingsScreen( val scClientId by scPrefsVm.clientId.collectAsStateWithLifecycle() val scUsername by scPrefsVm.username.collectAsStateWithLifecycle() val scSession by scPrefsVm.session.collectAsStateWithLifecycle() + val includeDownloadsInLibrary by scPrefsVm.includeDownloadsInLibrary.collectAsStateWithLifecycle() var clientIdDraft by remember(scClientId) { mutableStateOf(scClientId) } var usernameDraft by remember(scUsername) { mutableStateOf(scUsername) } val context = LocalContext.current @@ -265,6 +266,12 @@ fun ExperimentalSettingsScreen( label = { Text(stringResource(R.string.soundcloud_username_label)) }, placeholder = { Text(stringResource(R.string.soundcloud_username_hint)) }, ) + SwitchSettingItem( + title = stringResource(R.string.soundcloud_include_downloads_in_library), + subtitle = stringResource(R.string.soundcloud_include_downloads_in_library_subtitle), + checked = includeDownloadsInLibrary, + onCheckedChange = scPrefsVm::setIncludeDownloadsInLibrary, + ) FilledTonalButton( onClick = { scPrefsVm.saveClientId(clientIdDraft) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index b2140330d5..a31892f821 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -102,6 +102,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -249,14 +250,14 @@ fun SoundCloudScreen( scope.launch { try { // Resolve only the tapped track — don't wait on the whole list. - val startSong = viewModel.resolveStartForPlayback(hit) + val (startSong, startHit) = viewModel.resolvePlayableStart(hit, queueHits) playerViewModel.playSongs( songsToPlay = listOf(startSong), startSong = startSong, queueName = "SoundCloud", ) viewModel.setIdle() - viewModel.prefetchQueueAfter(hit, queueHits) { song -> + viewModel.prefetchQueueAfter(startHit, queueHits) { song -> playerViewModel.addSongToQueue(song) } } catch (t: Throwable) { @@ -674,6 +675,7 @@ fun SoundCloudScreen( isWideScreen = isWideScreen, likedTrackUrls = uiState.likedTrackUrls, likingUrl = uiState.likingUrl, + unplayableUrls = uiState.unplayableUrls, currentSong = currentSong, isPlaying = isPlaying, onPlay = onHitPlay, @@ -971,6 +973,7 @@ private fun SoundCloudResultsGrid( isResolving = uiState.resolvingKey == hit.url, isLiked = hit.url in uiState.likedTrackUrls, isLiking = uiState.likingUrl == hit.url, + unavailable = hit.kind == SoundCloudSearchHit.Kind.TRACK && hit.url in uiState.unplayableUrls, isCurrent = isCurrent, isPlaying = isCurrent && isPlaying, onPlay = { onHitPlay(hit, uiState.results) }, @@ -1049,6 +1052,7 @@ private fun SoundCloudResultsList( isResolving = uiState.resolvingKey == hit.url, isLiked = hit.url in uiState.likedTrackUrls, isLiking = uiState.likingUrl == hit.url, + unavailable = hit.kind == SoundCloudSearchHit.Kind.TRACK && hit.url in uiState.unplayableUrls, isCurrent = isCurrent, isPlaying = isCurrent && isPlaying, onPlay = { onHitPlay(hit, uiState.results) }, @@ -1071,6 +1075,7 @@ private fun SoundCloudFeedHome( isWideScreen: Boolean, likedTrackUrls: Set, likingUrl: String?, + unplayableUrls: Set, currentSong: Song?, isPlaying: Boolean, onPlay: (SoundCloudSearchHit, List) -> Unit, @@ -1107,6 +1112,7 @@ private fun SoundCloudFeedHome( resolvingKey = resolvingKey, likedTrackUrls = likedTrackUrls, likingUrl = likingUrl, + unplayableUrls = unplayableUrls, currentSong = currentSong, isPlaying = isPlaying, showTitle = true, @@ -1192,6 +1198,7 @@ private fun SoundCloudFeedHome( resolvingKey = resolvingKey, likedTrackUrls = likedTrackUrls, likingUrl = likingUrl, + unplayableUrls = unplayableUrls, currentSong = currentSong, isPlaying = isPlaying, showTitle = false, @@ -1214,6 +1221,7 @@ private fun SoundCloudFeedShelfBody( resolvingKey: String?, likedTrackUrls: Set, likingUrl: String?, + unplayableUrls: Set, currentSong: Song?, isPlaying: Boolean, showTitle: Boolean, @@ -1251,6 +1259,8 @@ private fun SoundCloudFeedShelfBody( isResolving = resolvingKey == hit.url, isLiked = hit.url in likedTrackUrls, isLiking = likingUrl == hit.url, + unavailable = hit.kind == SoundCloudSearchHit.Kind.TRACK && + hit.url in unplayableUrls, isCurrent = isCurrent, isPlaying = isCurrent && isPlaying, onPlay = { onPlay(hit, shelf.items) }, @@ -1280,6 +1290,8 @@ private fun SoundCloudFeedShelfBody( isResolving = resolvingKey == hit.url, isLiked = hit.url in likedTrackUrls, isLiking = likingUrl == hit.url, + unavailable = hit.kind == SoundCloudSearchHit.Kind.TRACK && + hit.url in unplayableUrls, isCurrent = isCurrent, isPlaying = isCurrent && isPlaying, onPlay = { onPlay(hit, shelf.items) }, @@ -1545,6 +1557,7 @@ private fun SoundCloudResultTile( isResolving: Boolean = false, isLiked: Boolean = false, isLiking: Boolean = false, + unavailable: Boolean = false, isCurrent: Boolean = false, isPlaying: Boolean = false, onPlay: () -> Unit, @@ -1555,7 +1568,8 @@ private fun SoundCloudResultTile( Column( modifier = Modifier .fillMaxWidth() - .clickable(enabled = enabled && !isResolving, onClick = onPlay), + .alpha(if (unavailable) 0.38f else 1f) + .clickable(enabled = enabled && !isResolving && !unavailable, onClick = onPlay), ) { Box( modifier = Modifier @@ -1636,7 +1650,7 @@ private fun SoundCloudResultTile( ) } } - if (!isResolving && hit.kind == SoundCloudSearchHit.Kind.TRACK) { + if (!isResolving && hit.kind == SoundCloudSearchHit.Kind.TRACK && !unavailable) { IconButton( onClick = onToggleLike, enabled = enabled && !isLiking, @@ -1663,7 +1677,7 @@ private fun SoundCloudResultTile( } IconButton( onClick = onDownload, - enabled = enabled, + enabled = enabled && !unavailable, modifier = Modifier .align(Alignment.BottomEnd) .padding(2.dp) @@ -1692,7 +1706,7 @@ private fun SoundCloudResultTile( color = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, ) Text( - text = hit.artist, + text = if (unavailable) stringResource(R.string.soundcloud_unavailable) else hit.artist, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -1708,6 +1722,7 @@ private fun SoundCloudResultRow( isResolving: Boolean = false, isLiked: Boolean = false, isLiking: Boolean = false, + unavailable: Boolean = false, isCurrent: Boolean = false, isPlaying: Boolean = false, onPlay: () -> Unit, @@ -1721,8 +1736,9 @@ private fun SoundCloudResultRow( shape = RoundedCornerShape(12.dp), modifier = Modifier .fillMaxWidth() + .alpha(if (unavailable) 0.38f else 1f) .clip(RoundedCornerShape(12.dp)) - .clickable(enabled = enabled && !isResolving, onClick = onPlay), + .clickable(enabled = enabled && !isResolving && !unavailable, onClick = onPlay), ) { Row( modifier = Modifier.padding(12.dp), @@ -1781,12 +1797,13 @@ private fun SoundCloudResultRow( color = if (isCurrent) colors.primary else colors.onSurface, ) Text( - text = hit.artist, + text = if (unavailable) stringResource(R.string.soundcloud_unavailable) else hit.artist, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, ) val meta = when { + unavailable -> null hit.kind == SoundCloudSearchHit.Kind.PLAYLIST && hit.streamCount > 0 -> stringResource(R.string.soundcloud_playlist_count, hit.streamCount) hit.durationSec > 0 -> formatDuration(hit.durationSec) @@ -1835,7 +1852,7 @@ private fun SoundCloudResultRow( } IconButton( onClick = onDownload, - enabled = enabled, + enabled = enabled && !unavailable, modifier = Modifier.size(32.dp), ) { Icon( diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt index 19a1898dc1..7c4c019ade 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt @@ -31,6 +31,7 @@ import javax.inject.Singleton @Singleton class SoundCloudClient @Inject constructor( private val sessionApi: SoundCloudSessionApi, + private val playability: SoundCloudPlayabilityStore, ) { private val downloader = SoundCloudDownloader() private val runtimeClientId = AtomicReference(null) @@ -202,6 +203,67 @@ class SoundCloudClient @Inject constructor( ) } + fun isKnownUnplayable(url: String): Boolean = playability.isUnplayable(url) + + fun knowsPlayability(url: String): Boolean = playability.knows(url) + + fun rememberPlayable(url: String) { + playability.remember(url, playable = true) + } + + fun rememberUnplayable(url: String) { + playability.remember(url, playable = false) + } + + /** + * Cheap api-v2 check (no stream extraction). Null means the probe failed and + * the row should stay unchanged so a blip doesn't gray the whole list. + */ + fun probePlayable(url: String): Boolean? { + val key = url.trim() + if (playability.isUnplayable(key)) return false + if (playability.knows(key)) return true + resolveCache[key]?.let { + playability.remember(key, playable = true) + return true + } + val playable = sessionApi.playbackAvailability(key) ?: return null + playability.remember(key, playable) + return playable + } + + fun isPermanentStreamFailure(error: Throwable): Boolean { + if (looksLikeClientIdOrRateLimit(error)) return false + val msg = generateSequence(error) { it.cause } + .mapNotNull { it.message } + .joinToString(" ") + .lowercase() + return msg.contains("no playable stream") || + msg.contains("could not get any stream") || + msg.contains("no progressive") || + msg.contains("drm") || + msg.contains("encrypted") + } + + fun fileTags(track: SoundCloudResolvedTrack): SoundCloudFileTags { + val fromApi = runCatching { sessionApi.loadFileTags(track.url) }.getOrNull() + val artist = fromApi?.artist?.takeUnless { it.isSoundCloudPlaceholder() } + ?: track.artist.takeUnless { it.isSoundCloudPlaceholder() } + ?: "Unknown artist" + val title = fromApi?.title?.takeIf { it.isNotBlank() } ?: track.title + val album = fromApi?.album?.takeUnless { it.isSoundCloudPlaceholder() } ?: artist + val genre = fromApi?.genre?.takeUnless { it.isSoundCloudPlaceholder() } + val artwork = fromApi?.artworkUrl?.takeIf { it.isNotBlank() } ?: track.artworkUrl + return SoundCloudFileTags( + title = title, + artist = artist, + album = album, + albumArtist = artist, + genre = genre, + artworkUrl = artwork, + ) + } + fun toSong(track: SoundCloudResolvedTrack, permalinkUrl: String = track.url): Song { // Prefer the list permalink so UI can match the now-playing row without resolving again. val id = songIdForUrl(permalinkUrl.ifBlank { track.url }) diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt index 1a370b3caa..5159c9bb69 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt @@ -3,19 +3,30 @@ package com.theveloper.pixelplay.soundcloud import android.content.ContentUris import android.content.ContentValues import android.content.Context +import android.net.Uri import android.os.Build import android.os.Environment +import android.os.ParcelFileDescriptor import android.provider.MediaStore +import androidx.core.net.toUri +import com.kyant.taglib.Picture +import com.kyant.taglib.TagLib import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.utils.AlbumArtUtils import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.OkHttpClient import okhttp3.Request +import timber.log.Timber import java.io.File import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +data class SoundCloudDownloadCatalog( + val songs: List, + val repairs: List = emptyList(), +) + @Singleton class SoundCloudDownloadService @Inject constructor( @ApplicationContext private val context: Context, @@ -23,6 +34,9 @@ class SoundCloudDownloadService @Inject constructor( private val artworkPreferences by lazy { context.getSharedPreferences(ARTWORK_PREFERENCES, Context.MODE_PRIVATE) } + private val repairedPreferences by lazy { + context.getSharedPreferences(REPAIRED_PREFERENCES, Context.MODE_PRIVATE) + } private val http = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) @@ -31,11 +45,11 @@ class SoundCloudDownloadService @Inject constructor( /** * Saves a progressive/HTTP audio URL into public Music/PixelPlayer/SoundCloud - * so it can appear in the system library / PixelPlayer local scan. + * and writes the track's real title, artist, album, and cover into the file. */ - fun downloadToMusicFolder(track: SoundCloudResolvedTrack): File { - val safeTitle = track.title.replace(Regex("""[\\/:*?"<>|]"""), "_").take(80).ifBlank { "track" } - val safeArtist = track.artist.replace(Regex("""[\\/:*?"<>|]"""), "_").take(40).ifBlank { "SoundCloud" } + fun downloadToMusicFolder(track: SoundCloudResolvedTrack, tags: SoundCloudFileTags): File { + val safeTitle = tags.title.replace(UNSAFE_FILENAME, "_").take(80).ifBlank { "track" } + val safeArtist = tags.artist.replace(UNSAFE_FILENAME, "_").take(40).ifBlank { "Unknown artist" } val ext = when { track.mimeType?.contains("mpeg") == true -> "mp3" track.mimeType?.contains("mp4") == true || track.mimeType?.contains("m4a") == true -> "m4a" @@ -46,65 +60,82 @@ class SoundCloudDownloadService @Inject constructor( else -> "mp3" } val fileName = "$safeArtist - $safeTitle.$ext" - - val request = Request.Builder() - .url(track.streamUrl) - .header( - "User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", - ) - .header("Referer", "https://soundcloud.com/") - .build() + val request = audioRequest(track.streamUrl) http.newCall(request).execute().use { response -> if (!response.isSuccessful) { throw IllegalStateException("Download HTTP ${response.code}") } val body = response.body ?: throw IllegalStateException("Empty download body") + val artwork = tags.artworkUrl?.let { downloadArtwork(it) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val values = ContentValues().apply { - put(MediaStore.Audio.Media.DISPLAY_NAME, fileName) - put(MediaStore.Audio.Media.MIME_TYPE, track.mimeType ?: "audio/mpeg") - put(MediaStore.Audio.Media.RELATIVE_PATH, "Music/PixelPlayer/SoundCloud") - put(MediaStore.Audio.Media.IS_PENDING, 1) - put(MediaStore.Audio.Media.ARTIST, track.artist) - put(MediaStore.Audio.Media.TITLE, track.title) - put(MediaStore.Audio.Media.ALBUM, "SoundCloud") - } + val values = contentValuesFor(fileName, track.mimeType, tags) + values.put(MediaStore.Audio.Media.IS_PENDING, 1) val resolver = context.contentResolver val collection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val uri = resolver.insert(collection, values) ?: throw IllegalStateException("MediaStore insert failed") resolver.openOutputStream(uri)?.use { out -> body.byteStream().copyTo(out) } ?: throw IllegalStateException("Could not open MediaStore stream") + val embedded = embedTags(uri, tags, artwork) values.clear() + values.putAll(metadataValues(tags)) values.put(MediaStore.Audio.Media.IS_PENDING, 0) resolver.update(uri, values, null, null) - rememberArtwork(fileName, track.artworkUrl) - return File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), - "PixelPlayer/SoundCloud/$fileName", - ) + rememberArtwork(fileName, tags.artworkUrl) + if (embedded) markRepaired(fileName) + return File(downloadDirectory(), fileName) } - val dir = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), - "PixelPlayer/SoundCloud", - ) + val dir = downloadDirectory() if (!dir.exists() && !dir.mkdirs()) { throw IllegalStateException("Could not create ${dir.absolutePath}") } val outFile = File(dir, fileName) outFile.outputStream().use { out -> body.byteStream().copyTo(out) } - rememberArtwork(fileName, track.artworkUrl) + val embedded = embedTags(outFile.toUri(), tags, artwork) + rememberArtwork(fileName, tags.artworkUrl) + if (embedded) markRepaired(fileName) return outFile } } - fun rememberArtwork(song: Song, artworkUrl: String?) { + /** + * Writes corrected tags and cover art into a download that was saved with + * placeholder metadata. Returns an artwork URI the downloads tab can show. + */ + fun rewriteTags(song: Song, tags: SoundCloudFileTags): String? { + val uri = song.contentUriString.toUri() + val artwork = tags.artworkUrl?.let { downloadArtwork(it) } + val tagged = embedTags(uri, tags, artwork) + if (!tagged) return null + if (uri.scheme == "content") { + val values = metadataValues(tags) + values.put(MediaStore.Audio.Media.DATE_MODIFIED, System.currentTimeMillis() / 1000L) + context.contentResolver.update(uri, values, null, null) + } val fileName = song.path.takeIf { it.isNotBlank() }?.let { File(it).name } - ?: return + if (!fileName.isNullOrBlank()) { + rememberArtwork(fileName, tags.artworkUrl) + markRepaired(fileName) + } + val mediaStoreId = song.id.toLongOrNull() + val localArt = if (mediaStoreId != null && !song.path.isBlank()) { + AlbumArtUtils.getAlbumArtUri( + appContext = context, + path = song.path, + songId = mediaStoreId, + forceRefresh = true, + ) + } else { + null + } + return localArt ?: tags.artworkUrl + } + + fun rememberArtwork(song: Song, artworkUrl: String?) { + val fileName = song.path.takeIf { it.isNotBlank() }?.let { File(it).name } ?: return rememberArtwork(fileName, artworkUrl) } @@ -116,18 +147,22 @@ class SoundCloudDownloadService @Inject constructor( private fun savedArtwork(fileName: String?): String? = fileName?.let { artworkPreferences.getString(it, null) }?.takeIf { it.isNotBlank() } + private fun isRepaired(fileName: String): Boolean = + repairedPreferences.getBoolean(fileName, false) + + private fun markRepaired(fileName: String) { + repairedPreferences.edit().putBoolean(fileName, true).apply() + } + /** Local SoundCloud downloads as playable [Song]s (MediaStore first, file fallback). */ - fun listDownloadedSongs(): List { + fun listDownloadedSongs(): SoundCloudDownloadCatalog { val fromStore = queryMediaStoreDownloads() - if (fromStore.isNotEmpty()) return fromStore - return listDownloadedFiles().map { fileToSong(it) } + if (fromStore.songs.isNotEmpty() || fromStore.repairs.isNotEmpty()) return fromStore + return listDownloadedFiles().map { fileToEntry(it) }.toCatalog() } fun listDownloadedFiles(): List { - val dir = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), - "PixelPlayer/SoundCloud", - ) + val dir = downloadDirectory() if (!dir.isDirectory) return emptyList() return dir.listFiles() ?.filter { it.isFile && it.extension.lowercase() in AUDIO_EXTS } @@ -135,14 +170,16 @@ class SoundCloudDownloadService @Inject constructor( .orEmpty() } - private fun queryMediaStoreDownloads(): List { - val songs = ArrayList() + private fun queryMediaStoreDownloads(): SoundCloudDownloadCatalog { + val entries = ArrayList() val projection = arrayOf( MediaStore.Audio.Media._ID, MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.ALBUM_ID, + MediaStore.Audio.Media.ALBUM_ARTIST, + MediaStore.Audio.Media.GENRE, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.DATA, MediaStore.Audio.Media.MIME_TYPE, @@ -168,6 +205,8 @@ class SoundCloudDownloadService @Inject constructor( val artistCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) val albumCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM) val albumIdCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) + val albumArtistCol = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM_ARTIST) + val genreCol = cursor.getColumnIndex(MediaStore.Audio.Media.GENRE) val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) val dataCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA) val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.MIME_TYPE) @@ -178,45 +217,58 @@ class SoundCloudDownloadService @Inject constructor( while (cursor.moveToNext()) { val id = cursor.getLong(idCol) val path = cursor.getString(dataCol).orEmpty() - val title = cursor.getString(titleCol) - ?.takeIf { it.isNotBlank() && it != "" } - ?: cursor.getString(displayCol)?.substringBeforeLast('.') - ?: "Unknown" - val artist = cursor.getString(artistCol) - ?.takeIf { it.isNotBlank() && it != "" } - ?: "SoundCloud" - val albumId = cursor.getLong(albumIdCol) + val displayName = cursor.getString(displayCol) + val (fileArtist, fileTitle) = splitDisplayName(displayName ?: File(path).name) + val rawTitle = cursor.getString(titleCol) + val rawArtist = cursor.getString(artistCol) + val rawAlbum = cursor.getString(albumCol) + val rawAlbumArtist = if (albumArtistCol >= 0) cursor.getString(albumArtistCol) else null + val rawGenre = if (genreCol >= 0) cursor.getString(genreCol) else null + val title = rawTitle?.takeUnless { it.isSoundCloudPlaceholder() || it == "" } + ?: fileTitle + val artist = rawArtist?.takeUnless { it.isSoundCloudPlaceholder() } ?: fileArtist + val album = rawAlbum?.takeUnless { it.isSoundCloudPlaceholder() } ?: artist + val albumArtist = rawAlbumArtist?.takeUnless { it.isSoundCloudPlaceholder() } ?: artist + val genre = rawGenre?.takeUnless { it.isSoundCloudPlaceholder() } + val fileName = displayName ?: File(path).name val albumArt = AlbumArtUtils.getAlbumArtUri( appContext = context, path = path, songId = id, forceRefresh = false, - ) ?: savedArtwork(cursor.getString(displayCol)) - songs.add( - Song( - id = id.toString(), - title = title, - artist = artist, - artistId = -1L, - artists = emptyList(), - album = cursor.getString(albumCol)?.takeIf { it.isNotBlank() } ?: "SoundCloud", - albumId = albumId, - albumArtist = artist, - path = path, - contentUriString = ContentUris.withAppendedId( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, - id, - ).toString(), - albumArtUriString = albumArt, - duration = cursor.getLong(durationCol).coerceAtLeast(0L), - genre = "SoundCloud", - dateAdded = cursor.getLong(addedCol), - dateModified = cursor.getLong(modifiedCol), - mimeType = cursor.getString(mimeCol), - bitrate = null, - sampleRate = null, - ), + ) ?: savedArtwork(fileName) + val song = Song( + id = id.toString(), + title = title, + artist = artist, + artistId = -1L, + artists = emptyList(), + album = album, + albumId = cursor.getLong(albumIdCol), + albumArtist = albumArtist, + path = path, + contentUriString = ContentUris.withAppendedId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + id, + ).toString(), + albumArtUriString = albumArt, + duration = cursor.getLong(durationCol).coerceAtLeast(0L), + genre = genre, + dateAdded = cursor.getLong(addedCol), + dateModified = cursor.getLong(modifiedCol), + mimeType = cursor.getString(mimeCol), + bitrate = null, + sampleRate = null, ) + val needsRepair = !isRepaired(fileName) && ( + rawArtist.isSoundCloudPlaceholder() || + rawAlbum.isSoundCloudPlaceholder() || + rawAlbumArtist.isSoundCloudPlaceholder() || + rawGenre.isSoundCloudPlaceholder() || + albumArt.isNullOrBlank() || + albumArt.startsWith("http") + ) + entries.add(DownloadEntry(song, needsRepair)) } } } catch (_: SecurityException) { @@ -224,28 +276,27 @@ class SoundCloudDownloadService @Inject constructor( } catch (_: Exception) { // Fall back to filesystem listing. } - return songs + return entries.toCatalog() } - private fun fileToSong(file: File): Song { - val base = file.nameWithoutExtension - val parts = base.split(" - ", limit = 2) - val (artist, title) = if (parts.size == 2) parts[0] to parts[1] else "SoundCloud" to base + private fun fileToEntry(file: File): DownloadEntry { + val (fileArtist, fileTitle) = splitDisplayName(file.name) val id = "sc_dl_${file.absolutePath.hashCode().toUInt()}" - return Song( + val artwork = savedArtwork(file.name) + val song = Song( id = id, - title = title, - artist = artist, + title = fileTitle, + artist = fileArtist, artistId = -1L, artists = emptyList(), - album = "SoundCloud", + album = fileArtist, albumId = -1L, - albumArtist = artist, + albumArtist = fileArtist, path = file.absolutePath, contentUriString = file.toURI().toString(), - albumArtUriString = savedArtwork(file.name), + albumArtUriString = artwork, duration = 0L, - genre = "SoundCloud", + genre = null, dateAdded = file.lastModified() / 1000L, dateModified = file.lastModified() / 1000L, mimeType = when (file.extension.lowercase()) { @@ -257,10 +308,138 @@ class SoundCloudDownloadService @Inject constructor( bitrate = null, sampleRate = null, ) + return DownloadEntry(song, needsRepair = !isRepaired(file.name)) + } + + private fun List.toCatalog(): SoundCloudDownloadCatalog { + return SoundCloudDownloadCatalog( + songs = map { it.song }, + repairs = filter { it.needsRepair }.map { it.song }, + ) + } + + private fun splitDisplayName(displayName: String): Pair { + val base = displayName.substringBeforeLast('.') + val parts = base.split(" - ", limit = 2) + return if (parts.size == 2) { + parts[0].trim().ifBlank { "Unknown artist" } to parts[1].trim().ifBlank { base } + } else { + "Unknown artist" to base + } + } + + private fun contentValuesFor( + fileName: String, + mimeType: String?, + tags: SoundCloudFileTags, + ): ContentValues = metadataValues(tags).apply { + put(MediaStore.Audio.Media.DISPLAY_NAME, fileName) + put(MediaStore.Audio.Media.MIME_TYPE, mimeType ?: "audio/mpeg") + put(MediaStore.Audio.Media.RELATIVE_PATH, "Music/PixelPlayer/SoundCloud") + } + + private fun metadataValues(tags: SoundCloudFileTags): ContentValues = ContentValues().apply { + put(MediaStore.Audio.Media.TITLE, tags.title) + put(MediaStore.Audio.Media.ARTIST, tags.artist) + put(MediaStore.Audio.Media.ALBUM, tags.album) + put(MediaStore.Audio.Media.ALBUM_ARTIST, tags.albumArtist) + if (tags.genre.isNullOrBlank()) { + putNull(MediaStore.Audio.Media.GENRE) + } else { + put(MediaStore.Audio.Media.GENRE, tags.genre) + } + } + + private fun audioRequest(url: String): Request = Request.Builder() + .url(url) + .header("User-Agent", USER_AGENT) + .header("Referer", "https://soundcloud.com/") + .build() + + private fun downloadArtwork(url: String): Pair? { + return try { + http.newCall(audioRequest(url)).execute().use { response -> + if (!response.isSuccessful) return null + val bytes = response.body?.bytes() ?: return null + if (bytes.isEmpty() || bytes.size > MAX_ARTWORK_BYTES) return null + val mime = response.header("Content-Type") + ?.substringBefore(';') + ?.trim() + ?.takeIf { it.startsWith("image/") } + ?: "image/jpeg" + bytes to mime + } + } catch (t: Throwable) { + Timber.w(t, "SoundCloud artwork download failed") + null + } } + private fun embedTags( + uri: Uri, + tags: SoundCloudFileTags, + artwork: Pair?, + ): Boolean { + return try { + openReadWrite(uri)?.use { fd -> + val existing = TagLib.getMetadata(fd.dup().detachFd()) + val propertyMap = HashMap(existing?.propertyMap ?: emptyMap()) + propertyMap["TITLE"] = arrayOf(tags.title) + propertyMap["ARTIST"] = arrayOf(tags.artist) + propertyMap["ALBUM"] = arrayOf(tags.album) + propertyMap["ALBUMARTIST"] = arrayOf(tags.albumArtist) + if (tags.genre.isNullOrBlank()) { + propertyMap.remove("GENRE") + } else { + propertyMap["GENRE"] = arrayOf(tags.genre) + } + val saved = TagLib.savePropertyMap(fd.dup().detachFd(), propertyMap) + if (!saved) return false + if (artwork != null) { + TagLib.savePictures( + fd.dup().detachFd(), + arrayOf( + Picture( + data = artwork.first, + description = "Front Cover", + pictureType = "Front Cover", + mimeType = artwork.second, + ), + ), + ) + } + true + } ?: false + } catch (t: Throwable) { + Timber.w(t, "SoundCloud tag embed failed for %s", uri) + false + } + } + + private fun openReadWrite(uri: Uri): ParcelFileDescriptor? { + return when (uri.scheme) { + "file" -> { + val path = uri.path ?: return null + ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_WRITE) + } + else -> context.contentResolver.openFileDescriptor(uri, "rw") + } + } + + private data class DownloadEntry(val song: Song, val needsRepair: Boolean) + companion object { private const val ARTWORK_PREFERENCES = "soundcloud_download_artwork" + private const val REPAIRED_PREFERENCES = "soundcloud_download_tags_repaired" + private const val USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0" + private const val MAX_ARTWORK_BYTES = 8 * 1024 * 1024 private val AUDIO_EXTS = setOf("mp3", "m4a", "ogg", "aac", "wav", "flac") + private val UNSAFE_FILENAME = Regex("""[\\/:*?"<>|]""") + + fun downloadDirectory(): File = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), + "PixelPlayer/SoundCloud", + ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudLibraryGate.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudLibraryGate.kt new file mode 100644 index 0000000000..2b45284dd8 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudLibraryGate.kt @@ -0,0 +1,36 @@ +package com.theveloper.pixelplay.soundcloud + +import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository +import com.theveloper.pixelplay.data.worker.SyncManager +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Keeps Music/PixelPlayer/SoundCloud in or out of the local library without + * blocking the SoundCloud screen. The folder rule is applied in the background + * scan the library already uses. + */ +@Singleton +class SoundCloudLibraryGate @Inject constructor( + private val preferences: UserPreferencesRepository, + private val syncManager: SyncManager, +) { + suspend fun apply(includeInLibrary: Boolean) { + val path = SoundCloudDownloadService.downloadDirectory().absolutePath + val blocked = preferences.blockedDirectoriesFlow.first() + .map { it.trimEnd('/', '\\') } + .toMutableSet() + val allowed = preferences.allowedDirectoriesFlow.first() + val currentlyBlocked = blocked.any { it.equals(path, ignoreCase = true) } + val shouldBlock = !includeInLibrary + if (currentlyBlocked == shouldBlock) return + if (shouldBlock) { + blocked.add(path) + } else { + blocked.removeAll { it.equals(path, ignoreCase = true) } + } + preferences.updateDirectorySelections(allowed, blocked) + syncManager.incrementalSync() + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt index f94874552e..04dec8319e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt @@ -8,8 +8,30 @@ data class SoundCloudSearchHit( val thumbnailUrl: String?, val kind: Kind = Kind.TRACK, val streamCount: Long = 0, + /** + * Session lists already know a track can stream. NewPipe lists stay [Availability.UNKNOWN] + * so playability is checked after the list is on screen. + */ + val availability: Availability = Availability.UNKNOWN, ) { enum class Kind { TRACK, PLAYLIST } + enum class Availability { UNKNOWN, AVAILABLE } +} + +data class SoundCloudFileTags( + val title: String, + val artist: String, + val album: String, + val albumArtist: String, + val genre: String?, + val artworkUrl: String?, +) + +internal fun String?.isSoundCloudPlaceholder(): Boolean { + val value = this?.trim().orEmpty() + return value.isEmpty() || + value.equals("SoundCloud", ignoreCase = true) || + value.equals("", ignoreCase = true) } data class SoundCloudFeedShelf( diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPlayabilityStore.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPlayabilityStore.kt new file mode 100644 index 0000000000..6d5e36fd4b --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPlayabilityStore.kt @@ -0,0 +1,55 @@ +package com.theveloper.pixelplay.soundcloud + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Remembers which permalinks cannot be streamed. + * Unplayable URLs are persisted so the next list can gray them out without + * resolving streams again. Playable results stay in memory for this process. + */ +@Singleton +class SoundCloudPlayabilityStore @Inject constructor( + @ApplicationContext context: Context, +) { + private val preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val known = ConcurrentHashMap() + + init { + preferences.getStringSet(KEY_UNPLAYABLE, emptySet()).orEmpty().forEach { url -> + if (url.isNotBlank()) known[url] = false + } + } + + fun knows(url: String): Boolean = known.containsKey(url) + + fun isUnplayable(url: String): Boolean = known[url] == false + + fun remember(url: String, playable: Boolean) { + val key = url.trim() + if (key.isEmpty()) return + if (!playable && known[key] == false) return + known[key] = playable + if (!playable) persistUnplayable() + } + + private fun persistUnplayable() { + val urls = known.entries + .asSequence() + .filter { !it.value } + .map { it.key } + .toList() + .takeLast(MAX_PERSISTED) + .toSet() + preferences.edit().putStringSet(KEY_UNPLAYABLE, urls).apply() + } + + private companion object { + const val PREFS = "soundcloud_playability" + const val KEY_UNPLAYABLE = "unplayable_urls" + const val MAX_PERSISTED = 800 + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt index 68b5755248..ea2b720642 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt @@ -17,6 +17,7 @@ class SoundCloudPrefsViewModel @Inject constructor( private val settings: SoundCloudSettings, private val sessionApi: SoundCloudSessionApi, private val client: SoundCloudClient, + private val libraryGate: SoundCloudLibraryGate, ) : ViewModel() { val clientId: StateFlow = settings.clientId.stateIn( viewModelScope, @@ -33,6 +34,11 @@ class SoundCloudPrefsViewModel @Inject constructor( SharingStarted.WhileSubscribed(5_000), SoundCloudSession(), ) + val includeDownloadsInLibrary: StateFlow = settings.includeDownloadsInLibrary.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + true, + ) fun saveClientId(value: String) { viewModelScope.launch { settings.setClientId(value) } @@ -59,6 +65,13 @@ class SoundCloudPrefsViewModel @Inject constructor( } } + fun setIncludeDownloadsInLibrary(include: Boolean) { + viewModelScope.launch { + settings.setIncludeDownloadsInLibrary(include) + runCatching { libraryGate.apply(include) } + } + } + fun signOut() { viewModelScope.launch { settings.clearSession() diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt index 0e8ebf3136..c97c2b5cbe 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt @@ -195,7 +195,7 @@ class SoundCloudSessionApi @Inject constructor() { } private fun trackToHit(track: JSONObject): SoundCloudSearchHit? { - if (isDrmOnlyTrack(track)) return null + if (isUnplayableTrack(track)) return null val url = track.optString("permalink_url").ifBlank { return null } val user = track.optJSONObject("user") val durationMs = track.optLong("duration", 0L) @@ -208,6 +208,7 @@ class SoundCloudSessionApi @Inject constructor() { .ifBlank { user?.optString("avatar_url").orEmpty() } .let { if (it.isBlank()) null else upgradeArtworkUrl(it) }, kind = SoundCloudSearchHit.Kind.TRACK, + availability = SoundCloudSearchHit.Availability.AVAILABLE, ) } @@ -246,6 +247,18 @@ class SoundCloudSessionApi @Inject constructor() { .replace("-badge.", "-t500x500.") } + /** + * True when the track JSON itself says it cannot be streamed: blocked, not + * streamable, or every audio transcoding is DRM. Usable when the payload is + * already in memory, so list loading does not gain extra requests. + */ + internal fun isUnplayableTrack(track: JSONObject): Boolean { + if (track.has("streamable") && !track.optBoolean("streamable", true)) return true + val policy = track.optString("policy").lowercase() + if (policy == "block" || policy == "blocked") return true + return isDrmOnlyTrack(track) + } + /** * Returns true when every known audio transcoding is DRM encrypted-hls * (cbc-/ctr-encrypted-hls). Tracks with progressive or plain HLS stay visible. @@ -271,21 +284,60 @@ class SoundCloudSessionApi @Inject constructor() { return sawAny && !sawUsable } - /** Resolve a permalink and report whether it is DRM-only (for NewPipe list filtering). */ - fun isDrmOnlyUrl(trackUrl: String): Boolean { + /** + * True when the permalink can stream, false when the track is blocked or DRM-only. + * Null when the lookup itself failed — callers must not treat that as unplayable. + */ + fun playbackAvailability(trackUrl: String): Boolean? { return try { - val encoded = java.net.URLEncoder.encode(trackUrl.trim(), Charsets.UTF_8.name()) - val json = getJson( - "https://api-v2.soundcloud.com/resolve?url=$encoded", - requireAuth = false, - ) - val track = when { - json.has("media") || (json.has("permalink_url") && json.has("title")) -> json - else -> json.optJSONObject("track") - } ?: return false - isDrmOnlyTrack(track) + resolvedTrack(trackUrl)?.let { !isUnplayableTrack(it) } + } catch (_: Throwable) { + null + } + } + + /** Title, artist, album, genre, and artwork from api-v2. Null when the lookup fails. */ + fun loadFileTags(trackUrl: String): SoundCloudFileTags? { + val track = try { + resolvedTrack(trackUrl) } catch (_: Throwable) { - false + null + } ?: return null + val user = track.optJSONObject("user") + val publisher = track.optJSONObject("publisher_metadata") + val title = track.optString("title").trim() + if (title.isEmpty()) return null + val artist = publisher?.optString("artist").orEmpty().trim() + .ifBlank { user?.optString("username").orEmpty().trim() } + .ifBlank { return null } + val album = publisher?.optString("album_title").orEmpty().trim() + .ifBlank { publisher?.optString("release_title").orEmpty().trim() } + .ifBlank { artist } + val genre = track.optString("genre").trim().ifBlank { null } + val artwork = track.optString("artwork_url") + .ifBlank { user?.optString("avatar_url").orEmpty() } + .trim() + .ifBlank { null } + ?.let { upgradeArtworkUrl(it) } + return SoundCloudFileTags( + title = title, + artist = artist, + album = album, + albumArtist = artist, + genre = genre?.takeUnless { it.isSoundCloudPlaceholder() }, + artworkUrl = artwork, + ) + } + + private fun resolvedTrack(trackUrl: String): JSONObject? { + val encoded = java.net.URLEncoder.encode(trackUrl.trim(), Charsets.UTF_8.name()) + val json = getJson( + "https://api-v2.soundcloud.com/resolve?url=$encoded", + requireAuth = false, + ) + return when { + json.has("media") || (json.has("permalink_url") && json.has("title")) -> json + else -> json.optJSONObject("track") } } diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSettings.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSettings.kt index c78e41c327..c1baa47db3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSettings.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSettings.kt @@ -3,6 +3,7 @@ package com.theveloper.pixelplay.soundcloud import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore @@ -57,6 +58,11 @@ class SoundCloudSettings @Inject constructor( val isSignedIn: Flow = session.map { it.isSignedIn } + /** When false, SoundCloud downloads stay on the downloads tab and out of the library. */ + val includeDownloadsInLibrary: Flow = dataStore.data.map { prefs -> + prefs[Keys.INCLUDE_DOWNLOADS_IN_LIBRARY] ?: true + } + suspend fun setClientId(value: String) { dataStore.edit { it[Keys.CLIENT_ID] = value.trim() } } @@ -82,6 +88,10 @@ class SoundCloudSettings @Inject constructor( } } + suspend fun setIncludeDownloadsInLibrary(include: Boolean) { + dataStore.edit { it[Keys.INCLUDE_DOWNLOADS_IN_LIBRARY] = include } + } + suspend fun clearSession() { dataStore.edit { prefs -> prefs.remove(Keys.OAUTH_TOKEN) @@ -98,5 +108,6 @@ class SoundCloudSettings @Inject constructor( val COOKIE_HEADER = stringPreferencesKey("cookie_header") val DISPLAY_NAME = stringPreferencesKey("display_name") val PERMALINK = stringPreferencesKey("permalink") + val INCLUDE_DOWNLOADS_IN_LIBRARY = booleanPreferencesKey("include_downloads_in_library") } } diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt index cf55599bc8..2b5ccf9364 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt @@ -3,8 +3,10 @@ package com.theveloper.pixelplay.soundcloud import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow @@ -12,6 +14,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import timber.log.Timber import com.theveloper.pixelplay.data.model.Song @@ -42,6 +46,8 @@ data class SoundCloudUiState( val endReached: Boolean = false, val likedTrackUrls: Set = emptySet(), val likingUrl: String? = null, + /** Permalinks that cannot be streamed. Populated after the list is shown. */ + val unplayableUrls: Set = emptySet(), ) @HiltViewModel @@ -49,6 +55,7 @@ class SoundCloudViewModel @Inject constructor( private val client: SoundCloudClient, private val settings: SoundCloudSettings, private val downloader: SoundCloudDownloadService, + private val libraryGate: SoundCloudLibraryGate, ) : ViewModel() { private data class NavFrame( @@ -64,7 +71,8 @@ class SoundCloudViewModel @Inject constructor( private var searchJob: Job? = null private var loadJob: Job? = null - private var artworkJob: Job? = null + private var repairJob: Job? = null + private var probeJob: Job? = null private var queuePrefetchJob: Job? = null private val artworkAttempts = mutableSetOf() private val navStack = ArrayDeque() @@ -92,6 +100,12 @@ class SoundCloudViewModel @Inject constructor( } } } + viewModelScope.launch { + settings.includeDownloadsInLibrary.collect { include -> + runCatching { libraryGate.apply(include) } + .onFailure { Timber.w(it, "SoundCloud library visibility update failed") } + } + } viewModelScope.launch { delay(350) val state = _uiState.value @@ -267,15 +281,50 @@ class SoundCloudViewModel @Inject constructor( } /** - * Resolves only the tapped track so playback can start immediately. - * Call [prefetchQueueAfter] afterward to fill upcoming songs without blocking. + * Resolves the tapped track, then the following tracks, until one can play. + * Unplayable permalinks are remembered so the list can gray them without + * another resolve. Playback is not stopped when a track in the list fails. */ - suspend fun resolveStartForPlayback(hit: SoundCloudSearchHit): Song { - setBusy(hit.url) - return withContext(Dispatchers.IO) { - ensureClient() - client.toSong(client.resolveTrack(hit.url), permalinkUrl = hit.url) + suspend fun resolvePlayableStart( + hit: SoundCloudSearchHit, + queueHits: List, + ): Pair { + val tracks = queueHits + .asSequence() + .filter { it.kind == SoundCloudSearchHit.Kind.TRACK } + .distinctBy { it.url } + .toList() + val startIndex = tracks.indexOfFirst { it.url == hit.url } + val ordered = when { + hit.kind == SoundCloudSearchHit.Kind.TRACK && startIndex >= 0 -> tracks.drop(startIndex) + hit.kind == SoundCloudSearchHit.Kind.TRACK -> listOf(hit) + tracks.filter { it.url != hit.url } + else -> tracks + } + var lastError: Throwable? = null + for (candidate in ordered) { + if (client.isKnownUnplayable(candidate.url)) { + noteUnplayable(candidate.url) + continue + } + setBusy(candidate.url) + try { + val song = withContext(Dispatchers.IO) { + ensureClient() + client.toSong(client.resolveTrack(candidate.url), permalinkUrl = candidate.url) + } + client.rememberPlayable(candidate.url) + return song to candidate + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + if (!client.isPermanentStreamFailure(t)) throw t + client.rememberUnplayable(candidate.url) + noteUnplayable(candidate.url) + lastError = t + Timber.w(t, "Skip unplayable SoundCloud track: %s", candidate.url) + } } + throw lastError ?: IllegalStateException("No playable streams in this list") } /** @@ -320,11 +369,22 @@ class SoundCloudViewModel @Inject constructor( ensureClient() var first: Song? = null for (hit in tracks) { + if (client.isKnownUnplayable(hit.url)) { + noteUnplayable(hit.url) + continue + } runCatching { client.toSong(client.resolveTrack(hit.url), permalinkUrl = hit.url) }.onSuccess { song -> + client.rememberPlayable(hit.url) first = song - }.onFailure { Timber.w(it, "Skip unplayable SoundCloud track: %s", hit.url) } + }.onFailure { error -> + if (client.isPermanentStreamFailure(error)) { + client.rememberUnplayable(hit.url) + noteUnplayable(hit.url) + } + Timber.w(error, "Skip unplayable SoundCloud track: %s", hit.url) + } if (first != null) break } first ?: throw IllegalStateException("No playable streams in this list") @@ -349,12 +409,21 @@ class SoundCloudViewModel @Inject constructor( for (hit in hits) { ensureActive() if (hit.kind != SoundCloudSearchHit.Kind.TRACK) continue + if (client.isKnownUnplayable(hit.url)) { + noteUnplayable(hit.url) + continue + } runCatching { client.toSong(client.resolveTrack(hit.url), permalinkUrl = hit.url) }.onSuccess { song -> + client.rememberPlayable(hit.url) withContext(Dispatchers.Main.immediate) { onSong(song) } - }.onFailure { - Timber.w(it, "Skip unplayable SoundCloud track: %s", hit.url) + }.onFailure { error -> + if (client.isPermanentStreamFailure(error)) { + client.rememberUnplayable(hit.url) + noteUnplayable(hit.url) + } + Timber.w(error, "Skip unplayable SoundCloud track: %s", hit.url) } } } @@ -372,7 +441,7 @@ class SoundCloudViewModel @Inject constructor( ensureClient() val file = withContext(Dispatchers.IO) { val track = client.resolveTrack(hit.url) - downloader.downloadToMusicFolder(track) + downloader.downloadToMusicFolder(track, client.fileTags(track)) } refreshDownloads() _uiState.update { @@ -439,54 +508,74 @@ class SoundCloudViewModel @Inject constructor( } fun refreshDownloads() { - val songs = downloader.listDownloadedSongs() + val catalog = downloader.listDownloadedSongs() _uiState.update { it.copy( - downloads = songs, + downloads = catalog.songs, results = emptyList(), isLoading = false, statusMessage = null, ) } - hydrateMissingDownloadArtwork(songs) + repairDownloadedFiles(catalog.repairs) } - private fun hydrateMissingDownloadArtwork(songs: List) { - if (_uiState.value.clientId.isBlank()) return - val missing = songs.filter { - it.albumArtUriString.isNullOrBlank() && artworkAttempts.add(it.id) - } - if (missing.isEmpty()) return - artworkJob?.cancel() - artworkJob = viewModelScope.launch(Dispatchers.IO) { - ensureClient() - val hydrated = missing.mapNotNull { song -> - val query = "${song.artist} ${song.title}".trim() - val candidates = runCatching { client.search(query, 5) }.getOrDefault(emptyList()) - val normalizedTitle = song.title.normalizedForArtworkMatch() - val match = candidates.firstOrNull { - it.kind == SoundCloudSearchHit.Kind.TRACK && - it.title.normalizedForArtworkMatch() == normalizedTitle && - !it.thumbnailUrl.isNullOrBlank() - } ?: candidates.firstOrNull { - it.kind == SoundCloudSearchHit.Kind.TRACK && !it.thumbnailUrl.isNullOrBlank() - } - val artwork = match?.thumbnailUrl ?: return@mapNotNull null - downloader.rememberArtwork(song, artwork) - song.id to artwork - }.toMap() - if (hydrated.isNotEmpty()) { - _uiState.update { state -> - state.copy( - downloads = state.downloads.map { song -> - hydrated[song.id]?.let { song.copy(albumArtUriString = it) } ?: song - }, - ) + /** + * Rewrites tags and embeds cover art after the downloads list is already shown. + * One file at a time so opening the tab stays as fast as the MediaStore query. + */ + private fun repairDownloadedFiles(songs: List) { + if (songs.isEmpty()) return + repairJob?.cancel() + repairJob = viewModelScope.launch(Dispatchers.IO) { + if (_uiState.value.clientId.isNotBlank()) ensureClient() + for (song in songs) { + ensureActive() + val artworkUrl = song.albumArtUriString?.takeIf { it.startsWith("http") } + ?: findArtworkUrl(song) + val artist = song.artist.takeUnless { it.isSoundCloudPlaceholder() } ?: "Unknown artist" + val album = song.album.takeUnless { it.isSoundCloudPlaceholder() } ?: artist + val tags = SoundCloudFileTags( + title = song.title, + artist = artist, + album = album, + albumArtist = song.albumArtist?.takeUnless { it.isSoundCloudPlaceholder() } ?: artist, + genre = song.genre?.takeUnless { it.isSoundCloudPlaceholder() }, + artworkUrl = artworkUrl, + ) + val updatedArt = runCatching { downloader.rewriteTags(song, tags) }.getOrNull() + if (!updatedArt.isNullOrBlank()) { + _uiState.update { state -> + state.copy( + downloads = state.downloads.map { current -> + if (current.id == song.id) { + current.copy(albumArtUriString = updatedArt) + } else { + current + } + }, + ) + } } } } } + private fun findArtworkUrl(song: Song): String? { + if (_uiState.value.clientId.isBlank() || !artworkAttempts.add(song.id)) return null + val query = "${song.artist} ${song.title}".trim() + val candidates = runCatching { client.search(query, 5) }.getOrDefault(emptyList()) + val normalizedTitle = song.title.normalizedForArtworkMatch() + val match = candidates.firstOrNull { + it.kind == SoundCloudSearchHit.Kind.TRACK && + it.title.normalizedForArtworkMatch() == normalizedTitle && + !it.thumbnailUrl.isNullOrBlank() + } ?: candidates.firstOrNull { + it.kind == SoundCloudSearchHit.Kind.TRACK && !it.thumbnailUrl.isNullOrBlank() + } + return match?.thumbnailUrl + } + private fun String.normalizedForArtworkMatch(): String = lowercase().replace(Regex("[^a-z0-9]+"), "") @@ -679,6 +768,47 @@ class SoundCloudViewModel @Inject constructor( } } + private fun noteUnplayable(url: String) { + _uiState.update { it.copy(unplayableUrls = it.unplayableUrls + url) } + } + + /** + * Checks tracks that NewPipe could not classify, after the list is visible. + * Known results are reused. Concurrency stays low so a tap can still resolve first. + */ + private fun schedulePlayabilityProbe(hits: List) { + val targets = hits + .asSequence() + .filter { it.kind == SoundCloudSearchHit.Kind.TRACK } + .filter { it.availability == SoundCloudSearchHit.Availability.UNKNOWN } + .filter { !client.knowsPlayability(it.url) } + .distinctBy { it.url } + .take(PROBE_LIMIT) + .toList() + probeJob?.cancel() + if (targets.isEmpty()) return + probeJob = viewModelScope.launch(Dispatchers.IO) { + delay(PROBE_START_DELAY_MS) + val gate = Semaphore(PROBE_CONCURRENCY) + coroutineScope { + for (hit in targets) { + ensureActive() + launch { + gate.withPermit { + ensureActive() + while (_uiState.value.resolvingKey != null) { + ensureActive() + delay(150) + } + val playable = client.probePlayable(hit.url) ?: return@withPermit + if (!playable) noteUnplayable(hit.url) + } + } + } + } + } + } + private fun runLoad( @Suppress("UNUSED_PARAMETER") status: String, cancelPrevious: Boolean = true, @@ -689,6 +819,7 @@ class SoundCloudViewModel @Inject constructor( ) { if (cancelPrevious) { loadJob?.cancel() + probeJob?.cancel() } val job = viewModelScope.launch { val previousCount = _uiState.value.results.size @@ -705,16 +836,24 @@ class SoundCloudViewModel @Inject constructor( try { ensureClient() val hits = withContext(Dispatchers.IO) { block() } + val visible = hits + _uiState.value.feedShelves.flatMap { shelf -> shelf.items } + val knownUnplayable = visible + .asSequence() + .map { it.url } + .filter { client.isKnownUnplayable(it) } + .toSet() _uiState.update { it.copy( isLoading = false, isRefreshing = false, isLoadingMore = false, results = hits, + unplayableUrls = knownUnplayable, endReached = loadingMore && hits.size <= previousCount, statusMessage = null, ) } + schedulePlayabilityProbe(visible) } catch (t: Throwable) { if (t is kotlinx.coroutines.CancellationException) throw t Timber.w(t, "SoundCloud load failed") @@ -750,5 +889,8 @@ class SoundCloudViewModel @Inject constructor( private const val INITIAL_LIST_SIZE = 40 private const val PAGE_SIZE = 30 private const val MAX_RESULTS = 200 + private const val PROBE_LIMIT = 40 + private const val PROBE_CONCURRENCY = 2 + private const val PROBE_START_DELAY_MS = 450L } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b1d61ad96..58326a455f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,6 +27,9 @@ public profile handle (no @) Sign in with your SoundCloud account for personalized feed and likes. client_id is still required for streaming. Optional public username is a fallback when signed out. Save SoundCloud settings + Add downloads to library + On: downloaded songs show in your library and on the Downloads tab. Off: they stay only on the SoundCloud Downloads tab. + Unavailable Sign in to SoundCloud Sign out I\'m signed in From b5345c4e0263f66f34b4da78997ef097f7b3cd32 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Fri, 25 Sep 2026 13:43:27 +0300 Subject: [PATCH 29/77] feat: move SoundCloud settings and download newer releases in-app Keep the SoundCloud tab, and open its setup from the cloud sheet. Installed builds can notice a newer GitHub release and download the matching APK. Co-authored-by: Cursor --- README.md | 7 +- app/src/main/AndroidManifest.xml | 1 + .../com/theveloper/pixelplay/MainActivity.kt | 127 ++++ .../data/github/GitHubReleaseUpdateService.kt | 176 +++++ .../components/ChangelogBottomSheet.kt | 2 +- .../components/PlayStoreAnnouncementDialog.kt | 35 +- .../components/StreamingProviderSheet.kt | 20 + .../presentation/navigation/AppNavigation.kt | 8 + .../presentation/navigation/Screen.kt | 1 + .../screens/ExperimentalSettingsScreen.kt | 105 --- .../presentation/screens/HomeScreen.kt | 11 +- .../presentation/screens/SoundCloudScreen.kt | 2 +- .../screens/SoundCloudSettingsScreen.kt | 222 ++++++ .../pixelplay/soundcloud/SoundCloudClient.kt | 6 +- app/src/main/res/values/strings.xml | 11 + docs/soundcloud-implementation.md | 705 ++++++++++++++++++ 16 files changed, 1312 insertions(+), 127 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateService.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudSettingsScreen.kt create mode 100644 docs/soundcloud-implementation.md diff --git a/README.md b/README.md index 75eb382b47..8206462473 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,8 @@ SoundCloud in PixelPlayer needs a **web `client_id`** (from SoundCloud’s websi ### 1. Open SoundCloud settings in the app -1. Go to the **SoundCloud** tab. -2. Tap the **gear** (top right) → opens **Experimental**. -3. You’ll see fields for **client_id**, optional username, and **Sign in**. +1. On **Home**, tap the **cloud** icon and choose **SoundCloud**. The gear on the **SoundCloud** tab opens the same page. +2. You’ll see fields for **client_id**, optional username, and **Sign in**. The SoundCloud tab itself stays in the bottom bar. ### 2. Get a `client_id` from your browser @@ -71,7 +70,7 @@ Do this on a computer (Chrome / Edge / Firefox): ### 3. Paste it into PixelPlayer -1. Back in **Experimental**, paste the value into **Web client_id**. +1. Back in **SoundCloud settings**, paste the value into **Web client_id**. 2. Tap **Save SoundCloud settings**. 3. (Recommended) Tap **Sign in to SoundCloud**, log in in the browser window, then confirm you’re signed in. 4. Return to the SoundCloud tab — Feed / Likes / playlists should load after a moment. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 67da09bace..6a064e6adc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + diff --git a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt index e7c1ce5ae6..a3c3356133 100644 --- a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt @@ -10,6 +10,7 @@ import android.content.Context import android.content.ComponentName import android.content.Intent import android.os.Build +import java.io.File import android.graphics.RenderEffect as AndroidRenderEffect import android.graphics.Shader as AndroidShader import androidx.compose.ui.graphics.asComposeRenderEffect @@ -92,6 +93,7 @@ import com.theveloper.pixelplay.presentation.viewmodel.PlayerSheetState import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp +import androidx.core.content.FileProvider import androidx.core.net.toUri import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.media3.common.util.UnstableApi @@ -105,6 +107,7 @@ import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.MoreExecutors import com.theveloper.pixelplay.data.github.GitHubAnnouncementPropertiesService +import com.theveloper.pixelplay.data.github.GitHubReleaseUpdateService import com.theveloper.pixelplay.data.github.PlayStoreAnnouncementRemoteConfig import com.theveloper.pixelplay.data.preferences.AppThemeMode import com.theveloper.pixelplay.data.preferences.NavBarStyle @@ -143,6 +146,7 @@ import com.theveloper.pixelplay.utils.LogUtils import dagger.hilt.android.AndroidEntryPoint import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.math.roundToInt @@ -478,6 +482,39 @@ class MainActivity : ComponentActivity() { } } + private fun openReleasePage(url: String) { + if (!GitHubReleaseUpdateService.isAllowedReleasePage(url)) { + LogUtils.w(this, "Refusing to open release URL: $url") + return + } + try { + startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + } catch (_: ActivityNotFoundException) { + LogUtils.w(this, "No activity available to open URL: $url") + } + } + + private fun installDownloadedApk(file: File) { + val uri = FileProvider.getUriForFile(this, "$packageName.provider", file) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + startActivity(intent) + } catch (_: ActivityNotFoundException) { + LogUtils.w(this, "No activity available to install update") + } + } + + private fun rememberDismissedUpdate(version: String) { + getSharedPreferences(UPDATE_PREFS, MODE_PRIVATE) + .edit() + .putString(UPDATE_DISMISSED_VERSION, version) + .apply() + } + private fun PlayStoreAnnouncementRemoteConfig.toUiModel(context: Context): PlayStoreAnnouncementUiModel { val fallback = PlayStoreAnnouncementDefaults.localizedTemplate(context) return fallback.copy( @@ -637,6 +674,7 @@ class MainActivity : ComponentActivity() { Screen.Stats.route, Screen.EditTransition.route, Screen.Experimental.route, + Screen.SoundCloudSettings.route, Screen.ArtistSettings.route, Screen.Equalizer.route, Screen.SettingsCategory.route, @@ -743,11 +781,19 @@ class MainActivity : ComponentActivity() { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() val announcementService = remember { GitHubAnnouncementPropertiesService() } + val releaseUpdateService = remember { GitHubReleaseUpdateService() } val context = LocalContext.current var playStoreAnnouncement by remember { mutableStateOf(PlayStoreAnnouncementDefaults.localizedTemplate(context)) } var showPlayStoreAnnouncement by remember { mutableStateOf(false) } + var pendingUpdateVersion by remember { mutableStateOf(null) } + var updateAnnouncement by remember { + mutableStateOf(PlayStoreAnnouncementDefaults.localizedTemplate(context)) + } + var showUpdateAnnouncement by remember { mutableStateOf(false) } + var updateDownloadBusy by remember { mutableStateOf(false) } + var updateDownloadJob by remember { mutableStateOf(null) } LaunchedEffect(Unit) { if (PlayStoreAnnouncementDefaults.LOCAL_PREVIEW_ENABLED) { @@ -770,6 +816,35 @@ class MainActivity : ComponentActivity() { } } + LaunchedEffect(Unit) { + val installed = BuildConfig.VERSION_NAME.trim().removePrefix("v") + if (installed.isEmpty() || installed == "1.0.0") return@LaunchedEffect + releaseUpdateService.fetchLatestRelease() + .onSuccess { latest -> + if (latest.version == installed) return@onSuccess + val dismissed = getSharedPreferences(UPDATE_PREFS, MODE_PRIVATE) + .getString(UPDATE_DISMISSED_VERSION, null) + if (dismissed == latest.version) return@onSuccess + pendingUpdateVersion = latest.version + updateAnnouncement = PlayStoreAnnouncementUiModel( + enabled = true, + title = getString(R.string.update_dialog_title), + body = getString(R.string.update_dialog_body, latest.version), + playStoreUrl = latest.apkUrl.ifBlank { latest.pageUrl }, + primaryActionLabel = getString(R.string.update_dialog_action), + dismissActionLabel = getString(R.string.update_dialog_later), + linkPendingMessage = "", + ) + showUpdateAnnouncement = true + } + .onFailure { throwable -> + LogUtils.w( + this@MainActivity, + "Release check unavailable. ${throwable.message ?: ""}", + ) + } + } + LaunchedEffect(userPreferencesRepository) { userPreferencesRepository.clearDeprecatedPlayerSheetPreference() } @@ -1060,6 +1135,53 @@ class MainActivity : ComponentActivity() { } ) } + if (showUpdateAnnouncement) { + PlayStoreAnnouncementDialog( + announcement = updateAnnouncement, + primaryBusy = updateDownloadBusy, + onDismiss = { + updateDownloadJob?.cancel() + updateDownloadBusy = false + pendingUpdateVersion?.let(::rememberDismissedUpdate) + showUpdateAnnouncement = false + }, + onOpenPlayStore = { url -> + if (GitHubReleaseUpdateService.isAllowedApkDownload(url)) { + if (!packageManager.canRequestPackageInstalls()) { + updateAnnouncement = updateAnnouncement.copy( + body = getString(R.string.update_dialog_install_permission), + ) + startActivity( + Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + "package:$packageName".toUri(), + ), + ) + return@PlayStoreAnnouncementDialog + } + if (updateDownloadBusy) return@PlayStoreAnnouncementDialog + updateDownloadBusy = true + updateDownloadJob = scope.launch { + val file = File(cacheDir, "updates/PixelPlayer-update.apk") + releaseUpdateService.downloadApk(url, file) + .onSuccess { apk -> + updateDownloadBusy = false + showUpdateAnnouncement = false + installDownloadedApk(apk) + } + .onFailure { + updateDownloadBusy = false + updateAnnouncement = updateAnnouncement.copy( + body = getString(R.string.update_dialog_failed), + ) + } + } + } else { + openReleasePage(url) + } + }, + ) + } } } } @@ -1211,6 +1333,11 @@ private class NavBarShapeCache { } return cached } + + private companion object { + const val UPDATE_PREFS = "pixelplay_updates" + const val UPDATE_DISMISSED_VERSION = "dismissed_release_version" + } } /** diff --git a/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateService.kt b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateService.kt new file mode 100644 index 0000000000..786b6073bb --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateService.kt @@ -0,0 +1,176 @@ +package com.theveloper.pixelplay.data.github + +import android.os.Build +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import kotlin.coroutines.cancellation.CancellationException + +data class GitHubLatestRelease( + val version: String, + val pageUrl: String, + val apkUrl: String, +) + +@Singleton +class GitHubReleaseUpdateService @Inject constructor() { + + /** + * Latest GitHub Release for this fork. A push to master publishes that + * release from CI, so installed builds can notice a newer tag. + */ + suspend fun fetchLatestRelease( + owner: String = OWNER, + repo: String = REPO, + ): Result = withContext(Dispatchers.IO) { + var connection: HttpURLConnection? = null + try { + val endpoint = "https://api.github.com/repos/$owner/$repo/releases/latest" + connection = (URL(endpoint).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = 10_000 + readTimeout = 10_000 + setRequestProperty("Accept", "application/vnd.github+json") + setRequestProperty("User-Agent", "PixelPlayer") + } + when (val code = connection.responseCode) { + HttpURLConnection.HTTP_OK -> { + val body = connection.inputStream.bufferedReader().use { it.readText() } + val json = JSONObject(body) + val version = json.optString("tag_name").trim().removePrefix("v") + val pageUrl = json.optString("html_url").trim() + val apkUrl = selectApkUrl(json) + if (version.isEmpty() || !isAllowedReleasePage(pageUrl)) { + Result.failure(IllegalStateException("Latest release is missing a usable tag or page")) + } else { + Result.success( + GitHubLatestRelease(version = version, pageUrl = pageUrl, apkUrl = apkUrl), + ) + } + } + else -> Result.failure( + IllegalStateException("Latest release request failed: $code"), + ) + } + } catch (e: Exception) { + Result.failure(e) + } finally { + connection?.disconnect() + } + } + + suspend fun downloadApk(url: String, destination: File): Result = withContext(Dispatchers.IO) { + if (!isAllowedApkDownload(url)) { + return@withContext Result.failure(IllegalStateException("Refusing APK URL")) + } + var current = URL(url) + var connection: HttpURLConnection? = null + try { + repeat(MAX_REDIRECTS) { + connection?.disconnect() + connection = (current.openConnection() as HttpURLConnection).apply { + instanceFollowRedirects = false + connectTimeout = 15_000 + readTimeout = 60_000 + setRequestProperty("User-Agent", "PixelPlayer") + setRequestProperty("Accept", "application/octet-stream, */*") + } + val code = connection!!.responseCode + if (code in 300..399) { + val location = connection!!.getHeaderField("Location") + ?: return@withContext Result.failure(IllegalStateException("Redirect missing location")) + val next = URL(current, location) + if (!isAllowedDownloadHop(next)) { + return@withContext Result.failure(IllegalStateException("Refusing redirect host")) + } + current = next + return@repeat + } + if (code != HttpURLConnection.HTTP_OK) { + return@withContext Result.failure(IllegalStateException("APK download failed: $code")) + } + destination.parentFile?.mkdirs() + if (destination.exists()) destination.delete() + connection!!.inputStream.use { input -> + destination.outputStream().use { output -> input.copyTo(output) } + } + if (destination.length() < MIN_APK_BYTES) { + destination.delete() + return@withContext Result.failure(IllegalStateException("Downloaded file is too small")) + } + return@withContext Result.success(destination) + } + Result.failure(IllegalStateException("Too many redirects")) + } catch (e: Exception) { + if (e is CancellationException) throw e + destination.delete() + Result.failure(e) + } finally { + connection?.disconnect() + } + } + + private fun selectApkUrl(json: JSONObject): String { + val assets = json.optJSONArray("assets") ?: return "" + val abi = preferredAbi() + var matched = "" + var fallback = "" + for (i in 0 until assets.length()) { + val asset = assets.optJSONObject(i) ?: continue + val name = asset.optString("name") + val url = asset.optString("browser_download_url") + if (!name.endsWith(".apk", ignoreCase = true) || !isAllowedApkDownload(url)) continue + if (!name.contains("arm64-v8a") && !name.contains("armeabi-v7a")) continue + if (fallback.isEmpty() && name.contains("armeabi-v7a")) fallback = url + if (name.contains(abi)) matched = url + } + return matched.ifEmpty { fallback } + } + + companion object { + private const val MAX_REDIRECTS = 5 + private const val MIN_APK_BYTES = 1024L + const val OWNER = "kibetmasi" + const val REPO = "PixelPlayer" + + fun isAllowedReleasePage(url: String): Boolean { + val parsed = runCatching { URL(url) }.getOrNull() ?: return false + if (!parsed.protocol.equals("https", ignoreCase = true)) return false + if (!parsed.host.equals("github.com", ignoreCase = true)) return false + val path = parsed.path.orEmpty() + return path == "/$OWNER/$REPO/releases" || + path.startsWith("/$OWNER/$REPO/releases/") + } + + fun isAllowedApkDownload(url: String): Boolean { + val parsed = runCatching { URL(url) }.getOrNull() ?: return false + if (!parsed.protocol.equals("https", ignoreCase = true)) return false + if (!parsed.host.equals("github.com", ignoreCase = true)) return false + val path = parsed.path.orEmpty() + return path.startsWith("/$OWNER/$REPO/releases/download/") && + path.endsWith(".apk", ignoreCase = true) + } + + private fun isAllowedDownloadHop(url: URL): Boolean { + if (!url.protocol.equals("https", ignoreCase = true)) return false + val host = url.host.orEmpty().lowercase() + if (host == "github.com") return isAllowedApkDownload(url.toString()) + return host == "release-assets.githubusercontent.com" || + host == "objects.githubusercontent.com" || + host == "github-releases.githubusercontent.com" + } + + private fun preferredAbi(): String { + val abis = Build.SUPPORTED_ABIS + return when { + abis.any { it == "arm64-v8a" } -> "arm64-v8a" + else -> "armeabi-v7a" + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt index 5d8a3860d1..d9f7de83d0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt @@ -132,7 +132,7 @@ fun ChangelogBottomSheet( modifier: Modifier = Modifier ) { val context = LocalContext.current - val changelogUrl = "https://github.com/theovilardo/PixelPlayer/blob/master/CHANGELOG.md" + val changelogUrl = "https://github.com/kibetmasi/PixelPlayer/blob/master/CHANGELOG.md" val changelog = changelogVersions() val fabCornerRadius = 16.dp diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlayStoreAnnouncementDialog.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlayStoreAnnouncementDialog.kt index bc079fedae..83b5e81a82 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlayStoreAnnouncementDialog.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlayStoreAnnouncementDialog.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon @@ -67,6 +68,7 @@ fun PlayStoreAnnouncementDialog( announcement: PlayStoreAnnouncementUiModel, onDismiss: () -> Unit, onOpenPlayStore: (String) -> Unit, + primaryBusy: Boolean = false, ) { val cardShape = AbsoluteSmoothCornerShape( cornerRadiusTL = 30.dp, @@ -180,27 +182,36 @@ fun PlayStoreAnnouncementDialog( Button( onClick = { - announcement.playStoreUrl?.let(onOpenPlayStore) + if (!primaryBusy) { + announcement.playStoreUrl?.let(onOpenPlayStore) + } }, - enabled = hasPlayStoreLink, + enabled = hasPlayStoreLink && !primaryBusy, shape = actionShape, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, ), - //modifier = Modifier.weight(1f), ) { - Icon( - painter = painterResource(id = R.drawable.rounded_arrow_forward_24), - contentDescription = null, - modifier = Modifier.size(18.dp), - ) + if (primaryBusy) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon( + painter = painterResource(id = R.drawable.rounded_arrow_forward_24), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } Spacer(modifier = Modifier.width(6.dp)) Text( - text = if (hasPlayStoreLink) { - announcement.primaryActionLabel - } else { - stringResource(R.string.playstore_dialog_coming_soon) + text = when { + primaryBusy -> stringResource(R.string.update_dialog_downloading) + hasPlayStoreLink -> announcement.primaryActionLabel + else -> stringResource(R.string.playstore_dialog_coming_soon) }, textAlign = TextAlign.Center, ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/StreamingProviderSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/StreamingProviderSheet.kt index a75eeefa9c..34b6c021b5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/StreamingProviderSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/StreamingProviderSheet.kt @@ -49,6 +49,9 @@ fun StreamingProviderSheet( onNavigateToNavidromeDashboard: () -> Unit = {}, isJellyfinLoggedIn: Boolean = false, onNavigateToJellyfinDashboard: () -> Unit = {}, + isSoundCloudSignedIn: Boolean = false, + isSoundCloudConfigured: Boolean = false, + onOpenSoundCloudSettings: () -> Unit = {}, sheetState: SheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) @@ -193,6 +196,23 @@ fun StreamingProviderSheet( onDismissRequest() } ) + + ProviderRow( + iconPainter = painterResource(R.drawable.ic_soundcloud_24), + iconTint = Color(0xFFFF5500), + title = stringResource(R.string.soundcloud_section), + subtitle = when { + isSoundCloudSignedIn -> stringResource(R.string.soundcloud_provider_subtitle_signed_in) + isSoundCloudConfigured -> stringResource(R.string.soundcloud_provider_subtitle_ready) + else -> stringResource(R.string.soundcloud_provider_subtitle_setup) + }, + shape = providerSegmentItemShape, + isConnected = isSoundCloudSignedIn || isSoundCloudConfigured, + onClick = { + onOpenSoundCloudSettings() + onDismissRequest() + } + ) } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt index a782db03ba..1200f8ac5b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt @@ -41,6 +41,7 @@ import com.theveloper.pixelplay.presentation.screens.DailyMixScreen import com.theveloper.pixelplay.presentation.screens.EditTransitionScreen import com.theveloper.pixelplay.presentation.screens.EasterEggScreen import com.theveloper.pixelplay.presentation.screens.ExperimentalSettingsScreen +import com.theveloper.pixelplay.presentation.screens.SoundCloudSettingsScreen import com.theveloper.pixelplay.presentation.screens.GenreDetailScreen import com.theveloper.pixelplay.presentation.screens.HomeScreen import com.theveloper.pixelplay.presentation.screens.LibraryScreen @@ -304,6 +305,13 @@ fun AppNavigation( ) } } + composable( + Screen.SoundCloudSettings.route, + ) { + ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) { + SoundCloudSettingsScreen(navController = navController) + } + } composable( Screen.Experimental.route, ) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt index af8ac6433b..4bc5bffb89 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt @@ -9,6 +9,7 @@ sealed class Screen(val route: String) { object Search : Screen("search") object Library : Screen("library") object SoundCloud : Screen("soundcloud") + object SoundCloudSettings : Screen("soundcloud_settings") object Settings : Screen("settings") object Accounts : Screen("settings_accounts") object SettingsCategory : Screen("settings_category/{categoryId}") { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt index e7b604da34..8198b71062 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.LinearScale import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.Style -import androidx.compose.material.icons.rounded.Cloud import androidx.compose.material.icons.rounded.LinearScale import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.Rectangle @@ -45,10 +44,8 @@ import androidx.compose.material.icons.rounded.Title import androidx.compose.material.icons.rounded.ViewCarousel import androidx.compose.material.icons.rounded.BlurOn import androidx.compose.material.icons.rounded.Visibility -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -69,13 +66,11 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.media3.common.util.UnstableApi import androidx.navigation.NavController -import android.content.Intent import com.theveloper.pixelplay.presentation.components.CollapsibleCommonTopBar import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel @@ -83,10 +78,8 @@ import com.theveloper.pixelplay.presentation.viewmodel.SettingsViewModel import kotlinx.coroutines.launch import kotlin.math.roundToInt import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.preferences.AlbumArtQuality -import com.theveloper.pixelplay.soundcloud.SoundCloudPrefsViewModel @OptIn(UnstableApi::class) @Composable @@ -187,104 +180,6 @@ fun ExperimentalSettingsScreen( contentPadding = PaddingValues(top = currentTopBarHeightDp + 8.dp), modifier = Modifier.fillMaxSize() ) { - item(key = "soundcloud_section") { - val scPrefsVm: SoundCloudPrefsViewModel = hiltViewModel() - val scClientId by scPrefsVm.clientId.collectAsStateWithLifecycle() - val scUsername by scPrefsVm.username.collectAsStateWithLifecycle() - val scSession by scPrefsVm.session.collectAsStateWithLifecycle() - val includeDownloadsInLibrary by scPrefsVm.includeDownloadsInLibrary.collectAsStateWithLifecycle() - var clientIdDraft by remember(scClientId) { mutableStateOf(scClientId) } - var usernameDraft by remember(scUsername) { mutableStateOf(scUsername) } - val context = LocalContext.current - - SettingsSection( - title = stringResource(R.string.soundcloud_section), - icon = { - Icon( - imageVector = Icons.Rounded.Cloud, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - } - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 0.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.soundcloud_settings_help), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), - ) - if (scSession.isSignedIn) { - Text( - text = stringResource( - R.string.soundcloud_signed_in_as, - scSession.displayName.ifBlank { scSession.permalink }.ifBlank { "account" }, - ), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 4.dp), - ) - FilledTonalButton( - onClick = { scPrefsVm.signOut() }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.soundcloud_sign_out)) - } - } else { - FilledTonalButton( - onClick = { - context.startActivity( - Intent( - context, - com.theveloper.pixelplay.presentation.soundcloud.auth.SoundCloudLoginActivity::class.java, - ), - ) - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.soundcloud_sign_in)) - } - } - OutlinedTextField( - value = clientIdDraft, - onValueChange = { clientIdDraft = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text(stringResource(R.string.soundcloud_client_id_label)) }, - placeholder = { Text(stringResource(R.string.soundcloud_client_id_hint)) }, - ) - OutlinedTextField( - value = usernameDraft, - onValueChange = { usernameDraft = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text(stringResource(R.string.soundcloud_username_label)) }, - placeholder = { Text(stringResource(R.string.soundcloud_username_hint)) }, - ) - SwitchSettingItem( - title = stringResource(R.string.soundcloud_include_downloads_in_library), - subtitle = stringResource(R.string.soundcloud_include_downloads_in_library_subtitle), - checked = includeDownloadsInLibrary, - onCheckedChange = scPrefsVm::setIncludeDownloadsInLibrary, - ) - FilledTonalButton( - onClick = { - scPrefsVm.saveClientId(clientIdDraft) - scPrefsVm.saveUsername(usernameDraft) - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.soundcloud_save_settings)) - } - } - } - } - item(key = "player_ui_tweaks_section") { SettingsSection( title = stringResource(R.string.settings_exp_player_ui_tweaks_section), diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt index ba622628f7..cf3f905e51 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt @@ -108,6 +108,7 @@ import com.theveloper.pixelplay.presentation.navigation.Screen import com.theveloper.pixelplay.presentation.components.StreamingProviderSheet import com.theveloper.pixelplay.presentation.telegram.auth.TelegramLoginActivity import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel +import com.theveloper.pixelplay.soundcloud.SoundCloudPrefsViewModel import com.theveloper.pixelplay.presentation.viewmodel.SettingsViewModel import com.theveloper.pixelplay.presentation.viewmodel.StatsViewModel import com.theveloper.pixelplay.ui.theme.ExpTitleTypography @@ -135,6 +136,7 @@ fun HomeScreen( qqMusicViewModel: QqMusicDashboardViewModel = hiltViewModel(), navidromeViewModel: NavidromeDashboardViewModel = hiltViewModel(), jellyfinViewModel: JellyfinDashboardViewModel = hiltViewModel(), + soundCloudPrefsViewModel: SoundCloudPrefsViewModel = hiltViewModel(), onOpenSidebar: () -> Unit ) { val context = LocalContext.current @@ -553,6 +555,8 @@ fun HomeScreen( val isQqMusicLoggedIn by qqMusicViewModel.isLoggedIn.collectAsStateWithLifecycle() val isNavidromeLoggedIn by navidromeViewModel.isLoggedIn.collectAsStateWithLifecycle() val isJellyfinLoggedIn by jellyfinViewModel.isLoggedIn.collectAsStateWithLifecycle() + val soundCloudSession by soundCloudPrefsViewModel.session.collectAsStateWithLifecycle() + val soundCloudClientId by soundCloudPrefsViewModel.clientId.collectAsStateWithLifecycle() StreamingProviderSheet( onDismissRequest = { showStreamingProviderSheet = false }, isNeteaseLoggedIn = isNeteaseLoggedIn, @@ -570,7 +574,12 @@ fun HomeScreen( isJellyfinLoggedIn = isJellyfinLoggedIn, onNavigateToJellyfinDashboard = { navController.navigateSafely(Screen.JellyfinDashboard.route) - } + }, + isSoundCloudSignedIn = soundCloudSession.isSignedIn, + isSoundCloudConfigured = soundCloudClientId.isNotBlank(), + onOpenSoundCloudSettings = { + navController.navigateSafely(Screen.SoundCloudSettings.route) + }, ) } if (shouldShowCleanInstallDisclaimer) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt index a31892f821..8ef362f570 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt @@ -374,7 +374,7 @@ fun SoundCloudScreen( actions = { FilledIconButton( modifier = Modifier.padding(end = 14.dp), - onClick = { navController.navigateSafely(Screen.Experimental.route) }, + onClick = { navController.navigateSafely(Screen.SoundCloudSettings.route) }, colors = IconButtonDefaults.filledIconButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudSettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudSettingsScreen.kt new file mode 100644 index 0000000000..1964516476 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudSettingsScreen.kt @@ -0,0 +1,222 @@ +package com.theveloper.pixelplay.presentation.screens + +import android.content.Intent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Cloud +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.presentation.components.CollapsibleCommonTopBar +import com.theveloper.pixelplay.presentation.soundcloud.auth.SoundCloudLoginActivity +import com.theveloper.pixelplay.soundcloud.SoundCloudPrefsViewModel +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +fun SoundCloudSettingsScreen( + navController: NavController, + viewModel: SoundCloudPrefsViewModel = hiltViewModel(), +) { + val clientId by viewModel.clientId.collectAsStateWithLifecycle() + val username by viewModel.username.collectAsStateWithLifecycle() + val session by viewModel.session.collectAsStateWithLifecycle() + val includeDownloadsInLibrary by viewModel.includeDownloadsInLibrary.collectAsStateWithLifecycle() + var clientIdDraft by remember(clientId) { mutableStateOf(clientId) } + var usernameDraft by remember(username) { mutableStateOf(username) } + val context = LocalContext.current + + val density = LocalDensity.current + val coroutineScope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val minTopBarHeight = 64.dp + statusBarHeight + val maxTopBarHeight = 180.dp + val minTopBarHeightPx = with(density) { minTopBarHeight.toPx() } + val maxTopBarHeightPx = with(density) { maxTopBarHeight.toPx() } + val topBarHeight = remember { Animatable(maxTopBarHeightPx) } + var collapseFraction by remember { mutableStateOf(0f) } + + LaunchedEffect(topBarHeight.value) { + collapseFraction = 1f - ( + (topBarHeight.value - minTopBarHeightPx) / (maxTopBarHeightPx - minTopBarHeightPx) + ).coerceIn(0f, 1f) + } + + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.y + val isScrollingDown = delta < 0 + if (!isScrollingDown && + (lazyListState.firstVisibleItemIndex > 0 || lazyListState.firstVisibleItemScrollOffset > 0) + ) { + return Offset.Zero + } + val previousHeight = topBarHeight.value + val newHeight = (previousHeight + delta).coerceIn(minTopBarHeightPx, maxTopBarHeightPx) + val consumed = newHeight - previousHeight + if (consumed.roundToInt() != 0) { + coroutineScope.launch { topBarHeight.snapTo(newHeight) } + } + val canConsumeScroll = !(isScrollingDown && newHeight == minTopBarHeightPx) + return if (canConsumeScroll) Offset(0f, consumed) else Offset.Zero + } + } + } + + LaunchedEffect(lazyListState.isScrollInProgress) { + if (!lazyListState.isScrollInProgress) { + val shouldExpand = topBarHeight.value > (minTopBarHeightPx + maxTopBarHeightPx) / 2 + val canExpand = lazyListState.firstVisibleItemIndex == 0 && + lazyListState.firstVisibleItemScrollOffset == 0 + val targetValue = if (shouldExpand && canExpand) maxTopBarHeightPx else minTopBarHeightPx + if (topBarHeight.value != targetValue) { + coroutineScope.launch { + topBarHeight.animateTo(targetValue, spring(stiffness = Spring.StiffnessMedium)) + } + } + } + } + + Box( + modifier = Modifier + .nestedScroll(nestedScrollConnection) + .fillMaxSize(), + ) { + val currentTopBarHeightDp = with(density) { topBarHeight.value.toDp() } + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = currentTopBarHeightDp + 8.dp, bottom = 100.dp), + ) { + item(key = "soundcloud_settings") { + SettingsSection( + title = stringResource(R.string.soundcloud_section), + icon = { + Icon( + imageVector = Icons.Rounded.Cloud, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.soundcloud_settings_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), + ) + if (session.isSignedIn) { + Text( + text = stringResource( + R.string.soundcloud_signed_in_as, + session.displayName.ifBlank { session.permalink }.ifBlank { "account" }, + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 4.dp), + ) + FilledTonalButton( + onClick = { viewModel.signOut() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.soundcloud_sign_out)) + } + } else { + FilledTonalButton( + onClick = { + context.startActivity(Intent(context, SoundCloudLoginActivity::class.java)) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.soundcloud_sign_in)) + } + } + OutlinedTextField( + value = clientIdDraft, + onValueChange = { clientIdDraft = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringResource(R.string.soundcloud_client_id_label)) }, + placeholder = { Text(stringResource(R.string.soundcloud_client_id_hint)) }, + ) + OutlinedTextField( + value = usernameDraft, + onValueChange = { usernameDraft = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringResource(R.string.soundcloud_username_label)) }, + placeholder = { Text(stringResource(R.string.soundcloud_username_hint)) }, + ) + SwitchSettingItem( + title = stringResource(R.string.soundcloud_include_downloads_in_library), + subtitle = stringResource(R.string.soundcloud_include_downloads_in_library_subtitle), + checked = includeDownloadsInLibrary, + onCheckedChange = viewModel::setIncludeDownloadsInLibrary, + ) + FilledTonalButton( + onClick = { + viewModel.saveClientId(clientIdDraft) + viewModel.saveUsername(usernameDraft) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.soundcloud_save_settings)) + } + } + } + } + } + + CollapsibleCommonTopBar( + title = stringResource(R.string.soundcloud_settings_title), + collapseFraction = collapseFraction, + headerHeight = currentTopBarHeightDp, + onBackClick = { navController.popBackStack() }, + expandedTitleStartPadding = 20.dp, + ) + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt index 7c4c019ade..bd7fb21cf9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt @@ -305,7 +305,7 @@ class SoundCloudClient @Inject constructor( ensureClientIdReady() val user = username.trim().removePrefix("@").removePrefix("https://soundcloud.com/") .substringBefore('/') - require(user.isNotEmpty()) { "Set your SoundCloud username in Settings → Experimental" } + require(user.isNotEmpty()) { "Set your SoundCloud username in SoundCloud settings" } val url = "https://soundcloud.com/$user" + when (tab) { ChannelTabs.LIKES -> "/likes" @@ -335,7 +335,7 @@ class SoundCloudClient @Inject constructor( SoundcloudParsingHelper.clientId() } catch (t: Throwable) { throw IllegalStateException( - "Couldn't auto-fetch SoundCloud client_id. Paste one in Settings → Experimental. (${t.message})", + "Couldn't auto-fetch SoundCloud client_id. Paste one in SoundCloud settings. (${t.message})", t, ) } @@ -424,7 +424,7 @@ class SoundCloudClient @Inject constructor( msg.contains("rate limited", ignoreCase = true) -> "$prefix: SoundCloud rate-limited you. Wait a bit and try again." msg.contains("client", ignoreCase = true) -> - "$prefix: client_id missing/expired. Open Settings → Experimental and save a fresh client_id (or retry)." + "$prefix: client_id missing/expired. Open SoundCloud settings and save a fresh client_id (or retry)." else -> "$prefix. $msg" } return IllegalStateException(friendly, t) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58326a455f..723f1fa769 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,6 +48,10 @@ Tile view List view SoundCloud + SoundCloud + Signed in + client_id saved + Set client_id and sign in Profile: @%1$s Playlist: %1$s %1$d tracks @@ -85,6 +89,13 @@ PixelPlayer Release announcement Coming soon + A new version is available + PixelPlayer %1$s is ready to install. Open the latest release and download the APK for your phone. + Download + Downloading… + Allow PixelPlayer to install updates, then tap Download again. + Couldn\'t download the update. Check your connection and try again. + Later Thank you for using PixelPlayer! diff --git a/docs/soundcloud-implementation.md b/docs/soundcloud-implementation.md new file mode 100644 index 0000000000..d5e6a9a426 --- /dev/null +++ b/docs/soundcloud-implementation.md @@ -0,0 +1,705 @@ +# SoundCloud in PixelPlayer — Implementation Guide + +This document describes how PixelPlayer talks to SoundCloud **without** an official +SoundCloud developer OAuth app. It covers NewPipe Extractor, the unofficial web +`api-v2` client, WebView sign-in, playback (including HLS), downloads, and known +limits. + +> **Status:** experimental, reverse-engineered against SoundCloud’s public website. +> Endpoints, cookies, and `client_id` values can change without notice. + +--- + +## Table of contents + +1. [Goals and non-goals](#1-goals-and-non-goals) +2. [Architecture overview](#2-architecture-overview) +3. [Two stacks: NewPipe vs api-v2](#3-two-stacks-newpipe-vs-api-v2) +4. [Credentials model (the “auth bypass”)](#4-credentials-model-the-auth-bypass) +5. [Obtaining a web `client_id`](#5-obtaining-a-web-client_id) +6. [Sign-in via in-app WebView](#6-sign-in-via-in-app-webview) +7. [NewPipe Extractor deep dive](#7-newpipe-extractor-deep-dive) +8. [SoundCloud `api-v2` deep dive](#8-soundcloud-api-v2-deep-dive) +9. [Playback pipeline](#9-playback-pipeline) +10. [Downloads, likes, playlists, feed](#10-downloads-likes-playlists-feed) +11. [UI surface](#11-ui-surface) +12. [Dependencies, ProGuard, build config](#12-dependencies-proguard-build-config) +13. [Failure modes and limitations](#13-failure-modes-and-limitations) +14. [File index](#14-file-index) +15. [End-to-end sequence diagrams](#15-end-to-end-sequence-diagrams) + +--- + +## 1. Goals and non-goals + +### Goals + +- Browse Discover / Search / public profiles via NewPipe (no official SDK). +- When the user signs in (web session), show **personal** Feed, Likes, Tracks, Playlists. +- Resolve a track to a playable HTTP(S) audio URL and hand it to Media3 / ExoPlayer. +- Prefer progressive MP3 when available; fall back to plain (non-DRM) HLS. +- Optional progressive downloads into `Music/PixelPlayer/SoundCloud`. + +### Non-goals + +- Official SoundCloud developer portal apps / registered OAuth clients. +- Playing DRM-only / encrypted HLS tracks. +- Guaranteed long-lived stream URLs (CDN links expire; we re-resolve). +- Using SoundCloud as a licensed commercial streaming partner. + +--- + +## 2. Architecture overview + +SoundCloud is **not** a separate Gradle module. It lives inside `:app` under +`com.theveloper.pixelplay.soundcloud`, with UI under `presentation` and auth under +`presentation.soundcloud.auth`. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SoundCloudScreen (Compose) │ +│ sections: FEED · DISCOVER · SEARCH · LIKES · TRACKS · PLAYLISTS · DL │ +└───────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ SoundCloudViewModel │ +│ load sections · resolve start track · prefetch queue · like/download │ +└───────────────┬───────────────────────────────┬─────────────────────────┘ + │ │ + ▼ ▼ +┌───────────────────────────┐ ┌───────────────────────────────────────┐ +│ SoundCloudClient │ │ SoundCloudSessionApi │ +│ (NewPipe Extractor) │ │ (OkHttp → api-v2.soundcloud.com) │ +│ discover · search · │ │ /me · /stream · /users/{id}/… │ +│ channel tabs · playlist │ │ resolve · track_likes PUT/DELETE │ +│ StreamInfo resolve │ │ │ +└─────────────┬─────────────┘ └──────────────────┬────────────────────┘ + │ │ + ▼ │ +┌───────────────────────────┐ │ +│ SoundCloudDownloader │◄── oauth + cookies ────┘ +│ (NewPipe Downloader impl)│ +└───────────────────────────┘ + + credentials Persist via SoundCloudSettings (DataStore) + playback → PlayerViewModel → MusicService / Media3 + downloads → SoundCloudDownloadService → MediaStore +``` + +### Core types + +| Type | Role | +|------|------| +| `SoundCloudSearchHit` | List row: permalink URL, title, artist, art, track vs playlist | +| `SoundCloudResolvedTrack` | After NewPipe resolve: stream URL + metadata | +| `SoundCloudFeedShelf` | Named horizontal/vertical shelf on Feed | +| `SoundCloudSection` | Tab/section enum | +| `SoundCloudSession` | Token + cookies + display name + permalink (in settings) | +| `Song` | Shared library model; SoundCloud songs use `id = sc_{hash}`, `path`/`contentUriString` = stream URL | + +### Entry points + +| Entry | How | +|-------|-----| +| Bottom nav “SoundCloud” | `MainActivity` → `Screen.SoundCloud` → `SoundCloudScreen` (tab stays) | +| Home cloud sheet | SoundCloud row → `Screen.SoundCloudSettings` | +| Gear on SoundCloud | Same settings page (`Screen.SoundCloudSettings`) | +| Sign in | Starts `SoundCloudLoginActivity` (not exported) | + +--- + +## 3. Two stacks: NewPipe vs api-v2 + +PixelPlayer deliberately uses **two** backends. + +| Concern | Stack | Why | +|---------|-------|-----| +| Discover kiosk, search, public channel tabs, playlist track lists, **stream URL resolve** | **NewPipe Extractor** (`SoundcloudService`) | Already understands SoundCloud page/API scraping; exposes `StreamInfo` with progressive + HLS | +| Signed-in Feed, Likes, Tracks, Playlists, like/unlike | **Direct `api-v2`** (`SoundCloudSessionApi`) | NewPipe does not own the user’s private library; web `/users/{id}/…` endpoints do | + +Rule of thumb: + +- **Need a playable URL** → NewPipe `StreamInfo.getInfo(permalink)`. +- **Need “my” library while signed in** → SessionApi with OAuth + cookies + `client_id`. +- **Signed out** → NewPipe public profile URL `soundcloud.com/{username}/likes|tracks|sets` (public only). + +--- + +## 4. Credentials model (the “auth bypass”) + +PixelPlayer does **not** register an OAuth application with SoundCloud. Instead it +reuses what the **website** already uses: + +| Credential | What it is | Required for | +|------------|------------|--------------| +| **`client_id`** | Public web app id SoundCloud embeds in front-end JS / Network calls (`?client_id=…`) | Almost all api-v2 and media calls; **streaming** | +| **`oauth_token`** | Cookie set after browser login (`oauth_token=…`) | Personal Feed / Likes / Tracks / Playlists / liking | +| **Cookie jar** | Full `Cookie` header from `soundcloud.com` + `api-v2.soundcloud.com` | Same as token; some requests expect both | +| **Username / permalink** | Public handle | Fallback when signed out (public tabs only) | + +### Why this works + +SoundCloud’s website is itself a client of `https://api-v2.soundcloud.com`. Every +browser request carries: + +```http +GET /me?client_id= HTTP/1.1 +Host: api-v2.soundcloud.com +Authorization: OAuth +Cookie: oauth_token=<…>; …other cookies… +User-Agent: +Origin: https://soundcloud.com +Referer: https://soundcloud.com/ +``` + +PixelPlayer copies that pattern. That is the “bypass”: **session reuse**, not a +secret exploit of a private API. It is fragile and unofficial. + +### What is stored (DataStore `soundcloud_settings`) + +Keys in `SoundCloudSettings`: + +| Preference key | Purpose | +|----------------|---------| +| `client_id` | Web client id (survives sign-out) | +| `username` | Public handle / permalink sync | +| `oauth_token` | Web session OAuth | +| `cookie_header` | Merged cookie string | +| `display_name` | From `/me` | +| `permalink` | From `/me` | + +`clearSession()` drops token/cookies/display/permalink but **keeps** `client_id` +so streaming still works after sign-out for public content. + +### Optional build-time seed + +`local.properties`: + +```properties +soundcloud.clientId=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +exposed as `BuildConfig.SOUNDCLOUD_CLIENT_ID`. Runtime DataStore override always +wins when the user pastes a value in SoundCloud settings. + +--- + +## 5. Obtaining a web `client_id` + +### Manual (documented in README) + +1. Open [soundcloud.com](https://soundcloud.com) on a desktop browser and play any track. +2. DevTools → **Network** → filter `client_id`. +3. Open a request to `api-v2.soundcloud.com` (often `/me` or media). +4. Copy the query value after `client_id=`. +5. Paste into **SoundCloud settings → Web client_id → Save**. + +Screenshot asset: `assets/soundcloud-devtools-request.png` (redacted). + +### Automatic (NewPipe scrape) + +`SoundCloudClient.refreshClientIdFromWeb()`: + +1. Clears any previously injected NewPipe client id. +2. Calls `SoundcloudParsingHelper.clientId()` (NewPipe scrapes SoundCloud’s JS bundles). +3. Injects the result into NewPipe **and** SessionApi. + +Triggered when: + +- Prefs / runtime override is empty (`ensureClientIdReady`). +- Resolve/playlist fails with messages suggesting client_id / 429 / recaptcha + (`looksLikeClientIdOrRateLimit` → refresh once → retry). + +### Reflective injection into NewPipe + +NewPipe stores the client id in a **private static** field: + +```kotlin +// SoundCloudClient.injectClientId +val field = SoundcloudParsingHelper::class.java.getDeclaredField("clientId") +field.isAccessible = true +synchronized(SoundcloudParsingHelper::class.java) { + field.set(null, clientId) +} +``` + +ProGuard keeps that field so release builds still work: + +```proguard +-keepclassmembers class org.schabi.newpipe.extractor.services.soundcloud.SoundcloudParsingHelper { + static java.lang.String clientId; +} +``` + +--- + +## 6. Sign-in via in-app WebView + +Class: `SoundCloudLoginActivity` +Route: started from SoundCloud settings (“Sign in to SoundCloud”). + +### Flow + +``` +SoundCloud settings → startActivity(SoundCloudLoginActivity) + │ + ▼ + WebView loads https://soundcloud.com/signin + Desktop Chrome UA · JS · DOM storage · 3rd-party cookies + │ + ▼ + User logs in (email/Google/etc. as on the website) + │ + onPageFinished → try extractSoundCloudSession() + or user taps “I’m signed in” + │ + ▼ + CookieManager cookies from: + https://soundcloud.com + https://api-v2.soundcloud.com + │ + ▼ + Require cookie key oauth_token + Build cookie_header = all key=value pairs + │ + ▼ + SoundCloudPrefsViewModel.saveSession(token, cookies) + → DataStore + → sessionApi.fetchMe() → displayName + permalink + → sync username to permalink + │ + ▼ + Activity RESULT_OK + finish +``` + +### Session extraction (`extractSoundCloudSession`) + +```kotlin +val main = CookieManager.getInstance().getCookie("https://soundcloud.com") +val api = CookieManager.getInstance().getCookie("https://api-v2.soundcloud.com") +// merge, parse key=value, require oauth_token +return token to cookieHeader +``` + +If `oauth_token` is missing, the UI shows “sign-in incomplete” and does not save. + +### After sign-in + +`SoundCloudViewModel` collects `settings.session` and calls: + +```kotlin +client.setSession(session.oauthToken, session.cookieHeader) +``` + +which updates both: + +- `SoundCloudDownloader` (NewPipe HTTP layer) — Authorization + Cookie headers +- `SoundCloudSessionApi` — same credentials + current `client_id` + +Personal sections then use SessionApi. Default landing section becomes **FEED** +when signed in, otherwise **DISCOVER**. + +### Sign-out + +Clears session prefs (keeps `client_id`). UI returns to public Discover / username +tabs. + +--- + +## 7. NewPipe Extractor deep dive + +### Dependency + +```toml +# gradle/libs.versions.toml +newpipeExtractor = "v0.26.5" +newpipe-extractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeExtractor" } +``` + +Resolved via JitPack (`teamnewpipe` allowed in `settings.gradle.kts`). + +### Initialization + +Once per process: + +```kotlin +NewPipe.init(SoundCloudDownloader()) +``` + +Service handle: `ServiceList.SoundCloud` → `SoundcloudService`. + +### Isolated HTTP downloader (critical) + +`SoundCloudDownloader` extends NewPipe’s `Downloader` with its **own** OkHttpClient. + +**Why not reuse the app OkHttpClient?** +`AppModule` forces a PixelPlayer User-Agent that SoundCloud rejects or mishandles +for api-v2 / media. The SoundCloud downloader uses a Firefox desktop UA plus: + +- `Origin: https://soundcloud.com` +- `Referer: https://soundcloud.com/` +- Optional `Authorization: OAuth …` +- Optional `Cookie: …` + +HTTP **429** is mapped to `IOException("SoundCloud rate limited…")` so the UI can +show a friendly message. + +### Operations mapped to NewPipe + +| App method | NewPipe API | +|------------|-------------| +| `loadDiscover` | Default kiosk → `KioskInfo.getInfo` | +| `search(query)` | `getSearchExtractor` → `initialPage.items` | +| `loadUserLikes/Tracks/Playlists(username)` | `ChannelTabInfo` on `/likes`, `/tracks`, `/sets` | +| `loadPlaylistTracks(url)` | `PlaylistInfo.getInfo` | +| `resolveTrack(url)` | `StreamInfo.getInfo` → `audioStreams` | + +Info items become `SoundCloudSearchHit` (tracks + playlists). + +### Resolve path (`resolveTrack`) + +``` +permalink URL + → ensureClientIdReady() // prefs or scrape + inject + → StreamInfo.getInfo(service, url) + → pickBestAudioStream(audioStreams) + → SoundCloudResolvedTrack(streamUrl, mime, art, …) + → cache in ConcurrentHashMap (TTL 15 minutes) +``` + +On client_id / 429 / recaptcha-looking errors: refresh client_id from web once and +retry. + +### Stream selection policy (`pickBestAudioStream`) + +1. **Progressive HTTP** preferred (single-file MP3/M4A — fastest first buffer). +2. Else **plain HLS** (`DeliveryMethod.HLS`, URL must not contain `encrypted`). + Prefer **lower** bitrate for quicker start. +3. Else any non-encrypted stream; else fail (“DRM-protected…”). + +Encrypted / DRM-only tracks are intentionally unplayable. + +### Song id stability + +```kotlin +fun songIdForUrl(url: String) = "sc_${url.trim().hashCode().toUInt()}" +``` + +`toSong(..., permalinkUrl = hit.url)` always keys the `Song.id` off the **list +permalink**, so the UI can highlight the now-playing row without waiting for +NewPipe’s canonical URL. + +--- + +## 8. SoundCloud `api-v2` deep dive + +Class: `SoundCloudSessionApi` +Base host: `https://api-v2.soundcloud.com` + +### Request shaping + +Every call: + +1. Append `client_id` query param when known. +2. If session present: `Authorization: OAuth {token}`. +3. If cookies present: `Cookie: {cookieHeader}`. +4. Browser-like `User-Agent`, `Origin`, `Referer`. + +`hasSession` ≡ non-blank oauth token. + +### Endpoints + +| Feature | Method | Path | Auth | +|---------|--------|------|------| +| Current user | GET | `/me` | required | +| Home stream | GET | `/stream?limit=&linked_partitioning=1` | required | +| Play history | GET | `/me/play-history/tracks` (fallback `/me/play-history`) | required | +| Likes | GET | `/users/{id}/likes/tracks` (fallback `/users/{id}/track_likes`) | required | +| Uploads | GET | `/users/{id}/tracks` | required | +| Playlists | GET | `/users/{id}/playlists_without_albums` (fallback `/playlists`) | required | +| Resolve URL → JSON | GET | `/resolve?url=` | like: auth; DRM probe: optional | +| Like | PUT | `/users/{id}/track_likes/{trackId}` | required | +| Unlike | DELETE | `/users/{id}/track_likes/{trackId}` | required | + +Important: the web client uses **`/users/{id}/…`**, not `/me/likes` / `/me/tracks` +(those 404). User id is cached from `/me` via `requireUserId()`. + +### DRM filtering in library lists + +When parsing track JSON, SessionApi inspects `media.transcodings`. If **every** +protocol looks encrypted, the track is skipped (`isDrmOnlyTrack`) so the list +does not show unplayable DRM-only items. + +### Like toggle + +``` +hit.url + → GET /resolve?url=… → track id + → PUT|DELETE /users/{me}/track_likes/{trackId} + → update local likedTrackUrls in ViewModel +``` + +--- + +## 9. Playback pipeline + +### Why the official app feels instant + +SoundCloud’s app already holds stream manifests and uses first-party CDNs. +PixelPlayer must **resolve** each permalink through NewPipe (network + scrape) +before ExoPlayer can buffer. + +### Historical latency bug (fixed) + +Early queue building called `StreamInfo.getInfo` for **up to ~40** tracks +**before** `playSongs`. That dominated start latency. HLS support alone did not +fix it. + +### Current fast path + +``` +User taps track + → resolveStartForPlayback(hit) // ONE NewPipe resolve + → PlayerViewModel.playSongs([startSong], startSong, "SoundCloud") + → setIdle() // clear spinner + → prefetchQueueAfter(hit, list) // background: resolve next ≤24 + └─ each success → addSongToQueue(song) +``` + +Shuffle: + +``` +resolveShuffleStart() → first playable Song + remaining hits +playSongsShuffled([startSong], …) +prefetchHits(remaining) → addSongToQueue +``` + +### Media3 / ExoPlayer + +- `Song.path` / `contentUriString` = HTTPS progressive URL **or** `.m3u8` HLS URL. +- Shared `MediaItemBuilder` / `DualPlayerEngine` uses `DefaultMediaSourceFactory`. +- **`media3-exoplayer-hls` must be on the classpath** so Media3 can load + `HlsMediaSource$Factory` reflectively. Missing that dependency caused + `ClassNotFoundException` and an endless “loading” state. +- ProGuard: `-keep class androidx.media3.exoplayer.hls.** { *; }` + +There is **no** on-disk SoundCloud stream cache. Only: + +- 15‑minute resolve cache in `SoundCloudClient` +- ExoPlayer in-memory buffering +- Optional download files under Music/ + +### Now-playing highlight + +`isSoundCloudHitCurrent(hit, currentSong)`: + +1. `currentSong.id == songIdForUrl(hit.url)`, or +2. Fallback: `album == "SoundCloud"` and title/artist match (older queue ids). + +UI: primary tint + `PlayingEqIcon` on list/tiles. + +--- + +## 10. Downloads, likes, playlists, feed + +### Downloads + +`SoundCloudDownloadService`: + +- Resolves via NewPipe, then HTTP GET of **progressive** URL only. +- Rejects `.m3u8` (“HLS-only — download needs a progressive stream”). +- Writes to `Music/PixelPlayer/SoundCloud` (MediaStore `RELATIVE_PATH` on Q+). +- Remembers artwork URLs in SharedPreferences for list hydration. +- Downloads section supports Library-style multi-select (play, queue, playlist, delete, share ZIP). + +### Likes + +Requires signed-in session. Optimistic UI update of `likedTrackUrls`; unliking on +the Likes section removes the row. + +### Playlists + +- Signed in: SessionApi playlist list → tap → NewPipe `PlaylistInfo` for tracks. +- Signed out: NewPipe channel tab `/sets`. +- Nested browse uses an in-tab nav stack (`BackHandler` stays inside SoundCloud). + +### Feed shelves (signed in) + +`SoundCloudViewModel.loadFeed` builds shelves from parallel `runCatching` loads: + +| Shelf id (approx.) | Source | +|--------------------|--------| +| More of what you like | Session likes | +| Recently played | Play history | +| Mixed / curated | Discover (NewPipe) + session mixes | +| Playlists for you | Session playlists | +| Latest from people you follow | `/stream` | + +Empty shelves are omitted; Discover remains available when APIs fail. + +--- + +## 11. UI surface + +### `SoundCloudScreen` + +- Library-style section switcher (compact pill / sheet). +- Search field always available (section switches to SEARCH at ≥2 chars). +- Tiles on wide screens; list on phone (override toggle). +- Pull-to-refresh; transparent loading indicator. +- Share action sends the **permalink**, not a local file. + +### SoundCloud settings (`SoundCloudSettingsScreen`) + +- Help copy: client_id required for streaming; sign-in for personal library. +- Fields: Web client_id, username. +- Actions: Save, Sign in, Sign out. +- Status of session (display name when signed in). + +### User-facing README + +`README.md` § “SoundCloud setup” mirrors the DevTools `client_id` steps and +common error table (rate limit, DRM, empty feed). + +--- + +## 12. Dependencies, ProGuard, build config + +### Gradle (relevant) + +| Artifact | Role | +|----------|------| +| `NewPipeExtractor` v0.26.5 | SoundCloud scrape / resolve | +| `media3-exoplayer` 1.10.1 | Playback | +| `media3-exoplayer-hls` 1.10.1 | HLS streams | +| `media3-session` / `ui` | Notification / controls | +| OkHttp | SessionApi + NewPipe downloader | +| DataStore Preferences | Credentials | + +### ProGuard highlights + +- Keep Media3 HLS classes (reflective factory). +- Keep NewPipe timeago / Rhino patterns; dontwarn extractor optional APIs. +- Keep `SoundcloudParsingHelper.clientId` for reflective inject. + +--- + +## 13. Failure modes and limitations + +| Symptom / limit | Cause | Mitigation | +|-----------------|-------|------------| +| Endless load / `HlsMediaSource$Factory` CNFE | HLS dep missing (historical) | Ship `media3-exoplayer-hls` | +| Long delay before sound | Resolving many tracks before play (historical) | Resolve one → play → prefetch | +| “No playable stream” | DRM-only / encrypted HLS only | Skip; show message | +| “Rate limited” / 429 | Too many api-v2 / media calls | Wait; auto client_id refresh once | +| Streaming dies after weeks | Web `client_id` rotated | Re-scrape or paste fresh id | +| Empty Feed while signed in | Bad/expired cookies or missing client_id | Re-sign-in; Save client_id | +| Private likes missing when signed out | Public profile scrape only | Sign in | +| Download fails on some tracks | HLS-only | Play in-app instead | +| Stream URL stops working later | CDN expiry | Resolve again (cache TTL 15m) | +| App UA breaks SoundCloud | Shared OkHttp UA | Isolated `SoundCloudDownloader` | + +### Legal / ToS note + +Reusing web `client_id` and session cookies is **unofficial**. SoundCloud’s terms +may prohibit third-party clients. This feature is experimental and may break or +be removed if the site changes. + +--- + +## 14. File index + +### Domain + +| File | Responsibility | +|------|----------------| +| `soundcloud/SoundCloudClient.kt` | NewPipe facade, resolve, inject client_id, `toSong` | +| `soundcloud/SoundCloudSessionApi.kt` | Authenticated api-v2 | +| `soundcloud/SoundCloudDownloader.kt` | NewPipe HTTP downloader | +| `soundcloud/SoundCloudDownloadService.kt` | Progressive downloads | +| `soundcloud/SoundCloudSettings.kt` | DataStore credentials | +| `soundcloud/SoundCloudModels.kt` | Hits, shelves, sections, resolved track | +| `soundcloud/SoundCloudViewModel.kt` | UI state, prefetch, feed, likes | +| `soundcloud/SoundCloudPrefsViewModel.kt` | Settings save / session / `/me` | + +### UI / auth + +| File | Responsibility | +|------|----------------| +| `presentation/screens/SoundCloudScreen.kt` | Main tab UI + play wiring | +| `presentation/screens/SoundCloudSettingsScreen.kt` | client_id / sign-in UI | +| `presentation/soundcloud/auth/SoundCloudLoginActivity.kt` | WebView login + cookie extract | + +### Shared playback + +| File | Responsibility | +|------|----------------| +| `presentation/viewmodel/PlayerViewModel.kt` | `playSongs`, `addSongToQueue` | +| `data/service/player/DualPlayerEngine.kt` | ExoPlayer + `DefaultMediaSourceFactory` | +| `utils/MediaItemBuilder.kt` | `Song` → `MediaItem` | + +### Build / docs + +| File | Responsibility | +|------|----------------| +| `gradle/libs.versions.toml` | NewPipe + Media3 versions | +| `app/build.gradle.kts` | Deps + `SOUNDCLOUD_CLIENT_ID` | +| `app/proguard-rules.pro` | HLS + NewPipe keep rules | +| `README.md` | End-user setup | +| `docs/soundcloud-implementation.md` | This document | + +--- + +## 15. End-to-end sequence diagrams + +### A. First-time setup + +``` +User SoundCloud settings Browser / WebView DataStore + | | | | + |-- open gear ------------->| | | + |-- paste client_id ------->|-- saveClientId ---------------------------------->| + |-- Sign in --------------->|-- start LoginActivity -->| | + | | |-- soundcloud.com/signin | + | | |<-- oauth_token cookie --| + | |<-- saveSession(token,cookies) ------------------->| + | |-- fetchMe(/me) ---------> api-v2 | + |<-- signed-in UI ----------| | | +``` + +### B. Tap to play + +``` +User SoundCloudScreen ViewModel Client/NewPipe PlayerViewModel + | | | | | + |-- tap track ---->|-- resolveStart --->|-- StreamInfo.getInfo-| | + | | |<-- stream URL -------| | + | |-- playSongs([one]) ------------------------------------------>| + | | | | ExoPlayer prepare | + | |-- prefetchQueue -->|-- resolve next… -----| | + | | |-- addSongToQueue -------------------------->| +``` + +### C. Signed-in likes + +``` +ViewModel SessionApi api-v2 + |-- loadMyLikes --------->|-- GET /me (cache id) ----------->| + | |-- GET /users/{id}/likes/tracks ->| + |<-- SearchHit list ------|<-- JSON collection --------------| + |-- toggleLike ---------->|-- GET /resolve?url= ------------>| + | |-- PUT /users/{id}/track_likes/X->| +``` + +--- + +## Appendix: “Bypass auth” in one paragraph + +PixelPlayer never obtains a developer OAuth client secret. It (1) uses the same +public web **`client_id`** the SoundCloud SPA uses, optionally scraped by NewPipe; +(2) optionally captures the user’s **browser session** (`oauth_token` + cookies) +via an in-app WebView login; and (3) calls the same **`api-v2`** JSON endpoints the +website calls, while NewPipe resolves permalinks into progressive or plain HLS +URLs for Media3. Sign-in unlocks personal libraries; **`client_id` is still +required to stream**. From ac1ac2a0729a6c565e5e49b2a56eb6d13dac2694 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Fri, 25 Sep 2026 13:56:45 +0300 Subject: [PATCH 30/77] fix: place update preference keys on MainActivity The release compile could not see the constants because they were nested in NavBarShapeCache. Co-authored-by: Cursor --- .../main/java/com/theveloper/pixelplay/MainActivity.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt index a3c3356133..68eddacf34 100644 --- a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt @@ -1268,7 +1268,10 @@ class MainActivity : ComponentActivity() { super.onResume() } - + private companion object { + const val UPDATE_PREFS = "pixelplay_updates" + const val UPDATE_DISMISSED_VERSION = "dismissed_release_version" + } } /** @@ -1333,11 +1336,6 @@ private class NavBarShapeCache { } return cached } - - private companion object { - const val UPDATE_PREFS = "pixelplay_updates" - const val UPDATE_DISMISSED_VERSION = "dismissed_release_version" - } } /** From 0061c7f2f8191ea6bbcc4811d999a3ea16debe4f Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Fri, 25 Sep 2026 14:09:47 +0300 Subject: [PATCH 31/77] chore: publish a follow-up release for update prompts Installed builds compare their version with the latest GitHub release. Co-authored-by: Cursor From 73cbf362de8a61d682aa92cbbb38510f5573a5d7 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Fri, 25 Sep 2026 14:13:59 +0300 Subject: [PATCH 32/77] feat(stats): badge integration tracks with a source logo Local library rows stay unmarked. SoundCloud and other streams show a small logo. Co-authored-by: Cursor --- .../data/stats/PlaybackStatsRepository.kt | 47 ++++++++++- .../presentation/screens/StatsScreen.kt | 80 ++++++++++++++++--- 2 files changed, 113 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt index bdd5cd4a70..3e53509658 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt @@ -70,7 +70,8 @@ class PlaybackStatsRepository @Inject constructor( val artist: String, val albumArtUri: String?, val totalDurationMs: Long, - val playCount: Int + val playCount: Int, + val origin: PlaybackOrigin? = null, ) data class ArtistPlaybackSummary( @@ -92,9 +93,41 @@ class PlaybackStatsRepository @Inject constructor( val albumArtUri: String?, val totalDurationMs: Long, val playCount: Int, - val uniqueSongs: Int + val uniqueSongs: Int, + val origin: PlaybackOrigin? = null, ) + enum class PlaybackOrigin { + SOUNDCLOUD, + TELEGRAM, + NETEASE, + QQ_MUSIC, + NAVIDROME, + JELLYFIN, + GOOGLE_DRIVE, + } + + private fun Song.statsOrigin(): PlaybackOrigin? { + val uri = contentUriString.lowercase() + val pathLower = path.lowercase() + val art = albumArtUriString.orEmpty().lowercase() + return when { + telegramChatId != null || telegramFileId != null || uri.startsWith("telegram://") -> + PlaybackOrigin.TELEGRAM + neteaseId != null || uri.startsWith("netease://") -> PlaybackOrigin.NETEASE + !qqMusicMid.isNullOrBlank() || uri.startsWith("qqmusic://") -> PlaybackOrigin.QQ_MUSIC + !navidromeId.isNullOrBlank() || uri.startsWith("navidrome://") -> PlaybackOrigin.NAVIDROME + !jellyfinId.isNullOrBlank() || uri.startsWith("jellyfin://") -> PlaybackOrigin.JELLYFIN + !gdriveFileId.isNullOrBlank() || uri.startsWith("gdrive://") -> PlaybackOrigin.GOOGLE_DRIVE + id.startsWith("sc_") || + genre.equals("SoundCloud", ignoreCase = true) || + pathLower.contains("pixelplayer/soundcloud") || + uri.contains("soundcloud") || + art.contains("sndcdn") -> PlaybackOrigin.SOUNDCLOUD + else -> null + } + } + data class TimelineEntry( val label: String, val totalDurationMs: Long, @@ -270,7 +303,8 @@ class PlaybackStatsRepository @Inject constructor( artist = artist, albumArtUri = song.albumArtUriString, totalDurationMs = segmentsForSong.sumOf { it.durationMs }, - playCount = segmentsForSong.size + playCount = segmentsForSong.size, + origin = song.statsOrigin(), ) } .sortedWith( @@ -395,12 +429,17 @@ class PlaybackStatsRepository @Inject constructor( .asSequence() .mapNotNull { songMap[it.key] } .firstOrNull() + val origin = groupedSongs + .map { songMap[it.key]?.statsOrigin() } + .distinct() + .singleOrNull() AlbumPlaybackSummary( album = album, albumArtUri = firstSong?.albumArtUriString, totalDurationMs = flattened.sumOf { it.durationMs }, playCount = flattened.size, - uniqueSongs = uniqueSongCount + uniqueSongs = uniqueSongCount, + origin = origin, ) } .sortedWith( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt index 0cbace0d4b..9b7d36df19 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt @@ -93,6 +93,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font @@ -2039,13 +2040,12 @@ private fun TopAlbumsCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - SmartImage( + StatsArtwork( model = albumSummary.albumArtUri, contentDescription = albumSummary.album, - modifier = Modifier - .size(56.dp) - .clip(RoundedCornerShape(16.dp)), - shape = RoundedCornerShape(16.dp) + size = 56.dp, + corner = 16.dp, + origin = albumSummary.origin, ) Column(modifier = Modifier.weight(1f)) { Text( @@ -2179,13 +2179,12 @@ private fun SongStatsCard( accentOnColor = accentOnColor, highlighted = position == 0 ) - SmartImage( + StatsArtwork( model = songSummary.albumArtUri, contentDescription = songSummary.title, - modifier = Modifier - .size(52.dp) - .clip(RoundedCornerShape(14.dp)), - shape = RoundedCornerShape(14.dp) + size = 52.dp, + corner = 14.dp, + origin = songSummary.origin, ) Column(modifier = Modifier.weight(1f)) { Text( @@ -2259,6 +2258,67 @@ private fun SongStatsCard( } } +@Composable +private fun StatsArtwork( + model: String?, + contentDescription: String, + size: androidx.compose.ui.unit.Dp, + corner: androidx.compose.ui.unit.Dp, + origin: PlaybackStatsRepository.PlaybackOrigin?, +) { + val shape = RoundedCornerShape(corner) + Box(modifier = Modifier.size(size)) { + SmartImage( + model = model, + contentDescription = contentDescription, + modifier = Modifier + .matchParentSize() + .clip(shape), + shape = shape, + ) + if (origin != null) { + Surface( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(2.dp) + .size(16.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.surface, + shadowElevation = 1.dp, + ) { + Icon( + painter = painterResource(origin.iconRes), + contentDescription = null, + modifier = Modifier.padding(2.dp), + tint = origin.logoTint, + ) + } + } + } +} + +private val PlaybackStatsRepository.PlaybackOrigin.iconRes: Int + get() = when (this) { + PlaybackStatsRepository.PlaybackOrigin.SOUNDCLOUD -> R.drawable.ic_soundcloud_24 + PlaybackStatsRepository.PlaybackOrigin.TELEGRAM -> R.drawable.telegram + PlaybackStatsRepository.PlaybackOrigin.NETEASE -> R.drawable.netease_cloud_music_logo_icon_206716__1_ + PlaybackStatsRepository.PlaybackOrigin.QQ_MUSIC -> R.drawable.qq_music + PlaybackStatsRepository.PlaybackOrigin.NAVIDROME -> R.drawable.ic_navidrome_md3 + PlaybackStatsRepository.PlaybackOrigin.JELLYFIN -> R.drawable.ic_jellyfin + PlaybackStatsRepository.PlaybackOrigin.GOOGLE_DRIVE -> R.drawable.rounded_drive_export_24 + } + +private val PlaybackStatsRepository.PlaybackOrigin.logoTint: Color + get() = when (this) { + PlaybackStatsRepository.PlaybackOrigin.SOUNDCLOUD -> Color(0xFFFF5500) + PlaybackStatsRepository.PlaybackOrigin.TELEGRAM -> Color(0xFF2AABEE) + PlaybackStatsRepository.PlaybackOrigin.NETEASE -> Color(0xFFE85959) + PlaybackStatsRepository.PlaybackOrigin.QQ_MUSIC -> Color(0xFF31C27C) + PlaybackStatsRepository.PlaybackOrigin.NAVIDROME -> Color.Unspecified + PlaybackStatsRepository.PlaybackOrigin.JELLYFIN -> Color.Unspecified + PlaybackStatsRepository.PlaybackOrigin.GOOGLE_DRIVE -> Color(0xFF4285F4) + } + @Composable private fun TrackConcentrationCard( summary: PlaybackStatsRepository.PlaybackStatsSummary?, From d5c4d0ea0077fc02662ea673f7e98d176c3d1304 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Fri, 25 Sep 2026 14:16:27 +0300 Subject: [PATCH 33/77] feat(soundcloud): like the current track from the player card The now-playing card shows a heart for SoundCloud streams. A signed-in listener can like or unlike that track. Co-authored-by: Cursor --- .../components/UnifiedPlayerSheetLayers.kt | 24 ++++++++++++ .../components/UnifiedPlayerSheetShared.kt | 38 +++++++++++++++++++ .../pixelplay/soundcloud/SoundCloudClient.kt | 24 +++++++++++- .../soundcloud/SoundCloudPrefsViewModel.kt | 18 +++++++++ .../soundcloud/SoundCloudViewModel.kt | 9 +++-- 5 files changed, 107 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt index 2aed352222..377a25164f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,6 +30,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.util.lerp import androidx.compose.ui.zIndex +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi import com.theveloper.pixelplay.data.model.Song @@ -39,6 +41,10 @@ import com.theveloper.pixelplay.presentation.components.scoped.rememberFullPlaye import com.theveloper.pixelplay.presentation.viewmodel.PlayerSheetState import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.presentation.viewmodel.StablePlayerState +import com.theveloper.pixelplay.soundcloud.SoundCloudPrefsViewModel +import android.widget.Toast +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.launch @OptIn(UnstableApi::class) @Composable @@ -118,6 +124,11 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( val isMiniPlayerVisible by remember { derivedStateOf { playerContentExpansionFraction.value < 0.01f } } + val soundCloudPrefs = hiltViewModel() + val likedPermalinks by soundCloudPrefs.likedPermalinks.collectAsStateWithLifecycle() + val soundCloudPermalink = soundCloudPrefs.permalinkFor(currentSongNonNull.id) + val context = LocalContext.current + val likeScope = rememberCoroutineScope() MiniPlayerContentInternal( song = currentSongNonNull, isPlaying = infrequentPlayerState.isPlaying, @@ -126,6 +137,19 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( onPlayPause = { playerViewModel.playPause() }, onPrevious = { playerViewModel.previousSong() }, onNext = { playerViewModel.nextSong() }, + soundCloudLiked = soundCloudPermalink != null && soundCloudPermalink in likedPermalinks, + onSoundCloudLike = if (soundCloudPermalink == null) { + null + } else { + { + likeScope.launch { + val error = soundCloudPrefs.toggleSoundCloudLike(currentSongNonNull.id) + if (!error.isNullOrBlank()) { + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + } + } + }, canScroll = isMiniPlayerVisible && infrequentPlayerState.isPlaying, modifier = Modifier.fillMaxSize() ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt index 59ae7c316d..e5983f9775 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt @@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.Pause import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.SkipNext @@ -40,11 +42,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.size.Size +import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.ui.theme.GoogleSansRounded @@ -69,6 +73,8 @@ internal fun MiniPlayerContentInternal( onPlayPause: () -> Unit, onPrevious: () -> Unit, onNext: () -> Unit, + soundCloudLiked: Boolean = false, + onSoundCloudLike: (() -> Unit)? = null, modifier: Modifier = Modifier, canScroll: Boolean = true ) { @@ -149,6 +155,38 @@ internal fun MiniPlayerContentInternal( } Spacer(modifier = Modifier.width(8.dp)) + if (onSoundCloudLike != null) { + val likeInteraction = remember(song.id) { MutableInteractionSource() } + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .clickable( + interactionSource = likeInteraction, + indication = miniPlayerIndication, + enabled = controlsEnabled, + ) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onSoundCloudLike() + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (soundCloudLiked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, + contentDescription = stringResource( + if (soundCloudLiked) R.string.soundcloud_unlike else R.string.soundcloud_like, + ), + tint = if (soundCloudLiked) { + LocalMaterialTheme.current.error + } else { + LocalMaterialTheme.current.onPrimaryContainer + }, + modifier = Modifier.size(22.dp), + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + Box( modifier = Modifier .size(36.dp) diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt index bd7fb21cf9..e9ceb37caa 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt @@ -21,6 +21,10 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update /** * SoundCloud access via NewPipe Extractor (web api-v2). @@ -36,6 +40,9 @@ class SoundCloudClient @Inject constructor( private val downloader = SoundCloudDownloader() private val runtimeClientId = AtomicReference(null) private val resolveCache = ConcurrentHashMap() + private val permalinkBySongId = ConcurrentHashMap() + private val _likedPermalinks = MutableStateFlow>(emptySet()) + val likedPermalinks: StateFlow> = _likedPermalinks.asStateFlow() private data class CachedResolve( val track: SoundCloudResolvedTrack, @@ -90,8 +97,19 @@ class SoundCloudClient @Inject constructor( fun loadMyPlaylists(limit: Int = 40): List = sessionApi.loadMyPlaylists(limit) @Throws(Exception::class) - fun setTrackLiked(trackUrl: String, liked: Boolean) = - sessionApi.setTrackLiked(trackUrl, liked) + fun setTrackLiked(trackUrl: String, liked: Boolean) { + val url = trackUrl.trim() + sessionApi.setTrackLiked(url, liked) + _likedPermalinks.update { current -> if (liked) current + url else current - url } + } + + fun rememberLikes(urls: Collection) { + val clean = urls.map { it.trim() }.filter { it.isNotEmpty() } + if (clean.isEmpty()) return + _likedPermalinks.update { it + clean } + } + + fun permalinkForSong(songId: String): String? = permalinkBySongId[songId] @Throws(Exception::class) fun loadDiscover(limit: Int = 30): List { @@ -267,6 +285,8 @@ class SoundCloudClient @Inject constructor( fun toSong(track: SoundCloudResolvedTrack, permalinkUrl: String = track.url): Song { // Prefer the list permalink so UI can match the now-playing row without resolving again. val id = songIdForUrl(permalinkUrl.ifBlank { track.url }) + val permalink = permalinkUrl.ifBlank { track.url }.trim() + if (permalink.isNotEmpty()) permalinkBySongId[id] = permalink return Song( id = id, title = track.title, diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt index ea2b720642..afc4a7d113 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt @@ -79,4 +79,22 @@ class SoundCloudPrefsViewModel @Inject constructor( sessionApi.updateSession(null, null, clientId.value) } } + + val likedPermalinks: StateFlow> = client.likedPermalinks + + fun permalinkFor(songId: String): String? = client.permalinkForSong(songId) + + /** + * Returns an error message when the like could not be saved. + */ + suspend fun toggleSoundCloudLike(songId: String): String? = withContext(Dispatchers.IO) { + val url = client.permalinkForSong(songId) + ?: return@withContext "This SoundCloud track can't be liked from here" + if (!client.hasSession) return@withContext "Sign in to SoundCloud to like tracks" + val shouldLike = url !in client.likedPermalinks.value + runCatching { client.setTrackLiked(url, shouldLike) } + .exceptionOrNull() + ?.message + ?.ifBlank { "Couldn't update the SoundCloud like" } + } } diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt index 2b5ccf9364..0220a70788 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt @@ -724,11 +724,12 @@ class SoundCloudViewModel @Inject constructor( client.loadUserLikes(_uiState.value.username, limit) } if (client.hasSession) { + val likedUrls = hits + .filter { it.kind == SoundCloudSearchHit.Kind.TRACK } + .map { it.url } + client.rememberLikes(likedUrls) _uiState.update { state -> - state.copy( - likedTrackUrls = state.likedTrackUrls + - hits.filter { it.kind == SoundCloudSearchHit.Kind.TRACK }.map { it.url }, - ) + state.copy(likedTrackUrls = state.likedTrackUrls + likedUrls) } } hits From fb3c82e72870c9fa2a0440351b49301799f1e0b9 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Fri, 25 Sep 2026 14:47:57 +0300 Subject: [PATCH 34/77] feat: check for updates from the changelog sheet Automatic checks run every 48 days. The changelog loads from this repo and can check immediately. Co-authored-by: Cursor --- .../com/theveloper/pixelplay/MainActivity.kt | 54 ++- .../data/ai/provider/GenericOpenAiClient.kt | 2 +- .../GitHubAnnouncementPropertiesService.kt | 4 +- .../data/github/GitHubContributorService.kt | 4 +- .../components/BetaInfoBottomSheet.kt | 4 +- .../components/ChangelogBottomSheet.kt | 308 ++++++++++-------- .../presentation/screens/AboutScreen.kt | 2 +- .../presentation/screens/HomeScreen.kt | 2 +- .../main/res/values/strings_changelogs.xml | 3 + 9 files changed, 235 insertions(+), 148 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt index 68eddacf34..722b595029 100644 --- a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt @@ -10,6 +10,8 @@ import android.content.Context import android.content.ComponentName import android.content.Intent import android.os.Build +import android.os.SystemClock +import android.widget.Toast import java.io.File import android.graphics.RenderEffect as AndroidRenderEffect import android.graphics.Shader as AndroidShader @@ -66,10 +68,14 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.lifecycleScope import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -117,7 +123,7 @@ import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository import com.theveloper.pixelplay.data.service.MusicService import com.theveloper.pixelplay.data.worker.SyncManager import com.theveloper.pixelplay.data.worker.SyncProgress -import com.theveloper.pixelplay.presentation.components.AllFilesAccessDialog +import com.theveloper.pixelplay.presentation.components.LocalRequestAppUpdateCheck import com.theveloper.pixelplay.presentation.components.AppSidebarDrawer import com.theveloper.pixelplay.presentation.components.CrashReportDialog import com.theveloper.pixelplay.presentation.components.DismissUndoBar @@ -146,6 +152,7 @@ import com.theveloper.pixelplay.utils.LogUtils import dagger.hilt.android.AndroidEntryPoint import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject @@ -816,15 +823,36 @@ class MainActivity : ComponentActivity() { } } - LaunchedEffect(Unit) { + var lastUpdateCheckElapsed by remember { mutableLongStateOf(0L) } + val lifecycleOwner = LocalLifecycleOwner.current + + suspend fun refreshAvailableUpdate(force: Boolean = false) { val installed = BuildConfig.VERSION_NAME.trim().removePrefix("v") - if (installed.isEmpty() || installed == "1.0.0") return@LaunchedEffect + if (!force && (installed.isEmpty() || installed == "1.0.0")) return + if (showUpdateAnnouncement || updateDownloadBusy) return + val now = SystemClock.elapsedRealtime() + if (!force && + lastUpdateCheckElapsed != 0L && + now - lastUpdateCheckElapsed < UPDATE_CHECK_INTERVAL_MS + ) { + return + } releaseUpdateService.fetchLatestRelease() .onSuccess { latest -> - if (latest.version == installed) return@onSuccess + lastUpdateCheckElapsed = SystemClock.elapsedRealtime() + if (latest.version == installed) { + if (force) { + Toast.makeText( + this@MainActivity, + getString(R.string.changelog_up_to_date), + Toast.LENGTH_SHORT, + ).show() + } + return@onSuccess + } val dismissed = getSharedPreferences(UPDATE_PREFS, MODE_PRIVATE) .getString(UPDATE_DISMISSED_VERSION, null) - if (dismissed == latest.version) return@onSuccess + if (!force && dismissed == latest.version) return@onSuccess pendingUpdateVersion = latest.version updateAnnouncement = PlayStoreAnnouncementUiModel( enabled = true, @@ -845,6 +873,15 @@ class MainActivity : ComponentActivity() { } } + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (isActive) { + refreshAvailableUpdate() + delay(UPDATE_CHECK_INTERVAL_MS) + } + } + } + LaunchedEffect(userPreferencesRepository) { userPreferencesRepository.clearDeprecatedPlayerSheetPreference() } @@ -1041,6 +1078,11 @@ class MainActivity : ComponentActivity() { } } ) { + CompositionLocalProvider( + LocalRequestAppUpdateCheck provides { + scope.launch { refreshAvailableUpdate(force = true) } + }, + ) { AppNavigation( playerViewModel = playerViewModel, navController = navController, @@ -1049,6 +1091,7 @@ class MainActivity : ComponentActivity() { onSearchBarActiveChange = { isSearchBarActive = it }, onOpenSidebar = { scope.launch { drawerState.open() } } ) + } } val isExpandedOrExpanding by remember { @@ -1271,6 +1314,7 @@ class MainActivity : ComponentActivity() { private companion object { const val UPDATE_PREFS = "pixelplay_updates" const val UPDATE_DISMISSED_VERSION = "dismissed_release_version" + const val UPDATE_CHECK_INTERVAL_MS = 48L * 24 * 60 * 60 * 1000 } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt index fd0fb1c1d8..18a64f98e7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt @@ -96,7 +96,7 @@ class GenericOpenAiClient( .addHeader("Content-Type", "application/json") if (providerName.equals("OpenRouter", ignoreCase = true)) { - requestBuilder.addHeader("HTTP-Referer", "https://github.com/theovilardo/PixelPlayer") + requestBuilder.addHeader("HTTP-Referer", "https://github.com/kibetmasi/PixelPlayer") requestBuilder.addHeader("X-Title", "PixelPlayer") } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubAnnouncementPropertiesService.kt b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubAnnouncementPropertiesService.kt index d540ad1cae..fd3719481e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubAnnouncementPropertiesService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubAnnouncementPropertiesService.kt @@ -36,8 +36,8 @@ class GitHubAnnouncementPropertiesService @Inject constructor() { * - play_store_link_pending_message */ suspend fun fetchPlayStoreAnnouncement( - owner: String = "theovilardo", - repo: String = "PixelPlay", + owner: String = "kibetmasi", + repo: String = "PixelPlayer", branch: String = "master", configPath: String = "remote-config/app-announcements.properties", ): Result { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubContributorService.kt b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubContributorService.kt index 7f963aed68..4d84e78667 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubContributorService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubContributorService.kt @@ -25,8 +25,8 @@ class GitHubContributorService @Inject constructor() { * Fetches contributors from the GitHub repository */ suspend fun fetchContributors( - owner: String = "theovilardo", - repo: String = "PixelPlay" + owner: String = "kibetmasi", + repo: String = "PixelPlayer" ): Result> { return withContext(Dispatchers.IO) { try { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/BetaInfoBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/BetaInfoBottomSheet.kt index 0370ac54df..e7794a1d84 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/BetaInfoBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/BetaInfoBottomSheet.kt @@ -71,8 +71,8 @@ import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape @Composable fun BetaInfoBottomSheet(modifier: Modifier = Modifier) { val context = LocalContext.current - val issuesUrl = "https://github.com/theovilardo/PixelPlayer/issues" - val reportUrl = "https://github.com/theovilardo/PixelPlayer/issues/new/choose" + val issuesUrl = "https://github.com/kibetmasi/PixelPlayer/issues" + val reportUrl = "https://github.com/kibetmasi/PixelPlayer/issues/new/choose" val fabCornerRadius = 18.dp diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt index d9f7de83d0..2688ec22e5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt @@ -1,10 +1,8 @@ -package com.theveloper.pixelplay.presentation.components +package com.theveloper.pixelplay.presentation.components import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import androidx.annotation.ArrayRes -import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -20,27 +18,34 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.MediumExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumExtendedFloatingActionButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import androidx.core.net.toUri @@ -48,111 +53,61 @@ import com.theveloper.pixelplay.R import com.theveloper.pixelplay.presentation.components.subcomps.SineWaveLine import com.theveloper.pixelplay.ui.theme.ExpTitleTypography import com.theveloper.pixelplay.ui.theme.GoogleSansRounded +import java.net.HttpURLConnection +import java.net.URL +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape +val LocalRequestAppUpdateCheck = staticCompositionLocalOf<() -> Unit> { {} } + +private const val CHANGELOG_RAW_URL = + "https://raw.githubusercontent.com/kibetmasi/PixelPlayer/master/CHANGELOG.md" +private const val CHANGELOG_PAGE_URL = + "https://github.com/kibetmasi/PixelPlayer/blob/master/CHANGELOG.md" + data class ChangelogSection( - @StringRes val titleRes: Int, - @ArrayRes val itemsRes: Int + val title: String, + val items: List, ) data class ChangelogVersion( val version: String, val date: String, - val sections: List -) - -@Composable -private fun changelogVersions(): List = listOf( - ChangelogVersion( - version = "0.7.5-beta", - date = "2026-06-13", - sections = listOf( - ChangelogSection(R.string.changelog_sec_whats_new, R.array.changelog_075_whats_new), - ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_075_improvements), - ChangelogSection(R.string.changelog_sec_fixes, R.array.changelog_075_fixes) - ) - ), - ChangelogVersion( - version = "0.7.0-beta", - date = "2026-05-25", - sections = listOf( - ChangelogSection(R.string.changelog_sec_whats_new, R.array.changelog_070_whats_new), - ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_070_improvements), - ChangelogSection(R.string.changelog_sec_fixes, R.array.changelog_070_fixes), - ChangelogSection(R.string.changelog_sec_added, R.array.changelog_070_added) - ) - ), - ChangelogVersion( - version = "0.6.0-beta", - date = "2026-03-05", - sections = listOf( - ChangelogSection(R.string.changelog_sec_whats_new, R.array.changelog_060_whats_new), - ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_060_improvements), - ChangelogSection(R.string.changelog_sec_fixes, R.array.changelog_060_fixes) - ) - ), - ChangelogVersion( - version = "0.5.0-beta", - date = "2026-01-14", - sections = listOf( - ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_050_improvements), - ChangelogSection(R.string.changelog_sec_fixes, R.array.changelog_050_fixes) - ) - ), - ChangelogVersion( - version = "0.4.0-beta", - date = "2025-12-15", - sections = listOf( - ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_040_improvements) - ) - ), - ChangelogVersion( - version = "0.3.0-beta", - date = "2025-10-28", - sections = listOf( - ChangelogSection(R.string.changelog_sec_whats_new, R.array.changelog_030_whats_new), - ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_030_improvements), - ChangelogSection(R.string.changelog_sec_fixes, R.array.changelog_030_fixes) - ) - ), - ChangelogVersion( - version = "0.2.0-beta", - date = "2024-09-15", - sections = listOf( - ChangelogSection(R.string.changelog_sec_added, R.array.changelog_020_added), - ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_020_improvements), - ChangelogSection(R.string.changelog_sec_fixes, R.array.changelog_020_fixes) - ) - ) + val sections: List, ) @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChangelogBottomSheet( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onDismiss: () -> Unit = {}, ) { val context = LocalContext.current - val changelogUrl = "https://github.com/kibetmasi/PixelPlayer/blob/master/CHANGELOG.md" - val changelog = changelogVersions() - + val requestUpdateCheck = LocalRequestAppUpdateCheck.current + var changelog by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } val fabCornerRadius = 16.dp + LaunchedEffect(Unit) { + changelog = withContext(Dispatchers.IO) { fetchRepoChangelog() } + loading = false + } + Box(modifier = modifier.fillMaxWidth()) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 0.dp), - horizontalAlignment = Alignment.CenterHorizontally + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = stringResource(R.string.changelog_title), fontFamily = GoogleSansRounded, style = ExpTitleTypography.displaySmall, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) - Spacer(modifier = Modifier.height(16.dp)) - SineWaveLine( modifier = Modifier .fillMaxWidth() @@ -166,24 +121,55 @@ fun ChangelogBottomSheet( strokeWidth = 4.dp, amplitude = 4.dp, waves = 7.6f, - phase = 0f + phase = 0f, ) - LazyColumn( modifier = Modifier .fillMaxWidth() .padding(top = 16.dp), verticalArrangement = Arrangement.spacedBy(24.dp), - contentPadding = PaddingValues(bottom = 120.dp) + contentPadding = PaddingValues(bottom = 120.dp), ) { - items(changelog, key = { it.version }) { version -> - ChangelogVersionItem(version = version) + item(key = "check_for_updates") { + FilledTonalButton( + onClick = { + onDismiss() + requestUpdateCheck() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.changelog_check_updates)) + } + } + if (loading) { + item(key = "loading") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } else if (changelog.isEmpty()) { + item(key = "empty") { + Text( + text = stringResource(R.string.changelog_unavailable), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + items(changelog, key = { it.version + it.date }) { version -> + ChangelogVersionItem(version = version) + } } } } MediumExtendedFloatingActionButton( - onClick = { openUrl(context, changelogUrl) }, + onClick = { openUrl(context, CHANGELOG_PAGE_URL) }, shape = AbsoluteSmoothCornerShape( cornerRadiusBR = fabCornerRadius, smoothnessAsPercentBR = 60, @@ -192,20 +178,20 @@ fun ChangelogBottomSheet( cornerRadiusTR = fabCornerRadius, smoothnessAsPercentTR = 60, cornerRadiusTL = fabCornerRadius, - smoothnessAsPercentTL = 60 + smoothnessAsPercentTL = 60, ), containerColor = MaterialTheme.colorScheme.tertiaryContainer, contentColor = MaterialTheme.colorScheme.onTertiaryContainer, icon = { Icon( painter = painterResource(id = R.drawable.github), - contentDescription = null + contentDescription = null, ) }, text = { Text(text = stringResource(R.string.changelog_view_github)) }, modifier = Modifier .align(Alignment.BottomEnd) - .padding(horizontal = 24.dp, vertical = 24.dp) + .padding(horizontal = 24.dp, vertical = 24.dp), ) Box( @@ -217,13 +203,11 @@ fun ChangelogBottomSheet( brush = Brush.verticalGradient( listOf( Color.Transparent, - MaterialTheme.colorScheme.surfaceContainerLow - ) - ) - ) - ) { - - } + MaterialTheme.colorScheme.surfaceContainerLow, + ), + ), + ), + ) } } @@ -231,7 +215,7 @@ fun ChangelogBottomSheet( fun ChangelogVersionItem(version: ChangelogVersion) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { VersionBadge(versionNumber = version.version) @@ -239,10 +223,9 @@ fun ChangelogVersionItem(version: ChangelogVersion) { Text( text = version.date, style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { version.sections.forEach { section -> ChangelogCategory(section = section) @@ -253,35 +236,33 @@ fun ChangelogVersionItem(version: ChangelogVersion) { @Composable fun ChangelogCategory(section: ChangelogSection) { - val items = stringArrayResource(section.itemsRes).toList() Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = RoundedCornerShape(22.dp), - tonalElevation = 6.dp + tonalElevation = 6.dp, ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 20.dp, vertical = 18.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = stringResource(section.titleRes), + text = section.title, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) - - items.forEachIndexed { index, item -> + section.items.forEachIndexed { index, item -> Row( verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Box( modifier = Modifier .padding(top = 6.dp) .size(8.dp) - .background(MaterialTheme.colorScheme.primary, CircleShape) + .background(MaterialTheme.colorScheme.primary, CircleShape), ) val linkColor = MaterialTheme.colorScheme.primary val annotatedText = buildAnnotatedString { @@ -293,30 +274,25 @@ fun ChangelogCategory(section: ChangelogSection) { withLink( LinkAnnotation.Url( url = "https://github.com/$username", - styles = TextLinkStyles( - style = SpanStyle(color = linkColor) - ) - ) + styles = TextLinkStyles(style = SpanStyle(color = linkColor)), + ), ) { append(match.value) } lastIndex = match.range.last + 1 } - if (lastIndex < item.length) { - append(item.substring(lastIndex)) - } + if (lastIndex < item.length) append(item.substring(lastIndex)) } Text( text = annotatedText, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) } - - if (index != items.lastIndex) { + if (index != section.items.lastIndex) { HorizontalDivider( modifier = Modifier.padding(vertical = 10.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f) + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), ) } } @@ -325,30 +301,94 @@ fun ChangelogCategory(section: ChangelogSection) { } @Composable -fun VersionBadge( - versionNumber: String -) { +fun VersionBadge(versionNumber: String) { Box( - modifier = Modifier - .background( - color = MaterialTheme.colorScheme.primaryContainer, - shape = CircleShape - ) + modifier = Modifier.background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), ) { Text( modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp), text = versionNumber, color = MaterialTheme.colorScheme.onPrimaryContainer, style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) } } +private fun fetchRepoChangelog(): List { + var connection: HttpURLConnection? = null + return try { + connection = (URL(CHANGELOG_RAW_URL).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = 10_000 + readTimeout = 10_000 + setRequestProperty("User-Agent", "PixelPlayer") + setRequestProperty("Accept", "text/plain") + } + if (connection.responseCode != HttpURLConnection.HTTP_OK) return emptyList() + connection.inputStream.bufferedReader().use { parseChangelog(it.readText()) } + } catch (_: Exception) { + emptyList() + } finally { + connection?.disconnect() + } +} + +internal fun parseChangelog(markdown: String): List { + val versions = mutableListOf() + var version: String? = null + var date = "" + val sections = mutableListOf() + var sectionTitle: String? = null + val items = mutableListOf() + val heading = Regex("""^## \[(.+)](?:\s*-\s*(.*))?$""") + + fun flushSection() { + val title = sectionTitle ?: return + if (items.isNotEmpty()) sections += ChangelogSection(title, items.toList()) + items.clear() + sectionTitle = null + } + + fun flushVersion() { + flushSection() + val name = version ?: return + if (sections.isNotEmpty()) versions += ChangelogVersion(name, date, sections.toList()) + sections.clear() + version = null + date = "" + } + + markdown.lineSequence().forEach { raw -> + val line = raw.trim() + val match = heading.matchEntire(line) + when { + match != null -> { + flushVersion() + val name = match.groupValues[1].trim() + if (!name.equals("Unreleased", ignoreCase = true)) { + version = name + date = match.groupValues.getOrNull(2)?.trim().orEmpty() + } + } + version != null && line.startsWith("### ") -> { + flushSection() + sectionTitle = line.removePrefix("### ").trim() + } + version != null && sectionTitle != null && line.startsWith("- ") -> { + items += line.removePrefix("- ").trim() + } + } + } + flushVersion() + return versions +} + private fun openUrl(context: Context, url: String) { - val uri = try { url.toUri() } catch (_: Throwable) { url.toUri() } - val intent = Intent(Intent.ACTION_VIEW, uri) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val intent = Intent(Intent.ACTION_VIEW, url.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) try { context.startActivity(intent) } catch (_: ActivityNotFoundException) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt index f1050c4b3d..70a40641e3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt @@ -663,7 +663,7 @@ private fun AboutHeroCard( subtitle = stringResource(R.string.about_github_subtitle), iconRes = R.drawable.github, contentDescription = stringResource(R.string.about_cd_open_github_repo), - onClick = { openUrl(context, "https://github.com/theovilardo/PixelPlayer") }, + onClick = { openUrl(context, "https://github.com/kibetmasi/PixelPlayer") }, modifier = Modifier.weight(1f), ) SocialChip( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt index cf3f905e51..fb3fbb858b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt @@ -538,7 +538,7 @@ fun HomeScreen( onDismissRequest = { showChangelogBottomSheet = false }, sheetState = sheetState ) { - ChangelogBottomSheet() + ChangelogBottomSheet(onDismiss = { showChangelogBottomSheet = false }) } } if (showBetaInfoBottomSheet) { diff --git a/app/src/main/res/values/strings_changelogs.xml b/app/src/main/res/values/strings_changelogs.xml index 21e8758403..c1b3757428 100644 --- a/app/src/main/res/values/strings_changelogs.xml +++ b/app/src/main/res/values/strings_changelogs.xml @@ -2,6 +2,9 @@ Changelog View on GitHub + Check for updates + You are on the latest release. + Couldn't load the changelog. View it on GitHub. Improvements Fixes What\'s New From d58f4bca7fb763759fde4790d539d312ab6d5529 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Fri, 25 Sep 2026 14:55:10 +0300 Subject: [PATCH 35/77] fix(soundcloud): refresh expired stream links after restart Save each track permalink with the queue. Ask SoundCloud for a new link when the saved stream no longer plays. Co-authored-by: Cursor --- .../data/model/PlaybackQueueSnapshot.kt | 1 + .../pixelplay/data/service/MusicService.kt | 112 ++++++++++++++---- .../pixelplay/soundcloud/SoundCloudClient.kt | 15 ++- .../pixelplay/utils/MediaItemBuilder.kt | 28 ++++- 4 files changed, 123 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/model/PlaybackQueueSnapshot.kt b/app/src/main/java/com/theveloper/pixelplay/data/model/PlaybackQueueSnapshot.kt index 9906d3ce4f..e355e6d654 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/model/PlaybackQueueSnapshot.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/model/PlaybackQueueSnapshot.kt @@ -11,6 +11,7 @@ data class PlaybackQueueItemSnapshot( val albumTitle: String? = null, val artworkUri: String? = null, val durationMs: Long? = null, + val soundCloudPermalink: String? = null, ) @Serializable diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index f084bf7826..860248bd55 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -52,11 +52,14 @@ import com.theveloper.pixelplay.data.preferences.EqualizerPreferencesRepository import com.theveloper.pixelplay.data.preferences.ThemePreferencesRepository import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.soundcloud.SoundCloudClient import com.theveloper.pixelplay.data.service.player.DualPlayerEngine import com.theveloper.pixelplay.data.service.player.TransitionController import com.theveloper.pixelplay.ui.glancewidget.PlayerActions import com.theveloper.pixelplay.utils.AlbumArtUtils import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -168,6 +171,8 @@ class MusicService : MediaLibraryService() { @Inject lateinit var listeningStatsTracker: ListeningStatsTracker @Inject + lateinit var soundCloudClient: SoundCloudClient + @Inject @AppScope lateinit var appScope: CoroutineScope @@ -199,6 +204,7 @@ class MusicService : MediaLibraryService() { private var countedOriginalId: String? = null private var countedPlayListener: Player.Listener? = null private var consecutivePlaybackErrorSkips = 0 + private val soundCloudRefreshAttempted = mutableSetOf() private val alarmManager by lazy { getSystemService(Context.ALARM_SERVICE) as AlarmManager } @@ -1340,6 +1346,8 @@ class MusicService : MediaLibraryService() { Timber.tag(TAG).d("Playback state changed: $playbackState") if (playbackState == Player.STATE_READY) { consecutivePlaybackErrorSkips = 0 + (mediaSession?.player ?: engine.masterPlayer).currentMediaItem?.mediaId + ?.let { soundCloudRefreshAttempted.remove(it) } } if (playbackState == Player.STATE_ENDED) { listeningStatsTracker.finalizeCurrentSession() @@ -1484,31 +1492,28 @@ class MusicService : MediaLibraryService() { override fun onPlayerError(error: PlaybackException) { val player = mediaSession?.player ?: engine.masterPlayer - val nextIndex = player.nextMediaItemIndex - val canSkipToAnother = nextIndex != C.INDEX_UNSET && nextIndex != player.currentMediaItemIndex - if (canSkipToAnother && consecutivePlaybackErrorSkips < MAX_PLAYBACK_ERROR_SKIPS) { - consecutivePlaybackErrorSkips++ - Timber.tag(TAG).w( - error, - "Skipping unplayable item (%d)", - consecutivePlaybackErrorSkips, - ) - player.seekToNextMediaItem() - player.prepare() - player.play() + val currentItem = player.currentMediaItem + val permalink = soundCloudPermalink(currentItem) + val mediaId = currentItem?.mediaId.orEmpty() + if (currentItem != null && !permalink.isNullOrBlank() && soundCloudRefreshAttempted.add(mediaId)) { + serviceScope.launch { + val refreshed = refreshSoundCloudItem(currentItem, permalink) + withContext(Dispatchers.Main.immediate) { + val sameTrack = player.currentMediaItem?.mediaId == mediaId + val oldUri = currentItem.localConfiguration?.uri + val newUri = refreshed.localConfiguration?.uri + if (sameTrack && newUri != null && newUri != oldUri) { + player.replaceMediaItem(player.currentMediaItemIndex, refreshed) + player.prepare() + player.play() + } else { + skipUnplayableItem(player, error) + } + } + } return } - consecutivePlaybackErrorSkips = 0 - Timber.tag(TAG).e(error, "Error en el reproductor: ") - serviceScope.launch { - val currentMediaItem = mediaSession?.player?.currentMediaItem - val trackTitle = currentMediaItem?.mediaMetadata?.title?.toString() - ?: currentMediaItem?.mediaId - ?: getString(R.string.common_unknown_track) - val errorMessage = error.localizedMessage ?: error.message ?: "Unknown error" - val toastMessage = getString(R.string.player_playback_error, "$trackTitle ($errorMessage)") - android.widget.Toast.makeText(this@MusicService, toastMessage, android.widget.Toast.LENGTH_LONG).show() - } + skipUnplayableItem(player, error) } } @@ -1733,6 +1738,8 @@ class MusicService : MediaLibraryService() { albumTitle = metadata.albumTitle?.toString(), artworkUri = resolveStoredArtworkUriString(metadata), durationMs = durationMs, + soundCloudPermalink = metadata.extras?.getString(SoundCloudClient.EXTRA_PERMALINK) + ?: SoundCloudClient.permalinkForSongId(mediaItem.mediaId), ) ) } @@ -1804,7 +1811,7 @@ class MusicService : MediaLibraryService() { else -> 0 } - val preparedItems = restoredItems.toMutableList() + val preparedItems = refreshSoundCloudQueue(restoredItems).toMutableList() preparedItems.getOrNull(resolvedIndex)?.let { currentItem -> val resolvedCurrentItem = runCatching { engine.resolveMediaItem(currentItem) }.getOrNull() if (resolvedCurrentItem != null && resolvedCurrentItem != currentItem) { @@ -1884,6 +1891,10 @@ class MusicService : MediaLibraryService() { snapshotItem.durationMs?.takeIf { it > 0L }?.let { putLong(MediaItemBuilder.EXTERNAL_EXTRA_DURATION, it) } + snapshotItem.soundCloudPermalink?.takeIf { it.isNotBlank() }?.let { permalink -> + putString(SoundCloudClient.EXTRA_PERMALINK, permalink) + SoundCloudClient.rememberPermalink(snapshotItem.mediaId, permalink) + } } metadataBuilder.setExtras(extras) @@ -1892,6 +1903,59 @@ class MusicService : MediaLibraryService() { .setUri(MediaItemBuilder.playbackUri(snapshotItem.uri)) .setMediaMetadata(metadataBuilder.build()) .build() + .let(MediaItemBuilder::withSoundCloudPermalink) + } + + private fun soundCloudPermalink(item: MediaItem?): String? { + if (item == null) return null + if (item.mediaId.startsWith("sc_dl_")) return null + return item.mediaMetadata.extras?.getString(SoundCloudClient.EXTRA_PERMALINK) + ?: SoundCloudClient.permalinkForSongId(item.mediaId) + } + + private suspend fun refreshSoundCloudQueue(items: List): List = coroutineScope { + items.map { item -> + async(Dispatchers.IO) { + val permalink = soundCloudPermalink(item) ?: return@async item + refreshSoundCloudItem(item, permalink) + } + }.awaitAll() + } + + private suspend fun refreshSoundCloudItem(item: MediaItem, permalink: String): MediaItem = + withContext(Dispatchers.IO) { + val streamUrl = runCatching { soundCloudClient.resolveTrack(permalink).streamUrl }.getOrNull() + if (streamUrl.isNullOrBlank()) return@withContext item + val refreshed = item.buildUpon().setUri(streamUrl).build() + MediaItemBuilder.withSoundCloudPermalink(refreshed) + } + + private fun skipUnplayableItem(player: Player, error: PlaybackException) { + val nextIndex = player.nextMediaItemIndex + val canSkipToAnother = nextIndex != C.INDEX_UNSET && nextIndex != player.currentMediaItemIndex + if (canSkipToAnother && consecutivePlaybackErrorSkips < MAX_PLAYBACK_ERROR_SKIPS) { + consecutivePlaybackErrorSkips++ + Timber.tag(TAG).w( + error, + "Skipping unplayable item (%d)", + consecutivePlaybackErrorSkips, + ) + player.seekToNextMediaItem() + player.prepare() + player.play() + return + } + consecutivePlaybackErrorSkips = 0 + Timber.tag(TAG).e(error, "Error en el reproductor: ") + serviceScope.launch { + val currentMediaItem = mediaSession?.player?.currentMediaItem + val trackTitle = currentMediaItem?.mediaMetadata?.title?.toString() + ?: currentMediaItem?.mediaId + ?: getString(R.string.common_unknown_track) + val errorMessage = error.localizedMessage ?: error.message ?: "Unknown error" + val toastMessage = getString(R.string.player_playback_error, "$trackTitle ($errorMessage)") + android.widget.Toast.makeText(this@MusicService, toastMessage, android.widget.Toast.LENGTH_LONG).show() + } } private fun getOpenAppPendingIntent(): PendingIntent { diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt index e9ceb37caa..32f56752a4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt @@ -40,7 +40,6 @@ class SoundCloudClient @Inject constructor( private val downloader = SoundCloudDownloader() private val runtimeClientId = AtomicReference(null) private val resolveCache = ConcurrentHashMap() - private val permalinkBySongId = ConcurrentHashMap() private val _likedPermalinks = MutableStateFlow>(emptySet()) val likedPermalinks: StateFlow> = _likedPermalinks.asStateFlow() @@ -109,7 +108,7 @@ class SoundCloudClient @Inject constructor( _likedPermalinks.update { it + clean } } - fun permalinkForSong(songId: String): String? = permalinkBySongId[songId] + fun permalinkForSong(songId: String): String? = permalinkForSongId(songId) @Throws(Exception::class) fun loadDiscover(limit: Int = 30): List { @@ -286,7 +285,7 @@ class SoundCloudClient @Inject constructor( // Prefer the list permalink so UI can match the now-playing row without resolving again. val id = songIdForUrl(permalinkUrl.ifBlank { track.url }) val permalink = permalinkUrl.ifBlank { track.url }.trim() - if (permalink.isNotEmpty()) permalinkBySongId[id] = permalink + if (permalink.isNotEmpty()) rememberPermalink(id, permalink) return Song( id = id, title = track.title, @@ -477,9 +476,19 @@ class SoundCloudClient @Inject constructor( private fun soundCloud(): SoundcloudService = ServiceList.SoundCloud companion object { + const val EXTRA_PERMALINK = "com.theveloper.pixelplay.soundcloud.PERMALINK" + private val permalinkBySongId = ConcurrentHashMap() private val initialized = AtomicBoolean(false) private const val RESOLVE_CACHE_TTL_MS = 15 * 60 * 1000L + fun rememberPermalink(songId: String, permalink: String) { + val id = songId.trim() + val url = permalink.trim() + if (id.isNotEmpty() && url.startsWith("http")) permalinkBySongId[id] = url + } + + fun permalinkForSongId(songId: String): String? = permalinkBySongId[songId] + fun songIdForUrl(url: String): String = "sc_${url.trim().hashCode().toUInt()}" diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt b/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt index 48a8e1bcea..4952a2d65b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt @@ -13,6 +13,7 @@ import androidx.media3.common.MediaMetadata.PICTURE_TYPE_FRONT_COVER import androidx.media3.common.util.UnstableApi import com.theveloper.pixelplay.data.provider.SharedArtworkContentProvider import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.soundcloud.SoundCloudClient import java.io.File object MediaItemBuilder { @@ -99,12 +100,14 @@ object MediaItemBuilder { const val EXTERNAL_EXTRA_NAVIDROME_ID = EXTERNAL_EXTRA_PREFIX + "NAVIDROME_ID" fun build(song: Song): MediaItem { - return MediaItem.Builder() - .setMediaId(song.id) - .setUri(playbackUri(song)) - .setMimeType(playbackMimeType(song)) - .setMediaMetadata(buildMediaMetadataForSong(song)) - .build() + return withSoundCloudPermalink( + MediaItem.Builder() + .setMediaId(song.id) + .setUri(playbackUri(song)) + .setMimeType(playbackMimeType(song)) + .setMediaMetadata(buildMediaMetadataForSong(song)) + .build(), + ) } fun buildForExternalController(context: Context, song: Song): MediaItem { @@ -124,9 +127,22 @@ object MediaItemBuilder { ) ) .build() + .let(::withSoundCloudPermalink) } } + fun withSoundCloudPermalink(item: MediaItem): MediaItem { + val permalink = item.mediaMetadata.extras?.getString(SoundCloudClient.EXTRA_PERMALINK) + ?: SoundCloudClient.permalinkForSongId(item.mediaId) + ?: return item + val metadata = item.mediaMetadata + val extras = Bundle(metadata.extras ?: Bundle()) + extras.putString(SoundCloudClient.EXTRA_PERMALINK, permalink) + return item.buildUpon() + .setMediaMetadata(metadata.buildUpon().setExtras(extras).build()) + .build() + } + fun playbackUri(song: Song): Uri = playbackUri( contentUriString = song.contentUriString, filePath = song.path, From 50bbdc3e791959ef62a36cd47a0102ad1005cc31 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Fri, 25 Sep 2026 14:58:29 +0300 Subject: [PATCH 36/77] fix: escape the changelog unavailable string An unescaped apostrophe stopped the release resource compile. Co-authored-by: Cursor --- app/src/main/res/values/strings_changelogs.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings_changelogs.xml b/app/src/main/res/values/strings_changelogs.xml index c1b3757428..c6cdcf56f0 100644 --- a/app/src/main/res/values/strings_changelogs.xml +++ b/app/src/main/res/values/strings_changelogs.xml @@ -4,7 +4,7 @@ View on GitHub Check for updates You are on the latest release. - Couldn't load the changelog. View it on GitHub. + Couldn\'t load the changelog. View it on GitHub. Improvements Fixes What\'s New From 4bfd6beb0b646086537b97f2a227752fcf753fe8 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Fri, 25 Sep 2026 15:02:55 +0300 Subject: [PATCH 37/77] fix: import awaitAll for SoundCloud stream refresh The release compile could not resolve the queue refresh call. Co-authored-by: Cursor --- .../java/com/theveloper/pixelplay/data/service/MusicService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index 860248bd55..c757df7121 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -59,6 +59,7 @@ import com.theveloper.pixelplay.ui.glancewidget.PlayerActions import com.theveloper.pixelplay.utils.AlbumArtUtils import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers From 469e199ab883151b2a235b43230248b43252a305 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 12:47:39 +0300 Subject: [PATCH 38/77] feat: play YouTube Music with feed, radio, and likes Document Google sign-in and the YouTube tabs so a master push can publish the release APK. Co-authored-by: Cursor --- .github/workflows/build-app.yml | 41 + README.md | 78 +- app/src/main/AndroidManifest.xml | 7 + .../com/theveloper/pixelplay/MainActivity.kt | 17 +- .../data/model/PlaybackQueueSnapshot.kt | 1 + .../pixelplay/data/service/MusicService.kt | 59 +- .../components/UnifiedPlayerSheetLayers.kt | 31 +- .../components/UnifiedPlayerSheetShared.kt | 43 +- .../presentation/navigation/AppNavigation.kt | 82 +- .../presentation/navigation/MainRootRoutes.kt | 12 +- .../presentation/navigation/Screen.kt | 4 + .../presentation/screens/SettingsScreen.kt | 24 +- .../presentation/screens/SetupScreen.kt | 5 +- .../presentation/screens/YoutubeScreen.kt | 725 ++++++++++++++++++ .../screens/YoutubeSettingsScreen.kt | 99 +++ .../presentation/viewmodel/PlayerViewModel.kt | 28 +- .../presentation/viewmodel/SetupViewModel.kt | 2 +- .../youtube/auth/YoutubeLoginActivity.kt | 214 ++++++ .../pixelplay/soundcloud/SoundCloudClient.kt | 14 - .../pixelplay/utils/MediaItemBuilder.kt | 30 +- .../pixelplay/youtube/YoutubeClient.kt | 272 +++++++ .../pixelplay/youtube/YoutubeDownloader.kt | 60 ++ .../pixelplay/youtube/YoutubeModels.kt | 44 ++ .../pixelplay/youtube/YoutubeMusicStore.kt | 124 +++ .../pixelplay/youtube/YoutubeSession.kt | 57 ++ .../pixelplay/youtube/YoutubeViewModel.kt | 343 +++++++++ app/src/main/res/values/strings.xml | 42 + 27 files changed, 2319 insertions(+), 139 deletions(-) create mode 100644 .github/workflows/build-app.yml create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeSettingsScreen.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml new file mode 100644 index 0000000000..dcb62ccfca --- /dev/null +++ b/.github/workflows/build-app.yml @@ -0,0 +1,41 @@ +name: Build app + +# Compiles a debug APK on every push so broken builds fail before release. +on: + push: + +permissions: + contents: read + +concurrency: + group: build-app-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + if: github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Build debug APK + run: gradle :app:assembleDebug -Ppixelplay.enableAbiSplits=true + + - name: Upload debug APKs + uses: actions/upload-artifact@v7 + with: + name: PixelPlay-debug-${{ github.sha }} + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: error + compression-level: 0 diff --git a/README.md b/README.md index 8206462473..a3fdc515fe 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@

- A beautiful music player for Android
- Local library, lyrics, playlists — plus SoundCloud streaming & downloads + A YouTube Music player for Android
+ Feed, radio, playlists, and likes — with background playback

@@ -33,81 +33,65 @@ - **`…-armeabi-v7a.apk`** — older 32-bit devices 3. On your phone, open the downloaded file and install. - If Android blocks it, allow **Install unknown apps** for your browser / Files app. -4. Open PixelPlayer and grant **music / files** access when asked so your local library can load. +4. Open PixelPlayer. Playback continues in the background with the notification controls. > Tip: each new release can update the app **in place** (same signing key). You usually don’t need to uninstall first — that keeps login and settings. --- -## SoundCloud setup +## YouTube Music -SoundCloud in PixelPlayer needs a **web `client_id`** (from SoundCloud’s website). Signing in alone is not enough for streaming. +PixelPlayer plays YouTube audio through the existing player. It does not scan a local music library. -### 1. Open SoundCloud settings in the app +| Tab | What it does | +|-----|----------------| +| **Feed** | Your Mix, artwork collage, trending songs, radio starters, and playlists | +| **Radio** | Starts a song, then keeps queuing similar tracks | +| **Search** | Songs, albums, and playlists | +| **Playlists** | Today's hits, workout, focus, and party | +| **Liked** | Songs you like on device, plus liked videos from your Google account after sign-in | -1. On **Home**, tap the **cloud** icon and choose **SoundCloud**. The gear on the **SoundCloud** tab opens the same page. -2. You’ll see fields for **client_id**, optional username, and **Sign in**. The SoundCloud tab itself stays in the bottom bar. +### Sign in with Google -### 2. Get a `client_id` from your browser +Liked videos and the “channels you follow” shelf need the Google account you use on YouTube. -Do this on a computer (Chrome / Edge / Firefox): +1. On **Feed**, tap the **account** icon in the colored header (Beta, account, changelog, settings). +2. Tap **Sign in with Google**. +3. Sign in inside the app, then tap **Done**. +4. Open **Liked** to load that account’s liked videos. -1. Open [https://soundcloud.com](https://soundcloud.com) and play any track (signed in or not). -2. Press **F12** (or right‑click → **Inspect**) to open Developer Tools. -3. Open the **Network** tab. -4. In the filter box, type: `client_id` -5. Click around or pause/play so new requests appear. -6. Click a request to SoundCloud’s API — for example: - `https://api-v2.soundcloud.com/me?client_id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` -7. Copy the long value after `client_id=` (a string of letters and numbers). - Yours will look similar but different, and it can change over time. +**Sign out** is on the same Account screen. The heart on a song still saves a like on the device if you are signed out. -

- Chrome DevTools Network tab showing api-v2.soundcloud.com/me?client_id=… -

- -

Network → filter client_id → open a request → copy client_id from the Request URL

+The header also opens the **changelog** and app **settings**. -### 3. Paste it into PixelPlayer - -1. Back in **SoundCloud settings**, paste the value into **Web client_id**. -2. Tap **Save SoundCloud settings**. -3. (Recommended) Tap **Sign in to SoundCloud**, log in in the browser window, then confirm you’re signed in. -4. Return to the SoundCloud tab — Feed / Likes / playlists should load after a moment. - -### If something fails +--- -| Message / symptom | What to try | -|-------------------|-------------| -| “Set client_id in Settings…” | Paste a fresh `client_id` from the steps above and Save. | -| “Rate limited” / API 429 | Wait a minute, then try again. Don’t spam refresh. | -| “No playable stream” | Track may be DRM-only, or `client_id` expired — grab a new one. | -| Empty Feed while signed in | Sign out/in again; confirm Save after pasting `client_id`. | +## SoundCloud -> `client_id` can expire when SoundCloud updates their site. If streaming suddenly stops, repeat step 2 and Save again. +SoundCloud is not a bottom-bar tab in this build. The previous web `client_id` and sign-in flow is described in [docs/soundcloud-implementation.md](docs/soundcloud-implementation.md). --- ## Everyday use (quick) -- **Library** — your local music (songs, albums, artists, playlists, folders). -- **SoundCloud** — Feed, Discover, Likes, Tracks, Playlists, Downloads; search or paste a SoundCloud URL. -- **Downloads** — long-press to multi-select (play, queue, next, playlist, delete, share ZIP). -- Share a track/playlist from SoundCloud with the **share** icon — sends the **web link**. +- **Feed** shuffle plays Your Mix and keeps playing in the background. +- **Radio** is a continuous mix based on the song you start, not a live broadcast. +- **Liked** and the heart icon keep songs you like. Sign in to also load YouTube liked videos. +- **Playlists** opens a collection; tap a track to play it. --- ## Requirements -- Android **11** or newer -- Storage / music permission for local files -- Internet for SoundCloud +- Android **11** or newer +- Internet for YouTube Music +- Notification permission so background playback can show controls --- ## Support & credits -This build is maintained at **[kibetmasi/PixelPlayer](https://github.com/kibetmasi/PixelPlayer)** (SoundCloud-focused fork). +This build is maintained at **[kibetmasi/PixelPlayer](https://github.com/kibetmasi/PixelPlayer)**. A push to `master` publishes a signed release APK on the Releases page. Upstream project by [theovilardo](https://github.com/theovilardo/PixelPlayer). Logo by [Aureal](https://github.com/NPSummers). diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6a064e6adc..2f8032f652 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -121,6 +121,13 @@ android:theme="@style/Theme.PixelPlay" android:windowSoftInputMode="adjustResize" /> + + = Build.VERSION_CODES.TIRAMISU) { - listOf(Manifest.permission.READ_MEDIA_AUDIO, Manifest.permission.POST_NOTIFICATIONS) + listOf(Manifest.permission.POST_NOTIFICATIONS) } else { - listOf(Manifest.permission.READ_EXTERNAL_STORAGE) + emptyList() } @OptIn(ExperimentalPermissionsApi::class) val permissionState = rememberMultiplePermissionsState(permissions = permissions) // Determine if we need to show Setup based on completion OR missing permissions - val permissionsValid = permissionState.allPermissionsGranted + val permissionsValid = permissions.isEmpty() || permissionState.allPermissionsGranted val showSetupScreen = remember(isSetupComplete, permissionsValid, isBenchmarkMode) { when { isBenchmarkMode -> false @@ -286,8 +286,7 @@ class MainActivity : ComponentActivity() { // Sync Trigger: When we are NOT showing setup (meaning permissions are good and setup is done) LaunchedEffect(showSetupScreen) { if (showSetupScreen == false) { - LogUtils.i(this, "Setup complete/skipped and permissions valid. Starting sync.") - mainViewModel.startSync() + LogUtils.i(this, "Setup complete. Local library sync is disabled.") } } @@ -654,10 +653,11 @@ class MainActivity : ComponentActivity() { val commonNavItems = remember { persistentListOf( - BottomNavItem("Home", R.string.nav_bar_home, R.drawable.rounded_home_24, R.drawable.home_24_rounded_filled, Screen.Home), + BottomNavItem("Feed", R.string.nav_bar_feed, R.drawable.rounded_home_24, R.drawable.home_24_rounded_filled, Screen.Home), + BottomNavItem("Radio", R.string.nav_bar_radio, R.drawable.rounded_instant_mix_24, null, Screen.Radio), BottomNavItem("Search", R.string.nav_bar_search, R.drawable.rounded_search_24, R.drawable.rounded_search_24, Screen.Search), - BottomNavItem("Library", R.string.nav_bar_library, R.drawable.rounded_library_music_24, R.drawable.round_library_music_24, Screen.Library), - BottomNavItem("SoundCloud", R.string.nav_bar_soundcloud, R.drawable.ic_soundcloud_24, R.drawable.ic_soundcloud_filled_24, Screen.SoundCloud), + BottomNavItem("Playlists", R.string.nav_bar_playlists, R.drawable.rounded_playlist_play_24, null, Screen.Playlists), + BottomNavItem("Liked", R.string.nav_bar_liked, R.drawable.rounded_favorite_24, R.drawable.round_favorite_24, Screen.Liked), ) } val navBackStackEntry by navController.currentBackStackEntryAsState() @@ -682,6 +682,7 @@ class MainActivity : ComponentActivity() { Screen.EditTransition.route, Screen.Experimental.route, Screen.SoundCloudSettings.route, + Screen.YoutubeSettings.route, Screen.ArtistSettings.route, Screen.Equalizer.route, Screen.SettingsCategory.route, diff --git a/app/src/main/java/com/theveloper/pixelplay/data/model/PlaybackQueueSnapshot.kt b/app/src/main/java/com/theveloper/pixelplay/data/model/PlaybackQueueSnapshot.kt index e355e6d654..1500c5c2d9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/model/PlaybackQueueSnapshot.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/model/PlaybackQueueSnapshot.kt @@ -12,6 +12,7 @@ data class PlaybackQueueItemSnapshot( val artworkUri: String? = null, val durationMs: Long? = null, val soundCloudPermalink: String? = null, + val youtubeWatchUrl: String? = null, ) @Serializable diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index c757df7121..d0a762229c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -174,6 +174,8 @@ class MusicService : MediaLibraryService() { @Inject lateinit var soundCloudClient: SoundCloudClient @Inject + lateinit var youtubeClient: com.theveloper.pixelplay.youtube.YoutubeClient + @Inject @AppScope lateinit var appScope: CoroutineScope @@ -206,6 +208,7 @@ class MusicService : MediaLibraryService() { private var countedPlayListener: Player.Listener? = null private var consecutivePlaybackErrorSkips = 0 private val soundCloudRefreshAttempted = mutableSetOf() + private val youtubeRefreshAttempted = mutableSetOf() private val alarmManager by lazy { getSystemService(Context.ALARM_SERVICE) as AlarmManager } @@ -1348,7 +1351,10 @@ class MusicService : MediaLibraryService() { if (playbackState == Player.STATE_READY) { consecutivePlaybackErrorSkips = 0 (mediaSession?.player ?: engine.masterPlayer).currentMediaItem?.mediaId - ?.let { soundCloudRefreshAttempted.remove(it) } + ?.let { + soundCloudRefreshAttempted.remove(it) + youtubeRefreshAttempted.remove(it) + } } if (playbackState == Player.STATE_ENDED) { listeningStatsTracker.finalizeCurrentSession() @@ -1514,6 +1520,25 @@ class MusicService : MediaLibraryService() { } return } + val watchUrl = youtubeWatchUrl(currentItem) + if (currentItem != null && !watchUrl.isNullOrBlank() && youtubeRefreshAttempted.add(mediaId)) { + serviceScope.launch { + val refreshed = refreshYoutubeItem(currentItem, watchUrl) + withContext(Dispatchers.Main.immediate) { + val sameTrack = player.currentMediaItem?.mediaId == mediaId + val oldUri = currentItem.localConfiguration?.uri + val newUri = refreshed.localConfiguration?.uri + if (sameTrack && newUri != null && newUri != oldUri) { + player.replaceMediaItem(player.currentMediaItemIndex, refreshed) + player.prepare() + player.play() + } else { + skipUnplayableItem(player, error) + } + } + } + return + } skipUnplayableItem(player, error) } } @@ -1741,6 +1766,8 @@ class MusicService : MediaLibraryService() { durationMs = durationMs, soundCloudPermalink = metadata.extras?.getString(SoundCloudClient.EXTRA_PERMALINK) ?: SoundCloudClient.permalinkForSongId(mediaItem.mediaId), + youtubeWatchUrl = metadata.extras?.getString(com.theveloper.pixelplay.youtube.YoutubeClient.EXTRA_WATCH_URL) + ?: com.theveloper.pixelplay.youtube.YoutubeClient.watchUrlForSongId(mediaItem.mediaId), ) ) } @@ -1812,7 +1839,7 @@ class MusicService : MediaLibraryService() { else -> 0 } - val preparedItems = refreshSoundCloudQueue(restoredItems).toMutableList() + val preparedItems = refreshYoutubeQueue(refreshSoundCloudQueue(restoredItems)).toMutableList() preparedItems.getOrNull(resolvedIndex)?.let { currentItem -> val resolvedCurrentItem = runCatching { engine.resolveMediaItem(currentItem) }.getOrNull() if (resolvedCurrentItem != null && resolvedCurrentItem != currentItem) { @@ -1896,6 +1923,10 @@ class MusicService : MediaLibraryService() { putString(SoundCloudClient.EXTRA_PERMALINK, permalink) SoundCloudClient.rememberPermalink(snapshotItem.mediaId, permalink) } + snapshotItem.youtubeWatchUrl?.takeIf { it.isNotBlank() }?.let { watchUrl -> + putString(com.theveloper.pixelplay.youtube.YoutubeClient.EXTRA_WATCH_URL, watchUrl) + com.theveloper.pixelplay.youtube.YoutubeClient.rememberWatchUrl(snapshotItem.mediaId, watchUrl) + } } metadataBuilder.setExtras(extras) @@ -1905,6 +1936,7 @@ class MusicService : MediaLibraryService() { .setMediaMetadata(metadataBuilder.build()) .build() .let(MediaItemBuilder::withSoundCloudPermalink) + .let(MediaItemBuilder::withYoutubeWatchUrl) } private fun soundCloudPermalink(item: MediaItem?): String? { @@ -1914,6 +1946,29 @@ class MusicService : MediaLibraryService() { ?: SoundCloudClient.permalinkForSongId(item.mediaId) } + private fun youtubeWatchUrl(item: MediaItem?): String? { + if (item == null) return null + return item.mediaMetadata.extras?.getString(com.theveloper.pixelplay.youtube.YoutubeClient.EXTRA_WATCH_URL) + ?: com.theveloper.pixelplay.youtube.YoutubeClient.watchUrlForSongId(item.mediaId) + } + + private suspend fun refreshYoutubeQueue(items: List): List = coroutineScope { + items.map { item -> + async(Dispatchers.IO) { + val watchUrl = youtubeWatchUrl(item) ?: return@async item + refreshYoutubeItem(item, watchUrl) + } + }.awaitAll() + } + + private suspend fun refreshYoutubeItem(item: MediaItem, watchUrl: String): MediaItem = + withContext(Dispatchers.IO) { + val streamUrl = runCatching { youtubeClient.resolveTrack(watchUrl).streamUrl }.getOrNull() + if (streamUrl.isNullOrBlank()) return@withContext item + val refreshed = item.buildUpon().setUri(streamUrl).build() + MediaItemBuilder.withYoutubeWatchUrl(refreshed) + } + private suspend fun refreshSoundCloudQueue(items: List): List = coroutineScope { items.map { item -> async(Dispatchers.IO) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt index 377a25164f..97a9eca362 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt @@ -127,6 +127,9 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( val soundCloudPrefs = hiltViewModel() val likedPermalinks by soundCloudPrefs.likedPermalinks.collectAsStateWithLifecycle() val soundCloudPermalink = soundCloudPrefs.permalinkFor(currentSongNonNull.id) + val youtubeSavedIds by playerViewModel.youtubeSavedSongIds.collectAsStateWithLifecycle() + val youtubeLiked by playerViewModel.isCurrentSongFavorite.collectAsStateWithLifecycle() + val isYoutubeSong = currentSongNonNull.id.startsWith("yt_") val context = LocalContext.current val likeScope = rememberCoroutineScope() MiniPlayerContentInternal( @@ -137,19 +140,31 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( onPlayPause = { playerViewModel.playPause() }, onPrevious = { playerViewModel.previousSong() }, onNext = { playerViewModel.nextSong() }, - soundCloudLiked = soundCloudPermalink != null && soundCloudPermalink in likedPermalinks, - onSoundCloudLike = if (soundCloudPermalink == null) { - null + soundCloudLiked = if (isYoutubeSong) { + youtubeLiked } else { - { - likeScope.launch { - val error = soundCloudPrefs.toggleSoundCloudLike(currentSongNonNull.id) - if (!error.isNullOrBlank()) { - Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + soundCloudPermalink != null && soundCloudPermalink in likedPermalinks + }, + onSoundCloudLike = when { + isYoutubeSong -> ({ playerViewModel.toggleFavorite() }) + soundCloudPermalink == null -> null + else -> { + { + likeScope.launch { + val error = soundCloudPrefs.toggleSoundCloudLike(currentSongNonNull.id) + if (!error.isNullOrBlank()) { + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } } } } }, + youtubeSaved = currentSongNonNull.id in youtubeSavedIds, + onYoutubeSave = if (isYoutubeSong) { + { playerViewModel.toggleYoutubeSave() } + } else { + null + }, canScroll = isMiniPlayerVisible && infrequentPlayerState.isPlaying, modifier = Modifier.fillMaxSize() ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt index e5983f9775..fefc86d1d8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt @@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Bookmark +import androidx.compose.material.icons.rounded.BookmarkBorder import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.Pause @@ -75,6 +77,8 @@ internal fun MiniPlayerContentInternal( onNext: () -> Unit, soundCloudLiked: Boolean = false, onSoundCloudLike: (() -> Unit)? = null, + youtubeSaved: Boolean = false, + onYoutubeSave: (() -> Unit)? = null, modifier: Modifier = Modifier, canScroll: Boolean = true ) { @@ -174,7 +178,12 @@ internal fun MiniPlayerContentInternal( Icon( imageVector = if (soundCloudLiked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, contentDescription = stringResource( - if (soundCloudLiked) R.string.soundcloud_unlike else R.string.soundcloud_like, + when { + onYoutubeSave != null && soundCloudLiked -> R.string.youtube_unlike + onYoutubeSave != null -> R.string.youtube_like + soundCloudLiked -> R.string.soundcloud_unlike + else -> R.string.soundcloud_like + }, ), tint = if (soundCloudLiked) { LocalMaterialTheme.current.error @@ -187,6 +196,38 @@ internal fun MiniPlayerContentInternal( Spacer(modifier = Modifier.width(4.dp)) } + if (onYoutubeSave != null) { + val saveInteraction = remember(song.id) { MutableInteractionSource() } + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .clickable( + interactionSource = saveInteraction, + indication = miniPlayerIndication, + enabled = controlsEnabled, + ) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onYoutubeSave() + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (youtubeSaved) Icons.Rounded.Bookmark else Icons.Rounded.BookmarkBorder, + contentDescription = stringResource( + if (youtubeSaved) R.string.youtube_unsave else R.string.youtube_save, + ), + tint = if (youtubeSaved) { + LocalMaterialTheme.current.primary + } else { + LocalMaterialTheme.current.onPrimaryContainer + }, + modifier = Modifier.size(22.dp), + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + Box( modifier = Modifier .size(36.dp) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt index 1200f8ac5b..c30bcf7a58 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt @@ -26,6 +26,7 @@ import com.theveloper.pixelplay.R import androidx.compose.ui.unit.IntOffset import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.media3.common.util.UnstableApi +import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -43,8 +44,8 @@ import com.theveloper.pixelplay.presentation.screens.EasterEggScreen import com.theveloper.pixelplay.presentation.screens.ExperimentalSettingsScreen import com.theveloper.pixelplay.presentation.screens.SoundCloudSettingsScreen import com.theveloper.pixelplay.presentation.screens.GenreDetailScreen -import com.theveloper.pixelplay.presentation.screens.HomeScreen -import com.theveloper.pixelplay.presentation.screens.LibraryScreen +import com.theveloper.pixelplay.presentation.screens.YoutubeScreen +import com.theveloper.pixelplay.presentation.screens.YoutubeSettingsScreen import com.theveloper.pixelplay.presentation.screens.MashupScreen import com.theveloper.pixelplay.presentation.screens.NavBarCornerRadiusScreen import com.theveloper.pixelplay.presentation.screens.PaletteStyleSettingsScreen @@ -53,8 +54,7 @@ import com.theveloper.pixelplay.presentation.screens.RecentlyPlayedScreen import com.theveloper.pixelplay.presentation.screens.AboutScreen import com.theveloper.pixelplay.presentation.screens.OpenSourceLicensesScreen -import com.theveloper.pixelplay.presentation.screens.SearchScreen -import com.theveloper.pixelplay.presentation.screens.SoundCloudScreen +import com.theveloper.pixelplay.youtube.YoutubeSection import com.theveloper.pixelplay.presentation.screens.StatsScreen import com.theveloper.pixelplay.presentation.screens.SettingsScreen import com.theveloper.pixelplay.presentation.screens.SettingsCategoryScreen @@ -124,11 +124,10 @@ fun AppNavigation( }, ) { ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) { - HomeScreen( - navController = navController, - paddingValuesParent = paddingValues, + YoutubeScreen( + section = YoutubeSection.HOME, playerViewModel = playerViewModel, - onOpenSidebar = onOpenSidebar + navController = navController, ) } } @@ -164,16 +163,17 @@ fun AppNavigation( }, ) { ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) { - SearchScreen( - paddingValues = paddingValues, + YoutubeScreen( + section = YoutubeSection.SEARCH, playerViewModel = playerViewModel, navController = navController, - onSearchBarActiveChange = onSearchBarActiveChange ) } } + youtubeTab(Screen.Radio.route, YoutubeSection.RADIO, navController, playerViewModel) + youtubeTab(Screen.Playlists.route, YoutubeSection.PLAYLISTS, navController, playerViewModel) composable( - Screen.Library.route, + Screen.Liked.route, enterTransition = { mainRootEnterTransition( fromRoute = initialState.destination.route, @@ -204,7 +204,11 @@ fun AppNavigation( }, ) { ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) { - LibraryScreen(navController = navController, playerViewModel = playerViewModel) + YoutubeScreen( + section = YoutubeSection.LIKED, + playerViewModel = playerViewModel, + navController = navController, + ) } } composable( @@ -239,7 +243,8 @@ fun AppNavigation( }, ) { ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) { - SoundCloudScreen( + YoutubeScreen( + section = YoutubeSection.HOME, playerViewModel = playerViewModel, navController = navController, ) @@ -542,6 +547,11 @@ fun AppNavigation( ) } } + composable(Screen.YoutubeSettings.route) { + ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) { + YoutubeSettingsScreen(navController = navController) + } + } composable( Screen.JellyfinDashboard.route, ) { @@ -555,9 +565,51 @@ fun AppNavigation( } } +private fun NavGraphBuilder.youtubeTab( + route: String, + section: com.theveloper.pixelplay.youtube.YoutubeSection, + navController: NavHostController, + playerViewModel: PlayerViewModel, +) { + composable( + route, + enterTransition = { + mainRootEnterTransition( + fromRoute = initialState.destination.route, + toRoute = targetState.destination.route, + fallback = enterTransition(), + ) + }, + exitTransition = { + mainRootExitTransition( + fromRoute = initialState.destination.route, + toRoute = targetState.destination.route, + fallback = exitTransition(), + ) + }, + popEnterTransition = { + mainRootEnterTransition( + fromRoute = initialState.destination.route, + toRoute = targetState.destination.route, + fallback = popEnterTransition(), + ) + }, + popExitTransition = { + mainRootExitTransition( + fromRoute = initialState.destination.route, + toRoute = targetState.destination.route, + fallback = popExitTransition(), + ) + }, + ) { + ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) { + YoutubeScreen(section = section, playerViewModel = playerViewModel, navController = navController) + } + } +} + private fun String.toRoute(): String = when (this) { LaunchTab.SEARCH -> Screen.Search.route - LaunchTab.LIBRARY -> Screen.Library.route else -> Screen.Home.route } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/MainRootRoutes.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/MainRootRoutes.kt index 0ab02bd050..a8921cd3bf 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/MainRootRoutes.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/MainRootRoutes.kt @@ -2,16 +2,18 @@ package com.theveloper.pixelplay.presentation.navigation internal fun isMainRootRoute(route: String?): Boolean = when (route) { Screen.Home.route, + Screen.Radio.route, Screen.Search.route, - Screen.Library.route, - Screen.SoundCloud.route -> true + Screen.Playlists.route, + Screen.Liked.route -> true else -> false } internal fun mainRootRouteIndex(route: String?): Int? = when (route) { Screen.Home.route -> 0 - Screen.Search.route -> 1 - Screen.Library.route -> 2 - Screen.SoundCloud.route -> 3 + Screen.Radio.route -> 1 + Screen.Search.route -> 2 + Screen.Playlists.route -> 3 + Screen.Liked.route -> 4 else -> null } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt index 4bc5bffb89..c5ab0e7292 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt @@ -7,6 +7,10 @@ import androidx.compose.runtime.Immutable sealed class Screen(val route: String) { object Home : Screen("home") object Search : Screen("search") + object Liked : Screen("liked") + object Radio : Screen("radio") + object Playlists : Screen("playlists") + object YoutubeSettings : Screen("youtube_settings") object Library : Screen("library") object SoundCloud : Screen("soundcloud") object SoundCloudSettings : Screen("soundcloud_settings") diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt index fa40ecb265..b5842979dd 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt @@ -36,7 +36,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton @@ -218,7 +217,7 @@ fun SettingsScreen( it != SettingsCategory.DEVICE_CAPABILITIES } - val totalItems = mainCategories.size + 3 // Device + Accounts + About + val totalItems = mainCategories.size + 2 // Device + About fun shapeFor(index: Int) = when { totalItems == 1 -> RoundedCornerShape(24.dp) @@ -261,19 +260,6 @@ fun SettingsScreen( } itemIndex++ - ExpressiveNavigationItem( - title = stringResource(R.string.settings_category_accounts_title), - subtitle = stringResource(R.string.settings_category_accounts_subtitle), - icon = Icons.Rounded.AccountCircle, - colors = getAccountsColors(isDark), - onClick = { navController.navigateSafely(Screen.Accounts.route) }, - shape = shapeFor(itemIndex) - ) - if (itemIndex < totalItems - 1) { - Spacer(modifier = Modifier.height(2.dp)) - } - itemIndex++ - ExpressiveCategoryItem( category = SettingsCategory.ABOUT, customColors = getCategoryColors(SettingsCategory.ABOUT, isDark), @@ -455,14 +441,6 @@ fun ExpressiveCategoryItem( } } -private fun getAccountsColors(isDark: Boolean): Pair { - return if (isDark) { - Color(0xFF37474F) to Color(0xFFBBD9E8) - } else { - Color(0xFFD6EAF5) to Color(0xFF103548) - } -} - @Composable fun ExpressiveSettingsGroup(content: @Composable () -> Unit) { Column( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SetupScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SetupScreen.kt index 5100106317..b56a5d399d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SetupScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SetupScreen.kt @@ -553,7 +553,6 @@ sealed class SetupPage { private fun buildSetupPages(sdkInt: Int): List { val pages = mutableListOf( SetupPage.Welcome, - SetupPage.MediaPermission ) if (sdkInt >= Build.VERSION_CODES.TIRAMISU) { @@ -561,7 +560,6 @@ private fun buildSetupPages(sdkInt: Int): List { } pages += SetupPage.BackupRestore - pages += SetupPage.DirectorySelection pages += SetupPage.ThemeSelection pages += SetupPage.LibraryLayout pages += SetupPage.NavBarLayout @@ -610,14 +608,13 @@ private fun isPermissionGateSatisfied( } private fun allRequiredPermissionsGrantedNow(context: Context): Boolean { - val mediaGranted = hasMediaPermissionNow(context) val notificationsGranted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission( context, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED - return mediaGranted && notificationsGranted + return notificationsGranted } private fun hasMediaPermissionNow(context: Context): Boolean { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt new file mode 100644 index 0000000000..9455e442fd --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -0,0 +1,725 @@ +@file:OptIn( + androidx.compose.material3.ExperimentalMaterial3Api::class, + androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class, +) + +package com.theveloper.pixelplay.presentation.screens + +import android.content.Intent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material.icons.rounded.Bookmark +import androidx.compose.material.icons.rounded.BookmarkBorder +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.FavoriteBorder +import androidx.compose.material.icons.rounded.Login +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material3.Button +import androidx.compose.material3.DockedSearchBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight +import com.theveloper.pixelplay.presentation.components.resolveNavBarOccupiedHeight +import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem +import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIcon +import androidx.navigation.NavController +import com.theveloper.pixelplay.presentation.navigation.Screen +import com.theveloper.pixelplay.presentation.navigation.navigateSafely +import com.theveloper.pixelplay.presentation.components.AlbumArtCollage +import com.theveloper.pixelplay.presentation.components.BetaInfoBottomSheet +import com.theveloper.pixelplay.presentation.components.ChangelogBottomSheet +import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel +import com.theveloper.pixelplay.presentation.youtube.auth.YoutubeLoginActivity +import com.theveloper.pixelplay.ui.theme.GoogleSansRounded +import com.theveloper.pixelplay.youtube.YoutubeClient +import com.theveloper.pixelplay.youtube.YoutubeHit +import com.theveloper.pixelplay.youtube.YoutubeSearchFilter +import com.theveloper.pixelplay.youtube.YoutubeSection +import com.theveloper.pixelplay.youtube.YoutubeViewModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch + +private const val WIDE_SCREEN_DP = 600 + +@Composable +fun YoutubeScreen( + section: YoutubeSection, + playerViewModel: PlayerViewModel, + navController: NavController, + viewModel: YoutubeViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val liked by viewModel.likedTracks.collectAsStateWithLifecycle() + val saved by viewModel.savedTracks.collectAsStateWithLifecycle() + val likedIds by viewModel.likedSongIds.collectAsStateWithLifecycle() + val savedIds by viewModel.savedSongIds.collectAsStateWithLifecycle() + val account by viewModel.account.collectAsStateWithLifecycle() + val context = LocalContext.current + var showChangelog by remember { mutableStateOf(false) } + var showBeta by remember { mutableStateOf(false) } + val stablePlayer by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val configuration = LocalConfiguration.current + val isWideScreen = configuration.screenWidthDp >= WIDE_SCREEN_DP + val systemNavBarInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val navBarCompactMode by playerViewModel.navBarCompactMode.collectAsStateWithLifecycle() + val bottomBarHeight = resolveNavBarOccupiedHeight(systemNavBarInset, navBarCompactMode) + val contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = bottomBarHeight + MiniPlayerHeight + 28.dp, + ) + val currentSongId = stablePlayer.currentSong?.id + val isPlaying = stablePlayer.isPlaying + val browseSection = when (section) { + YoutubeSection.SEARCH -> YoutubeSection.SEARCH + YoutubeSection.LIKED -> YoutubeSection.LIKED + YoutubeSection.PLAYLISTS -> YoutubeSection.PLAYLISTS + YoutubeSection.RADIO -> YoutubeSection.RADIO + else -> YoutubeSection.HOME + } + val title = when (browseSection) { + YoutubeSection.HOME -> stringResource(R.string.youtube_home_title) + YoutubeSection.SEARCH -> stringResource(R.string.youtube_search_title) + YoutubeSection.LIKED -> stringResource(R.string.youtube_liked_title) + YoutubeSection.SAVED -> stringResource(R.string.youtube_saved_title) + YoutubeSection.PLAYLISTS -> stringResource(R.string.youtube_playlists_title) + YoutubeSection.RADIO -> stringResource(R.string.youtube_radio_title) + } + val rows = when (browseSection) { + YoutubeSection.LIKED -> (uiState.results + liked).distinctBy { it.url } + YoutubeSection.SAVED -> saved + else -> uiState.results + } + var searchActive by remember { mutableStateOf(false) } + val pullState = rememberPullToRefreshState() + + LaunchedEffect(section) { + viewModel.openSection(section) + } + + if (uiState.collectionTitle != null) { + BackHandler { viewModel.closeCollection() } + } + + val playHit: (YoutubeHit, Boolean) -> Unit = { hit, asRadio -> + if (hit.kind == YoutubeHit.Kind.COLLECTION && !asRadio) { + viewModel.openCollection(hit) + } else if (asRadio) { + scope.launch { + runCatching { viewModel.radioTracks(hit) } + .onSuccess { queue -> + val playable = queue.ifEmpty { listOf(hit) } + val start = viewModel.resolvePlayable(playable.first()) + playerViewModel.playSongs( + songsToPlay = listOf(start), + startSong = start, + queueName = "YouTube Radio", + ) + viewModel.prefetchAfter(playable.first(), playable) { queued -> + playerViewModel.addSongToQueue(queued) + } + } + .onFailure { error -> + viewModel.reportError(error.message ?: "Couldn't start radio") + } + } + } else { + scope.launch { + runCatching { viewModel.resolvePlayable(hit) } + .onSuccess { song -> + playerViewModel.playSongs( + songsToPlay = listOf(song), + startSong = song, + queueName = "YouTube Music", + ) + viewModel.prefetchAfter(hit, rows) { queued -> + playerViewModel.addSongToQueue(queued) + } + } + .onFailure { error -> + viewModel.reportError(error.message ?: "Couldn't play this track") + } + } + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + if (browseSection == YoutubeSection.HOME && uiState.collectionTitle == null) { + YoutubeCompactHeader( + signedIn = account.isSignedIn, + onBeta = { showBeta = true }, + onChangelog = { showChangelog = true }, + onAccount = { navController.navigateSafely(Screen.YoutubeSettings.route) }, + onSettings = { navController.navigateSafely(Screen.Settings.route) }, + ) + } else { + TopAppBar( + title = { + Column { + Text( + text = uiState.collectionTitle ?: title, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (uiState.collectionTitle == null && browseSection == YoutubeSection.HOME) { + Text( + text = stringResource(R.string.youtube_home_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = GoogleSansRounded, + ) + } + } + }, + navigationIcon = { + if (uiState.collectionTitle != null) { + IconButton(onClick = viewModel::closeCollection) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.youtube_back)) + } + } + }, + actions = { + if (section == YoutubeSection.LIKED) { + TextButton( + onClick = { + if (account.isSignedIn) viewModel.signOut() + else context.startActivity(Intent(context, YoutubeLoginActivity::class.java)) + }, + ) { + Text( + stringResource( + if (account.isSignedIn) R.string.youtube_sign_out else R.string.youtube_sign_in, + ), + fontFamily = GoogleSansRounded, + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + }, + ) { innerPadding -> + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = viewModel::refresh, + state = pullState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + indicator = { + PullToRefreshDefaults.LoadingIndicator( + state = pullState, + isRefreshing = uiState.isRefreshing, + modifier = Modifier.align(Alignment.TopCenter), + containerColor = androidx.compose.ui.graphics.Color.Transparent, + elevation = 0.dp, + ) + }, + ) { + Column(Modifier.fillMaxSize()) { + if (section == YoutubeSection.SEARCH && uiState.collectionTitle == null) { + DockedSearchBar( + inputField = { + SearchBarDefaults.InputField( + query = uiState.query, + onQueryChange = viewModel::onQueryChange, + onSearch = { searchActive = false }, + expanded = searchActive, + onExpandedChange = { searchActive = it }, + placeholder = { + Text( + stringResource(R.string.youtube_search_placeholder), + fontFamily = GoogleSansRounded, + ) + }, + ) + }, + expanded = false, + onExpandedChange = { searchActive = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(28.dp), + ) {} + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + YoutubeSearchFilter.entries.forEach { filter -> + FilterChip( + selected = uiState.filter == filter, + onClick = { viewModel.setFilter(filter) }, + label = { + Text( + stringResource( + when (filter) { + YoutubeSearchFilter.SONGS -> R.string.youtube_filter_songs + YoutubeSearchFilter.ALBUMS -> R.string.youtube_filter_albums + YoutubeSearchFilter.PLAYLISTS -> R.string.youtube_filter_playlists + } + ), + fontFamily = GoogleSansRounded, + ) + }, + ) + } + } + } + + uiState.error?.let { message -> + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.errorContainer, + ) { + Text( + text = message, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + fontFamily = GoogleSansRounded, + ) + } + } + + when { + browseSection == YoutubeSection.HOME && uiState.collectionTitle == null -> { + if (uiState.isLoading && uiState.shelves.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LoadingIndicator() + } + } else if (uiState.shelves.isEmpty()) { + EmptyYoutubeState(message = stringResource(R.string.youtube_home_empty)) + } else { + val mixHits = uiState.shelves + .firstOrNull { it.id == "trending" } + ?.items + ?.filter { it.kind == YoutubeHit.Kind.TRACK } + .orEmpty() + .ifEmpty { + uiState.shelves.flatMap { it.items }.filter { it.kind == YoutubeHit.Kind.TRACK } + } + val mixSongs = remember(mixHits) { + mixHits.take(8).map { placeholderSong(it) }.toImmutableList() + } + LazyColumn(contentPadding = contentPadding) { + item(key = "your_mix") { + YourMixHeader( + song = stringResource(R.string.youtube_todays_mix), + onPlayShuffled = { + val seed = mixHits.shuffled() + if (seed.isNotEmpty()) playHit(seed.first(), false) + }, + ) + } + if (mixSongs.isNotEmpty()) { + item(key = "collage") { + AlbumArtCollage( + songs = mixSongs, + modifier = Modifier.fillMaxWidth(), + height = 280.dp, + padding = 8.dp, + onSongClick = { song -> + mixHits.firstOrNull { YoutubeClient.songIdForUrl(it.url) == song.id } + ?.let { playHit(it, false) } + }, + ) + } + } + items(uiState.shelves, key = { it.id }) { shelf -> + Text( + text = shelf.title, + modifier = Modifier.padding(bottom = 8.dp, top = 12.dp), + style = MaterialTheme.typography.titleLarge, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(shelf.items, key = { it.url }) { hit -> + YoutubeTile( + hit = hit, + modifier = Modifier.width(168.dp), + isCurrent = currentSongId == YoutubeClient.songIdForUrl(hit.url), + isPlaying = isPlaying, + subtitle = if (shelf.startsRadio) { + stringResource(R.string.youtube_radio_from, hit.artist) + } else { + hit.artist + }, + onClick = { playHit(hit, shelf.startsRadio) }, + ) + } + } + } + } + } + } + uiState.isLoading && rows.isEmpty() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LoadingIndicator() + } + } + rows.isEmpty() -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + EmptyYoutubeState( + message = when (browseSection) { + YoutubeSection.LIKED -> stringResource( + if (account.isSignedIn) R.string.youtube_liked_empty else R.string.youtube_liked_signed_out, + ) + YoutubeSection.PLAYLISTS -> stringResource(R.string.youtube_playlists_empty) + YoutubeSection.RADIO -> stringResource(R.string.youtube_radio_empty) + YoutubeSection.SEARCH -> stringResource(R.string.youtube_search_empty) + YoutubeSection.HOME -> stringResource(R.string.youtube_home_empty) + YoutubeSection.SAVED -> stringResource(R.string.youtube_saved_empty) + } + ) + if (browseSection == YoutubeSection.LIKED && !account.isSignedIn) { + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + context.startActivity(Intent(context, YoutubeLoginActivity::class.java)) + }, + ) { + Text(stringResource(R.string.youtube_sign_in), fontFamily = GoogleSansRounded) + } + } + } + } + isWideScreen && rows.any { it.kind == YoutubeHit.Kind.COLLECTION || browseSection == YoutubeSection.HOME } -> { + val columns = if (configuration.screenWidthDp >= 840) 4 else 3 + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(rows, key = { it.url }) { hit -> + YoutubeTile( + hit = hit, + isCurrent = currentSongId == YoutubeClient.songIdForUrl(hit.url), + isPlaying = isPlaying, + onClick = { playHit(hit, browseSection == YoutubeSection.RADIO) }, + ) + } + } + } + else -> { + LazyColumn(contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (browseSection == YoutubeSection.RADIO) { + item(key = "radio_explainer") { + Text( + text = stringResource(R.string.youtube_radio_explainer), + modifier = Modifier.padding(bottom = 8.dp), + fontFamily = GoogleSansRounded, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + items(rows, key = { it.url }) { hit -> + val song = remember(hit) { placeholderSong(hit) } + YoutubeTrackRow( + song = song, + isPlaying = isPlaying && currentSongId == song.id, + isCurrent = currentSongId == song.id, + liked = song.id in likedIds, + saved = song.id in savedIds, + onPlay = { playHit(hit, browseSection == YoutubeSection.RADIO) }, + onLike = { viewModel.toggleLike(hit) }, + onSave = { viewModel.toggleSave(hit) }, + ) + } + } + } + } + } + } + } + if (showChangelog) { + ModalBottomSheet(onDismissRequest = { showChangelog = false }) { + ChangelogBottomSheet(onDismiss = { showChangelog = false }) + } + } + if (showBeta) { + ModalBottomSheet(onDismissRequest = { showBeta = false }) { + BetaInfoBottomSheet() + } + } +} + +@Composable +private fun YoutubeCompactHeader( + signedIn: Boolean, + onBeta: () -> Unit, + onChangelog: () -> Unit, + onAccount: () -> Unit, + onSettings: () -> Unit, +) { + val headerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) + Row( + modifier = Modifier + .fillMaxWidth() + .background(headerColor) + .statusBarsPadding() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FilledTonalButton( + onClick = onBeta, + shape = CircleShape, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + text = stringResource(R.string.topbar_beta_letter) + " " + stringResource(R.string.topbar_beta_label), + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.weight(1f)) + FilledIconButton(onClick = onAccount) { + Icon( + imageVector = if (signedIn) Icons.Rounded.AccountCircle else Icons.Rounded.Login, + contentDescription = stringResource(R.string.youtube_sign_in), + ) + } + FilledIconButton(onClick = onChangelog) { + Icon( + painter = painterResource(R.drawable.round_newspaper_24), + contentDescription = stringResource(R.string.topbar_cd_changelog), + ) + } + FilledIconButton(onClick = onSettings) { + Icon( + painter = painterResource(R.drawable.rounded_settings_24), + contentDescription = stringResource(R.string.common_settings), + ) + } + } +} + +@Composable +private fun YoutubeTrackRow( + song: Song, + isPlaying: Boolean, + isCurrent: Boolean, + liked: Boolean, + saved: Boolean, + onPlay: () -> Unit, + onLike: () -> Unit, + onSave: () -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + EnhancedSongListItem( + modifier = Modifier.weight(1f), + song = song, + isPlaying = isPlaying, + isCurrentSong = isCurrent, + showMoreOptionsButton = false, + onMoreOptionsClick = {}, + onClick = onPlay, + ) + IconButton(onClick = onLike) { + Icon( + imageVector = if (liked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, + contentDescription = stringResource(if (liked) R.string.youtube_unlike else R.string.youtube_like), + tint = if (liked) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onSave) { + Icon( + imageVector = if (saved) Icons.Rounded.Bookmark else Icons.Rounded.BookmarkBorder, + contentDescription = stringResource(if (saved) R.string.youtube_unsave else R.string.youtube_save), + tint = if (saved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun YoutubeTile( + hit: YoutubeHit, + isCurrent: Boolean, + isPlaying: Boolean, + subtitle: String = hit.artist, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Surface( + onClick = onClick, + modifier = modifier, + shape = RoundedCornerShape(24.dp), + color = if (isCurrent) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerLow + }, + ) { + Column(Modifier.padding(10.dp)) { + Box { + AsyncImage( + model = hit.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(18.dp)), + contentScale = ContentScale.Crop, + ) + if (isCurrent && isPlaying) { + PlayingEqIcon( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + .size(22.dp), + color = MaterialTheme.colorScheme.primary, + ) + } + } + Spacer(Modifier.height(10.dp)) + Text( + text = hit.title, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = GoogleSansRounded, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun EmptyYoutubeState(message: String) { + Column( + modifier = Modifier.padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Rounded.MusicNote, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = message, + fontFamily = GoogleSansRounded, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private fun placeholderSong(hit: YoutubeHit): Song { + val id = YoutubeClient.songIdForUrl(hit.url) + YoutubeClient.rememberWatchUrl(id, hit.url) + return Song( + id = id, + title = hit.title, + artist = hit.artist, + artistId = -1L, + artists = emptyList(), + album = "YouTube Music", + albumId = -1L, + path = hit.url, + contentUriString = hit.url, + albumArtUriString = hit.thumbnailUrl, + duration = hit.durationSec.coerceAtLeast(0L) * 1000L, + genre = "YouTube Music", + mimeType = null, + bitrate = null, + sampleRate = null, + ) +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeSettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeSettingsScreen.kt new file mode 100644 index 0000000000..ace77af630 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeSettingsScreen.kt @@ -0,0 +1,99 @@ +package com.theveloper.pixelplay.presentation.screens + +import android.content.Intent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.presentation.youtube.auth.YoutubeLoginActivity +import com.theveloper.pixelplay.ui.theme.GoogleSansRounded +import com.theveloper.pixelplay.youtube.YoutubeViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun YoutubeSettingsScreen( + navController: NavController, + viewModel: YoutubeViewModel = hiltViewModel(), +) { + val account by viewModel.account.collectAsStateWithLifecycle() + val context = LocalContext.current + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + stringResource(R.string.youtube_account_title), + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + ) + }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.youtube_back)) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.youtube_account_body), + style = MaterialTheme.typography.bodyLarge, + fontFamily = GoogleSansRounded, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (account.isSignedIn) { + Text( + text = account.displayName.ifBlank { stringResource(R.string.youtube_signed_in) }, + style = MaterialTheme.typography.titleMedium, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + ) + OutlinedButton( + onClick = viewModel::signOut, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.youtube_sign_out), fontFamily = GoogleSansRounded) + } + } else { + Button( + onClick = { + context.startActivity(Intent(context, YoutubeLoginActivity::class.java)) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.youtube_sign_in_google), fontFamily = GoogleSansRounded) + } + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index 4ddadbedea..abebcb68de 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -216,7 +216,8 @@ class PlayerViewModel @Inject constructor( private val playbackDispatchStateHolder: PlaybackDispatchStateHolder, private val mediaControllerSyncStateHolder: MediaControllerSyncStateHolder, private val sessionToken: SessionToken, - private val mediaControllerFactory: com.theveloper.pixelplay.data.media.MediaControllerFactory + private val mediaControllerFactory: com.theveloper.pixelplay.data.media.MediaControllerFactory, + private val youtubeMusicStore: com.theveloper.pixelplay.youtube.YoutubeMusicStore, ) : ViewModel() { private val _playerUiState = MutableStateFlow(PlayerUiState()) @@ -1200,6 +1201,8 @@ class PlayerViewModel @Inject constructor( .getFavoriteSongIdsFlow() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) + val youtubeSavedSongIds: StateFlow> = youtubeMusicStore.savedSongIds + val isCurrentSongFavorite: StateFlow = combine( stablePlayerState .map { it.currentSong } @@ -1213,9 +1216,15 @@ class PlayerViewModel @Inject constructor( emit(resolveFavoriteSongId(song)) } }, - favoriteSongIds - ) { favoriteSongId, ids -> - favoriteSongId?.let { ids.contains(it) } ?: false + favoriteSongIds, + youtubeMusicStore.likedSongIds, + stablePlayerState.map { it.currentSong?.id }.distinctUntilChanged(), + ) { favoriteSongId, ids, youtubeLikedIds, currentSongId -> + if (currentSongId?.startsWith("yt_") == true) { + currentSongId in youtubeLikedIds + } else { + favoriteSongId?.let { ids.contains(it) } ?: false + } }.distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) @@ -2261,6 +2270,10 @@ class PlayerViewModel @Inject constructor( fun toggleFavorite() { val currentSong = playbackStateHolder.stablePlayerState.value.currentSong ?: return + if (currentSong.id.startsWith("yt_")) { + youtubeMusicStore.toggleLike(currentSong) + return + } viewModelScope.launch { val favoriteSongId = resolveFavoriteSongId(currentSong) ?: return@launch val currentlyFavorite = favoriteSongIds.value.contains(favoriteSongId) @@ -2268,6 +2281,13 @@ class PlayerViewModel @Inject constructor( } } + fun toggleYoutubeSave() { + val currentSong = playbackStateHolder.stablePlayerState.value.currentSong ?: return + if (currentSong.id.startsWith("yt_")) { + youtubeMusicStore.toggleSave(currentSong) + } + } + fun toggleFavoriteSpecificSong(song: Song, removing: Boolean = false) { viewModelScope.launch { val favoriteSongId = resolveFavoriteSongId(song) ?: return@launch diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SetupViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SetupViewModel.kt index 156e196ee2..c88810b1ca 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SetupViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SetupViewModel.kt @@ -51,7 +51,7 @@ data class SetupUiState( ) { val allPermissionsGranted: Boolean get() { - val mediaOk = mediaPermissionGranted + val mediaOk = true val notificationsOk = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) notificationsPermissionGranted else true return mediaOk && notificationsOk } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt new file mode 100644 index 0000000000..0366e8aa9a --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt @@ -0,0 +1,214 @@ +package com.theveloper.pixelplay.presentation.youtube.auth + +import android.annotation.SuppressLint +import android.os.Bundle +import android.webkit.CookieManager +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.viewinterop.AndroidView +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.ui.theme.GoogleSansRounded +import com.theveloper.pixelplay.ui.theme.PixelPlayTheme +import com.theveloper.pixelplay.youtube.YoutubeSession +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class YoutubeLoginActivity : ComponentActivity() { + + @Inject + lateinit var session: YoutubeSession + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + PixelPlayTheme { + YoutubeWebLoginScreen( + onClose = { finish() }, + onSignedIn = { cookies -> + session.save(cookies) + finish() + }, + ) + } + } + } + + companion object { + const val TARGET_URL = + "https://accounts.google.com/ServiceLogin?service=youtube&passive=true" + + "&continue=https%3A%2F%2Fwww.youtube.com%2F&hl=en" + const val DESKTOP_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/124.0.0.0 Safari/537.36" + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun YoutubeWebLoginScreen( + onClose: () -> Unit, + onSignedIn: (cookieHeader: String) -> Unit, +) { + var progress by remember { mutableIntStateOf(0) } + var webView by remember { mutableStateOf(null) } + + BackHandler { onClose() } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + stringResource(R.string.youtube_sign_in), + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null) + } + }, + actions = { + TextButton( + onClick = { + val cookies = extractYoutubeSession() + if (cookies != null) { + onSignedIn(cookies) + } else { + Toast.makeText( + webView?.context, + R.string.youtube_sign_in_incomplete, + Toast.LENGTH_SHORT, + ).show() + } + }, + ) { + Text(stringResource(R.string.youtube_sign_in_done), fontFamily = GoogleSansRounded) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + if (progress in 1..99) { + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + } + Box(Modifier.fillMaxSize()) { + YoutubeWebView( + onProgress = { progress = it }, + onWebViewCreated = { webView = it }, + onCookiesMaybeReady = { + val cookies = extractYoutubeSession() + if (cookies != null) onSignedIn(cookies) + }, + ) + } + } + } +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun YoutubeWebView( + onProgress: (Int) -> Unit, + onWebViewCreated: (WebView) -> Unit, + onCookiesMaybeReady: () -> Unit, +) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.cacheMode = WebSettings.LOAD_DEFAULT + settings.userAgentString = YoutubeLoginActivity.DESKTOP_UA + CookieManager.getInstance().setAcceptCookie(true) + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + onProgress(newProgress) + } + } + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + onCookiesMaybeReady() + } + } + loadUrl(YoutubeLoginActivity.TARGET_URL) + onWebViewCreated(this) + } + }, + ) +} + +internal fun extractYoutubeSession(): String? { + val manager = CookieManager.getInstance() + val parts = listOf( + "https://www.youtube.com", + "https://youtube.com", + "https://music.youtube.com", + "https://accounts.google.com", + "https://google.com", + ).map { manager.getCookie(it).orEmpty() } + val map = linkedMapOf() + parts.joinToString("; ") + .split(';') + .map { it.trim() } + .filter { it.contains('=') } + .forEach { part -> + val index = part.indexOf('=') + val key = part.substring(0, index).trim() + val value = part.substring(index + 1).trim() + if (key.isNotEmpty() && value.isNotEmpty()) map[key] = value + } + val signedIn = map.keys.any { key -> + key.equals("LOGIN_INFO", ignoreCase = true) || + key.equals("SAPISID", ignoreCase = true) || + key.equals("__Secure-1PSID", ignoreCase = true) || + key.equals("__Secure-3PSID", ignoreCase = true) + } + if (!signedIn) return null + return map.entries.joinToString("; ") { "${it.key}=${it.value}" } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt index 32f56752a4..76b4fae185 100644 --- a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt @@ -2,7 +2,6 @@ package com.theveloper.pixelplay.soundcloud import com.theveloper.pixelplay.data.model.Song import org.schabi.newpipe.extractor.InfoItem -import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs @@ -17,7 +16,6 @@ import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import timber.log.Timber import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton @@ -48,10 +46,6 @@ class SoundCloudClient @Inject constructor( val cachedAtMs: Long, ) - init { - ensureInitialized(downloader) - } - fun setClientIdOverride(clientId: String?) { val trimmed = clientId?.trim().orEmpty() if (trimmed.isEmpty()) { @@ -478,7 +472,6 @@ class SoundCloudClient @Inject constructor( companion object { const val EXTRA_PERMALINK = "com.theveloper.pixelplay.soundcloud.PERMALINK" private val permalinkBySongId = ConcurrentHashMap() - private val initialized = AtomicBoolean(false) private const val RESOLVE_CACHE_TTL_MS = 15 * 60 * 1000L fun rememberPermalink(songId: String, permalink: String) { @@ -491,12 +484,5 @@ class SoundCloudClient @Inject constructor( fun songIdForUrl(url: String): String = "sc_${url.trim().hashCode().toUInt()}" - - private fun ensureInitialized(downloader: SoundCloudDownloader) { - if (initialized.compareAndSet(false, true)) { - NewPipe.init(downloader) - Timber.i("SoundCloud: NewPipe Extractor initialized") - } - } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt b/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt index 4952a2d65b..910cbe1850 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt @@ -14,6 +14,7 @@ import androidx.media3.common.util.UnstableApi import com.theveloper.pixelplay.data.provider.SharedArtworkContentProvider import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.soundcloud.SoundCloudClient +import com.theveloper.pixelplay.youtube.YoutubeClient import java.io.File object MediaItemBuilder { @@ -100,13 +101,15 @@ object MediaItemBuilder { const val EXTERNAL_EXTRA_NAVIDROME_ID = EXTERNAL_EXTRA_PREFIX + "NAVIDROME_ID" fun build(song: Song): MediaItem { - return withSoundCloudPermalink( - MediaItem.Builder() - .setMediaId(song.id) - .setUri(playbackUri(song)) - .setMimeType(playbackMimeType(song)) - .setMediaMetadata(buildMediaMetadataForSong(song)) - .build(), + return withYoutubeWatchUrl( + withSoundCloudPermalink( + MediaItem.Builder() + .setMediaId(song.id) + .setUri(playbackUri(song)) + .setMimeType(playbackMimeType(song)) + .setMediaMetadata(buildMediaMetadataForSong(song)) + .build(), + ), ) } @@ -128,6 +131,7 @@ object MediaItemBuilder { ) .build() .let(::withSoundCloudPermalink) + .let(::withYoutubeWatchUrl) } } @@ -143,6 +147,18 @@ object MediaItemBuilder { .build() } + fun withYoutubeWatchUrl(item: MediaItem): MediaItem { + val watchUrl = item.mediaMetadata.extras?.getString(YoutubeClient.EXTRA_WATCH_URL) + ?: YoutubeClient.watchUrlForSongId(item.mediaId) + ?: return item + val metadata = item.mediaMetadata + val extras = Bundle(metadata.extras ?: Bundle()) + extras.putString(YoutubeClient.EXTRA_WATCH_URL, watchUrl) + return item.buildUpon() + .setMediaMetadata(metadata.buildUpon().setExtras(extras).build()) + .build() + } + fun playbackUri(song: Song): Uri = playbackUri( contentUriString = song.contentUriString, filePath = song.path, diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt new file mode 100644 index 0000000000..a648240a7d --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -0,0 +1,272 @@ +package com.theveloper.pixelplay.youtube + +import com.theveloper.pixelplay.data.model.Song +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.kiosk.KioskInfo +import org.schabi.newpipe.extractor.playlist.PlaylistInfo +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem +import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper +import org.schabi.newpipe.extractor.services.youtube.YoutubeService +import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory +import org.schabi.newpipe.extractor.stream.AudioStream +import org.schabi.newpipe.extractor.stream.DeliveryMethod +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class YoutubeClient @Inject constructor( + private val session: YoutubeSession, +) { + private val downloader = YoutubeDownloader() + private val resolveCache = ConcurrentHashMap() + + private data class CachedResolve( + val track: YoutubeResolvedTrack, + val cachedAtMs: Long, + ) + + init { + ensureInitialized(downloader) + } + + fun loadTrendingMusic(limit: Int = 30): List { + applySession() + val extractor = youtube().kioskList.getExtractorById("trending_music", null) + extractor.fetchPage() + val info = KioskInfo.getInfo(extractor) + return info.relatedItems + .asSequence() + .mapNotNull { it.toHitOrNull() } + .filter { it.url.isNotBlank() && it.kind == YoutubeHit.Kind.TRACK } + .take(limit) + .toList() + } + + fun search( + query: String, + filter: YoutubeSearchFilter, + limit: Int = 30, + ): List { + val trimmed = query.trim() + require(trimmed.isNotEmpty()) { "Query is empty" } + applySession() + val contentFilter = when (filter) { + YoutubeSearchFilter.SONGS -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS + YoutubeSearchFilter.ALBUMS -> YoutubeSearchQueryHandlerFactory.MUSIC_ALBUMS + YoutubeSearchFilter.PLAYLISTS -> YoutubeSearchQueryHandlerFactory.MUSIC_PLAYLISTS + } + val handler = youtube().searchQHFactory.fromQuery(trimmed, listOf(contentFilter), "") + val extractor = youtube().getSearchExtractor(handler) + extractor.fetchPage() + return extractor.initialPage.items + .asSequence() + .mapNotNull { it.toHitOrNull() } + .filter { it.url.isNotBlank() } + .take(limit) + .toList() + } + + fun loadLikedVideos(limit: Int = 50): List { + applySession() + if (!session.isSignedIn()) return emptyList() + val info = PlaylistInfo.getInfo(youtube(), "https://www.youtube.com/playlist?list=LL") + return info.relatedItems + .asSequence() + .mapNotNull { it.toHitOrNull() } + .filter { it.url.isNotBlank() && it.kind == YoutubeHit.Kind.TRACK } + .take(limit) + .toList() + } + + fun loadSubscriptionFeed(limit: Int = 20): List { + applySession() + val extractor = youtube().getFeedExtractor("https://www.youtube.com/feed/subscriptions") + extractor.fetchPage() + return extractor.initialPage.items + .asSequence() + .mapNotNull { it.toHitOrNull() } + .filter { it.url.isNotBlank() } + .take(limit) + .toList() + } + + fun loadRadio(watchUrl: String, limit: Int = 25): List { + applySession() + val videoId = watchUrl.substringAfter("v=", "") + .substringBefore('&') + .substringBefore('?') + .trim() + require(videoId.length >= 6) { "This track can't start a radio" } + return loadCollectionTracks("https://www.youtube.com/watch?v=$videoId&list=RD$videoId", limit) + } + + fun loadCollectionTracks(url: String, limit: Int = 80): List { + applySession() + val info = PlaylistInfo.getInfo(youtube(), url.trim()) + return info.relatedItems + .asSequence() + .mapNotNull { it.toHitOrNull() } + .filter { it.url.isNotBlank() && it.kind == YoutubeHit.Kind.TRACK } + .take(limit) + .toList() + } + + fun resolveTrack(watchUrl: String): YoutubeResolvedTrack { + applySession() + val key = watchUrl.trim() + resolveCache[key]?.takeIf { + System.currentTimeMillis() - it.cachedAtMs < RESOLVE_CACHE_TTL_MS + }?.let { return it.track } + + val info = StreamInfo.getInfo(youtube(), key) + val stream = pickBestAudioStream(info.audioStreams) + ?: throw IllegalStateException("No playable audio stream for this track.") + val resolved = YoutubeResolvedTrack( + watchUrl = info.url.orEmpty().ifBlank { key }, + title = info.name.orEmpty().ifBlank { "Unknown title" }, + artist = info.uploaderName.orEmpty().ifBlank { "Unknown artist" }, + durationMs = info.duration.coerceAtLeast(0L) * 1000L, + streamUrl = stream.content, + mimeType = stream.format?.mimeType, + artworkUrl = info.thumbnails.maxByOrNull { it.height }?.url, + ) + resolveCache[key] = CachedResolve(resolved, System.currentTimeMillis()) + return resolved + } + + fun toSong(track: YoutubeResolvedTrack, watchUrl: String = track.watchUrl): Song { + val permalink = watchUrl.ifBlank { track.watchUrl }.trim() + val id = songIdForUrl(permalink) + if (permalink.startsWith("http")) rememberWatchUrl(id, permalink) + return Song( + id = id, + title = track.title, + artist = track.artist, + artistId = -1L, + artists = emptyList(), + album = "YouTube Music", + albumId = -1L, + albumArtist = track.artist, + path = track.streamUrl, + contentUriString = track.streamUrl, + albumArtUriString = track.artworkUrl, + duration = track.durationMs, + genre = "YouTube Music", + mimeType = track.mimeType, + bitrate = null, + sampleRate = null, + ) + } + + fun listSong(hit: YoutubeHit): Song { + val id = songIdForUrl(hit.url) + rememberWatchUrl(id, hit.url) + return Song( + id = id, + title = hit.title, + artist = hit.artist, + artistId = -1L, + artists = emptyList(), + album = "YouTube Music", + albumId = -1L, + albumArtist = hit.artist, + path = hit.url, + contentUriString = hit.url, + albumArtUriString = hit.thumbnailUrl, + duration = hit.durationSec.coerceAtLeast(0L) * 1000L, + genre = "YouTube Music", + mimeType = null, + bitrate = null, + sampleRate = null, + ) + } + + private fun youtube(): YoutubeService = ServiceList.YouTube + + private fun applySession() { + val cookies = session.cookieHeader().takeIf { it.isNotBlank() } + downloader.cookieHeader = cookies + injectCookie(cookies) + } + + private fun injectCookie(cookie: String?) { + val helper = YoutubeParsingHelper::class.java + val field = helper.declaredFields.firstOrNull { candidate -> + java.lang.reflect.Modifier.isStatic(candidate.modifiers) && + candidate.type == String::class.java && + candidate.name.contains("cookie", ignoreCase = true) + } ?: return + field.isAccessible = true + field.set(null, cookie) + } + + private fun InfoItem.toHitOrNull(): YoutubeHit? = when (this) { + is StreamInfoItem -> YoutubeHit( + url = url.orEmpty(), + title = name.orEmpty().ifBlank { "Unknown title" }, + artist = uploaderName.orEmpty().ifBlank { "YouTube Music" }, + durationSec = duration.coerceAtLeast(0L), + thumbnailUrl = thumbnails.maxByOrNull { it.height }?.url, + kind = YoutubeHit.Kind.TRACK, + ) + is PlaylistInfoItem -> YoutubeHit( + url = url.orEmpty(), + title = name.orEmpty().ifBlank { "Playlist" }, + artist = uploaderName.orEmpty().ifBlank { "YouTube Music" }, + durationSec = 0L, + thumbnailUrl = thumbnails.maxByOrNull { it.height }?.url, + kind = YoutubeHit.Kind.COLLECTION, + ) + else -> null + } + + private fun pickBestAudioStream(streams: List): AudioStream? { + if (streams.isEmpty()) return null + val progressive = streams.filter { it.deliveryMethod == DeliveryMethod.PROGRESSIVE_HTTP } + if (progressive.isNotEmpty()) { + return progressive.maxWithOrNull( + compareBy { it.averageBitrate }.thenBy { it.bitrate }, + ) + } + val hls = streams.filter { + it.deliveryMethod == DeliveryMethod.HLS && !it.content.contains("encrypted") + } + if (hls.isNotEmpty()) { + return hls.maxWithOrNull( + compareBy { it.averageBitrate }.thenBy { it.bitrate }, + ) + } + return streams.firstOrNull { !it.content.contains("encrypted") } + } + + companion object { + const val EXTRA_WATCH_URL = "com.theveloper.pixelplay.youtube.WATCH_URL" + private val watchUrlBySongId = ConcurrentHashMap() + private val initialized = AtomicBoolean(false) + private const val RESOLVE_CACHE_TTL_MS = 15 * 60 * 1000L + + fun rememberWatchUrl(songId: String, watchUrl: String) { + val id = songId.trim() + val url = watchUrl.trim() + if (id.isNotEmpty() && url.startsWith("http")) watchUrlBySongId[id] = url + } + + fun watchUrlForSongId(songId: String): String? = watchUrlBySongId[songId] + + fun songIdForUrl(url: String): String = "yt_${url.trim().hashCode().toUInt()}" + + private fun ensureInitialized(downloader: YoutubeDownloader) { + if (initialized.compareAndSet(false, true)) { + NewPipe.init(downloader) + Timber.i("YouTube: NewPipe Extractor initialized") + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt new file mode 100644 index 0000000000..fdde6d5c15 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt @@ -0,0 +1,60 @@ +package com.theveloper.pixelplay.youtube + +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import org.schabi.newpipe.extractor.downloader.Downloader +import org.schabi.newpipe.extractor.downloader.Request +import org.schabi.newpipe.extractor.downloader.Response +import java.util.concurrent.TimeUnit + +/** + * NewPipe HTTP layer with a normal browser user agent. + * The app-wide OkHttp client sends a PixelPlayer user agent that YouTube rejects. + */ +internal class YoutubeDownloader : Downloader() { + @Volatile + var cookieHeader: String? = null + private val client: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .build() + + override fun execute(request: Request): Response { + val bodyBytes = request.dataToSend() + val requestBody = bodyBytes?.toRequestBody(null) + val builder = okhttp3.Request.Builder() + .method(request.httpMethod(), requestBody) + .url(request.url()) + .header("User-Agent", USER_AGENT) + .header("Accept-Language", "en-US,en;q=0.9") + + request.headers().forEach { (name, values) -> + builder.removeHeader(name) + values.forEach { value -> builder.addHeader(name, value) } + } + cookieHeader?.trim()?.takeIf { it.isNotEmpty() }?.let { cookies -> + builder.header("Cookie", cookies) + } + + client.newCall(builder.build()).execute().use { response -> + val responseBody = response.body.string() + if (response.code == 429) { + throw java.io.IOException("YouTube rate limited this request. Wait a moment and try again.") + } + return Response( + response.code, + response.message, + response.headers.toMultimap(), + responseBody, + response.request.url.toString(), + ) + } + } + + companion object { + private const val USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0" + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt new file mode 100644 index 0000000000..fa4327d52f --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt @@ -0,0 +1,44 @@ +package com.theveloper.pixelplay.youtube + +enum class YoutubeSection { + HOME, + SEARCH, + LIKED, + SAVED, + PLAYLISTS, + RADIO, +} + +data class YoutubeShelf( + val id: String, + val title: String, + val items: List, + val startsRadio: Boolean = false, +) + +enum class YoutubeSearchFilter { + SONGS, + ALBUMS, + PLAYLISTS, +} + +data class YoutubeHit( + val url: String, + val title: String, + val artist: String, + val durationSec: Long, + val thumbnailUrl: String?, + val kind: Kind, +) { + enum class Kind { TRACK, COLLECTION } +} + +data class YoutubeResolvedTrack( + val watchUrl: String, + val title: String, + val artist: String, + val durationMs: Long, + val streamUrl: String, + val mimeType: String?, + val artworkUrl: String?, +) diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt new file mode 100644 index 0000000000..7edbb22aa4 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt @@ -0,0 +1,124 @@ +package com.theveloper.pixelplay.youtube + +import android.content.Context +import com.theveloper.pixelplay.data.model.Song +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONArray +import org.json.JSONObject +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Liked and saved YouTube Music tracks. These lists are not device files. + */ +@Singleton +class YoutubeMusicStore @Inject constructor( + @ApplicationContext context: Context, +) { + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val _liked = MutableStateFlow(read(KEY_LIKED)) + private val _saved = MutableStateFlow(read(KEY_SAVED)) + private val _likedSongIds = MutableStateFlow(idsOf(_liked.value)) + private val _savedSongIds = MutableStateFlow(idsOf(_saved.value)) + + val liked: StateFlow> = _liked.asStateFlow() + val saved: StateFlow> = _saved.asStateFlow() + val likedSongIds: StateFlow> = _likedSongIds.asStateFlow() + val savedSongIds: StateFlow> = _savedSongIds.asStateFlow() + + fun toggleLike(hit: YoutubeHit) { + _liked.value = toggle(_liked.value, hit) + _likedSongIds.value = idsOf(_liked.value) + persist(KEY_LIKED, _liked.value) + } + + fun toggleSave(hit: YoutubeHit) { + _saved.value = toggle(_saved.value, hit) + _savedSongIds.value = idsOf(_saved.value) + persist(KEY_SAVED, _saved.value) + } + + fun toggleLike(song: Song) { + hitFromSong(song)?.let(::toggleLike) + } + + fun toggleSave(song: Song) { + hitFromSong(song)?.let(::toggleSave) + } + + private fun idsOf(hits: List): Set = + hits.map { YoutubeClient.songIdForUrl(it.url) }.toSet() + + private fun toggle(current: List, hit: YoutubeHit): List { + if (hit.url.isBlank() || hit.kind != YoutubeHit.Kind.TRACK) return current + val without = current.filterNot { it.url == hit.url } + return if (without.size == current.size) listOf(hit) + current else without + } + + private fun hitFromSong(song: Song): YoutubeHit? { + val url = YoutubeClient.watchUrlForSongId(song.id) ?: return null + return YoutubeHit( + url = url, + title = song.title.ifBlank { "Unknown title" }, + artist = song.displayArtist.ifBlank { "YouTube Music" }, + durationSec = song.duration.coerceAtLeast(0L) / 1000L, + thumbnailUrl = song.albumArtUriString, + kind = YoutubeHit.Kind.TRACK, + ) + } + + private fun read(key: String): List { + val raw = prefs.getString(key, null) ?: return emptyList() + return runCatching { decode(raw) }.getOrDefault(emptyList()) + } + + private fun persist(key: String, hits: List) { + prefs.edit().putString(key, encode(hits.take(MAX_TRACKS))).apply() + } + + private fun encode(hits: List): String { + val array = JSONArray() + hits.forEach { hit -> + array.put( + JSONObject() + .put("url", hit.url) + .put("title", hit.title) + .put("artist", hit.artist) + .put("durationSec", hit.durationSec) + .put("thumbnailUrl", hit.thumbnailUrl ?: "") + ) + } + return array.toString() + } + + private fun decode(raw: String): List { + val array = JSONArray(raw) + return buildList { + for (index in 0 until array.length()) { + val item = array.getJSONObject(index) + val url = item.optString("url") + if (url.isBlank()) continue + add( + YoutubeHit( + url = url, + title = item.optString("title").ifBlank { "Unknown title" }, + artist = item.optString("artist").ifBlank { "YouTube Music" }, + durationSec = item.optLong("durationSec"), + thumbnailUrl = item.optString("thumbnailUrl").ifBlank { null }, + kind = YoutubeHit.Kind.TRACK, + ) + ) + } + } + } + + companion object { + private const val PREFS = "youtube_music" + private const val KEY_LIKED = "liked" + private const val KEY_SAVED = "saved" + private const val MAX_TRACKS = 200 + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt new file mode 100644 index 0000000000..dc5f4cfe6b --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt @@ -0,0 +1,57 @@ +package com.theveloper.pixelplay.youtube + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +data class YoutubeAccount( + val cookieHeader: String = "", + val displayName: String = "", +) { + val isSignedIn: Boolean get() = cookieHeader.isNotBlank() +} + +@Singleton +class YoutubeSession @Inject constructor( + @ApplicationContext context: Context, +) { + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val _account = MutableStateFlow(read()) + val account: StateFlow = _account.asStateFlow() + + fun cookieHeader(): String = _account.value.cookieHeader + + fun isSignedIn(): Boolean = _account.value.isSignedIn + + fun save(cookieHeader: String, displayName: String = "") { + val account = YoutubeAccount( + cookieHeader = cookieHeader.trim(), + displayName = displayName.trim().ifBlank { "Signed in" }, + ) + prefs.edit() + .putString(KEY_COOKIES, account.cookieHeader) + .putString(KEY_NAME, account.displayName) + .apply() + _account.value = account + } + + fun clear() { + prefs.edit().clear().apply() + _account.value = YoutubeAccount() + } + + private fun read(): YoutubeAccount = YoutubeAccount( + cookieHeader = prefs.getString(KEY_COOKIES, "").orEmpty(), + displayName = prefs.getString(KEY_NAME, "").orEmpty(), + ) + + companion object { + private const val PREFS = "youtube_session" + private const val KEY_COOKIES = "cookies" + private const val KEY_NAME = "display_name" + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt new file mode 100644 index 0000000000..5ee686ca7c --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -0,0 +1,343 @@ +package com.theveloper.pixelplay.youtube + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.theveloper.pixelplay.data.model.Song +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +data class YoutubeUiState( + val section: YoutubeSection = YoutubeSection.HOME, + val query: String = "", + val filter: YoutubeSearchFilter = YoutubeSearchFilter.SONGS, + val results: List = emptyList(), + val shelves: List = emptyList(), + val collectionTitle: String? = null, + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val error: String? = null, +) + +@HiltViewModel +class YoutubeViewModel @Inject constructor( + private val client: YoutubeClient, + private val musicStore: YoutubeMusicStore, + private val session: YoutubeSession, +) : ViewModel() { + private val _uiState = MutableStateFlow(YoutubeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + val likedTracks: StateFlow> = musicStore.liked + val savedTracks: StateFlow> = musicStore.saved + val likedSongIds: StateFlow> = musicStore.likedSongIds + val savedSongIds: StateFlow> = musicStore.savedSongIds + val account: StateFlow = session.account + + init { + viewModelScope.launch { + session.account.collect { account -> + if (_uiState.value.section == YoutubeSection.LIKED) { + loadLiked(refreshing = account.isSignedIn) + } + } + } + } + + fun openSection(section: YoutubeSection) { + val current = _uiState.value + if (current.section == section && current.collectionTitle == null) { + val hasContent = when (section) { + YoutubeSection.HOME -> current.shelves.isNotEmpty() + YoutubeSection.SEARCH -> current.query.trim().length < 2 || current.results.isNotEmpty() + YoutubeSection.SAVED -> true + else -> current.results.isNotEmpty() + } + if (hasContent) return + } + _uiState.update { + it.copy(section = section, collectionTitle = null, error = null) + } + when (section) { + YoutubeSection.HOME -> loadFeed() + YoutubeSection.SEARCH -> { + if (_uiState.value.query.trim().length >= 2) search(_uiState.value.query) + else _uiState.update { it.copy(results = emptyList(), shelves = emptyList(), isLoading = false) } + } + YoutubeSection.LIKED -> loadLiked() + YoutubeSection.PLAYLISTS -> loadPlaylists() + YoutubeSection.RADIO -> loadRadioStations() + YoutubeSection.SAVED -> _uiState.update { it.copy(results = emptyList(), shelves = emptyList(), isLoading = false) } + } + } + + fun onQueryChange(query: String) { + _uiState.update { it.copy(query = query, error = null) } + if (query.trim().length >= 2) search(query) else { + _uiState.update { it.copy(results = emptyList(), isLoading = false, collectionTitle = null) } + } + } + + fun setFilter(filter: YoutubeSearchFilter) { + _uiState.update { it.copy(filter = filter, collectionTitle = null) } + val query = _uiState.value.query + if (query.trim().length >= 2) search(query) + } + + fun refresh() { + when (_uiState.value.section) { + YoutubeSection.HOME -> loadFeed(refreshing = true) + YoutubeSection.SEARCH -> { + val query = _uiState.value.query + if (query.trim().length >= 2) search(query, refreshing = true) + } + YoutubeSection.LIKED -> loadLiked(refreshing = true) + YoutubeSection.PLAYLISTS -> loadPlaylists(refreshing = true) + YoutubeSection.RADIO -> loadRadioStations(refreshing = true) + YoutubeSection.SAVED -> Unit + } + } + + fun openCollection(hit: YoutubeHit) { + if (hit.kind != YoutubeHit.Kind.COLLECTION) return + viewModelScope.launch { + _uiState.update { + it.copy(isLoading = true, error = null, collectionTitle = hit.title, results = emptyList()) + } + val loaded = runCatching { + withContext(Dispatchers.IO) { client.loadCollectionTracks(hit.url) } + } + loaded.fold( + onSuccess = { tracks -> + _uiState.update { it.copy(isLoading = false, results = tracks) } + }, + onFailure = { error -> + _uiState.update { + it.copy(isLoading = false, error = error.message ?: "Couldn't open this collection") + } + }, + ) + } + } + + fun reportError(message: String) { + _uiState.update { it.copy(error = message, isLoading = false) } + } + + fun closeCollection() { + val state = _uiState.value + _uiState.update { it.copy(collectionTitle = null, error = null) } + if (state.section == YoutubeSection.SEARCH && state.query.trim().length >= 2) { + search(state.query) + } else if (state.section == YoutubeSection.HOME) { + loadFeed() + } else if (state.section == YoutubeSection.PLAYLISTS) { + loadPlaylists() + } + } + + suspend fun resolvePlayable(hit: YoutubeHit): Song = withContext(Dispatchers.IO) { + val track = client.resolveTrack(hit.url) + client.toSong(track, hit.url) + } + + fun toggleLike(hit: YoutubeHit) { + musicStore.toggleLike(hit) + } + + fun toggleSave(hit: YoutubeHit) { + musicStore.toggleSave(hit) + } + + fun signOut() { + session.clear() + if (_uiState.value.section == YoutubeSection.LIKED) loadLiked(refreshing = true) + } + + private fun loadLiked(refreshing: Boolean = false) { + viewModelScope.launch { + _uiState.update { + it.copy( + isLoading = !refreshing && it.results.isEmpty(), + isRefreshing = refreshing, + error = null, + ) + } + val local = musicStore.liked.value + val remote = if (session.isSignedIn()) { + runCatching { withContext(Dispatchers.IO) { client.loadLikedVideos() } } + } else { + Result.success(emptyList()) + } + remote.fold( + onSuccess = { accountLikes -> + val merged = (accountLikes + local).distinctBy { it.url } + _uiState.update { it.copy(isLoading = false, isRefreshing = false, results = merged) } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + results = local, + error = error.message ?: "Couldn't load liked videos", + ) + } + }, + ) + } + } + + fun prefetchAfter(start: YoutubeHit, queue: List, onSong: (Song) -> Unit) { + val startIndex = queue.indexOfFirst { it.url == start.url } + if (startIndex < 0) return + val upcoming = queue.drop(startIndex + 1) + .filter { it.kind == YoutubeHit.Kind.TRACK } + .take(4) + viewModelScope.launch { + upcoming.forEach { hit -> + val song = runCatching { resolvePlayable(hit) }.getOrNull() ?: return@forEach + onSong(song) + } + } + } + + suspend fun radioTracks(seed: YoutubeHit): List = withContext(Dispatchers.IO) { + client.loadRadio(seed.url) + } + + private fun loadFeed(refreshing: Boolean = false) { + viewModelScope.launch { + _uiState.update { + it.copy( + isLoading = !refreshing && it.shelves.isEmpty(), + isRefreshing = refreshing, + error = null, + collectionTitle = null, + ) + } + val built = runCatching { + withContext(Dispatchers.IO) { + val trending = runCatching { client.loadTrendingMusic(18) }.getOrDefault(emptyList()) + val playlists = runCatching { + client.search("top music", YoutubeSearchFilter.PLAYLISTS, 12) + }.getOrDefault(emptyList()) + val followed = if (session.isSignedIn()) { + runCatching { client.loadSubscriptionFeed(12) }.getOrDefault(emptyList()) + } else { + emptyList() + } + listOfNotNull( + followed.takeIf { it.isNotEmpty() }?.let { + YoutubeShelf("followed", "From your feed", it) + }, + trending.takeIf { it.isNotEmpty() }?.let { + YoutubeShelf("trending", "Trending songs", it) + }, + trending.take(10).takeIf { it.isNotEmpty() }?.let { + YoutubeShelf("radio", "Radio", it, startsRadio = true) + }, + playlists.takeIf { it.isNotEmpty() }?.let { + YoutubeShelf("playlists", "Playlists", it) + }, + ) + } + } + built.fold( + onSuccess = { shelves -> + _uiState.update { it.copy(isLoading = false, isRefreshing = false, shelves = shelves) } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + error = error.message ?: "Couldn't load the feed", + ) + } + }, + ) + } + } + + private fun loadPlaylists(refreshing: Boolean = false) { + viewModelScope.launch { + _uiState.update { + it.copy(isLoading = !refreshing && it.results.isEmpty(), isRefreshing = refreshing, error = null, shelves = emptyList()) + } + val loaded = runCatching { + withContext(Dispatchers.IO) { + listOf("today's hits", "workout", "focus", "party").flatMap { query -> + runCatching { client.search(query, YoutubeSearchFilter.PLAYLISTS, 6) }.getOrDefault(emptyList()) + }.distinctBy { it.url } + } + } + loaded.fold( + onSuccess = { hits -> + _uiState.update { it.copy(isLoading = false, isRefreshing = false, results = hits) } + }, + onFailure = { error -> + _uiState.update { + it.copy(isLoading = false, isRefreshing = false, error = error.message ?: "Couldn't load playlists") + } + }, + ) + } + } + + private fun loadRadioStations(refreshing: Boolean = false) { + viewModelScope.launch { + _uiState.update { + it.copy(isLoading = !refreshing && it.results.isEmpty(), isRefreshing = refreshing, error = null, shelves = emptyList()) + } + val loaded = runCatching { withContext(Dispatchers.IO) { client.loadTrendingMusic(16) } } + loaded.fold( + onSuccess = { hits -> + _uiState.update { it.copy(isLoading = false, isRefreshing = false, results = hits) } + }, + onFailure = { error -> + _uiState.update { + it.copy(isLoading = false, isRefreshing = false, error = error.message ?: "Couldn't load radio") + } + }, + ) + } + } + + private fun search(query: String, refreshing: Boolean = false) { + val filter = _uiState.value.filter + viewModelScope.launch { + _uiState.update { + it.copy( + isLoading = !refreshing, + isRefreshing = refreshing, + error = null, + collectionTitle = null, + ) + } + val loaded = runCatching { + withContext(Dispatchers.IO) { client.search(query, filter) } + } + if (_uiState.value.query.trim() != query.trim()) return@launch + loaded.fold( + onSuccess = { hits -> + _uiState.update { it.copy(isLoading = false, isRefreshing = false, results = hits) } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + error = error.message ?: "Search failed", + ) + } + }, + ) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 723f1fa769..64e8b24210 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,10 +8,52 @@ Home + Feed + Radio + Playlists Search + Liked SoundCloud Library + Feed + Trending, radio, and playlists + Radio + Playlists + Search + Liked + Saved + Home + Liked + Saved + Like + Unlike + Save + Remove save + Songs, albums, playlists + Songs + Albums + Playlists + No liked videos on this account yet. + Sign in to load your liked videos. + Sign in + Sign out + Done + Finish signing in, then tap Done. + Saved songs show up here. + Search YouTube Music. + The feed is unavailable right now. + Radio stations are unavailable right now. + Playlists are unavailable right now. + Radio from %1$s + Today\'s Mix for you + Account + Sign in with the Google account you use for YouTube. Liked videos and your subscription feed load from that account. + Sign in with Google + Signed in + Radio keeps playing songs that sound like the one you start. + Back + SoundCloud SoundCloud tabs From ee33fa7c7e83128480c92d35f69c58a3979007ed Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 13:41:45 +0300 Subject: [PATCH 39/77] fix: keep YouTube playback, likes, and playlists working Sign in through Chrome, preserve NewPipe playlist fields in release builds, and move the seek bar when a stream clock stays put. Co-authored-by: Cursor --- README.md | 9 +- app/proguard-rules.pro | 4 +- .../presentation/navigation/AppNavigation.kt | 2 +- .../presentation/screens/YoutubeScreen.kt | 35 +++- .../viewmodel/PlaybackStateHolder.kt | 37 +++- .../youtube/auth/YoutubeLoginActivity.kt | 193 ++++++------------ .../pixelplay/youtube/YoutubeClient.kt | 87 +++++++- .../pixelplay/youtube/YoutubeDownloader.kt | 23 +++ .../pixelplay/youtube/YoutubeViewModel.kt | 58 +++++- app/src/main/res/values/strings.xml | 9 +- 10 files changed, 293 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index a3fdc515fe..170fe4a959 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,11 @@ PixelPlayer plays YouTube audio through the existing player. It does not scan a Liked videos and the “channels you follow” shelf need the Google account you use on YouTube. -1. On **Feed**, tap the **account** icon in the colored header (Beta, account, changelog, settings). -2. Tap **Sign in with Google**. -3. Sign in inside the app, then tap **Done**. -4. Open **Liked** to load that account’s liked videos. +1. On **Feed**, tap the **account** icon. +2. Tap **Sign in with Google**, then **Open YouTube in Chrome**, and sign in there. Google blocks the in-app page (“browser or app may not be secure”). +3. On a computer, open [youtube.com](https://www.youtube.com) while signed in. Press **F12** → **Network** → click a `youtube.com` request → copy the **Cookie** request header. +4. Paste that header into **Account** and tap **Save session**. +5. Open **Liked** to load that account’s liked videos. **Sign out** is on the same Account screen. The heart on a song still saves a like on the device if you are signed out. diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index cb99e77533..f8d4f917e2 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -215,10 +215,12 @@ public static int i(...); } -# NewPipe Extractor (SoundCloud) +# NewPipe Extractor reads protobuf fields by name (browseId_). R8 must not rename them. +-keep class org.schabi.newpipe.extractor.** { *; } -keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } -keep class org.mozilla.javascript.** { *; } -keep class org.mozilla.javascript.XMLLib.** { *; } +-keep class com.google.protobuf.** { *; } -dontwarn org.mozilla.javascript.XMLLib -dontwarn org.schabi.newpipe.extractor.** diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt index c30bcf7a58..8ca293488a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt @@ -619,7 +619,7 @@ private enum class MainRootDirection { } // Base duration for bottom-nav switches at 1x — at 0.5x system scale = ~190 ms. -private const val BOTTOM_NAV_TRANSITION_DURATION = 380 +private const val BOTTOM_NAV_TRANSITION_DURATION = 160 // MD3 Expressive easing for bottom-nav switches private val BottomNavEasing = CubicBezierEasing(0.2f, 0f, 0f, 1f) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 9455e442fd..286299b1e2 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.rounded.Bookmark import androidx.compose.material.icons.rounded.BookmarkBorder import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Login import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material3.Button @@ -72,6 +73,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale @@ -100,6 +102,7 @@ import com.theveloper.pixelplay.presentation.components.ChangelogBottomSheet import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.presentation.youtube.auth.YoutubeLoginActivity import com.theveloper.pixelplay.ui.theme.GoogleSansRounded +import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import com.theveloper.pixelplay.youtube.YoutubeClient import com.theveloper.pixelplay.youtube.YoutubeHit import com.theveloper.pixelplay.youtube.YoutubeSearchFilter @@ -310,6 +313,18 @@ fun YoutubeScreen( fontFamily = GoogleSansRounded, ) }, + trailingIcon = if (uiState.query.isNotEmpty()) { + { + IconButton(onClick = { viewModel.onQueryChange("") }) { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(R.string.youtube_clear_search), + ) + } + } + } else { + null + }, ) }, expanded = false, @@ -454,7 +469,9 @@ fun YoutubeScreen( YoutubeSection.PLAYLISTS -> stringResource(R.string.youtube_playlists_empty) YoutubeSection.RADIO -> stringResource(R.string.youtube_radio_empty) YoutubeSection.SEARCH -> stringResource(R.string.youtube_search_empty) - YoutubeSection.HOME -> stringResource(R.string.youtube_home_empty) + YoutubeSection.HOME -> stringResource( + if (uiState.collectionTitle != null) R.string.youtube_playlist_empty else R.string.youtube_home_empty, + ) YoutubeSection.SAVED -> stringResource(R.string.youtube_saved_empty) } ) @@ -539,13 +556,25 @@ private fun YoutubeCompactHeader( onAccount: () -> Unit, onSettings: () -> Unit, ) { - val headerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) + val headerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f) + val headerShape = AbsoluteSmoothCornerShape( + cornerRadiusTL = 0.dp, + smoothnessAsPercentTL = 60, + cornerRadiusTR = 0.dp, + smoothnessAsPercentTR = 60, + cornerRadiusBL = 36.dp, + smoothnessAsPercentBL = 70, + cornerRadiusBR = 36.dp, + smoothnessAsPercentBR = 70, + ) Row( modifier = Modifier .fillMaxWidth() + .clip(headerShape) .background(headerColor) .statusBarsPadding() - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = 12.dp) + .padding(top = 6.dp, bottom = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { FilledTonalButton( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt index 1ef56cb287..906ac24cce 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt @@ -243,7 +243,11 @@ class PlaybackStateHolder @Inject constructor( } fun setCurrentPosition(positionMs: Long) { - _currentPosition.value = positionMs.coerceAtLeast(0L) + val safePosition = positionMs.coerceAtLeast(0L) + stuckClockRawPositionMs = safePosition + stuckClockBasePositionMs = safePosition + stuckClockBaseElapsedMs = SystemClock.elapsedRealtime() + _currentPosition.value = safePosition } fun syncCurrentPositionFromPlayer(mediaId: String?, reportedPositionMs: Long) { @@ -570,6 +574,31 @@ class PlaybackStateHolder @Inject constructor( /* -------------------------------------------------------------------------- */ private var progressJob: kotlinx.coroutines.Job? = null + private var stuckClockMediaId: String? = null + private var stuckClockRawPositionMs = 0L + private var stuckClockBasePositionMs = 0L + private var stuckClockBaseElapsedMs = 0L + + private fun positionForProgressUi(mediaId: String?, rawPositionMs: Long, isPlaying: Boolean): Long { + val raw = rawPositionMs.coerceAtLeast(0L) + val now = SystemClock.elapsedRealtime() + if (mediaId != stuckClockMediaId) { + stuckClockMediaId = mediaId + stuckClockRawPositionMs = raw + stuckClockBasePositionMs = raw + stuckClockBaseElapsedMs = now + return raw + } + if (!isPlaying || raw != stuckClockRawPositionMs) { + stuckClockRawPositionMs = raw + stuckClockBasePositionMs = raw + stuckClockBaseElapsedMs = now + return raw + } + val hintedDuration = _stablePlayerState.value.currentSong?.duration ?: 0L + val estimated = stuckClockBasePositionMs + (now - stuckClockBaseElapsedMs) + return if (hintedDuration > 0L) estimated.coerceAtMost(hintedDuration) else estimated + } /** * Reconciles duration reported by the player with the current song metadata duration. @@ -705,7 +734,11 @@ class PlaybackStateHolder @Inject constructor( continue } - val currentPosition = controller.currentPosition.coerceAtLeast(0L) + val currentPosition = positionForProgressUi( + mediaId = currentMediaId, + rawPositionMs = controller.currentPosition, + isPlaying = controller.isPlaying, + ) val songDurationHint = visibleSong?.duration ?: 0L val duration = resolveEffectiveDuration( reportedDurationMs = controller.duration, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt index 0366e8aa9a..bac1952512 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt @@ -1,93 +1,75 @@ package com.theveloper.pixelplay.presentation.youtube.auth -import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri import android.os.Bundle -import android.webkit.CookieManager -import android.webkit.WebChromeClient -import android.webkit.WebSettings -import android.webkit.WebView -import android.webkit.WebViewClient import android.widget.Toast -import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler +import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.theveloper.pixelplay.R import com.theveloper.pixelplay.ui.theme.GoogleSansRounded import com.theveloper.pixelplay.ui.theme.PixelPlayTheme -import com.theveloper.pixelplay.youtube.YoutubeSession +import com.theveloper.pixelplay.youtube.YoutubeViewModel import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject @AndroidEntryPoint class YoutubeLoginActivity : ComponentActivity() { - @Inject - lateinit var session: YoutubeSession - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { PixelPlayTheme { - YoutubeWebLoginScreen( - onClose = { finish() }, - onSignedIn = { cookies -> - session.save(cookies) - finish() - }, - ) + YoutubeBrowserSignInScreen(onClose = { finish() }) } } } companion object { - const val TARGET_URL = - "https://accounts.google.com/ServiceLogin?service=youtube&passive=true" + - "&continue=https%3A%2F%2Fwww.youtube.com%2F&hl=en" - const val DESKTOP_UA = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + - "AppleWebKit/537.36 (KHTML, like Gecko) " + - "Chrome/124.0.0.0 Safari/537.36" + const val YOUTUBE_HOME = "https://www.youtube.com/" } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun YoutubeWebLoginScreen( +private fun YoutubeBrowserSignInScreen( onClose: () -> Unit, - onSignedIn: (cookieHeader: String) -> Unit, + viewModel: YoutubeViewModel = hiltViewModel(), ) { - var progress by remember { mutableIntStateOf(0) } - var webView by remember { mutableStateOf(null) } - - BackHandler { onClose() } - + val context = LocalContext.current + var cookies by remember { mutableStateOf("") } + BackHandler(onBack = onClose) Scaffold( topBar = { TopAppBar( @@ -100,25 +82,7 @@ private fun YoutubeWebLoginScreen( }, navigationIcon = { IconButton(onClick = onClose) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null) - } - }, - actions = { - TextButton( - onClick = { - val cookies = extractYoutubeSession() - if (cookies != null) { - onSignedIn(cookies) - } else { - Toast.makeText( - webView?.context, - R.string.youtube_sign_in_incomplete, - Toast.LENGTH_SHORT, - ).show() - } - }, - ) { - Text(stringResource(R.string.youtube_sign_in_done), fontFamily = GoogleSansRounded) + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.youtube_back)) } }, ) @@ -127,88 +91,47 @@ private fun YoutubeWebLoginScreen( Column( modifier = Modifier .fillMaxSize() - .padding(padding), + .padding(padding) + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - if (progress in 1..99) { - LinearProgressIndicator( - progress = { progress / 100f }, - modifier = Modifier.fillMaxWidth(), - ) - } - Box(Modifier.fillMaxSize()) { - YoutubeWebView( - onProgress = { progress = it }, - onWebViewCreated = { webView = it }, - onCookiesMaybeReady = { - val cookies = extractYoutubeSession() - if (cookies != null) onSignedIn(cookies) - }, - ) + Text( + text = stringResource(R.string.youtube_signin_browser_body), + fontFamily = GoogleSansRounded, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(YoutubeLoginActivity.YOUTUBE_HOME)), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.youtube_open_chrome), fontFamily = GoogleSansRounded) } - } - } -} - -@SuppressLint("SetJavaScriptEnabled") -@Composable -private fun YoutubeWebView( - onProgress: (Int) -> Unit, - onWebViewCreated: (WebView) -> Unit, - onCookiesMaybeReady: () -> Unit, -) { - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { ctx -> - WebView(ctx).apply { - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - settings.cacheMode = WebSettings.LOAD_DEFAULT - settings.userAgentString = YoutubeLoginActivity.DESKTOP_UA - CookieManager.getInstance().setAcceptCookie(true) - CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) - webChromeClient = object : WebChromeClient() { - override fun onProgressChanged(view: WebView?, newProgress: Int) { - onProgress(newProgress) - } - } - webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView?, url: String?) { - onCookiesMaybeReady() + OutlinedTextField( + value = cookies, + onValueChange = { cookies = it }, + modifier = Modifier.fillMaxWidth(), + minLines = 4, + label = { Text(stringResource(R.string.youtube_cookie_label)) }, + ) + Button( + onClick = { + val saved = viewModel.importSession(cookies) + if (saved) { + onClose() + } else { + Toast.makeText(context, R.string.youtube_cookie_invalid, Toast.LENGTH_LONG).show() } - } - loadUrl(YoutubeLoginActivity.TARGET_URL) - onWebViewCreated(this) + }, + modifier = Modifier.fillMaxWidth(), + enabled = cookies.isNotBlank(), + ) { + Text(stringResource(R.string.youtube_cookie_save), fontFamily = GoogleSansRounded) } - }, - ) -} - -internal fun extractYoutubeSession(): String? { - val manager = CookieManager.getInstance() - val parts = listOf( - "https://www.youtube.com", - "https://youtube.com", - "https://music.youtube.com", - "https://accounts.google.com", - "https://google.com", - ).map { manager.getCookie(it).orEmpty() } - val map = linkedMapOf() - parts.joinToString("; ") - .split(';') - .map { it.trim() } - .filter { it.contains('=') } - .forEach { part -> - val index = part.indexOf('=') - val key = part.substring(0, index).trim() - val value = part.substring(index + 1).trim() - if (key.isNotEmpty() && value.isNotEmpty()) map[key] = value } - val signedIn = map.keys.any { key -> - key.equals("LOGIN_INFO", ignoreCase = true) || - key.equals("SAPISID", ignoreCase = true) || - key.equals("__Secure-1PSID", ignoreCase = true) || - key.equals("__Secure-3PSID", ignoreCase = true) } - if (!signedIn) return null - return map.entries.joinToString("; ") { "${it.key}=${it.value}" } } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index a648240a7d..51cfdf528d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -7,6 +7,8 @@ import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.kiosk.KioskInfo import org.schabi.newpipe.extractor.playlist.PlaylistInfo import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem +import org.json.JSONArray +import org.json.JSONObject import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper import org.schabi.newpipe.extractor.services.youtube.YoutubeService import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory @@ -26,6 +28,10 @@ class YoutubeClient @Inject constructor( ) { private val downloader = YoutubeDownloader() private val resolveCache = ConcurrentHashMap() + @Volatile var feedShelves: List = emptyList() + @Volatile var playlistHits: List = emptyList() + @Volatile var radioHits: List = emptyList() + @Volatile var likedHits: List = emptyList() private data class CachedResolve( val track: YoutubeResolvedTrack, @@ -76,13 +82,21 @@ class YoutubeClient @Inject constructor( fun loadLikedVideos(limit: Int = 50): List { applySession() if (!session.isSignedIn()) return emptyList() - val info = PlaylistInfo.getInfo(youtube(), "https://www.youtube.com/playlist?list=LL") - return info.relatedItems - .asSequence() - .mapNotNull { it.toHitOrNull() } - .filter { it.url.isNotBlank() && it.kind == YoutubeHit.Kind.TRACK } - .take(limit) - .toList() + val hits = linkedSetOf() + listOf("VLLl", "FEmusic_liked_videos").forEach { browseId -> + if (hits.size >= limit) return hits.take(limit).toList() + val body = """ + {"context":{"client":{"clientName":"WEB","clientVersion":"2.20250301.01.00","hl":"en","gl":"US"}},"browseId":"$browseId"} + """.trim() + val json = runCatching { + downloader.postJson("https://www.youtube.com/youtubei/v1/browse?prettyPrint=false", body) + }.getOrNull() ?: return@forEach + collectVideoHits(JSONObject(json), hits, limit) + } + if (hits.isEmpty()) { + throw IllegalStateException("Couldn't read liked videos from this session. Sign in again from Account.") + } + return hits.take(limit).toList() } fun loadSubscriptionFeed(limit: Int = 20): List { @@ -135,7 +149,7 @@ class YoutubeClient @Inject constructor( durationMs = info.duration.coerceAtLeast(0L) * 1000L, streamUrl = stream.content, mimeType = stream.format?.mimeType, - artworkUrl = info.thumbnails.maxByOrNull { it.height }?.url, + artworkUrl = fullBleedArtwork(info.thumbnails.maxByOrNull { it.height }?.url), ) resolveCache[key] = CachedResolve(resolved, System.currentTimeMillis()) return resolved @@ -207,13 +221,58 @@ class YoutubeClient @Inject constructor( field.set(null, cookie) } + private fun collectVideoHits(node: Any?, hits: MutableSet, limit: Int) { + if (hits.size >= limit || node == null) return + when (node) { + is JSONObject -> { + val videoId = node.optString("videoId") + if (videoId.length == 11 && node.has("title")) { + val title = jsonText(node.optJSONObject("title")) + if (title.isNotBlank()) { + hits += YoutubeHit( + url = "https://www.youtube.com/watch?v=$videoId", + title = title, + artist = jsonText(node.optJSONObject("ownerText")) + .ifBlank { jsonText(node.optJSONObject("shortBylineText")) } + .ifBlank { "YouTube Music" }, + durationSec = 0, + thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hqdefault.jpg"), + kind = YoutubeHit.Kind.TRACK, + ) + } + } + val keys = node.keys() + while (keys.hasNext() && hits.size < limit) { + collectVideoHits(node.opt(keys.next()), hits, limit) + } + } + is JSONArray -> { + for (index in 0 until node.length()) { + if (hits.size >= limit) return + collectVideoHits(node.opt(index), hits, limit) + } + } + } + } + + private fun jsonText(node: JSONObject?): String { + if (node == null) return "" + node.optString("simpleText").takeIf { it.isNotBlank() }?.let { return it } + val runs = node.optJSONArray("runs") ?: return "" + return buildString { + for (index in 0 until runs.length()) { + append(runs.optJSONObject(index)?.optString("text").orEmpty()) + } + } + } + private fun InfoItem.toHitOrNull(): YoutubeHit? = when (this) { is StreamInfoItem -> YoutubeHit( url = url.orEmpty(), title = name.orEmpty().ifBlank { "Unknown title" }, artist = uploaderName.orEmpty().ifBlank { "YouTube Music" }, durationSec = duration.coerceAtLeast(0L), - thumbnailUrl = thumbnails.maxByOrNull { it.height }?.url, + thumbnailUrl = fullBleedArtwork(thumbnails.maxByOrNull { it.height }?.url), kind = YoutubeHit.Kind.TRACK, ) is PlaylistInfoItem -> YoutubeHit( @@ -221,7 +280,7 @@ class YoutubeClient @Inject constructor( title = name.orEmpty().ifBlank { "Playlist" }, artist = uploaderName.orEmpty().ifBlank { "YouTube Music" }, durationSec = 0L, - thumbnailUrl = thumbnails.maxByOrNull { it.height }?.url, + thumbnailUrl = fullBleedArtwork(thumbnails.maxByOrNull { it.height }?.url), kind = YoutubeHit.Kind.COLLECTION, ) else -> null @@ -262,6 +321,14 @@ class YoutubeClient @Inject constructor( fun songIdForUrl(url: String): String = "yt_${url.trim().hashCode().toUInt()}" + fun fullBleedArtwork(url: String?): String? { + val raw = url?.trim().orEmpty() + if (raw.isEmpty()) return null + val videoId = Regex("/vi/([A-Za-z0-9_-]{6,})/").find(raw)?.groupValues?.getOrNull(1) + ?: return raw + return "https://i.ytimg.com/vi/$videoId/hq720.jpg" + } + private fun ensureInitialized(downloader: YoutubeDownloader) { if (initialized.compareAndSet(false, true)) { NewPipe.init(downloader) diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt index fdde6d5c15..1cd35018df 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt @@ -1,5 +1,6 @@ package com.theveloper.pixelplay.youtube +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody import org.schabi.newpipe.extractor.downloader.Downloader @@ -53,7 +54,29 @@ internal class YoutubeDownloader : Downloader() { } } + fun postJson(url: String, json: String): String { + val request = okhttp3.Request.Builder() + .url(url) + .post(json.toRequestBody(JSON)) + .header("User-Agent", USER_AGENT) + .header("Content-Type", "application/json") + .header("Origin", "https://www.youtube.com") + .header("Referer", "https://www.youtube.com/") + .apply { + cookieHeader?.trim()?.takeIf { it.isNotEmpty() }?.let { header("Cookie", it) } + } + .build() + client.newCall(request).execute().use { response -> + val responseBody = response.body.string() + if (!response.isSuccessful) { + throw java.io.IOException("YouTube browse failed (${response.code})") + } + return responseBody + } + } + companion object { + private val JSON = "application/json".toMediaType() private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0" } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt index 5ee686ca7c..9e0c6cc93b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.theveloper.pixelplay.data.model.Song import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -38,6 +40,7 @@ class YoutubeViewModel @Inject constructor( val likedSongIds: StateFlow> = musicStore.likedSongIds val savedSongIds: StateFlow> = musicStore.savedSongIds val account: StateFlow = session.account + private var searchJob: Job? = null init { viewModelScope.launch { @@ -64,22 +67,48 @@ class YoutubeViewModel @Inject constructor( it.copy(section = section, collectionTitle = null, error = null) } when (section) { - YoutubeSection.HOME -> loadFeed() + YoutubeSection.HOME -> { + if (client.feedShelves.isNotEmpty()) { + _uiState.update { it.copy(shelves = client.feedShelves, isLoading = false) } + } + loadFeed(refreshing = client.feedShelves.isNotEmpty()) + } YoutubeSection.SEARCH -> { if (_uiState.value.query.trim().length >= 2) search(_uiState.value.query) else _uiState.update { it.copy(results = emptyList(), shelves = emptyList(), isLoading = false) } } - YoutubeSection.LIKED -> loadLiked() - YoutubeSection.PLAYLISTS -> loadPlaylists() - YoutubeSection.RADIO -> loadRadioStations() + YoutubeSection.LIKED -> { + if (client.likedHits.isNotEmpty()) { + _uiState.update { it.copy(results = client.likedHits, isLoading = false) } + } + loadLiked(refreshing = client.likedHits.isNotEmpty()) + } + YoutubeSection.PLAYLISTS -> { + if (client.playlistHits.isNotEmpty()) { + _uiState.update { it.copy(results = client.playlistHits, isLoading = false) } + } + loadPlaylists(refreshing = client.playlistHits.isNotEmpty()) + } + YoutubeSection.RADIO -> { + if (client.radioHits.isNotEmpty()) { + _uiState.update { it.copy(results = client.radioHits, isLoading = false) } + } + loadRadioStations(refreshing = client.radioHits.isNotEmpty()) + } YoutubeSection.SAVED -> _uiState.update { it.copy(results = emptyList(), shelves = emptyList(), isLoading = false) } } } fun onQueryChange(query: String) { - _uiState.update { it.copy(query = query, error = null) } - if (query.trim().length >= 2) search(query) else { - _uiState.update { it.copy(results = emptyList(), isLoading = false, collectionTitle = null) } + _uiState.update { it.copy(query = query, error = null, collectionTitle = null) } + searchJob?.cancel() + if (query.trim().length < 2) { + _uiState.update { it.copy(results = emptyList(), isLoading = false) } + return + } + searchJob = viewModelScope.launch { + delay(250) + search(query) } } @@ -154,6 +183,17 @@ class YoutubeViewModel @Inject constructor( musicStore.toggleSave(hit) } + fun importSession(cookieHeader: String): Boolean { + val normalized = cookieHeader.trim() + val signedIn = listOf("LOGIN_INFO", "SAPISID", "__Secure-1PSID", "__Secure-3PSID") + .any { key -> normalized.contains("$key=", ignoreCase = true) } + if (!signedIn) return false + session.save(normalized) + if (_uiState.value.section == YoutubeSection.LIKED) loadLiked(refreshing = true) + if (_uiState.value.section == YoutubeSection.HOME) loadFeed(refreshing = true) + return true + } + fun signOut() { session.clear() if (_uiState.value.section == YoutubeSection.LIKED) loadLiked(refreshing = true) @@ -177,6 +217,7 @@ class YoutubeViewModel @Inject constructor( remote.fold( onSuccess = { accountLikes -> val merged = (accountLikes + local).distinctBy { it.url } + if (accountLikes.isNotEmpty()) client.likedHits = accountLikes _uiState.update { it.copy(isLoading = false, isRefreshing = false, results = merged) } }, onFailure = { error -> @@ -250,6 +291,7 @@ class YoutubeViewModel @Inject constructor( } built.fold( onSuccess = { shelves -> + client.feedShelves = shelves _uiState.update { it.copy(isLoading = false, isRefreshing = false, shelves = shelves) } }, onFailure = { error -> @@ -279,6 +321,7 @@ class YoutubeViewModel @Inject constructor( } loaded.fold( onSuccess = { hits -> + client.playlistHits = hits _uiState.update { it.copy(isLoading = false, isRefreshing = false, results = hits) } }, onFailure = { error -> @@ -298,6 +341,7 @@ class YoutubeViewModel @Inject constructor( val loaded = runCatching { withContext(Dispatchers.IO) { client.loadTrendingMusic(16) } } loaded.fold( onSuccess = { hits -> + client.radioHits = hits _uiState.update { it.copy(isLoading = false, isRefreshing = false, results = hits) } }, onFailure = { error -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64e8b24210..6c3c3412ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,6 +31,7 @@ Save Remove save Songs, albums, playlists + Clear search Songs Albums Playlists @@ -43,13 +44,19 @@ Saved songs show up here. Search YouTube Music. The feed is unavailable right now. + This playlist didn't return any tracks. Radio stations are unavailable right now. Playlists are unavailable right now. Radio from %1$s Today\'s Mix for you Account - Sign in with the Google account you use for YouTube. Liked videos and your subscription feed load from that account. + Google blocks sign-in inside the app. Open YouTube in Chrome, then paste the cookie from that signed-in browser. Sign in with Google + 1. Tap Open YouTube in Chrome and sign in there.\n2. On a computer, open youtube.com, press F12, open Network, click any youtube.com request, and copy the full Cookie request header.\n3. Paste it below and save. Liked videos then load from that account. + Open YouTube in Chrome + Cookie header + Save session + That cookie is missing a signed-in YouTube session. Copy the Cookie header from a youtube.com request after you are logged in. Signed in Radio keeps playing songs that sound like the one you start. Back From f0912fc265808b3fe9c9f3d2aa242e3cb11e9063 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 13:47:56 +0300 Subject: [PATCH 40/77] fix: escape the playlist empty-state apostrophe Release resource compilation treats an unescaped apostrophe as an invalid string escape. Co-authored-by: Cursor --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c3c3412ff..ab4563e9ab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,7 +44,7 @@ Saved songs show up here. Search YouTube Music. The feed is unavailable right now. - This playlist didn't return any tracks. + This playlist didn\'t return any tracks. Radio stations are unavailable right now. Playlists are unavailable right now. Radio from %1$s From 72b860fe14be51754827fa88fd44dab6335f9011 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 13:50:26 +0300 Subject: [PATCH 41/77] fix: escape the empty playlist string Co-authored-by: Cursor --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab4563e9ab..096d50ddb7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,7 +44,7 @@ Saved songs show up here. Search YouTube Music. The feed is unavailable right now. - This playlist didn\'t return any tracks. + This playlist did not return any tracks. Radio stations are unavailable right now. Playlists are unavailable right now. Radio from %1$s From bc1d5ea8b846aae2dfebe4b67e9f21a1b1347c73 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 14:18:07 +0300 Subject: [PATCH 42/77] fix: restore in-app YouTube sign-in and one-tap play Drop the cookie field. Ignore extra taps until playback starts, and curve the black sheet downward. Co-authored-by: Cursor --- README.md | 7 +- .../presentation/screens/YoutubeScreen.kt | 43 +++-- .../youtube/auth/YoutubeLoginActivity.kt | 174 ++++++++++++------ app/src/main/res/values/strings.xml | 2 +- 4 files changed, 151 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 170fe4a959..c816d14a82 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,9 @@ PixelPlayer plays YouTube audio through the existing player. It does not scan a Liked videos and the “channels you follow” shelf need the Google account you use on YouTube. 1. On **Feed**, tap the **account** icon. -2. Tap **Sign in with Google**, then **Open YouTube in Chrome**, and sign in there. Google blocks the in-app page (“browser or app may not be secure”). -3. On a computer, open [youtube.com](https://www.youtube.com) while signed in. Press **F12** → **Network** → click a `youtube.com` request → copy the **Cookie** request header. -4. Paste that header into **Account** and tap **Save session**. -5. Open **Liked** to load that account’s liked videos. +2. Tap **Sign in with Google** and sign in on the page that opens. +3. Tap **Done** when YouTube finishes signing you in. +4. Open **Liked** to load that account’s liked videos. **Sign out** is on the same Account screen. The heart on a song still saves a like on the device if you are signed out. diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 286299b1e2..4a0ce87d43 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -110,6 +110,7 @@ import com.theveloper.pixelplay.youtube.YoutubeSection import com.theveloper.pixelplay.youtube.YoutubeViewModel import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean private const val WIDE_SCREEN_DP = 600 @@ -175,11 +176,15 @@ fun YoutubeScreen( BackHandler { viewModel.closeCollection() } } + val playGate = remember { AtomicBoolean(false) } val playHit: (YoutubeHit, Boolean) -> Unit = { hit, asRadio -> if (hit.kind == YoutubeHit.Kind.COLLECTION && !asRadio) { viewModel.openCollection(hit) + } else if (!playGate.compareAndSet(false, true)) { + Unit } else if (asRadio) { scope.launch { + try { runCatching { viewModel.radioTracks(hit) } .onSuccess { queue -> val playable = queue.ifEmpty { listOf(hit) } @@ -196,9 +201,13 @@ fun YoutubeScreen( .onFailure { error -> viewModel.reportError(error.message ?: "Couldn't start radio") } + } finally { + playGate.set(false) + } } } else { scope.launch { + try { runCatching { viewModel.resolvePlayable(hit) } .onSuccess { song -> playerViewModel.playSongs( @@ -213,6 +222,9 @@ fun YoutubeScreen( .onFailure { error -> viewModel.reportError(error.message ?: "Couldn't play this track") } + } finally { + playGate.set(false) + } } } } @@ -557,24 +569,23 @@ private fun YoutubeCompactHeader( onSettings: () -> Unit, ) { val headerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f) - val headerShape = AbsoluteSmoothCornerShape( - cornerRadiusTL = 0.dp, - smoothnessAsPercentTL = 60, - cornerRadiusTR = 0.dp, - smoothnessAsPercentTR = 60, - cornerRadiusBL = 36.dp, - smoothnessAsPercentBL = 70, - cornerRadiusBR = 36.dp, - smoothnessAsPercentBR = 70, + val sheetCurve = AbsoluteSmoothCornerShape( + cornerRadiusTL = 32.dp, + smoothnessAsPercentTL = 70, + cornerRadiusTR = 32.dp, + smoothnessAsPercentTR = 70, + cornerRadiusBL = 0.dp, + smoothnessAsPercentBL = 60, + cornerRadiusBR = 0.dp, + smoothnessAsPercentBR = 60, ) + Column(Modifier.fillMaxWidth().background(headerColor)) { Row( modifier = Modifier .fillMaxWidth() - .clip(headerShape) - .background(headerColor) .statusBarsPadding() .padding(horizontal = 12.dp) - .padding(top = 6.dp, bottom = 14.dp), + .padding(top = 6.dp, bottom = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { FilledTonalButton( @@ -608,6 +619,14 @@ private fun YoutubeCompactHeader( ) } } + Box( + Modifier + .fillMaxWidth() + .height(28.dp) + .clip(sheetCurve) + .background(MaterialTheme.colorScheme.surface), + ) + } } @Composable diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt index bac1952512..95bf587e8d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt @@ -1,41 +1,42 @@ package com.theveloper.pixelplay.presentation.youtube.auth -import android.content.Intent -import android.net.Uri +import android.annotation.SuppressLint import android.os.Bundle +import android.webkit.CookieManager +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient import android.widget.Toast -import androidx.activity.compose.BackHandler import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.theveloper.pixelplay.R import com.theveloper.pixelplay.ui.theme.GoogleSansRounded @@ -51,25 +52,27 @@ class YoutubeLoginActivity : ComponentActivity() { enableEdgeToEdge() setContent { PixelPlayTheme { - YoutubeBrowserSignInScreen(onClose = { finish() }) + YoutubeWebLoginScreen(onClose = { finish() }) } } } companion object { - const val YOUTUBE_HOME = "https://www.youtube.com/" + const val TARGET_URL = "https://accounts.google.com/ServiceLogin?service=youtube&passive=true&continue=https%3A%2F%2Fwww.youtube.com%2F&hl=en" + const val DESKTOP_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun YoutubeBrowserSignInScreen( +private fun YoutubeWebLoginScreen( onClose: () -> Unit, viewModel: YoutubeViewModel = hiltViewModel(), ) { - val context = LocalContext.current - var cookies by remember { mutableStateOf("") } - BackHandler(onBack = onClose) + var progress by remember { mutableIntStateOf(0) } + var webView by remember { mutableStateOf(null) } + BackHandler { onClose() } Scaffold( topBar = { TopAppBar( @@ -85,53 +88,108 @@ private fun YoutubeBrowserSignInScreen( Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.youtube_back)) } }, + actions = { + TextButton( + onClick = { + val cookies = extractYoutubeSession() + if (cookies != null && viewModel.importSession(cookies)) { + onClose() + } else { + Toast.makeText( + webView?.context, + R.string.youtube_sign_in_incomplete, + Toast.LENGTH_SHORT, + ).show() + } + }, + ) { + Text(stringResource(R.string.youtube_sign_in_done), fontFamily = GoogleSansRounded) + } + }, ) }, ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = stringResource(R.string.youtube_signin_browser_body), - fontFamily = GoogleSansRounded, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button( - onClick = { - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(YoutubeLoginActivity.YOUTUBE_HOME)), - ) - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.youtube_open_chrome), fontFamily = GoogleSansRounded) + Column(Modifier.fillMaxSize().padding(padding)) { + if (progress in 1..99) { + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier.fillMaxWidth(), + ) } - OutlinedTextField( - value = cookies, - onValueChange = { cookies = it }, - modifier = Modifier.fillMaxWidth(), - minLines = 4, - label = { Text(stringResource(R.string.youtube_cookie_label)) }, - ) - Button( - onClick = { - val saved = viewModel.importSession(cookies) - if (saved) { - onClose() - } else { - Toast.makeText(context, R.string.youtube_cookie_invalid, Toast.LENGTH_LONG).show() + Box(Modifier.fillMaxSize()) { + YoutubeWebView( + onProgress = { progress = it }, + onWebViewCreated = { webView = it }, + onCookiesMaybeReady = { + val cookies = extractYoutubeSession() ?: return@YoutubeWebView + if (viewModel.importSession(cookies)) onClose() + }, + ) + } + } + } +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun YoutubeWebView( + onProgress: (Int) -> Unit, + onWebViewCreated: (WebView) -> Unit, + onCookiesMaybeReady: () -> Unit, +) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.cacheMode = WebSettings.LOAD_DEFAULT + settings.userAgentString = YoutubeLoginActivity.DESKTOP_UA + CookieManager.getInstance().setAcceptCookie(true) + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + onProgress(newProgress) } - }, - modifier = Modifier.fillMaxWidth(), - enabled = cookies.isNotBlank(), - ) { - Text(stringResource(R.string.youtube_cookie_save), fontFamily = GoogleSansRounded) + } + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + onCookiesMaybeReady() + } + } + loadUrl(YoutubeLoginActivity.TARGET_URL) + onWebViewCreated(this) } + }, + ) +} + +internal fun extractYoutubeSession(): String? { + val manager = CookieManager.getInstance() + val map = linkedMapOf() + listOf( + "https://www.youtube.com", + "https://youtube.com", + "https://music.youtube.com", + "https://accounts.google.com", + "https://google.com", + ).map { manager.getCookie(it).orEmpty() } + .joinToString("; ") + .split(';') + .map { it.trim() } + .filter { it.contains('=') } + .forEach { part -> + val index = part.indexOf('=') + val key = part.substring(0, index).trim() + val value = part.substring(index + 1).trim() + if (key.isNotEmpty() && value.isNotEmpty()) map[key] = value } + val signedIn = map.keys.any { key -> + key.equals("LOGIN_INFO", ignoreCase = true) || + key.equals("SAPISID", ignoreCase = true) || + key.equals("__Secure-1PSID", ignoreCase = true) || + key.equals("__Secure-3PSID", ignoreCase = true) } + if (!signedIn) return null + return map.entries.joinToString("; ") { "${it.key}=${it.value}" } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 096d50ddb7..638e33be89 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,7 +50,7 @@ Radio from %1$s Today\'s Mix for you Account - Google blocks sign-in inside the app. Open YouTube in Chrome, then paste the cookie from that signed-in browser. + Sign in with the Google account you use on YouTube. Liked videos and your feed then load from that account. Sign in with Google 1. Tap Open YouTube in Chrome and sign in there.\n2. On a computer, open youtube.com, press F12, open Network, click any youtube.com request, and copy the full Cookie request header.\n3. Paste it below and save. Liked videos then load from that account. Open YouTube in Chrome From 4370a3f465413c1b1f98f6b339f66859eb0278a2 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 14:45:54 +0300 Subject: [PATCH 43/77] fix: log out of YouTube fully and start playback on one tap Wipe WebView cookies with the session. Show the player immediately, then resolve the stream. Match the SoundCloud sheet curve and header padding. Co-authored-by: Cursor --- .../presentation/screens/YoutubeScreen.kt | 437 +++++++++--------- .../presentation/viewmodel/PlayerViewModel.kt | 13 + .../pixelplay/youtube/YoutubeSession.kt | 5 + .../pixelplay/youtube/YoutubeViewModel.kt | 15 +- 4 files changed, 252 insertions(+), 218 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 4a0ce87d43..db4d57e785 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -41,7 +41,7 @@ import androidx.compose.material.icons.rounded.BookmarkBorder import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.Login +import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material3.Button import androidx.compose.material3.DockedSearchBar @@ -141,7 +141,7 @@ fun YoutubeScreen( start = 16.dp, end = 16.dp, top = 8.dp, - bottom = bottomBarHeight + MiniPlayerHeight + 28.dp, + bottom = bottomBarHeight + MiniPlayerHeight + 30.dp, ) val currentSongId = stablePlayer.currentSong?.id val isPlaying = stablePlayer.isPlaying @@ -165,7 +165,6 @@ fun YoutubeScreen( YoutubeSection.SAVED -> saved else -> uiState.results } - var searchActive by remember { mutableStateOf(false) } val pullState = rememberPullToRefreshState() LaunchedEffect(section) { @@ -182,45 +181,42 @@ fun YoutubeScreen( viewModel.openCollection(hit) } else if (!playGate.compareAndSet(false, true)) { Unit - } else if (asRadio) { - scope.launch { - try { - runCatching { viewModel.radioTracks(hit) } - .onSuccess { queue -> - val playable = queue.ifEmpty { listOf(hit) } - val start = viewModel.resolvePlayable(playable.first()) - playerViewModel.playSongs( - songsToPlay = listOf(start), - startSong = start, - queueName = "YouTube Radio", - ) - viewModel.prefetchAfter(playable.first(), playable) { queued -> - playerViewModel.addSongToQueue(queued) - } - } - .onFailure { error -> - viewModel.reportError(error.message ?: "Couldn't start radio") - } - } finally { - playGate.set(false) - } - } } else { + playerViewModel.showPreparingSong(placeholderSong(hit)) scope.launch { try { - runCatching { viewModel.resolvePlayable(hit) } - .onSuccess { song -> - playerViewModel.playSongs( - songsToPlay = listOf(song), - startSong = song, - queueName = "YouTube Music", - ) - viewModel.prefetchAfter(hit, rows) { queued -> - playerViewModel.addSongToQueue(queued) - } - } - .onFailure { error -> - viewModel.reportError(error.message ?: "Couldn't play this track") + if (asRadio) { + runCatching { viewModel.radioTracks(hit) } + .onSuccess { queue -> + val playable = queue.ifEmpty { listOf(hit) } + val start = viewModel.resolvePlayable(playable.first()) + playerViewModel.playSongs( + songsToPlay = listOf(start), + startSong = start, + queueName = "YouTube Radio", + ) + viewModel.prefetchAfter(playable.first(), playable) { queued -> + playerViewModel.addSongToQueue(queued) + } + } + .onFailure { error -> + viewModel.reportError(error.message ?: "Couldn't start radio") + } + } else { + runCatching { viewModel.resolvePlayable(hit) } + .onSuccess { song -> + playerViewModel.playSongs( + songsToPlay = listOf(song), + startSong = song, + queueName = "YouTube Music", + ) + viewModel.prefetchAfter(hit, rows) { queued -> + playerViewModel.addSongToQueue(queued) + } + } + .onFailure { error -> + viewModel.reportError(error.message ?: "Couldn't play this track") + } } } finally { playGate.set(false) @@ -229,76 +225,59 @@ fun YoutubeScreen( } } + val headerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) Scaffold( - containerColor = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxSize() + .background(headerColor), + containerColor = headerColor, topBar = { - if (browseSection == YoutubeSection.HOME && uiState.collectionTitle == null) { - YoutubeCompactHeader( - signedIn = account.isSignedIn, - onBeta = { showBeta = true }, - onChangelog = { showChangelog = true }, - onAccount = { navController.navigateSafely(Screen.YoutubeSettings.route) }, - onSettings = { navController.navigateSafely(Screen.Settings.route) }, - ) - } else { - TopAppBar( - title = { - Column { - Text( - text = uiState.collectionTitle ?: title, - fontFamily = GoogleSansRounded, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (uiState.collectionTitle == null && browseSection == YoutubeSection.HOME) { - Text( - text = stringResource(R.string.youtube_home_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = GoogleSansRounded, - ) - } - } - }, - navigationIcon = { - if (uiState.collectionTitle != null) { - IconButton(onClick = viewModel::closeCollection) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.youtube_back)) - } - } + YoutubeCompactHeader( + title = if (browseSection == YoutubeSection.SEARCH && uiState.collectionTitle == null) { + null + } else { + uiState.collectionTitle ?: title }, - actions = { - if (section == YoutubeSection.LIKED) { - TextButton( - onClick = { - if (account.isSignedIn) viewModel.signOut() - else context.startActivity(Intent(context, YoutubeLoginActivity::class.java)) - }, - ) { - Text( - stringResource( - if (account.isSignedIn) R.string.youtube_sign_out else R.string.youtube_sign_in, - ), - fontFamily = GoogleSansRounded, - ) - } - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), + showTitle = browseSection != YoutubeSection.HOME || uiState.collectionTitle != null, + query = uiState.query, + onQueryChange = viewModel::onQueryChange, + showSearch = browseSection == YoutubeSection.SEARCH && uiState.collectionTitle == null, + searchFilter = uiState.filter, + onFilter = viewModel::setFilter, + onBack = if (uiState.collectionTitle != null) viewModel::closeCollection else null, + signedIn = account.isSignedIn, + onBeta = { showBeta = true }, + onChangelog = { showChangelog = true }, + onAccount = { navController.navigateSafely(Screen.YoutubeSettings.route) }, + onSettings = { navController.navigateSafely(Screen.Settings.route) }, ) - } }, ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()) + .background(headerColor), + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surface, + shape = AbsoluteSmoothCornerShape( + cornerRadiusTL = 34.dp, + cornerRadiusTR = 34.dp, + cornerRadiusBL = 0.dp, + cornerRadiusBR = 0.dp, + smoothnessAsPercentTL = 60, + smoothnessAsPercentTR = 60, + smoothnessAsPercentBL = 60, + smoothnessAsPercentBR = 60, + ), + ) { PullToRefreshBox( isRefreshing = uiState.isRefreshing, onRefresh = viewModel::refresh, state = pullState, - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + modifier = Modifier.fillMaxSize(), indicator = { PullToRefreshDefaults.LoadingIndicator( state = pullState, @@ -310,69 +289,6 @@ fun YoutubeScreen( }, ) { Column(Modifier.fillMaxSize()) { - if (section == YoutubeSection.SEARCH && uiState.collectionTitle == null) { - DockedSearchBar( - inputField = { - SearchBarDefaults.InputField( - query = uiState.query, - onQueryChange = viewModel::onQueryChange, - onSearch = { searchActive = false }, - expanded = searchActive, - onExpandedChange = { searchActive = it }, - placeholder = { - Text( - stringResource(R.string.youtube_search_placeholder), - fontFamily = GoogleSansRounded, - ) - }, - trailingIcon = if (uiState.query.isNotEmpty()) { - { - IconButton(onClick = { viewModel.onQueryChange("") }) { - Icon( - Icons.Rounded.Close, - contentDescription = stringResource(R.string.youtube_clear_search), - ) - } - } - } else { - null - }, - ) - }, - expanded = false, - onExpandedChange = { searchActive = it }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - shape = RoundedCornerShape(28.dp), - ) {} - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - YoutubeSearchFilter.entries.forEach { filter -> - FilterChip( - selected = uiState.filter == filter, - onClick = { viewModel.setFilter(filter) }, - label = { - Text( - stringResource( - when (filter) { - YoutubeSearchFilter.SONGS -> R.string.youtube_filter_songs - YoutubeSearchFilter.ALBUMS -> R.string.youtube_filter_albums - YoutubeSearchFilter.PLAYLISTS -> R.string.youtube_filter_playlists - } - ), - fontFamily = GoogleSansRounded, - ) - }, - ) - } - } - } - uiState.error?.let { message -> Surface( modifier = Modifier @@ -547,6 +463,8 @@ fun YoutubeScreen( } } } + } + } } if (showChangelog) { ModalBottomSheet(onDismissRequest = { showChangelog = false }) { @@ -562,71 +480,158 @@ fun YoutubeScreen( @Composable private fun YoutubeCompactHeader( + title: String?, + showTitle: Boolean, + query: String, + onQueryChange: (String) -> Unit, + showSearch: Boolean, + searchFilter: YoutubeSearchFilter, + onFilter: (YoutubeSearchFilter) -> Unit, + onBack: (() -> Unit)?, signedIn: Boolean, onBeta: () -> Unit, onChangelog: () -> Unit, onAccount: () -> Unit, onSettings: () -> Unit, ) { - val headerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f) - val sheetCurve = AbsoluteSmoothCornerShape( - cornerRadiusTL = 32.dp, - smoothnessAsPercentTL = 70, - cornerRadiusTR = 32.dp, - smoothnessAsPercentTR = 70, - cornerRadiusBL = 0.dp, - smoothnessAsPercentBL = 60, - cornerRadiusBR = 0.dp, - smoothnessAsPercentBR = 60, - ) + val headerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) Column(Modifier.fillMaxWidth().background(headerColor)) { - Row( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(horizontal = 12.dp) - .padding(top = 6.dp, bottom = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - FilledTonalButton( - onClick = onBeta, - shape = CircleShape, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(start = 8.dp, end = 14.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = stringResource(R.string.topbar_beta_letter) + " " + stringResource(R.string.topbar_beta_label), - fontFamily = GoogleSansRounded, - fontWeight = FontWeight.Bold, - ) - } - Spacer(Modifier.weight(1f)) - FilledIconButton(onClick = onAccount) { - Icon( - imageVector = if (signedIn) Icons.Rounded.AccountCircle else Icons.Rounded.Login, - contentDescription = stringResource(R.string.youtube_sign_in), - ) - } - FilledIconButton(onClick = onChangelog) { - Icon( - painter = painterResource(R.drawable.round_newspaper_24), - contentDescription = stringResource(R.string.topbar_cd_changelog), - ) + if (onBack != null) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.youtube_back), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } else { + FilledTonalButton( + onClick = onBeta, + shape = CircleShape, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + text = stringResource(R.string.topbar_beta_letter) + " " + stringResource(R.string.topbar_beta_label), + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + ) + } + } + if (showTitle && !title.isNullOrBlank()) { + Text( + text = title, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } else { + Spacer(Modifier.weight(1f)) + } + FilledIconButton(onClick = onAccount) { + Icon( + imageVector = if (signedIn) Icons.Rounded.AccountCircle else Icons.Rounded.AccountCircle, + contentDescription = stringResource(R.string.youtube_sign_in), + ) + } + FilledIconButton(onClick = onChangelog) { + Icon( + painter = painterResource(R.drawable.round_newspaper_24), + contentDescription = stringResource(R.string.topbar_cd_changelog), + ) + } + FilledIconButton(onClick = onSettings) { + Icon( + painter = painterResource(R.drawable.rounded_settings_24), + contentDescription = stringResource(R.string.common_settings), + ) + } } - FilledIconButton(onClick = onSettings) { - Icon( - painter = painterResource(R.drawable.rounded_settings_24), - contentDescription = stringResource(R.string.common_settings), - ) + if (showSearch) { + DockedSearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = {}, + expanded = false, + onExpandedChange = {}, + placeholder = { + Text( + stringResource(R.string.youtube_search_placeholder), + fontFamily = GoogleSansRounded, + ) + }, + leadingIcon = { + Icon( + Icons.Rounded.Search, + contentDescription = stringResource(R.string.youtube_search_title), + modifier = Modifier.size(24.dp), + ) + }, + trailingIcon = if (query.isNotEmpty()) { + { + IconButton( + onClick = { onQueryChange("") }, + modifier = Modifier.size(48.dp), + ) { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(R.string.youtube_clear_search), + ) + } + } + } else { + null + }, + ) + }, + expanded = false, + onExpandedChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(28.dp), + ) {} + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + YoutubeSearchFilter.entries.forEach { filter -> + FilterChip( + selected = searchFilter == filter, + onClick = { onFilter(filter) }, + label = { + Text( + stringResource( + when (filter) { + YoutubeSearchFilter.SONGS -> R.string.youtube_filter_songs + YoutubeSearchFilter.ALBUMS -> R.string.youtube_filter_albums + YoutubeSearchFilter.PLAYLISTS -> R.string.youtube_filter_playlists + } + ), + fontFamily = GoogleSansRounded, + ) + }, + ) + } + } } } - Box( - Modifier - .fillMaxWidth() - .height(28.dp) - .clip(sheetCurve) - .background(MaterialTheme.colorScheme.surface), - ) - } } @Composable diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index abebcb68de..c01d6e4344 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -2225,6 +2225,19 @@ class PlayerViewModel @Inject constructor( fun playExternalUri(uri: Uri) = playbackDispatchStateHolder.playExternalUri(uri) + fun showPreparingSong(song: Song) { + _isSheetVisible.value = true + _sheetState.value = PlayerSheetState.EXPANDED + playbackStateHolder.updateStablePlayerState { + it.copy( + currentSong = song, + isPlaying = false, + playWhenReady = true, + totalDuration = song.duration.coerceAtLeast(0L), + ) + } + } + fun showPlayer() { if (stablePlayerState.value.currentSong != null) { _isSheetVisible.value = true diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt index dc5f4cfe6b..6e38d4e18d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt @@ -1,6 +1,7 @@ package com.theveloper.pixelplay.youtube import android.content.Context +import android.webkit.CookieManager import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -42,6 +43,10 @@ class YoutubeSession @Inject constructor( fun clear() { prefs.edit().clear().apply() _account.value = YoutubeAccount() + val cookies = CookieManager.getInstance() + cookies.removeAllCookies(null) + cookies.removeSessionCookies(null) + cookies.flush() } private fun read(): YoutubeAccount = YoutubeAccount( diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt index 9e0c6cc93b..0eafbca93e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -171,8 +171,16 @@ class YoutubeViewModel @Inject constructor( } suspend fun resolvePlayable(hit: YoutubeHit): Song = withContext(Dispatchers.IO) { - val track = client.resolveTrack(hit.url) - client.toSong(track, hit.url) + var lastError: Throwable? = null + repeat(2) { + val resolved = runCatching { + val track = client.resolveTrack(hit.url) + client.toSong(track, hit.url) + } + if (resolved.isSuccess) return@withContext resolved.getOrThrow() + lastError = resolved.exceptionOrNull() + } + throw lastError ?: IllegalStateException("Couldn't play this track") } fun toggleLike(hit: YoutubeHit) { @@ -196,7 +204,10 @@ class YoutubeViewModel @Inject constructor( fun signOut() { session.clear() + client.likedHits = emptyList() + client.feedShelves = emptyList() if (_uiState.value.section == YoutubeSection.LIKED) loadLiked(refreshing = true) + if (_uiState.value.section == YoutubeSection.HOME) loadFeed(refreshing = true) } private fun loadLiked(refreshing: Boolean = false) { From 15337ada4d9d825f44f553149d8248621f23fa22 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 15:20:08 +0300 Subject: [PATCH 44/77] fix: restore YouTube search and clear liked session on sign-out Put the search field back in the sheet. Wipe cookies and drop the stale liked error. Co-authored-by: Cursor --- .../presentation/screens/YoutubeScreen.kt | 163 ++++++++---------- .../pixelplay/youtube/YoutubeClient.kt | 52 ++++-- .../pixelplay/youtube/YoutubeDownloader.kt | 31 +++- .../pixelplay/youtube/YoutubeSession.kt | 44 ++++- .../pixelplay/youtube/YoutubeViewModel.kt | 44 ++++- 5 files changed, 218 insertions(+), 116 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index db4d57e785..cb2f3a2c82 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -165,6 +165,7 @@ fun YoutubeScreen( YoutubeSection.SAVED -> saved else -> uiState.results } + var searchActive by remember { mutableStateOf(false) } val pullState = rememberPullToRefreshState() LaunchedEffect(section) { @@ -233,17 +234,8 @@ fun YoutubeScreen( containerColor = headerColor, topBar = { YoutubeCompactHeader( - title = if (browseSection == YoutubeSection.SEARCH && uiState.collectionTitle == null) { - null - } else { - uiState.collectionTitle ?: title - }, + title = uiState.collectionTitle ?: title, showTitle = browseSection != YoutubeSection.HOME || uiState.collectionTitle != null, - query = uiState.query, - onQueryChange = viewModel::onQueryChange, - showSearch = browseSection == YoutubeSection.SEARCH && uiState.collectionTitle == null, - searchFilter = uiState.filter, - onFilter = viewModel::setFilter, onBack = if (uiState.collectionTitle != null) viewModel::closeCollection else null, signedIn = account.isSignedIn, onBeta = { showBeta = true }, @@ -289,7 +281,78 @@ fun YoutubeScreen( }, ) { Column(Modifier.fillMaxSize()) { - uiState.error?.let { message -> + if (browseSection == YoutubeSection.SEARCH && uiState.collectionTitle == null) { + DockedSearchBar( + inputField = { + SearchBarDefaults.InputField( + query = uiState.query, + onQueryChange = viewModel::onQueryChange, + onSearch = { searchActive = false }, + expanded = searchActive, + onExpandedChange = { searchActive = it }, + placeholder = { + Text( + stringResource(R.string.youtube_search_placeholder), + fontFamily = GoogleSansRounded, + ) + }, + leadingIcon = { + Icon( + Icons.Rounded.Search, + contentDescription = stringResource(R.string.youtube_search_title), + modifier = Modifier.size(24.dp), + ) + }, + trailingIcon = if (uiState.query.isNotEmpty()) { + { + IconButton(onClick = { viewModel.onQueryChange("") }) { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(R.string.youtube_clear_search), + ) + } + } + } else { + null + }, + ) + }, + expanded = false, + onExpandedChange = { searchActive = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(28.dp), + ) {} + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + YoutubeSearchFilter.entries.forEach { filter -> + FilterChip( + selected = uiState.filter == filter, + onClick = { viewModel.setFilter(filter) }, + label = { + Text( + stringResource( + when (filter) { + YoutubeSearchFilter.SONGS -> R.string.youtube_filter_songs + YoutubeSearchFilter.ALBUMS -> R.string.youtube_filter_albums + YoutubeSearchFilter.PLAYLISTS -> R.string.youtube_filter_playlists + } + ), + fontFamily = GoogleSansRounded, + ) + }, + ) + } + } + } + + uiState.error?.takeUnless { browseSection == YoutubeSection.LIKED && !account.isSignedIn }?.let { message -> Surface( modifier = Modifier .fillMaxWidth() @@ -482,11 +545,6 @@ fun YoutubeScreen( private fun YoutubeCompactHeader( title: String?, showTitle: Boolean, - query: String, - onQueryChange: (String) -> Unit, - showSearch: Boolean, - searchFilter: YoutubeSearchFilter, - onFilter: (YoutubeSearchFilter) -> Unit, onBack: (() -> Unit)?, signedIn: Boolean, onBeta: () -> Unit, @@ -558,79 +616,6 @@ private fun YoutubeCompactHeader( ) } } - if (showSearch) { - DockedSearchBar( - inputField = { - SearchBarDefaults.InputField( - query = query, - onQueryChange = onQueryChange, - onSearch = {}, - expanded = false, - onExpandedChange = {}, - placeholder = { - Text( - stringResource(R.string.youtube_search_placeholder), - fontFamily = GoogleSansRounded, - ) - }, - leadingIcon = { - Icon( - Icons.Rounded.Search, - contentDescription = stringResource(R.string.youtube_search_title), - modifier = Modifier.size(24.dp), - ) - }, - trailingIcon = if (query.isNotEmpty()) { - { - IconButton( - onClick = { onQueryChange("") }, - modifier = Modifier.size(48.dp), - ) { - Icon( - Icons.Rounded.Close, - contentDescription = stringResource(R.string.youtube_clear_search), - ) - } - } - } else { - null - }, - ) - }, - expanded = false, - onExpandedChange = {}, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - shape = RoundedCornerShape(28.dp), - ) {} - Spacer(Modifier.height(8.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - YoutubeSearchFilter.entries.forEach { filter -> - FilterChip( - selected = searchFilter == filter, - onClick = { onFilter(filter) }, - label = { - Text( - stringResource( - when (filter) { - YoutubeSearchFilter.SONGS -> R.string.youtube_filter_songs - YoutubeSearchFilter.ALBUMS -> R.string.youtube_filter_albums - YoutubeSearchFilter.PLAYLISTS -> R.string.youtube_filter_playlists - } - ), - fontFamily = GoogleSansRounded, - ) - }, - ) - } - } - } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index 51cfdf528d..f9703d6c51 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -83,18 +83,29 @@ class YoutubeClient @Inject constructor( applySession() if (!session.isSignedIn()) return emptyList() val hits = linkedSetOf() - listOf("VLLl", "FEmusic_liked_videos").forEach { browseId -> + val requests = listOf( + Triple( + "VLLl", + "WEB", + "https://www.youtube.com", + ), + Triple( + "FEmusic_liked_videos", + "WEB_REMIX", + "https://music.youtube.com", + ), + ) + requests.forEach { (browseId, clientName, origin) -> if (hits.size >= limit) return hits.take(limit).toList() + val version = if (clientName == "WEB_REMIX") "1.20250922.01.00" else "2.20250301.01.00" val body = """ - {"context":{"client":{"clientName":"WEB","clientVersion":"2.20250301.01.00","hl":"en","gl":"US"}},"browseId":"$browseId"} + {"context":{"client":{"clientName":"$clientName","clientVersion":"$version","hl":"en","gl":"US"}},"browseId":"$browseId"} """.trim() val json = runCatching { - downloader.postJson("https://www.youtube.com/youtubei/v1/browse?prettyPrint=false", body) + downloader.postJson("https://www.youtube.com/youtubei/v1/browse?prettyPrint=false", body, origin) }.getOrNull() ?: return@forEach - collectVideoHits(JSONObject(json), hits, limit) - } - if (hits.isEmpty()) { - throw IllegalStateException("Couldn't read liked videos from this session. Sign in again from Account.") + val root = runCatching { JSONObject(json) }.getOrNull() ?: return@forEach + collectVideoHits(root, hits, limit) } return hits.take(limit).toList() } @@ -225,15 +236,23 @@ class YoutubeClient @Inject constructor( if (hits.size >= limit || node == null) return when (node) { is JSONObject -> { - val videoId = node.optString("videoId") - if (videoId.length == 11 && node.has("title")) { - val title = jsonText(node.optJSONObject("title")) + val videoId = node.optString("videoId").ifBlank { + node.optJSONObject("playlistItemData")?.optString("videoId").orEmpty() + }.ifBlank { + node.optJSONObject("navigationEndpoint") + ?.optJSONObject("watchEndpoint") + ?.optString("videoId") + .orEmpty() + } + if (videoId.length == 11) { + val title = videoTitle(node) if (title.isNotBlank()) { hits += YoutubeHit( url = "https://www.youtube.com/watch?v=$videoId", title = title, artist = jsonText(node.optJSONObject("ownerText")) .ifBlank { jsonText(node.optJSONObject("shortBylineText")) } + .ifBlank { flexColumnText(node, 1) } .ifBlank { "YouTube Music" }, durationSec = 0, thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hqdefault.jpg"), @@ -255,6 +274,19 @@ class YoutubeClient @Inject constructor( } } + private fun videoTitle(node: JSONObject): String { + jsonText(node.optJSONObject("title")).takeIf { it.isNotBlank() }?.let { return it } + return flexColumnText(node, 0) + } + + private fun flexColumnText(node: JSONObject, column: Int): String { + val flex = node.optJSONArray("flexColumns") ?: return "" + val text = flex.optJSONObject(column) + ?.optJSONObject("musicResponsiveListItemFlexColumnRenderer") + ?.optJSONObject("text") + return jsonText(text) + } + private fun jsonText(node: JSONObject?): String { if (node == null) return "" node.optString("simpleText").takeIf { it.isNotBlank() }?.let { return it } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt index 1cd35018df..3636cc8111 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt @@ -6,6 +6,7 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.schabi.newpipe.extractor.downloader.Downloader import org.schabi.newpipe.extractor.downloader.Request import org.schabi.newpipe.extractor.downloader.Response +import java.security.MessageDigest import java.util.concurrent.TimeUnit /** @@ -54,16 +55,21 @@ internal class YoutubeDownloader : Downloader() { } } - fun postJson(url: String, json: String): String { + fun postJson(url: String, json: String, origin: String = "https://www.youtube.com"): String { + val cookies = cookieHeader?.trim().orEmpty() val request = okhttp3.Request.Builder() .url(url) .post(json.toRequestBody(JSON)) .header("User-Agent", USER_AGENT) .header("Content-Type", "application/json") - .header("Origin", "https://www.youtube.com") - .header("Referer", "https://www.youtube.com/") + .header("Origin", origin) + .header("Referer", "$origin/") + .header("X-Origin", origin) .apply { - cookieHeader?.trim()?.takeIf { it.isNotEmpty() }?.let { header("Cookie", it) } + if (cookies.isNotEmpty()) { + header("Cookie", cookies) + authorization(cookies, origin)?.let { header("Authorization", it) } + } } .build() client.newCall(request).execute().use { response -> @@ -79,5 +85,22 @@ internal class YoutubeDownloader : Downloader() { private val JSON = "application/json".toMediaType() private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0" + + private fun authorization(cookies: String, origin: String): String? { + val values = cookies.split(';') + .map { it.trim() } + .filter { it.contains('=') } + .associate { part -> + val index = part.indexOf('=') + part.substring(0, index) to part.substring(index + 1) + } + val sapisid = values["SAPISID"].orEmpty().ifBlank { values["__Secure-3PAPISID"].orEmpty() } + if (sapisid.isBlank()) return null + val timestamp = System.currentTimeMillis() / 1000 + val digest = MessageDigest.getInstance("SHA-1") + .digest("$timestamp $sapisid $origin".toByteArray()) + val hash = digest.joinToString("") { byte -> "%02x".format(byte) } + return "SAPISIDHASH ${timestamp}_$hash" + } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt index 6e38d4e18d..26ad844ff9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt @@ -1,11 +1,15 @@ package com.theveloper.pixelplay.youtube import android.content.Context +import android.os.Handler +import android.os.Looper import android.webkit.CookieManager +import android.webkit.WebStorage import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import javax.inject.Singleton @@ -24,10 +28,14 @@ class YoutubeSession @Inject constructor( private val _account = MutableStateFlow(read()) val account: StateFlow = _account.asStateFlow() + private val generation = AtomicInteger(0) + fun cookieHeader(): String = _account.value.cookieHeader fun isSignedIn(): Boolean = _account.value.isSignedIn + fun generation(): Int = generation.get() + fun save(cookieHeader: String, displayName: String = "") { val account = YoutubeAccount( cookieHeader = cookieHeader.trim(), @@ -41,12 +49,38 @@ class YoutubeSession @Inject constructor( } fun clear() { - prefs.edit().clear().apply() + generation.incrementAndGet() + prefs.edit().clear().commit() _account.value = YoutubeAccount() - val cookies = CookieManager.getInstance() - cookies.removeAllCookies(null) - cookies.removeSessionCookies(null) - cookies.flush() + val wipe = Runnable { + val cookies = CookieManager.getInstance() + expireStoredCookies(cookies) + cookies.removeAllCookies { cookies.flush() } + cookies.removeSessionCookies(null) + cookies.flush() + runCatching { WebStorage.getInstance().deleteAllData() } + } + if (Looper.myLooper() == Looper.getMainLooper()) wipe.run() + else Handler(Looper.getMainLooper()).post(wipe) + } + + private fun expireStoredCookies(cookies: CookieManager) { + val expired = "Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Path=/; Secure" + listOf( + "https://youtube.com", + "https://www.youtube.com", + "https://m.youtube.com", + "https://music.youtube.com", + "https://accounts.google.com", + "https://www.google.com", + "https://google.com", + "https://consent.youtube.com", + ).forEach { url -> + cookies.getCookie(url).orEmpty().split(';').forEach { part -> + val name = part.substringBefore('=').trim() + if (name.isNotEmpty()) cookies.setCookie(url, "$name=; $expired") + } + } } private fun read(): YoutubeAccount = YoutubeAccount( diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt index 0eafbca93e..f64c96787d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException import javax.inject.Inject data class YoutubeUiState( @@ -41,6 +42,7 @@ class YoutubeViewModel @Inject constructor( val savedSongIds: StateFlow> = musicStore.savedSongIds val account: StateFlow = session.account private var searchJob: Job? = null + private var likedJob: Job? = null init { viewModelScope.launch { @@ -203,15 +205,24 @@ class YoutubeViewModel @Inject constructor( } fun signOut() { + likedJob?.cancel() session.clear() client.likedHits = emptyList() client.feedShelves = emptyList() - if (_uiState.value.section == YoutubeSection.LIKED) loadLiked(refreshing = true) - if (_uiState.value.section == YoutubeSection.HOME) loadFeed(refreshing = true) + _uiState.update { + it.copy(error = null, results = emptyList(), isLoading = false, isRefreshing = false) + } + when (_uiState.value.section) { + YoutubeSection.LIKED -> loadLiked(refreshing = false) + YoutubeSection.HOME -> loadFeed(refreshing = true) + else -> Unit + } } private fun loadLiked(refreshing: Boolean = false) { - viewModelScope.launch { + likedJob?.cancel() + val epoch = session.generation() + likedJob = viewModelScope.launch { _uiState.update { it.copy( isLoading = !refreshing && it.results.isEmpty(), @@ -220,18 +231,35 @@ class YoutubeViewModel @Inject constructor( ) } val local = musicStore.liked.value - val remote = if (session.isSignedIn()) { - runCatching { withContext(Dispatchers.IO) { client.loadLikedVideos() } } + val remote = if (session.isSignedIn() && session.generation() == epoch) { + try { + Result.success(withContext(Dispatchers.IO) { client.loadLikedVideos() }) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + Result.failure(error) + } } else { Result.success(emptyList()) } + if (session.generation() != epoch) return@launch remote.fold( onSuccess = { accountLikes -> - val merged = (accountLikes + local).distinctBy { it.url } - if (accountLikes.isNotEmpty()) client.likedHits = accountLikes - _uiState.update { it.copy(isLoading = false, isRefreshing = false, results = merged) } + if (session.generation() != epoch) return@launch + val account = if (session.isSignedIn()) accountLikes else emptyList() + val merged = (account + local).distinctBy { it.url } + client.likedHits = account + _uiState.update { + it.copy(isLoading = false, isRefreshing = false, error = null, results = merged) + } }, onFailure = { error -> + if (session.generation() != epoch || !session.isSignedIn()) { + _uiState.update { + it.copy(isLoading = false, isRefreshing = false, error = null, results = local) + } + return@fold + } _uiState.update { it.copy( isLoading = false, From 8366d19549f3a41f5f38f0dcc10b6fa085bcee16 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 15:38:12 +0300 Subject: [PATCH 45/77] fix: put YouTube search in the brown header Keep a working clear control. Open Google sign-in as the desktop page. Co-authored-by: Cursor --- .../presentation/screens/YoutubeScreen.kt | 197 +++++++++++------- .../youtube/auth/YoutubeLoginActivity.kt | 11 + 2 files changed, 135 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index cb2f3a2c82..f0f719e54e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -73,9 +73,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.draw.clip import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -165,7 +166,6 @@ fun YoutubeScreen( YoutubeSection.SAVED -> saved else -> uiState.results } - var searchActive by remember { mutableStateOf(false) } val pullState = rememberPullToRefreshState() LaunchedEffect(section) { @@ -236,6 +236,11 @@ fun YoutubeScreen( YoutubeCompactHeader( title = uiState.collectionTitle ?: title, showTitle = browseSection != YoutubeSection.HOME || uiState.collectionTitle != null, + query = uiState.query, + onQueryChange = viewModel::onQueryChange, + showSearch = browseSection == YoutubeSection.SEARCH && uiState.collectionTitle == null, + searchFilter = uiState.filter, + onFilter = viewModel::setFilter, onBack = if (uiState.collectionTitle != null) viewModel::closeCollection else null, signedIn = account.isSignedIn, onBeta = { showBeta = true }, @@ -281,77 +286,6 @@ fun YoutubeScreen( }, ) { Column(Modifier.fillMaxSize()) { - if (browseSection == YoutubeSection.SEARCH && uiState.collectionTitle == null) { - DockedSearchBar( - inputField = { - SearchBarDefaults.InputField( - query = uiState.query, - onQueryChange = viewModel::onQueryChange, - onSearch = { searchActive = false }, - expanded = searchActive, - onExpandedChange = { searchActive = it }, - placeholder = { - Text( - stringResource(R.string.youtube_search_placeholder), - fontFamily = GoogleSansRounded, - ) - }, - leadingIcon = { - Icon( - Icons.Rounded.Search, - contentDescription = stringResource(R.string.youtube_search_title), - modifier = Modifier.size(24.dp), - ) - }, - trailingIcon = if (uiState.query.isNotEmpty()) { - { - IconButton(onClick = { viewModel.onQueryChange("") }) { - Icon( - Icons.Rounded.Close, - contentDescription = stringResource(R.string.youtube_clear_search), - ) - } - } - } else { - null - }, - ) - }, - expanded = false, - onExpandedChange = { searchActive = it }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - shape = RoundedCornerShape(28.dp), - ) {} - Spacer(Modifier.height(8.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - YoutubeSearchFilter.entries.forEach { filter -> - FilterChip( - selected = uiState.filter == filter, - onClick = { viewModel.setFilter(filter) }, - label = { - Text( - stringResource( - when (filter) { - YoutubeSearchFilter.SONGS -> R.string.youtube_filter_songs - YoutubeSearchFilter.ALBUMS -> R.string.youtube_filter_albums - YoutubeSearchFilter.PLAYLISTS -> R.string.youtube_filter_playlists - } - ), - fontFamily = GoogleSansRounded, - ) - }, - ) - } - } - } - uiState.error?.takeUnless { browseSection == YoutubeSection.LIKED && !account.isSignedIn }?.let { message -> Surface( modifier = Modifier @@ -545,6 +479,11 @@ fun YoutubeScreen( private fun YoutubeCompactHeader( title: String?, showTitle: Boolean, + query: String, + onQueryChange: (String) -> Unit, + showSearch: Boolean, + searchFilter: YoutubeSearchFilter, + onFilter: (YoutubeSearchFilter) -> Unit, onBack: (() -> Unit)?, signedIn: Boolean, onBeta: () -> Unit, @@ -616,6 +555,118 @@ private fun YoutubeCompactHeader( ) } } + if (showSearch) { + YoutubeSearchField( + query = query, + onQueryChange = onQueryChange, + searchFilter = searchFilter, + onFilter = onFilter, + ) + } + } +} + +@Composable +private fun YoutubeSearchField( + query: String, + onQueryChange: (String) -> Unit, + searchFilter: YoutubeSearchFilter, + onFilter: (YoutubeSearchFilter) -> Unit, +) { + val keyboard = LocalSoftwareKeyboardController.current + val corner = 28.dp + val fieldColors = SearchBarDefaults.inputFieldColors( + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary, + ) + DockedSearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = { keyboard?.hide() }, + expanded = false, + onExpandedChange = {}, + placeholder = { + Text( + stringResource(R.string.youtube_search_placeholder), + fontFamily = GoogleSansRounded, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingIcon = { + Icon( + Icons.Rounded.Search, + contentDescription = stringResource(R.string.youtube_search_title), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = if (query.isNotEmpty()) { + { + IconButton( + onClick = { + onQueryChange("") + keyboard?.hide() + }, + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + ) { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(R.string.youtube_clear_search), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } else { + null + }, + colors = fieldColors, + ) + }, + expanded = false, + onExpandedChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(corner)), + shape = RoundedCornerShape(corner), + colors = SearchBarDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f), + dividerColor = Color.Transparent, + inputFieldColors = fieldColors, + ), + ) {} + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + YoutubeSearchFilter.entries.forEach { filter -> + FilterChip( + selected = searchFilter == filter, + onClick = { onFilter(filter) }, + label = { + Text( + stringResource( + when (filter) { + YoutubeSearchFilter.SONGS -> R.string.youtube_filter_songs + YoutubeSearchFilter.ALBUMS -> R.string.youtube_filter_albums + YoutubeSearchFilter.PLAYLISTS -> R.string.youtube_filter_playlists + } + ), + fontFamily = GoogleSansRounded, + ) + }, + ) + } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt index 95bf587e8d..4b8fa14553 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt @@ -61,6 +61,8 @@ class YoutubeLoginActivity : ComponentActivity() { const val TARGET_URL = "https://accounts.google.com/ServiceLogin?service=youtube&passive=true&continue=https%3A%2F%2Fwww.youtube.com%2F&hl=en" const val DESKTOP_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + const val DESKTOP_VIEWPORT = + "(function(){var meta=document.querySelector('meta[name=viewport]');if(!meta){meta=document.createElement('meta');meta.name='viewport';(document.head||document.documentElement).appendChild(meta);}meta.setAttribute('content','width=1280');})();" } } @@ -145,6 +147,10 @@ private fun YoutubeWebView( settings.domStorageEnabled = true settings.cacheMode = WebSettings.LOAD_DEFAULT settings.userAgentString = YoutubeLoginActivity.DESKTOP_UA + settings.useWideViewPort = true + settings.loadWithOverviewMode = true + settings.layoutAlgorithm = WebSettings.LayoutAlgorithm.NORMAL + settings.setSupportZoom(true) CookieManager.getInstance().setAcceptCookie(true) CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) webChromeClient = object : WebChromeClient() { @@ -153,7 +159,12 @@ private fun YoutubeWebView( } } webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { + view?.settings?.userAgentString = YoutubeLoginActivity.DESKTOP_UA + } + override fun onPageFinished(view: WebView?, url: String?) { + view?.evaluateJavascript(YoutubeLoginActivity.DESKTOP_VIEWPORT, null) onCookiesMaybeReady() } } From db0eafde69fc32a25c0b2ae1e9aa15b67c2fde1d Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 16:22:51 +0300 Subject: [PATCH 46/77] fix: stop forcing the desktop Google sign-in page Use the normal phone WebView layout for YouTube account sign-in. Co-authored-by: Cursor --- .../youtube/auth/YoutubeLoginActivity.kt | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt index 4b8fa14553..20ed16633b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt @@ -59,10 +59,6 @@ class YoutubeLoginActivity : ComponentActivity() { companion object { const val TARGET_URL = "https://accounts.google.com/ServiceLogin?service=youtube&passive=true&continue=https%3A%2F%2Fwww.youtube.com%2F&hl=en" - const val DESKTOP_UA = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" - const val DESKTOP_VIEWPORT = - "(function(){var meta=document.querySelector('meta[name=viewport]');if(!meta){meta=document.createElement('meta');meta.name='viewport';(document.head||document.documentElement).appendChild(meta);}meta.setAttribute('content','width=1280');})();" } } @@ -146,11 +142,6 @@ private fun YoutubeWebView( settings.javaScriptEnabled = true settings.domStorageEnabled = true settings.cacheMode = WebSettings.LOAD_DEFAULT - settings.userAgentString = YoutubeLoginActivity.DESKTOP_UA - settings.useWideViewPort = true - settings.loadWithOverviewMode = true - settings.layoutAlgorithm = WebSettings.LayoutAlgorithm.NORMAL - settings.setSupportZoom(true) CookieManager.getInstance().setAcceptCookie(true) CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) webChromeClient = object : WebChromeClient() { @@ -159,12 +150,7 @@ private fun YoutubeWebView( } } webViewClient = object : WebViewClient() { - override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { - view?.settings?.userAgentString = YoutubeLoginActivity.DESKTOP_UA - } - override fun onPageFinished(view: WebView?, url: String?) { - view?.evaluateJavascript(YoutubeLoginActivity.DESKTOP_VIEWPORT, null) onCookiesMaybeReady() } } From 7b4d26edb825e52b43b5d98d064e1c9162346ce7 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 17:41:00 +0300 Subject: [PATCH 47/77] feat: show recently played tracks and YouTube Music likes Keep a recently played row at the top of Feed. Load liked songs from YouTube Music. Co-authored-by: Cursor --- .../presentation/screens/YoutubeScreen.kt | 30 +++- .../pixelplay/youtube/YoutubeClient.kt | 129 ++++++++++++++---- .../pixelplay/youtube/YoutubeDownloader.kt | 30 +++- .../pixelplay/youtube/YoutubeMusicStore.kt | 15 ++ .../pixelplay/youtube/YoutubeViewModel.kt | 5 + app/src/main/res/values/strings.xml | 1 + 6 files changed, 178 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index f0f719e54e..e187a6f395 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -125,6 +125,7 @@ fun YoutubeScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val liked by viewModel.likedTracks.collectAsStateWithLifecycle() val saved by viewModel.savedTracks.collectAsStateWithLifecycle() + val recent by viewModel.recentTracks.collectAsStateWithLifecycle() val likedIds by viewModel.likedSongIds.collectAsStateWithLifecycle() val savedIds by viewModel.savedSongIds.collectAsStateWithLifecycle() val account by viewModel.account.collectAsStateWithLifecycle() @@ -171,6 +172,9 @@ fun YoutubeScreen( LaunchedEffect(section) { viewModel.openSection(section) } + LaunchedEffect(stablePlayer.currentSong?.id) { + stablePlayer.currentSong?.let(viewModel::rememberPlayed) + } if (uiState.collectionTitle != null) { BackHandler { viewModel.closeCollection() } @@ -309,7 +313,7 @@ fun YoutubeScreen( Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator() } - } else if (uiState.shelves.isEmpty()) { + } else if (uiState.shelves.isEmpty() && recent.isEmpty()) { EmptyYoutubeState(message = stringResource(R.string.youtube_home_empty)) } else { val mixHits = uiState.shelves @@ -324,6 +328,29 @@ fun YoutubeScreen( mixHits.take(8).map { placeholderSong(it) }.toImmutableList() } LazyColumn(contentPadding = contentPadding) { + if (recent.isNotEmpty()) { + item(key = "recent") { + Text( + text = stringResource(R.string.youtube_recent_title), + modifier = Modifier.padding(bottom = 8.dp, top = 4.dp), + style = MaterialTheme.typography.titleLarge, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(recent, key = { "recent-${it.url}" }) { hit -> + YoutubeTile( + hit = hit, + modifier = Modifier.width(168.dp), + isCurrent = currentSongId == YoutubeClient.songIdForUrl(hit.url), + isPlaying = isPlaying, + onClick = { playHit(hit, false) }, + ) + } + } + } + } + if (mixHits.isNotEmpty()) { item(key = "your_mix") { YourMixHeader( song = stringResource(R.string.youtube_todays_mix), @@ -347,6 +374,7 @@ fun YoutubeScreen( ) } } + } items(uiState.shelves, key = { it.id }) { shelf -> Text( text = shelf.title, diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index f9703d6c51..d5ab7e9823 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -79,35 +79,73 @@ class YoutubeClient @Inject constructor( .toList() } - fun loadLikedVideos(limit: Int = 50): List { + fun loadLikedVideos(limit: Int = 100): List { applySession() if (!session.isSignedIn()) return emptyList() val hits = linkedSetOf() - val requests = listOf( - Triple( - "VLLl", - "WEB", - "https://www.youtube.com", - ), - Triple( - "FEmusic_liked_videos", - "WEB_REMIX", - "https://music.youtube.com", - ), + browseLibrary( + host = "https://music.youtube.com", + browseId = "FEmusic_liked_videos", + clientName = "WEB_REMIX", + clientVersion = "1.20250922.01.00", + clientHeader = "67", + hits = hits, + limit = limit, ) - requests.forEach { (browseId, clientName, origin) -> - if (hits.size >= limit) return hits.take(limit).toList() - val version = if (clientName == "WEB_REMIX") "1.20250922.01.00" else "2.20250301.01.00" - val body = """ - {"context":{"client":{"clientName":"$clientName","clientVersion":"$version","hl":"en","gl":"US"}},"browseId":"$browseId"} - """.trim() + if (hits.isEmpty()) { + browseLibrary( + host = "https://music.youtube.com", + browseId = "VLLM", + clientName = "WEB_REMIX", + clientVersion = "1.20250922.01.00", + clientHeader = "67", + hits = hits, + limit = limit, + ) + } + if (hits.isEmpty()) { + browseLibrary( + host = "https://www.youtube.com", + browseId = "VLLl", + clientName = "WEB", + clientVersion = "2.20250301.01.00", + clientHeader = "1", + hits = hits, + limit = limit, + ) + } + return hits.take(limit).toList() + } + + private fun browseLibrary( + host: String, + browseId: String, + clientName: String, + clientVersion: String, + clientHeader: String, + hits: MutableSet, + limit: Int, + ) { + val context = """ + {"client":{"clientName":"$clientName","clientVersion":"$clientVersion","hl":"en","gl":"US"}} + """.trim() + var body = """{"context":$context,"browseId":"$browseId"}""" + repeat(4) { + if (hits.size >= limit) return val json = runCatching { - downloader.postJson("https://www.youtube.com/youtubei/v1/browse?prettyPrint=false", body, origin) - }.getOrNull() ?: return@forEach - val root = runCatching { JSONObject(json) }.getOrNull() ?: return@forEach + downloader.postJson( + url = "$host/youtubei/v1/browse?prettyPrint=false", + json = body, + origin = host, + clientName = clientHeader, + clientVersion = clientVersion, + ) + }.getOrNull() ?: return + val root = runCatching { JSONObject(json) }.getOrNull() ?: return collectVideoHits(root, hits, limit) + val token = findContinuation(root) ?: return + body = """{"context":$context,"continuation":${JSONObject.quote(token)}}""" } - return hits.take(limit).toList() } fun loadSubscriptionFeed(limit: Int = 20): List { @@ -243,19 +281,21 @@ class YoutubeClient @Inject constructor( ?.optJSONObject("watchEndpoint") ?.optString("videoId") .orEmpty() + }.ifBlank { + if (node.has("flexColumns")) firstVideoId(node.optJSONObject("overlay")) else "" } if (videoId.length == 11) { val title = videoTitle(node) if (title.isNotBlank()) { hits += YoutubeHit( - url = "https://www.youtube.com/watch?v=$videoId", + url = "https://music.youtube.com/watch?v=$videoId", title = title, artist = jsonText(node.optJSONObject("ownerText")) .ifBlank { jsonText(node.optJSONObject("shortBylineText")) } .ifBlank { flexColumnText(node, 1) } .ifBlank { "YouTube Music" }, durationSec = 0, - thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hqdefault.jpg"), + thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"), kind = YoutubeHit.Kind.TRACK, ) } @@ -274,6 +314,47 @@ class YoutubeClient @Inject constructor( } } + private fun firstVideoId(node: JSONObject?): String { + if (node == null) return "" + node.optString("videoId").takeIf { it.length == 11 }?.let { return it } + val keys = node.keys() + while (keys.hasNext()) { + when (val child = node.opt(keys.next())) { + is JSONObject -> firstVideoId(child).takeIf { it.length == 11 }?.let { return it } + is JSONArray -> { + for (index in 0 until child.length()) { + val item = child.opt(index) + if (item is JSONObject) { + firstVideoId(item).takeIf { it.length == 11 }?.let { return it } + } + } + } + } + } + return "" + } + + private fun findContinuation(node: Any?): String? { + when (node) { + is JSONObject -> { + node.optJSONObject("continuationCommand") + ?.optString("token") + ?.takeIf { it.isNotBlank() } + ?.let { return it } + val keys = node.keys() + while (keys.hasNext()) { + findContinuation(node.opt(keys.next()))?.let { return it } + } + } + is JSONArray -> { + for (index in 0 until node.length()) { + findContinuation(node.opt(index))?.let { return it } + } + } + } + return null + } + private fun videoTitle(node: JSONObject): String { jsonText(node.optJSONObject("title")).takeIf { it.isNotBlank() }?.let { return it } return flexColumnText(node, 0) diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt index 3636cc8111..1e27586396 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt @@ -55,7 +55,13 @@ internal class YoutubeDownloader : Downloader() { } } - fun postJson(url: String, json: String, origin: String = "https://www.youtube.com"): String { + fun postJson( + url: String, + json: String, + origin: String = "https://www.youtube.com", + clientName: String? = null, + clientVersion: String? = null, + ): String { val cookies = cookieHeader?.trim().orEmpty() val request = okhttp3.Request.Builder() .url(url) @@ -65,7 +71,10 @@ internal class YoutubeDownloader : Downloader() { .header("Origin", origin) .header("Referer", "$origin/") .header("X-Origin", origin) + .header("X-Goog-AuthUser", "0") .apply { + if (!clientName.isNullOrBlank()) header("X-YouTube-Client-Name", clientName) + if (!clientVersion.isNullOrBlank()) header("X-YouTube-Client-Version", clientVersion) if (cookies.isNotEmpty()) { header("Cookie", cookies) authorization(cookies, origin)?.let { header("Authorization", it) } @@ -94,13 +103,20 @@ internal class YoutubeDownloader : Downloader() { val index = part.indexOf('=') part.substring(0, index) to part.substring(index + 1) } - val sapisid = values["SAPISID"].orEmpty().ifBlank { values["__Secure-3PAPISID"].orEmpty() } - if (sapisid.isBlank()) return null val timestamp = System.currentTimeMillis() / 1000 - val digest = MessageDigest.getInstance("SHA-1") - .digest("$timestamp $sapisid $origin".toByteArray()) - val hash = digest.joinToString("") { byte -> "%02x".format(byte) } - return "SAPISIDHASH ${timestamp}_$hash" + fun hash(secret: String): String? { + if (secret.isBlank()) return null + val digest = MessageDigest.getInstance("SHA-1") + .digest("$timestamp $secret $origin".toByteArray()) + return digest.joinToString("") { byte -> "%02x".format(byte) } + } + val parts = buildList { + hash(values["SAPISID"].orEmpty().ifBlank { values["__Secure-3PAPISID"].orEmpty() }) + ?.let { add("SAPISIDHASH ${timestamp}_$it") } + hash(values["__Secure-1PAPISID"].orEmpty())?.let { add("SAPISID1PHASH ${timestamp}_$it") } + hash(values["__Secure-3PAPISID"].orEmpty())?.let { add("SAPISID3PHASH ${timestamp}_$it") } + } + return parts.takeIf { it.isNotEmpty() }?.joinToString(" ") } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt index 7edbb22aa4..077745212f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt @@ -21,11 +21,13 @@ class YoutubeMusicStore @Inject constructor( private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val _liked = MutableStateFlow(read(KEY_LIKED)) private val _saved = MutableStateFlow(read(KEY_SAVED)) + private val _recent = MutableStateFlow(read(KEY_RECENT)) private val _likedSongIds = MutableStateFlow(idsOf(_liked.value)) private val _savedSongIds = MutableStateFlow(idsOf(_saved.value)) val liked: StateFlow> = _liked.asStateFlow() val saved: StateFlow> = _saved.asStateFlow() + val recent: StateFlow> = _recent.asStateFlow() val likedSongIds: StateFlow> = _likedSongIds.asStateFlow() val savedSongIds: StateFlow> = _savedSongIds.asStateFlow() @@ -49,6 +51,17 @@ class YoutubeMusicStore @Inject constructor( hitFromSong(song)?.let(::toggleSave) } + fun rememberRecent(hit: YoutubeHit) { + if (hit.url.isBlank() || hit.kind != YoutubeHit.Kind.TRACK) return + val next = listOf(hit) + _recent.value.filterNot { it.url == hit.url } + _recent.value = next.take(MAX_RECENT) + persist(KEY_RECENT, _recent.value) + } + + fun rememberRecent(song: Song) { + hitFromSong(song)?.let(::rememberRecent) + } + private fun idsOf(hits: List): Set = hits.map { YoutubeClient.songIdForUrl(it.url) }.toSet() @@ -119,6 +132,8 @@ class YoutubeMusicStore @Inject constructor( private const val PREFS = "youtube_music" private const val KEY_LIKED = "liked" private const val KEY_SAVED = "saved" + private const val KEY_RECENT = "recent" private const val MAX_TRACKS = 200 + private const val MAX_RECENT = 24 } } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt index f64c96787d..bc50529a8c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -38,6 +38,7 @@ class YoutubeViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() val likedTracks: StateFlow> = musicStore.liked val savedTracks: StateFlow> = musicStore.saved + val recentTracks: StateFlow> = musicStore.recent val likedSongIds: StateFlow> = musicStore.likedSongIds val savedSongIds: StateFlow> = musicStore.savedSongIds val account: StateFlow = session.account @@ -193,6 +194,10 @@ class YoutubeViewModel @Inject constructor( musicStore.toggleSave(hit) } + fun rememberPlayed(song: Song) { + musicStore.rememberRecent(song) + } + fun importSession(cookieHeader: String): Boolean { val normalized = cookieHeader.trim() val signedIn = listOf("LOGIN_INFO", "SAPISID", "__Secure-1PSID", "__Secure-3PSID") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 638e33be89..fb6aa16949 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,7 @@ Library Feed + Recently played Trending, radio, and playlists Radio Playlists From 5603b6519874cf7a9c0b16e2fce173f35688a4ac Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 18:05:06 +0300 Subject: [PATCH 48/77] fix: load the YouTube Music Liked Music playlist Ask playlist LM directly and save music cookies during sign-in. Co-authored-by: Cursor --- .../youtube/auth/YoutubeLoginActivity.kt | 17 ++- .../pixelplay/youtube/YoutubeClient.kt | 128 ++++++++++++------ .../pixelplay/youtube/YoutubeDownloader.kt | 4 +- 3 files changed, 106 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt index 20ed16633b..2dbe47f948 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt @@ -115,11 +115,22 @@ private fun YoutubeWebLoginScreen( ) } Box(Modifier.fillMaxSize()) { + var openedLikedMusic by remember { mutableStateOf(false) } YoutubeWebView( onProgress = { progress = it }, onWebViewCreated = { webView = it }, - onCookiesMaybeReady = { + onCookiesMaybeReady = { url -> val cookies = extractYoutubeSession() ?: return@YoutubeWebView + val hasAuth = cookies.contains("SAPISID=") || cookies.contains("__Secure-3PAPISID=") + if (!hasAuth) return@YoutubeWebView + val onMusic = url.orEmpty().contains("music.youtube.com") + if (!onMusic) { + if (!openedLikedMusic) { + openedLikedMusic = true + webView?.loadUrl("https://music.youtube.com/playlist?list=LM") + } + return@YoutubeWebView + } if (viewModel.importSession(cookies)) onClose() }, ) @@ -133,7 +144,7 @@ private fun YoutubeWebLoginScreen( private fun YoutubeWebView( onProgress: (Int) -> Unit, onWebViewCreated: (WebView) -> Unit, - onCookiesMaybeReady: () -> Unit, + onCookiesMaybeReady: (String?) -> Unit, ) { AndroidView( modifier = Modifier.fillMaxSize(), @@ -151,7 +162,7 @@ private fun YoutubeWebView( } webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { - onCookiesMaybeReady() + onCookiesMaybeReady(url) } } loadUrl(YoutubeLoginActivity.TARGET_URL) diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index d5ab7e9823..0f185a26b5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -83,67 +83,75 @@ class YoutubeClient @Inject constructor( applySession() if (!session.isSignedIn()) return emptyList() val hits = linkedSetOf() - browseLibrary( - host = "https://music.youtube.com", - browseId = "FEmusic_liked_videos", - clientName = "WEB_REMIX", - clientVersion = "1.20250922.01.00", - clientHeader = "67", - hits = hits, - limit = limit, - ) + runCatching { + queueLibrary( + endpoint = "next", + bodySeed = """"playlistId":"LM","isAudioOnly":true""", + hits = hits, + limit = limit, + ) + } if (hits.isEmpty()) { browseLibrary( - host = "https://music.youtube.com", - browseId = "VLLM", - clientName = "WEB_REMIX", - clientVersion = "1.20250922.01.00", - clientHeader = "67", + browseId = "FEmusic_liked_videos", hits = hits, limit = limit, ) } if (hits.isEmpty()) { browseLibrary( - host = "https://www.youtube.com", - browseId = "VLLl", - clientName = "WEB", - clientVersion = "2.20250301.01.00", - clientHeader = "1", + browseId = "VLLM", hits = hits, limit = limit, ) } + if (hits.isEmpty()) { + throw IllegalStateException("Couldn't load Liked Music. Sign in again from Account.") + } return hits.take(limit).toList() } private fun browseLibrary( - host: String, browseId: String, - clientName: String, - clientVersion: String, - clientHeader: String, hits: MutableSet, limit: Int, ) { + runCatching { + queueLibrary( + endpoint = "browse", + bodySeed = """"browseId":"$browseId"""", + hits = hits, + limit = limit, + ) + } + } + + private fun queueLibrary( + endpoint: String, + bodySeed: String, + hits: MutableSet, + limit: Int, + ) { + val version = "1.20250922.01.00" val context = """ - {"client":{"clientName":"$clientName","clientVersion":"$clientVersion","hl":"en","gl":"US"}} + {"client":{"clientName":"WEB_REMIX","clientVersion":"$version","hl":"en","gl":"US","platform":"DESKTOP","originalUrl":"https://music.youtube.com/playlist?list=LM"}} """.trim() - var body = """{"context":$context,"browseId":"$browseId"}""" - repeat(4) { + var body = """{"context":$context,$bodySeed}""" + repeat(6) { if (hits.size >= limit) return - val json = runCatching { - downloader.postJson( - url = "$host/youtubei/v1/browse?prettyPrint=false", - json = body, - origin = host, - clientName = clientHeader, - clientVersion = clientVersion, - ) - }.getOrNull() ?: return - val root = runCatching { JSONObject(json) }.getOrNull() ?: return + val json = downloader.postJson( + url = "https://music.youtube.com/youtubei/v1/$endpoint?prettyPrint=false", + json = body, + origin = "https://music.youtube.com", + clientName = "67", + clientVersion = version, + userAgent = MUSIC_USER_AGENT, + ) + val root = JSONObject(json) + val before = hits.size collectVideoHits(root, hits, limit) - val token = findContinuation(root) ?: return + if (hits.size == before && !json.contains("\"videoId\"")) return + val token = findPlaylistContinuation(root) ?: return body = """{"context":$context,"continuation":${JSONObject.quote(token)}}""" } } @@ -290,8 +298,9 @@ class YoutubeClient @Inject constructor( hits += YoutubeHit( url = "https://music.youtube.com/watch?v=$videoId", title = title, - artist = jsonText(node.optJSONObject("ownerText")) + artist = jsonText(node.optJSONObject("longBylineText")) .ifBlank { jsonText(node.optJSONObject("shortBylineText")) } + .ifBlank { jsonText(node.optJSONObject("ownerText")) } .ifBlank { flexColumnText(node, 1) } .ifBlank { "YouTube Music" }, durationSec = 0, @@ -334,9 +343,42 @@ class YoutubeClient @Inject constructor( return "" } + private fun findPlaylistContinuation(node: JSONObject): String? { + val shelf = findNamedObject(node, "musicPlaylistShelfRenderer") + ?: findNamedObject(node, "musicShelfRenderer") + ?: findNamedObject(node, "playlistPanelRenderer") + if (shelf != null) { + findContinuation(shelf.opt("continuations"))?.let { return it } + findContinuation(shelf.opt("contents"))?.let { return it } + } + return findContinuation(node) + } + + private fun findNamedObject(node: Any?, name: String): JSONObject? { + when (node) { + is JSONObject -> { + node.optJSONObject(name)?.let { return it } + val keys = node.keys() + while (keys.hasNext()) { + findNamedObject(node.opt(keys.next()), name)?.let { return it } + } + } + is JSONArray -> { + for (index in 0 until node.length()) { + findNamedObject(node.opt(index), name)?.let { return it } + } + } + } + return null + } + private fun findContinuation(node: Any?): String? { when (node) { is JSONObject -> { + node.optJSONObject("nextContinuationData") + ?.optString("continuation") + ?.takeIf { it.isNotBlank() } + ?.let { return it } node.optJSONObject("continuationCommand") ?.optString("token") ?.takeIf { it.isNotBlank() } @@ -357,7 +399,13 @@ class YoutubeClient @Inject constructor( private fun videoTitle(node: JSONObject): String { jsonText(node.optJSONObject("title")).takeIf { it.isNotBlank() }?.let { return it } - return flexColumnText(node, 0) + node.optString("title").takeIf { it.isNotBlank() && !it.trimStart().startsWith("{") }?.let { return it } + flexColumnText(node, 0).takeIf { it.isNotBlank() }?.let { return it } + val label = node.optJSONObject("accessibility") + ?.optJSONObject("accessibilityData") + ?.optString("label") + .orEmpty() + return label.substringBefore(" by ").substringBefore(',').trim() } private fun flexColumnText(node: JSONObject, column: Int): String { @@ -423,6 +471,8 @@ class YoutubeClient @Inject constructor( private val watchUrlBySongId = ConcurrentHashMap() private val initialized = AtomicBoolean(false) private const val RESOLVE_CACHE_TTL_MS = 15 * 60 * 1000L + private const val MUSIC_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" fun rememberWatchUrl(songId: String, watchUrl: String) { val id = songId.trim() diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt index 1e27586396..b920df48a2 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt @@ -61,12 +61,13 @@ internal class YoutubeDownloader : Downloader() { origin: String = "https://www.youtube.com", clientName: String? = null, clientVersion: String? = null, + userAgent: String = USER_AGENT, ): String { val cookies = cookieHeader?.trim().orEmpty() val request = okhttp3.Request.Builder() .url(url) .post(json.toRequestBody(JSON)) - .header("User-Agent", USER_AGENT) + .header("User-Agent", userAgent) .header("Content-Type", "application/json") .header("Origin", origin) .header("Referer", "$origin/") @@ -76,6 +77,7 @@ internal class YoutubeDownloader : Downloader() { if (!clientName.isNullOrBlank()) header("X-YouTube-Client-Name", clientName) if (!clientVersion.isNullOrBlank()) header("X-YouTube-Client-Version", clientVersion) if (cookies.isNotEmpty()) { + header("X-YouTube-Bootstrap-Logged-In", "true") header("Cookie", cookies) authorization(cookies, origin)?.let { header("Authorization", it) } } From 4243cfb0d3accffe66d2b2bcad2a9134e052f567 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 18:35:36 +0300 Subject: [PATCH 49/77] fix: send the YouTube Music request that returns Liked Music Use the current client, the music auth cookie, and the visitor id. Co-authored-by: Cursor --- .../pixelplay/youtube/YoutubeClient.kt | 125 +++++++++--------- .../pixelplay/youtube/YoutubeDownloader.kt | 42 +++--- 2 files changed, 89 insertions(+), 78 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index 0f185a26b5..89c767aa25 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -32,6 +32,7 @@ class YoutubeClient @Inject constructor( @Volatile var playlistHits: List = emptyList() @Volatile var radioHits: List = emptyList() @Volatile var likedHits: List = emptyList() + @Volatile private var visitorId: String? = null private data class CachedResolve( val track: YoutubeResolvedTrack, @@ -83,79 +84,71 @@ class YoutubeClient @Inject constructor( applySession() if (!session.isSignedIn()) return emptyList() val hits = linkedSetOf() - runCatching { - queueLibrary( - endpoint = "next", - bodySeed = """"playlistId":"LM","isAudioOnly":true""", - hits = hits, - limit = limit, - ) - } - if (hits.isEmpty()) { - browseLibrary( - browseId = "FEmusic_liked_videos", - hits = hits, - limit = limit, - ) - } - if (hits.isEmpty()) { - browseLibrary( - browseId = "VLLM", - hits = hits, - limit = limit, - ) + val failures = mutableListOf() + listOf("VLLM", "FEmusic_liked_videos").forEach { browseId -> + if (hits.isNotEmpty()) return@forEach + runCatching { + browseLiked(browseId, hits, limit) + }.onFailure { error -> + failures += error.message ?: error.javaClass.simpleName + } } if (hits.isEmpty()) { - throw IllegalStateException("Couldn't load Liked Music. Sign in again from Account.") + val reason = failures.firstOrNull()?.take(180) + throw IllegalStateException(reason ?: "Couldn't load Liked Music.") } return hits.take(limit).toList() } - private fun browseLibrary( + private fun browseLiked( browseId: String, hits: MutableSet, limit: Int, ) { - runCatching { - queueLibrary( - endpoint = "browse", - bodySeed = """"browseId":"$browseId"""", - hits = hits, - limit = limit, - ) - } - } - - private fun queueLibrary( - endpoint: String, - bodySeed: String, - hits: MutableSet, - limit: Int, - ) { - val version = "1.20250922.01.00" + val version = musicClientVersion() + val visitor = musicVisitorId() val context = """ - {"client":{"clientName":"WEB_REMIX","clientVersion":"$version","hl":"en","gl":"US","platform":"DESKTOP","originalUrl":"https://music.youtube.com/playlist?list=LM"}} + {"client":{"clientName":"WEB_REMIX","clientVersion":"$version","hl":"en","gl":"US"},"user":{}} """.trim() - var body = """{"context":$context,$bodySeed}""" + var body = """{"context":$context,"browseId":"$browseId"}""" repeat(6) { if (hits.size >= limit) return val json = downloader.postJson( - url = "https://music.youtube.com/youtubei/v1/$endpoint?prettyPrint=false", + url = "https://music.youtube.com/youtubei/v1/browse?prettyPrint=false&alt=json&key=$MUSIC_API_KEY", json = body, origin = "https://music.youtube.com", clientName = "67", clientVersion = version, userAgent = MUSIC_USER_AGENT, + visitorId = visitor, ) val root = JSONObject(json) - val before = hits.size collectVideoHits(root, hits, limit) - if (hits.size == before && !json.contains("\"videoId\"")) return val token = findPlaylistContinuation(root) ?: return body = """{"context":$context,"continuation":${JSONObject.quote(token)}}""" } } + private fun musicClientVersion(): String { + val format = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.US) + format.timeZone = java.util.TimeZone.getTimeZone("UTC") + return "1.${format.format(java.util.Date())}.01.00" + } + + private fun musicVisitorId(): String { + visitorId?.takeIf { it.isNotBlank() }?.let { return it } + val html = runCatching { + downloader.getText("https://music.youtube.com", MUSIC_USER_AGENT) + }.getOrNull().orEmpty() + val found = Regex(""""VISITOR_DATA"\s*:\s*"([^"]+)"""") + .find(html) + ?.groupValues + ?.getOrNull(1) + .orEmpty() + if (found.isNotBlank()) visitorId = found + return found + } + fun loadSubscriptionFeed(limit: Int = 20): List { applySession() val extractor = youtube().getFeedExtractor("https://www.youtube.com/feed/subscriptions") @@ -292,22 +285,20 @@ class YoutubeClient @Inject constructor( }.ifBlank { if (node.has("flexColumns")) firstVideoId(node.optJSONObject("overlay")) else "" } - if (videoId.length == 11) { - val title = videoTitle(node) - if (title.isNotBlank()) { - hits += YoutubeHit( - url = "https://music.youtube.com/watch?v=$videoId", - title = title, - artist = jsonText(node.optJSONObject("longBylineText")) - .ifBlank { jsonText(node.optJSONObject("shortBylineText")) } - .ifBlank { jsonText(node.optJSONObject("ownerText")) } - .ifBlank { flexColumnText(node, 1) } - .ifBlank { "YouTube Music" }, - durationSec = 0, - thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"), - kind = YoutubeHit.Kind.TRACK, - ) - } + if (videoId.length == 11 && looksLikeTrack(node)) { + val title = videoTitle(node).ifBlank { "YouTube Music" } + hits += YoutubeHit( + url = "https://music.youtube.com/watch?v=$videoId", + title = title, + artist = jsonText(node.optJSONObject("longBylineText")) + .ifBlank { jsonText(node.optJSONObject("shortBylineText")) } + .ifBlank { jsonText(node.optJSONObject("ownerText")) } + .ifBlank { flexColumnText(node, 1) } + .ifBlank { "YouTube Music" }, + durationSec = 0, + thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"), + kind = YoutubeHit.Kind.TRACK, + ) } val keys = node.keys() while (keys.hasNext() && hits.size < limit) { @@ -323,6 +314,15 @@ class YoutubeClient @Inject constructor( } } + private fun looksLikeTrack(node: JSONObject): Boolean { + return node.has("playlistItemData") || + node.has("flexColumns") || + node.has("longBylineText") || + node.has("shortBylineText") || + node.has("lengthText") || + node.optJSONObject("title") != null + } + private fun firstVideoId(node: JSONObject?): String { if (node == null) return "" node.optString("videoId").takeIf { it.length == 11 }?.let { return it } @@ -472,7 +472,8 @@ class YoutubeClient @Inject constructor( private val initialized = AtomicBoolean(false) private const val RESOLVE_CACHE_TTL_MS = 15 * 60 * 1000L private const val MUSIC_USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0" + private const val MUSIC_API_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" fun rememberWatchUrl(songId: String, watchUrl: String) { val id = songId.trim() diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt index b920df48a2..2ca40ffbd7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt @@ -62,6 +62,7 @@ internal class YoutubeDownloader : Downloader() { clientName: String? = null, clientVersion: String? = null, userAgent: String = USER_AGENT, + visitorId: String? = null, ): String { val cookies = cookieHeader?.trim().orEmpty() val request = okhttp3.Request.Builder() @@ -76,22 +77,36 @@ internal class YoutubeDownloader : Downloader() { .apply { if (!clientName.isNullOrBlank()) header("X-YouTube-Client-Name", clientName) if (!clientVersion.isNullOrBlank()) header("X-YouTube-Client-Version", clientVersion) + visitorId?.takeIf { it.isNotBlank() }?.let { header("X-Goog-Visitor-Id", it) } if (cookies.isNotEmpty()) { header("X-YouTube-Bootstrap-Logged-In", "true") - header("Cookie", cookies) - authorization(cookies, origin)?.let { header("Authorization", it) } + val cookieHeader = if (cookies.contains("SOCS=")) cookies else "$cookies; SOCS=CAI" + header("Cookie", cookieHeader) + authorization(cookieHeader, origin)?.let { header("Authorization", it) } } } .build() client.newCall(request).execute().use { response -> val responseBody = response.body.string() if (!response.isSuccessful) { - throw java.io.IOException("YouTube browse failed (${response.code})") + val hint = responseBody.replace(Regex("\\s+"), " ").take(160) + throw java.io.IOException("YouTube Music returned ${response.code}. $hint") } return responseBody } } + fun getText(url: String, userAgent: String): String { + val request = okhttp3.Request.Builder() + .url(url) + .header("User-Agent", userAgent) + .header("Accept-Language", "en-US,en;q=0.9") + .build() + client.newCall(request).execute().use { response -> + return response.body.string() + } + } + companion object { private val JSON = "application/json".toMediaType() private const val USER_AGENT = @@ -105,20 +120,15 @@ internal class YoutubeDownloader : Downloader() { val index = part.indexOf('=') part.substring(0, index) to part.substring(index + 1) } + val secret = values["__Secure-3PAPISID"].orEmpty() + .ifBlank { values["SAPISID"].orEmpty() } + .ifBlank { values["__Secure-1PAPISID"].orEmpty() } + if (secret.isBlank()) return null val timestamp = System.currentTimeMillis() / 1000 - fun hash(secret: String): String? { - if (secret.isBlank()) return null - val digest = MessageDigest.getInstance("SHA-1") - .digest("$timestamp $secret $origin".toByteArray()) - return digest.joinToString("") { byte -> "%02x".format(byte) } - } - val parts = buildList { - hash(values["SAPISID"].orEmpty().ifBlank { values["__Secure-3PAPISID"].orEmpty() }) - ?.let { add("SAPISIDHASH ${timestamp}_$it") } - hash(values["__Secure-1PAPISID"].orEmpty())?.let { add("SAPISID1PHASH ${timestamp}_$it") } - hash(values["__Secure-3PAPISID"].orEmpty())?.let { add("SAPISID3PHASH ${timestamp}_$it") } - } - return parts.takeIf { it.isNotEmpty() }?.joinToString(" ") + val digest = MessageDigest.getInstance("SHA-1") + .digest("$timestamp $secret $origin".toByteArray(Charsets.UTF_8)) + val hash = digest.joinToString("") { byte -> "%02x".format(byte) } + return "SAPISIDHASH ${timestamp}_$hash" } } } From dd63840988418ea9c2661e9cdb714ffd33b6e524 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 19:06:54 +0300 Subject: [PATCH 50/77] fix: load only YouTube Music liked songs Browse playlist LM the way InnerTune does and skip regular YouTube likes. Co-authored-by: Cursor --- .../pixelplay/youtube/YoutubeClient.kt | 89 +++++++++++++------ .../pixelplay/youtube/YoutubeDownloader.kt | 5 +- app/src/main/res/values/strings.xml | 4 +- 3 files changed, 69 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index 89c767aa25..80b986ecdb 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -84,17 +84,11 @@ class YoutubeClient @Inject constructor( applySession() if (!session.isSignedIn()) return emptyList() val hits = linkedSetOf() - val failures = mutableListOf() - listOf("VLLM", "FEmusic_liked_videos").forEach { browseId -> - if (hits.isNotEmpty()) return@forEach - runCatching { - browseLiked(browseId, hits, limit) - }.onFailure { error -> - failures += error.message ?: error.javaClass.simpleName - } - } + val failure = runCatching { + browseLiked("VLLM", hits, limit) + }.exceptionOrNull() if (hits.isEmpty()) { - val reason = failures.firstOrNull()?.take(180) + val reason = failure?.message?.take(180) throw IllegalStateException(reason ?: "Couldn't load Liked Music.") } return hits.take(limit).toList() @@ -105,34 +99,77 @@ class YoutubeClient @Inject constructor( hits: MutableSet, limit: Int, ) { - val version = musicClientVersion() - val visitor = musicVisitorId() + val visitor = musicVisitorId().ifBlank { INNER_TUNE_VISITOR } val context = """ - {"client":{"clientName":"WEB_REMIX","clientVersion":"$version","hl":"en","gl":"US"},"user":{}} + {"client":{"clientName":"WEB_REMIX","clientVersion":"$INNER_TUNE_VERSION","gl":"US","hl":"en","visitorData":${JSONObject.quote(visitor)}}} """.trim() - var body = """{"context":$context,"browseId":"$browseId"}""" + var continuation: String? = null repeat(6) { if (hits.size >= limit) return + val url = buildString { + append("https://music.youtube.com/youtubei/v1/browse?prettyPrint=false&key=$MUSIC_API_KEY") + if (!continuation.isNullOrBlank()) { + val token = java.net.URLEncoder.encode(continuation, Charsets.UTF_8.name()) + append("&continuation=$token&ctoken=$token&type=next") + } + } + val body = if (continuation.isNullOrBlank()) { + """{"context":$context,"browseId":"$browseId"}""" + } else { + """{"context":$context,"continuation":${JSONObject.quote(continuation)}}""" + } val json = downloader.postJson( - url = "https://music.youtube.com/youtubei/v1/browse?prettyPrint=false&alt=json&key=$MUSIC_API_KEY", + url = url, json = body, origin = "https://music.youtube.com", - clientName = "67", - clientVersion = version, + clientName = "WEB_REMIX", + clientVersion = INNER_TUNE_VERSION, userAgent = MUSIC_USER_AGENT, visitorId = visitor, ) val root = JSONObject(json) - collectVideoHits(root, hits, limit) - val token = findPlaylistContinuation(root) ?: return - body = """{"context":$context,"continuation":${JSONObject.quote(token)}}""" + collectLikedShelf(root, hits, limit) + continuation = findPlaylistContinuation(root) ?: return } } - private fun musicClientVersion(): String { - val format = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.US) - format.timeZone = java.util.TimeZone.getTimeZone("UTC") - return "1.${format.format(java.util.Date())}.01.00" + private fun collectLikedShelf(root: JSONObject, hits: MutableSet, limit: Int) { + val rows = root.optJSONObject("contents") + ?.optJSONObject("twoColumnBrowseResultsRenderer") + ?.optJSONObject("secondaryContents") + ?.optJSONObject("sectionListRenderer") + ?.optJSONArray("contents") + ?.let { contents -> + (0 until contents.length()).firstNotNullOfOrNull { index -> + contents.optJSONObject(index) + ?.optJSONObject("musicPlaylistShelfRenderer") + ?.optJSONArray("contents") + } + } + ?: root.optJSONObject("continuationContents") + ?.optJSONObject("musicPlaylistShelfContinuation") + ?.optJSONArray("contents") + if (rows == null) { + collectVideoHits(root, hits, limit) + return + } + for (index in 0 until rows.length()) { + if (hits.size >= limit) return + val renderer = rows.optJSONObject(index) + ?.optJSONObject("musicResponsiveListItemRenderer") + ?: continue + val videoId = renderer.optJSONObject("playlistItemData")?.optString("videoId").orEmpty() + val title = flexColumnText(renderer, 0) + if (videoId.length != 11 || title.isBlank()) continue + hits += YoutubeHit( + url = "https://music.youtube.com/watch?v=$videoId", + title = title, + artist = flexColumnText(renderer, 1).ifBlank { "YouTube Music" }, + durationSec = 0, + thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"), + kind = YoutubeHit.Kind.TRACK, + ) + } } private fun musicVisitorId(): String { @@ -472,8 +509,10 @@ class YoutubeClient @Inject constructor( private val initialized = AtomicBoolean(false) private const val RESOLVE_CACHE_TTL_MS = 15 * 60 * 1000L private const val MUSIC_USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36" private const val MUSIC_API_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" + private const val INNER_TUNE_VERSION = "1.20220606.03.00" + private const val INNER_TUNE_VISITOR = "CgtsZG1ySnZiQWtSbyiMjuGSBg==" fun rememberWatchUrl(songId: String, watchUrl: String) { val id = songId.trim() diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt index 2ca40ffbd7..4842f7a37c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt @@ -74,6 +74,7 @@ internal class YoutubeDownloader : Downloader() { .header("Referer", "$origin/") .header("X-Origin", origin) .header("X-Goog-AuthUser", "0") + .header("X-Goog-Api-Format-Version", "1") .apply { if (!clientName.isNullOrBlank()) header("X-YouTube-Client-Name", clientName) if (!clientVersion.isNullOrBlank()) header("X-YouTube-Client-Version", clientVersion) @@ -120,8 +121,8 @@ internal class YoutubeDownloader : Downloader() { val index = part.indexOf('=') part.substring(0, index) to part.substring(index + 1) } - val secret = values["__Secure-3PAPISID"].orEmpty() - .ifBlank { values["SAPISID"].orEmpty() } + val secret = values["SAPISID"].orEmpty() + .ifBlank { values["__Secure-3PAPISID"].orEmpty() } .ifBlank { values["__Secure-1PAPISID"].orEmpty() } if (secret.isBlank()) return null val timestamp = System.currentTimeMillis() / 1000 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fb6aa16949..6581055404 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,8 +36,8 @@ Songs Albums Playlists - No liked videos on this account yet. - Sign in to load your liked videos. + No liked music on this account yet. + Sign in to load your liked music. Sign in Sign out Done From fc01d85d3f63f433a2fc09d0396ba14c221a0e31 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 19:21:25 +0300 Subject: [PATCH 51/77] fix: keep the player closed when a song starts Open the now-playing card only when it is tapped. Co-authored-by: Cursor --- .../pixelplay/presentation/viewmodel/PlayerViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index c01d6e4344..fdc7fd1d17 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -2227,7 +2227,7 @@ class PlayerViewModel @Inject constructor( fun showPreparingSong(song: Song) { _isSheetVisible.value = true - _sheetState.value = PlayerSheetState.EXPANDED + _sheetState.value = PlayerSheetState.COLLAPSED playbackStateHolder.updateStablePlayerState { it.copy( currentSong = song, From 4d6e63b617424b34bd846c960bf9bd733ea6df39 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 19:43:02 +0300 Subject: [PATCH 52/77] fix: authenticate Liked Music and personalize recommendations Match the active Music client and build Feed and Radio from the user's music. Co-authored-by: Cursor --- .../presentation/screens/YoutubeScreen.kt | 2 +- .../youtube/auth/YoutubeLoginActivity.kt | 10 ++-- .../pixelplay/youtube/YoutubeClient.kt | 28 ++++++----- .../pixelplay/youtube/YoutubeSession.kt | 4 +- .../pixelplay/youtube/YoutubeViewModel.kt | 47 +++++++++++++------ 5 files changed, 57 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index e187a6f395..c01ac361ed 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -317,7 +317,7 @@ fun YoutubeScreen( EmptyYoutubeState(message = stringResource(R.string.youtube_home_empty)) } else { val mixHits = uiState.shelves - .firstOrNull { it.id == "trending" } + .firstOrNull { it.id == "yours" } ?.items ?.filter { it.kind == YoutubeHit.Kind.TRACK } .orEmpty() diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt index 2dbe47f948..ed97f054ad 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt @@ -121,7 +121,8 @@ private fun YoutubeWebLoginScreen( onWebViewCreated = { webView = it }, onCookiesMaybeReady = { url -> val cookies = extractYoutubeSession() ?: return@YoutubeWebView - val hasAuth = cookies.contains("SAPISID=") || cookies.contains("__Secure-3PAPISID=") + val hasAuth = listOf("SAPISID", "__Secure-1PAPISID", "__Secure-3PAPISID") + .any { key -> cookies.contains("$key=", ignoreCase = true) } if (!hasAuth) return@YoutubeWebView val onMusic = url.orEmpty().contains("music.youtube.com") if (!onMusic) { @@ -193,10 +194,9 @@ internal fun extractYoutubeSession(): String? { if (key.isNotEmpty() && value.isNotEmpty()) map[key] = value } val signedIn = map.keys.any { key -> - key.equals("LOGIN_INFO", ignoreCase = true) || - key.equals("SAPISID", ignoreCase = true) || - key.equals("__Secure-1PSID", ignoreCase = true) || - key.equals("__Secure-3PSID", ignoreCase = true) + key.equals("SAPISID", ignoreCase = true) || + key.equals("__Secure-1PAPISID", ignoreCase = true) || + key.equals("__Secure-3PAPISID", ignoreCase = true) } if (!signedIn) return null return map.entries.joinToString("; ") { "${it.key}=${it.value}" } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index 80b986ecdb..3d7b37a4eb 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -84,13 +84,16 @@ class YoutubeClient @Inject constructor( applySession() if (!session.isSignedIn()) return emptyList() val hits = linkedSetOf() - val failure = runCatching { - browseLiked("VLLM", hits, limit) - }.exceptionOrNull() - if (hits.isEmpty()) { - val reason = failure?.message?.take(180) - throw IllegalStateException(reason ?: "Couldn't load Liked Music.") - } + browseLiked("VLLM", hits, limit) + if (hits.isEmpty()) throw IllegalStateException("YouTube Music returned no liked songs.") + return hits.take(limit).toList() + } + + fun loadLikedMusic(limit: Int = 24): List { + applySession() + if (!session.isSignedIn()) return emptyList() + val hits = linkedSetOf() + runCatching { browseLiked("VLLM", hits, limit) } return hits.take(limit).toList() } @@ -101,7 +104,7 @@ class YoutubeClient @Inject constructor( ) { val visitor = musicVisitorId().ifBlank { INNER_TUNE_VISITOR } val context = """ - {"client":{"clientName":"WEB_REMIX","clientVersion":"$INNER_TUNE_VERSION","gl":"US","hl":"en","visitorData":${JSONObject.quote(visitor)}}} + {"client":{"clientName":"WEB_REMIX","clientVersion":"$MUSIC_CLIENT_VERSION","gl":"US","hl":"en","visitorData":${JSONObject.quote(visitor)}}} """.trim() var continuation: String? = null repeat(6) { @@ -122,8 +125,8 @@ class YoutubeClient @Inject constructor( url = url, json = body, origin = "https://music.youtube.com", - clientName = "WEB_REMIX", - clientVersion = INNER_TUNE_VERSION, + clientName = MUSIC_CLIENT_ID, + clientVersion = MUSIC_CLIENT_VERSION, userAgent = MUSIC_USER_AGENT, visitorId = visitor, ) @@ -509,9 +512,10 @@ class YoutubeClient @Inject constructor( private val initialized = AtomicBoolean(false) private const val RESOLVE_CACHE_TTL_MS = 15 * 60 * 1000L private const val MUSIC_USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" private const val MUSIC_API_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" - private const val INNER_TUNE_VERSION = "1.20220606.03.00" + private const val MUSIC_CLIENT_ID = "67" + private const val MUSIC_CLIENT_VERSION = "1.20260804.16.00" private const val INNER_TUNE_VISITOR = "CgtsZG1ySnZiQWtSbyiMjuGSBg==" fun rememberWatchUrl(songId: String, watchUrl: String) { diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt index 26ad844ff9..a1018d2562 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt @@ -17,7 +17,9 @@ data class YoutubeAccount( val cookieHeader: String = "", val displayName: String = "", ) { - val isSignedIn: Boolean get() = cookieHeader.isNotBlank() + val isSignedIn: Boolean + get() = listOf("SAPISID", "__Secure-1PAPISID", "__Secure-3PAPISID") + .any { key -> cookieHeader.contains("$key=", ignoreCase = true) } } @Singleton diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt index bc50529a8c..7789a083f2 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -200,7 +200,7 @@ class YoutubeViewModel @Inject constructor( fun importSession(cookieHeader: String): Boolean { val normalized = cookieHeader.trim() - val signedIn = listOf("LOGIN_INFO", "SAPISID", "__Secure-1PSID", "__Secure-3PSID") + val signedIn = listOf("SAPISID", "__Secure-1PAPISID", "__Secure-3PAPISID") .any { key -> normalized.contains("$key=", ignoreCase = true) } if (!signedIn) return false session.save(normalized) @@ -308,27 +308,33 @@ class YoutubeViewModel @Inject constructor( } val built = runCatching { withContext(Dispatchers.IO) { - val trending = runCatching { client.loadTrendingMusic(18) }.getOrDefault(emptyList()) - val playlists = runCatching { - client.search("top music", YoutubeSearchFilter.PLAYLISTS, 12) - }.getOrDefault(emptyList()) - val followed = if (session.isSignedIn()) { + val yours = if (session.isSignedIn()) { + runCatching { client.loadLikedMusic(24) }.getOrDefault(emptyList()) + } else { + emptyList() + }.ifEmpty { musicStore.recent.value } + val mix = yours.ifEmpty { + if (session.isSignedIn()) emptyList() + else runCatching { client.loadTrendingMusic(18) }.getOrDefault(emptyList()) + } + val followed = if (session.isSignedIn() && yours.isEmpty()) { runCatching { client.loadSubscriptionFeed(12) }.getOrDefault(emptyList()) } else { emptyList() } listOfNotNull( - followed.takeIf { it.isNotEmpty() }?.let { - YoutubeShelf("followed", "From your feed", it) - }, - trending.takeIf { it.isNotEmpty() }?.let { - YoutubeShelf("trending", "Trending songs", it) + mix.takeIf { it.isNotEmpty() }?.let { + YoutubeShelf( + id = "yours", + title = if (yours.isNotEmpty()) "For you" else "Trending songs", + items = it, + ) }, - trending.take(10).takeIf { it.isNotEmpty() }?.let { + mix.shuffled().take(12).takeIf { it.isNotEmpty() && yours.isNotEmpty() }?.let { YoutubeShelf("radio", "Radio", it, startsRadio = true) }, - playlists.takeIf { it.isNotEmpty() }?.let { - YoutubeShelf("playlists", "Playlists", it) + followed.takeIf { it.isNotEmpty() }?.let { + YoutubeShelf("followed", "From your feed", it) }, ) } @@ -382,7 +388,18 @@ class YoutubeViewModel @Inject constructor( _uiState.update { it.copy(isLoading = !refreshing && it.results.isEmpty(), isRefreshing = refreshing, error = null, shelves = emptyList()) } - val loaded = runCatching { withContext(Dispatchers.IO) { client.loadTrendingMusic(16) } } + val loaded = runCatching { + withContext(Dispatchers.IO) { + val yours = if (session.isSignedIn()) { + client.loadLikedMusic(16) + } else { + emptyList() + }.ifEmpty { musicStore.recent.value }.shuffled() + yours.ifEmpty { + if (session.isSignedIn()) emptyList() else client.loadTrendingMusic(16) + } + } + } loaded.fold( onSuccess = { hits -> client.radioHits = hits From 0342c4e5266592ac07758424c5081c7b95dd685a Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 19:53:41 +0300 Subject: [PATCH 53/77] fix: focus search on a second tab tap Show the keyboard and keep the clear control aligned to the input end. Co-authored-by: Cursor --- .../presentation/screens/YoutubeScreen.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index c01ac361ed..19bab47bb9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -75,6 +75,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.layout.ContentScale @@ -134,6 +136,8 @@ fun YoutubeScreen( var showBeta by remember { mutableStateOf(false) } val stablePlayer by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + val searchFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current val configuration = LocalConfiguration.current val isWideScreen = configuration.screenWidthDp >= WIDE_SCREEN_DP val systemNavBarInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() @@ -172,6 +176,14 @@ fun YoutubeScreen( LaunchedEffect(section) { viewModel.openSection(section) } + LaunchedEffect(section, playerViewModel) { + if (section == YoutubeSection.SEARCH) { + playerViewModel.searchNavDoubleTapEvents.collect { + searchFocusRequester.requestFocus() + keyboardController?.show() + } + } + } LaunchedEffect(stablePlayer.currentSong?.id) { stablePlayer.currentSong?.let(viewModel::rememberPlayed) } @@ -243,6 +255,7 @@ fun YoutubeScreen( query = uiState.query, onQueryChange = viewModel::onQueryChange, showSearch = browseSection == YoutubeSection.SEARCH && uiState.collectionTitle == null, + searchFocusRequester = searchFocusRequester, searchFilter = uiState.filter, onFilter = viewModel::setFilter, onBack = if (uiState.collectionTitle != null) viewModel::closeCollection else null, @@ -510,6 +523,7 @@ private fun YoutubeCompactHeader( query: String, onQueryChange: (String) -> Unit, showSearch: Boolean, + searchFocusRequester: FocusRequester, searchFilter: YoutubeSearchFilter, onFilter: (YoutubeSearchFilter) -> Unit, onBack: (() -> Unit)?, @@ -587,6 +601,7 @@ private fun YoutubeCompactHeader( YoutubeSearchField( query = query, onQueryChange = onQueryChange, + focusRequester = searchFocusRequester, searchFilter = searchFilter, onFilter = onFilter, ) @@ -598,6 +613,7 @@ private fun YoutubeCompactHeader( private fun YoutubeSearchField( query: String, onQueryChange: (String) -> Unit, + focusRequester: FocusRequester, searchFilter: YoutubeSearchFilter, onFilter: (YoutubeSearchFilter) -> Unit, ) { @@ -613,6 +629,9 @@ private fun YoutubeSearchField( DockedSearchBar( inputField = { SearchBarDefaults.InputField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), query = query, onQueryChange = onQueryChange, onSearch = { keyboard?.hide() }, From 50c11859d883edb93d7e1546f149a88594902ba2 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 20:17:46 +0300 Subject: [PATCH 54/77] feat: send the signed-in identity on YouTube Music requests Capture VISITOR_DATA and DATASYNC_ID from the login WebView (KuroMusic/InnerTune style), fall back to sw.js_data and the page config with cookies, and send them as context.client.visitorData and context.user.onBehalfOfUser so InnerTube answers for the signed-in channel. Prefer music.youtube.com cookies when importing, verify the session via account/account_menu, load the personalized FEmusic_home shelves for the Feed and FEmusic_liked_playlists for the Playlists tab, and report a clear sign-in error when Liked Music comes back logged out. Co-authored-by: Cursor --- .../youtube/auth/YoutubeLoginActivity.kt | 67 ++- .../pixelplay/youtube/YoutubeClient.kt | 406 ++++++++++++++++-- .../pixelplay/youtube/YoutubeDownloader.kt | 16 +- .../pixelplay/youtube/YoutubeSession.kt | 48 ++- .../pixelplay/youtube/YoutubeViewModel.kt | 70 ++- 5 files changed, 544 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt index ed97f054ad..d054f8770b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt @@ -116,10 +116,12 @@ private fun YoutubeWebLoginScreen( } Box(Modifier.fillMaxSize()) { var openedLikedMusic by remember { mutableStateOf(false) } + var importing by remember { mutableStateOf(false) } YoutubeWebView( onProgress = { progress = it }, onWebViewCreated = { webView = it }, onCookiesMaybeReady = { url -> + if (importing) return@YoutubeWebView val cookies = extractYoutubeSession() ?: return@YoutubeWebView val hasAuth = listOf("SAPISID", "__Secure-1PAPISID", "__Secure-3PAPISID") .any { key -> cookies.contains("$key=", ignoreCase = true) } @@ -132,7 +134,19 @@ private fun YoutubeWebLoginScreen( } return@YoutubeWebView } - if (viewModel.importSession(cookies)) onClose() + importing = true + val view = webView + if (view == null) { + if (viewModel.importSession(cookies)) onClose() else importing = false + return@YoutubeWebView + } + // Same trick KuroMusic/InnerTune use: read the page's InnerTube config + // so requests carry the signed-in visitorData and channel (DATASYNC_ID). + view.evaluateJavascript(IDENTITY_SCRIPT) { result -> + val (visitor, dataSync) = parseIdentity(result) + val fresh = extractYoutubeSession() ?: cookies + if (viewModel.importSession(fresh, visitor, dataSync)) onClose() else importing = false + } }, ) } @@ -173,26 +187,53 @@ private fun YoutubeWebView( ) } +private const val IDENTITY_SCRIPT = """ +(function () { + try { + var cfg = (window.ytcfg && (ytcfg.data_ || (ytcfg.get && {VISITOR_DATA: ytcfg.get('VISITOR_DATA'), DATASYNC_ID: ytcfg.get('DATASYNC_ID')}))) + || (window.yt && yt.config_) || {}; + return JSON.stringify({ v: cfg.VISITOR_DATA || '', d: cfg.DATASYNC_ID || '' }); + } catch (e) { return '{}'; } +})() +""" + +/** evaluateJavascript hands back a JSON string literal wrapping our JSON payload. */ +internal fun parseIdentity(result: String?): Pair { + if (result.isNullOrBlank() || result == "null") return "" to "" + return runCatching { + val inner = org.json.JSONTokener(result).nextValue() + val json = when (inner) { + is org.json.JSONObject -> inner + is String -> org.json.JSONObject(inner) + else -> return "" to "" + } + json.optString("v").trim() to json.optString("d").trim() + }.getOrDefault("" to "") +} + internal fun extractYoutubeSession(): String? { val manager = CookieManager.getInstance() val map = linkedMapOf() + // music.youtube.com first: those are the cookies YouTube Music itself sends. Other hosts only + // fill in names that are missing so we never overwrite a value with one from a different domain. listOf( + "https://music.youtube.com", "https://www.youtube.com", "https://youtube.com", - "https://music.youtube.com", "https://accounts.google.com", "https://google.com", - ).map { manager.getCookie(it).orEmpty() } - .joinToString("; ") - .split(';') - .map { it.trim() } - .filter { it.contains('=') } - .forEach { part -> - val index = part.indexOf('=') - val key = part.substring(0, index).trim() - val value = part.substring(index + 1).trim() - if (key.isNotEmpty() && value.isNotEmpty()) map[key] = value - } + ).forEach { host -> + manager.getCookie(host).orEmpty() + .split(';') + .map { it.trim() } + .filter { it.contains('=') } + .forEach { part -> + val index = part.indexOf('=') + val key = part.substring(0, index).trim() + val value = part.substring(index + 1).trim() + if (key.isNotEmpty() && value.isNotEmpty() && !map.containsKey(key)) map[key] = value + } + } val signedIn = map.keys.any { key -> key.equals("SAPISID", ignoreCase = true) || key.equals("__Secure-1PAPISID", ignoreCase = true) || diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index 3d7b37a4eb..c82113372c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -33,6 +33,7 @@ class YoutubeClient @Inject constructor( @Volatile var radioHits: List = emptyList() @Volatile var likedHits: List = emptyList() @Volatile private var visitorId: String? = null + @Volatile private var identityChecked = false private data class CachedResolve( val track: YoutubeResolvedTrack, @@ -84,8 +85,13 @@ class YoutubeClient @Inject constructor( applySession() if (!session.isSignedIn()) return emptyList() val hits = linkedSetOf() - browseLiked("VLLM", hits, limit) - if (hits.isEmpty()) throw IllegalStateException("YouTube Music returned no liked songs.") + val loggedIn = browseLiked("VLLM", hits, limit) + if (hits.isEmpty()) { + throw IllegalStateException( + if (loggedIn == false) "YouTube Music did not accept this session. Sign in again from Account." + else "YouTube Music returned no liked songs.", + ) + } return hits.take(limit).toList() } @@ -97,42 +103,384 @@ class YoutubeClient @Inject constructor( return hits.take(limit).toList() } + /** + * Personalized YouTube Music home (Quick picks, Listen again, Mixed for you, ...). + * Requires a signed-in session; anonymous sessions only get regional charts. + */ + fun loadHome(maxShelves: Int = 8): List { + applySession() + if (!session.isSignedIn()) return emptyList() + val shelves = mutableListOf() + var root = musicBrowse(browseId = "FEmusic_home") + var sectionList = root.optJSONObject("contents") + ?.optJSONObject("singleColumnBrowseResultsRenderer") + ?.optJSONArray("tabs") + ?.optJSONObject(0) + ?.optJSONObject("tabRenderer") + ?.optJSONObject("content") + ?.optJSONObject("sectionListRenderer") + var page = 0 + while (sectionList != null && shelves.size < maxShelves && page < 3) { + collectCarouselShelves(sectionList.optJSONArray("contents"), shelves) + val continuation = findContinuation(sectionList.opt("continuations")) ?: break + if (shelves.size >= maxShelves) break + root = musicBrowse(continuation = continuation) + sectionList = root.optJSONObject("continuationContents")?.optJSONObject("sectionListContinuation") + page++ + } + return shelves.take(maxShelves) + } + + /** Playlists saved in the user's YouTube Music library (FEmusic_liked_playlists). */ + fun loadLibraryPlaylists(limit: Int = 60): List { + applySession() + if (!session.isSignedIn()) return emptyList() + val hits = linkedSetOf() + var root = musicBrowse(browseId = "FEmusic_liked_playlists") + var grid = root.optJSONObject("contents") + ?.optJSONObject("singleColumnBrowseResultsRenderer") + ?.optJSONArray("tabs") + ?.optJSONObject(0) + ?.optJSONObject("tabRenderer") + ?.optJSONObject("content") + ?.optJSONObject("sectionListRenderer") + ?.optJSONArray("contents") + ?.let { contents -> + (0 until contents.length()).firstNotNullOfOrNull { index -> + val section = contents.optJSONObject(index) ?: return@firstNotNullOfOrNull null + section.optJSONObject("gridRenderer") ?: section.optJSONObject("musicShelfRenderer") + } + } + var page = 0 + while (grid != null && hits.size < limit && page < 4) { + collectShelfItems(grid.optJSONArray("items") ?: grid.optJSONArray("contents"), hits, limit) + val continuation = findContinuation(grid.opt("continuations")) ?: break + root = musicBrowse(continuation = continuation) + grid = root.optJSONObject("continuationContents")?.let { cont -> + cont.optJSONObject("gridContinuation") ?: cont.optJSONObject("musicShelfContinuation") + } + page++ + } + return hits.filter { it.kind == YoutubeHit.Kind.COLLECTION && !it.url.contains("list=LM") } + .take(limit) + } + + /** Verifies the session against InnerTube and returns the signed-in account name. */ + fun loadAccountName(): String { + applySession() + if (!session.isSignedIn()) return "" + val root = musicPost("account/account_menu", """{"context":${musicContext()}}""") + val header = root.optJSONArray("actions") + ?.optJSONObject(0) + ?.optJSONObject("openPopupAction") + ?.optJSONObject("popup") + ?.optJSONObject("multiPageMenuRenderer") + ?.optJSONObject("header") + ?.optJSONObject("activeAccountHeaderRenderer") + ?: throw IllegalStateException("YouTube Music did not recognise this session.") + return jsonText(header.optJSONObject("accountName")) + } + + /** + * Makes sure visitorData/dataSyncId for the signed-in account are known. + * Mirrors KuroMusic: visitorData from sw.js_data (with cookies), dataSyncId from the page config. + */ + fun resetIdentity() { + identityChecked = false + visitorId = null + } + + fun refreshIdentity() { + applySession() + val account = session.account.value + if (!account.isSignedIn) return + identityChecked = true + var visitor = account.visitorData + var dataSync = account.dataSyncId + if (visitor.isBlank()) visitor = fetchVisitorFromServiceWorker() + if (visitor.isBlank() || dataSync.isBlank()) { + val html = runCatching { + downloader.getText("https://music.youtube.com/", MUSIC_USER_AGENT, withCookies = true) + }.getOrNull().orEmpty() + if (visitor.isBlank()) visitor = configValue(html, "VISITOR_DATA") + if (dataSync.isBlank()) dataSync = configValue(html, "DATASYNC_ID") + } + session.updateIdentity(visitorData = visitor, dataSyncId = dataSync) + } + + private fun fetchVisitorFromServiceWorker(): String { + val body = runCatching { + downloader.getText("https://music.youtube.com/sw.js_data", MUSIC_USER_AGENT, withCookies = true) + }.getOrNull().orEmpty() + if (body.length < 6) return "" + return runCatching { + val candidates = JSONArray(body.substring(5)).optJSONArray(0)?.optJSONArray(2) ?: return "" + (0 until candidates.length()).asSequence() + .mapNotNull { candidates.opt(it) as? String } + .firstOrNull { VISITOR_REGEX.containsMatchIn(it) } + .orEmpty() + }.getOrDefault("") + } + + private fun configValue(html: String, key: String): String { + val raw = Regex(""""$key"\s*:\s*"([^"]*)"""").find(html)?.groupValues?.getOrNull(1).orEmpty() + return raw.replace("\\/", "/").replace("\\u003d", "=").replace("\\u0026", "&") + } + + /** Returns null when the response did not say whether the session is logged in. */ private fun browseLiked( browseId: String, hits: MutableSet, limit: Int, - ) { - val visitor = musicVisitorId().ifBlank { INNER_TUNE_VISITOR } - val context = """ - {"client":{"clientName":"WEB_REMIX","clientVersion":"$MUSIC_CLIENT_VERSION","gl":"US","hl":"en","visitorData":${JSONObject.quote(visitor)}}} - """.trim() + ): Boolean? { + var loggedIn: Boolean? = null var continuation: String? = null repeat(6) { + if (hits.size >= limit) return loggedIn + val root = musicBrowse(browseId = browseId, continuation = continuation) + if (loggedIn == null) loggedIn = loggedInFlag(root) + collectLikedShelf(root, hits, limit) + continuation = findPlaylistContinuation(root) ?: return loggedIn + } + return loggedIn + } + + private fun musicBrowse( + browseId: String? = null, + continuation: String? = null, + params: String? = null, + ): JSONObject { + val account = session.account.value + if (account.isSignedIn && account.visitorData.isBlank() && !identityChecked) { + identityChecked = true + runCatching { refreshIdentity() } + } + val body = JSONObject() + .put("context", JSONObject(musicContext())) + .apply { + if (!browseId.isNullOrBlank()) put("browseId", browseId) + if (!params.isNullOrBlank()) put("params", params) + if (!continuation.isNullOrBlank()) put("continuation", continuation) + } + val query = if (continuation.isNullOrBlank()) "" else { + val token = java.net.URLEncoder.encode(continuation, Charsets.UTF_8.name()) + "&continuation=$token&ctoken=$token&type=next" + } + return musicPost("browse", body.toString(), query) + } + + private fun musicPost(endpoint: String, body: String, query: String = ""): JSONObject { + val json = downloader.postJson( + url = "https://music.youtube.com/youtubei/v1/$endpoint?prettyPrint=false&key=$MUSIC_API_KEY$query", + json = body, + origin = "https://music.youtube.com", + clientName = MUSIC_CLIENT_ID, + clientVersion = MUSIC_CLIENT_VERSION, + userAgent = MUSIC_USER_AGENT, + visitorId = currentVisitor(), + ) + return JSONObject(json) + } + + private fun currentVisitor(): String { + val account = session.account.value + if (account.isSignedIn && account.visitorData.isNotBlank()) return account.visitorData + return musicVisitorId().ifBlank { INNER_TUNE_VISITOR } + } + + private fun musicContext(): String { + val account = session.account.value + val client = JSONObject() + .put("clientName", "WEB_REMIX") + .put("clientVersion", MUSIC_CLIENT_VERSION) + .put("gl", "US") + .put("hl", "en") + .put("visitorData", currentVisitor()) + val context = JSONObject().put("client", client) + val user = JSONObject().put("lockedSafetyMode", false) + if (account.isSignedIn && account.dataSyncId.isNotBlank()) { + user.put("onBehalfOfUser", account.dataSyncId) + } + context.put("user", user) + return context.toString() + } + + private fun loggedInFlag(root: JSONObject): Boolean? { + val services = root.optJSONObject("responseContext")?.optJSONArray("serviceTrackingParams") ?: return null + for (index in 0 until services.length()) { + val params = services.optJSONObject(index)?.optJSONArray("params") ?: continue + for (p in 0 until params.length()) { + val param = params.optJSONObject(p) ?: continue + if (param.optString("key") == "logged_in") return param.optString("value") == "1" + } + } + return null + } + + private fun collectCarouselShelves(sections: JSONArray?, shelves: MutableList) { + if (sections == null) return + for (index in 0 until sections.length()) { + val carousel = sections.optJSONObject(index)?.optJSONObject("musicCarouselShelfRenderer") ?: continue + val title = jsonText( + carousel.optJSONObject("header") + ?.optJSONObject("musicCarouselShelfBasicHeaderRenderer") + ?.optJSONObject("title"), + ).ifBlank { "For you" } + val hits = linkedSetOf() + collectShelfItems(carousel.optJSONArray("contents"), hits, 30) + if (hits.isEmpty()) continue + val id = "home_${index}_${title.hashCode().toUInt()}" + if (shelves.any { it.id == id }) continue + shelves += YoutubeShelf(id = id, title = title, items = hits.toList()) + } + } + + private fun collectShelfItems(items: JSONArray?, hits: MutableSet, limit: Int) { + if (items == null) return + for (index in 0 until items.length()) { if (hits.size >= limit) return - val url = buildString { - append("https://music.youtube.com/youtubei/v1/browse?prettyPrint=false&key=$MUSIC_API_KEY") - if (!continuation.isNullOrBlank()) { - val token = java.net.URLEncoder.encode(continuation, Charsets.UTF_8.name()) - append("&continuation=$token&ctoken=$token&type=next") - } + val item = items.optJSONObject(index) ?: continue + item.optJSONObject("musicTwoRowItemRenderer")?.let { twoRowHit(it) }?.let { hits += it } + item.optJSONObject("musicResponsiveListItemRenderer")?.let { listItemHit(it) }?.let { hits += it } + } + } + + private fun twoRowHit(renderer: JSONObject): YoutubeHit? { + val title = jsonText(renderer.optJSONObject("title")).trim() + if (title.isBlank()) return null + val subtitle = jsonText(renderer.optJSONObject("subtitle")).trim() + val navigation = renderer.optJSONObject("navigationEndpoint") + val thumbnail = lastThumbnail( + renderer.optJSONObject("thumbnailRenderer") + ?.optJSONObject("musicThumbnailRenderer") + ?.optJSONObject("thumbnail"), + ) + navigation?.optJSONObject("watchEndpoint")?.let { watch -> + val videoId = watch.optString("videoId") + val playlistId = watch.optString("playlistId") + if (videoId.length == 11) { + val isMix = playlistId.startsWith("RD") && !playlistId.startsWith("RDAMVM") + return YoutubeHit( + url = if (isMix) "https://www.youtube.com/watch?v=$videoId&list=$playlistId" + else "https://music.youtube.com/watch?v=$videoId", + title = title, + artist = subtitle.ifBlank { "YouTube Music" }, + durationSec = 0, + thumbnailUrl = if (isMix) thumbnail else fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"), + kind = if (isMix) YoutubeHit.Kind.COLLECTION else YoutubeHit.Kind.TRACK, + ) + } + } + navigation?.optJSONObject("watchPlaylistEndpoint")?.optString("playlistId") + ?.takeIf { it.isNotBlank() } + ?.let { playlistId -> + return YoutubeHit( + url = "https://music.youtube.com/playlist?list=$playlistId", + title = title, + artist = subtitle.ifBlank { "YouTube Music" }, + durationSec = 0, + thumbnailUrl = thumbnail, + kind = YoutubeHit.Kind.COLLECTION, + ) } - val body = if (continuation.isNullOrBlank()) { - """{"context":$context,"browseId":"$browseId"}""" - } else { - """{"context":$context,"continuation":${JSONObject.quote(continuation)}}""" + val browseId = navigation?.optJSONObject("browseEndpoint")?.optString("browseId").orEmpty() + val playlistId = renderer.optJSONObject("thumbnailOverlay") + ?.optJSONObject("musicItemThumbnailOverlayRenderer") + ?.optJSONObject("content") + ?.optJSONObject("musicPlayButtonRenderer") + ?.optJSONObject("playNavigationEndpoint") + ?.let { play -> + play.optJSONObject("watchPlaylistEndpoint")?.optString("playlistId") + ?.takeIf { it.isNotBlank() } + ?: play.optJSONObject("watchEndpoint")?.optString("playlistId")?.takeIf { it.isNotBlank() } } - val json = downloader.postJson( - url = url, - json = body, - origin = "https://music.youtube.com", - clientName = MUSIC_CLIENT_ID, - clientVersion = MUSIC_CLIENT_VERSION, - userAgent = MUSIC_USER_AGENT, - visitorId = visitor, + ?: browseId.takeIf { it.startsWith("VL") }?.removePrefix("VL") + if (playlistId.isNullOrBlank()) return null + if (browseId.startsWith("UC") || browseId.startsWith("MPLA")) return null // artists + return YoutubeHit( + url = "https://music.youtube.com/playlist?list=$playlistId", + title = title, + artist = subtitle.ifBlank { "YouTube Music" }, + durationSec = 0, + thumbnailUrl = thumbnail, + kind = YoutubeHit.Kind.COLLECTION, + ) + } + + private fun listItemHit(renderer: JSONObject): YoutubeHit? { + val title = flexColumnText(renderer, 0).trim() + if (title.isBlank()) return null + val videoId = renderer.optJSONObject("playlistItemData")?.optString("videoId").orEmpty() + .ifBlank { firstVideoId(renderer.optJSONObject("overlay")) } + .ifBlank { + renderer.optJSONObject("navigationEndpoint")?.optJSONObject("watchEndpoint")?.optString("videoId").orEmpty() + } + if (videoId.length == 11) { + return YoutubeHit( + url = "https://music.youtube.com/watch?v=$videoId", + title = title, + artist = flexColumnText(renderer, 1).ifBlank { "YouTube Music" }, + durationSec = 0, + thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"), + kind = YoutubeHit.Kind.TRACK, ) - val root = JSONObject(json) - collectLikedShelf(root, hits, limit) - continuation = findPlaylistContinuation(root) ?: return + } + val browseId = renderer.optJSONObject("navigationEndpoint") + ?.optJSONObject("browseEndpoint") + ?.optString("browseId") + .orEmpty() + val playlistId = browseId.takeIf { it.startsWith("VL") }?.removePrefix("VL") + ?: firstPlaylistId(renderer.optJSONObject("overlay")) + if (playlistId.isBlank()) return null + return YoutubeHit( + url = "https://music.youtube.com/playlist?list=$playlistId", + title = title, + artist = flexColumnText(renderer, 1).ifBlank { "YouTube Music" }, + durationSec = 0, + thumbnailUrl = lastThumbnail( + renderer.optJSONObject("thumbnail")?.optJSONObject("musicThumbnailRenderer")?.optJSONObject("thumbnail"), + ), + kind = YoutubeHit.Kind.COLLECTION, + ) + } + + private fun firstPlaylistId(node: JSONObject?): String { + if (node == null) return "" + node.optString("playlistId").takeIf { it.isNotBlank() }?.let { return it } + val keys = node.keys() + while (keys.hasNext()) { + when (val child = node.opt(keys.next())) { + is JSONObject -> firstPlaylistId(child).takeIf { it.isNotBlank() }?.let { return it } + is JSONArray -> { + for (index in 0 until child.length()) { + (child.opt(index) as? JSONObject)?.let { item -> + firstPlaylistId(item).takeIf { it.isNotBlank() }?.let { return it } + } + } + } + } + } + return "" + } + + private fun lastThumbnail(thumbnail: JSONObject?): String? { + val list = thumbnail?.optJSONArray("thumbnails") ?: return null + var best: String? = null + var bestSize = -1 + for (index in 0 until list.length()) { + val entry = list.optJSONObject(index) ?: continue + val size = entry.optInt("width", 0) + val url = entry.optString("url").takeIf { it.isNotBlank() } ?: continue + if (size >= bestSize) { + bestSize = size + best = url + } + } + return best?.let { url -> + // Ask Google's image CDN for a larger square when the URL is a googleusercontent one. + if (url.contains("googleusercontent.com") && url.contains("=w")) { + url.replace(Regex("=w\\d+-h\\d+"), "=w544-h544") + } else fullBleedArtwork(url) } } @@ -213,6 +561,7 @@ class YoutubeClient @Inject constructor( fun loadCollectionTracks(url: String, limit: Int = 80): List { applySession() + if (url.contains("list=LM")) return loadLikedVideos(limit) val info = PlaylistInfo.getInfo(youtube(), url.trim()) return info.relatedItems .asSequence() @@ -517,6 +866,7 @@ class YoutubeClient @Inject constructor( private const val MUSIC_CLIENT_ID = "67" private const val MUSIC_CLIENT_VERSION = "1.20260804.16.00" private const val INNER_TUNE_VISITOR = "CgtsZG1ySnZiQWtSbyiMjuGSBg==" + private val VISITOR_REGEX = Regex("^Cg[ts]") fun rememberWatchUrl(songId: String, watchUrl: String) { val id = songId.trim() diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt index 4842f7a37c..037e5df44a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt @@ -80,10 +80,8 @@ internal class YoutubeDownloader : Downloader() { if (!clientVersion.isNullOrBlank()) header("X-YouTube-Client-Version", clientVersion) visitorId?.takeIf { it.isNotBlank() }?.let { header("X-Goog-Visitor-Id", it) } if (cookies.isNotEmpty()) { - header("X-YouTube-Bootstrap-Logged-In", "true") - val cookieHeader = if (cookies.contains("SOCS=")) cookies else "$cookies; SOCS=CAI" - header("Cookie", cookieHeader) - authorization(cookieHeader, origin)?.let { header("Authorization", it) } + header("Cookie", cookies) + authorization(cookies, origin)?.let { header("Authorization", it) } } } .build() @@ -97,11 +95,19 @@ internal class YoutubeDownloader : Downloader() { } } - fun getText(url: String, userAgent: String): String { + fun getText(url: String, userAgent: String, withCookies: Boolean = false): String { + val cookies = cookieHeader?.trim().orEmpty() val request = okhttp3.Request.Builder() .url(url) .header("User-Agent", userAgent) .header("Accept-Language", "en-US,en;q=0.9") + .apply { + if (withCookies && cookies.isNotEmpty()) { + header("Cookie", cookies) + authorization(cookies, "https://music.youtube.com")?.let { header("Authorization", it) } + header("X-Goog-AuthUser", "0") + } + } .build() client.newCall(request).execute().use { response -> return response.body.string() diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt index a1018d2562..902d31a9c7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt @@ -16,6 +16,10 @@ import javax.inject.Singleton data class YoutubeAccount( val cookieHeader: String = "", val displayName: String = "", + /** InnerTube visitorData captured from the signed-in music.youtube.com page. */ + val visitorData: String = "", + /** DATASYNC_ID of the signed-in channel; sent as context.user.onBehalfOfUser. */ + val dataSyncId: String = "", ) { val isSignedIn: Boolean get() = listOf("SAPISID", "__Secure-1PAPISID", "__Secure-3PAPISID") @@ -38,18 +42,49 @@ class YoutubeSession @Inject constructor( fun generation(): Int = generation.get() - fun save(cookieHeader: String, displayName: String = "") { + fun save( + cookieHeader: String, + displayName: String = "", + visitorData: String = "", + dataSyncId: String = "", + ) { val account = YoutubeAccount( cookieHeader = cookieHeader.trim(), displayName = displayName.trim().ifBlank { "Signed in" }, + visitorData = visitorData.trim(), + dataSyncId = normalizeDataSyncId(dataSyncId), ) prefs.edit() .putString(KEY_COOKIES, account.cookieHeader) .putString(KEY_NAME, account.displayName) + .putString(KEY_VISITOR, account.visitorData) + .putString(KEY_DATASYNC, account.dataSyncId) .apply() _account.value = account } + /** Fills in identity fields discovered after sign-in without touching the cookies. */ + fun updateIdentity( + visitorData: String? = null, + dataSyncId: String? = null, + displayName: String? = null, + ) { + val current = _account.value + if (!current.isSignedIn) return + val updated = current.copy( + visitorData = visitorData?.trim()?.takeIf { it.isNotEmpty() } ?: current.visitorData, + dataSyncId = dataSyncId?.let(::normalizeDataSyncId)?.takeIf { it.isNotEmpty() } ?: current.dataSyncId, + displayName = displayName?.trim()?.takeIf { it.isNotEmpty() } ?: current.displayName, + ) + if (updated == current) return + prefs.edit() + .putString(KEY_NAME, updated.displayName) + .putString(KEY_VISITOR, updated.visitorData) + .putString(KEY_DATASYNC, updated.dataSyncId) + .apply() + _account.value = updated + } + fun clear() { generation.incrementAndGet() prefs.edit().clear().commit() @@ -88,11 +123,22 @@ class YoutubeSession @Inject constructor( private fun read(): YoutubeAccount = YoutubeAccount( cookieHeader = prefs.getString(KEY_COOKIES, "").orEmpty(), displayName = prefs.getString(KEY_NAME, "").orEmpty(), + visitorData = prefs.getString(KEY_VISITOR, "").orEmpty(), + dataSyncId = prefs.getString(KEY_DATASYNC, "").orEmpty(), ) companion object { private const val PREFS = "youtube_session" private const val KEY_COOKIES = "cookies" private const val KEY_NAME = "display_name" + private const val KEY_VISITOR = "visitor_data" + private const val KEY_DATASYNC = "datasync_id" + + /** DATASYNC_ID looks like "channel||user"; InnerTube wants the first non-empty part. */ + fun normalizeDataSyncId(raw: String): String { + val trimmed = raw.trim() + if (!trimmed.contains("||")) return trimmed + return trimmed.split("||").map { it.trim() }.firstOrNull { it.isNotEmpty() }.orEmpty() + } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt index 7789a083f2..13562e0fe7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -198,22 +198,44 @@ class YoutubeViewModel @Inject constructor( musicStore.rememberRecent(song) } - fun importSession(cookieHeader: String): Boolean { + fun importSession(cookieHeader: String, visitorData: String = "", dataSyncId: String = ""): Boolean { val normalized = cookieHeader.trim() val signedIn = listOf("SAPISID", "__Secure-1PAPISID", "__Secure-3PAPISID") .any { key -> normalized.contains("$key=", ignoreCase = true) } if (!signedIn) return false - session.save(normalized) - if (_uiState.value.section == YoutubeSection.LIKED) loadLiked(refreshing = true) - if (_uiState.value.section == YoutubeSection.HOME) loadFeed(refreshing = true) + session.save(normalized, visitorData = visitorData, dataSyncId = dataSyncId) + clearCaches() + val epoch = session.generation() + viewModelScope.launch { + withContext(Dispatchers.IO) { + runCatching { client.refreshIdentity() } + val name = runCatching { client.loadAccountName() }.getOrDefault("") + if (name.isNotBlank() && session.generation() == epoch) session.updateIdentity(displayName = name) + } + if (session.generation() != epoch) return@launch + when (_uiState.value.section) { + YoutubeSection.LIKED -> loadLiked(refreshing = true) + YoutubeSection.HOME -> loadFeed(refreshing = true) + YoutubeSection.PLAYLISTS -> loadPlaylists(refreshing = true) + YoutubeSection.RADIO -> loadRadioStations(refreshing = true) + else -> Unit + } + } return true } + private fun clearCaches() { + client.resetIdentity() + client.likedHits = emptyList() + client.feedShelves = emptyList() + client.playlistHits = emptyList() + client.radioHits = emptyList() + } + fun signOut() { likedJob?.cancel() session.clear() - client.likedHits = emptyList() - client.feedShelves = emptyList() + clearCaches() _uiState.update { it.copy(error = null, results = emptyList(), isLoading = false, isRefreshing = false) } @@ -308,29 +330,40 @@ class YoutubeViewModel @Inject constructor( } val built = runCatching { withContext(Dispatchers.IO) { - val yours = if (session.isSignedIn()) { + val signedIn = session.isSignedIn() + // Signed in: the real YouTube Music home (Quick picks, Listen again, Mixed for you...). + val home = if (signedIn) runCatching { client.loadHome() }.getOrElse { emptyList() } else emptyList() + val liked = if (signedIn) { runCatching { client.loadLikedMusic(24) }.getOrDefault(emptyList()) } else { emptyList() - }.ifEmpty { musicStore.recent.value } + } + val yours = liked.ifEmpty { musicStore.recent.value } val mix = yours.ifEmpty { - if (session.isSignedIn()) emptyList() + if (signedIn) emptyList() else runCatching { client.loadTrendingMusic(18) }.getOrDefault(emptyList()) } - val followed = if (session.isSignedIn() && yours.isEmpty()) { + val followed = if (signedIn && home.isEmpty() && yours.isEmpty()) { runCatching { client.loadSubscriptionFeed(12) }.getOrDefault(emptyList()) } else { emptyList() } + val radioSeeds = (liked.ifEmpty { home.flatMap { it.items } }.filter { it.kind == YoutubeHit.Kind.TRACK }) + .ifEmpty { musicStore.recent.value } listOfNotNull( - mix.takeIf { it.isNotEmpty() }?.let { + mix.takeIf { it.isNotEmpty() && (home.isEmpty() || liked.isNotEmpty()) }?.let { YoutubeShelf( id = "yours", - title = if (yours.isNotEmpty()) "For you" else "Trending songs", + title = when { + liked.isNotEmpty() -> "Your likes" + signedIn -> "For you" + else -> "Trending songs" + }, items = it, ) }, - mix.shuffled().take(12).takeIf { it.isNotEmpty() && yours.isNotEmpty() }?.let { + ) + home + listOfNotNull( + radioSeeds.shuffled().take(12).takeIf { it.isNotEmpty() && signedIn }?.let { YoutubeShelf("radio", "Radio", it, startsRadio = true) }, followed.takeIf { it.isNotEmpty() }?.let { @@ -364,9 +397,14 @@ class YoutubeViewModel @Inject constructor( } val loaded = runCatching { withContext(Dispatchers.IO) { - listOf("today's hits", "workout", "focus", "party").flatMap { query -> - runCatching { client.search(query, YoutubeSearchFilter.PLAYLISTS, 6) }.getOrDefault(emptyList()) - }.distinctBy { it.url } + if (session.isSignedIn()) { + // The user's own YouTube Music library, never generic search results. + client.loadLibraryPlaylists() + } else { + listOf("today's hits", "workout", "focus", "party").flatMap { query -> + runCatching { client.search(query, YoutubeSearchFilter.PLAYLISTS, 6) }.getOrDefault(emptyList()) + }.distinctBy { it.url } + } } } loaded.fold( From 0777f86fe71fb9543ba6afc94f5a6f5654937908 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 20:31:40 +0300 Subject: [PATCH 55/77] feat: add skip silence and cut the delay before a song starts Skip silence is a new playback setting wired through DataStore to both ExoPlayer instances, so silent passages inside a track are trimmed. Start playback sooner: the load control now reads its numbers from loadControlBufferProfileFor, which asks for 1s of audio before starting instead of 2.5s, and the YouTube screen resolves the first few tracks of the visible list in the background so a tap plays from cache. Co-authored-by: Cursor --- .../preferences/UserPreferencesRepository.kt | 8 ++++ .../pixelplay/data/service/MusicService.kt | 6 +++ .../data/service/player/DualPlayerEngine.kt | 48 +++++++++---------- .../screens/SettingsCategoryScreen.kt | 7 +++ .../presentation/screens/YoutubeScreen.kt | 8 ++++ .../viewmodel/SettingsViewModel.kt | 37 +++++++++----- .../pixelplay/youtube/YoutubeClient.kt | 26 ++++++++-- .../pixelplay/youtube/YoutubeViewModel.kt | 27 +++++++++++ app/src/main/res/values/strings_settings.xml | 2 + .../player/LoadControlBufferProfileTest.kt | 2 +- 10 files changed, 130 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt index 4598f1a86f..e7c224f32b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt @@ -140,6 +140,7 @@ class UserPreferencesRepository @Inject constructor( val KEEP_PLAYING_IN_BACKGROUND = booleanPreferencesKey("keep_playing_in_background") val IS_CROSSFADE_ENABLED = booleanPreferencesKey("is_crossfade_enabled") val HI_FI_MODE_ENABLED = booleanPreferencesKey("hi_fi_mode_enabled") + val SKIP_SILENCE_ENABLED = booleanPreferencesKey("skip_silence_enabled") val CROSSFADE_DURATION = intPreferencesKey("crossfade_duration") val CUSTOM_GENRES = stringSetPreferencesKey("custom_genres") val CUSTOM_GENRE_ICONS = stringPreferencesKey("custom_genre_icons") @@ -349,6 +350,13 @@ class UserPreferencesRepository @Inject constructor( dataStore.edit { it[PreferencesKeys.HI_FI_MODE_ENABLED] = enabled } } + val skipSilenceEnabledFlow: Flow = + pref { it[PreferencesKeys.SKIP_SILENCE_ENABLED] ?: false } + + suspend fun setSkipSilenceEnabled(enabled: Boolean) { + dataStore.edit { it[PreferencesKeys.SKIP_SILENCE_ENABLED] = enabled } + } + val keepPlayingInBackgroundFlow: Flow = pref { it[PreferencesKeys.KEEP_PLAYING_IN_BACKGROUND] ?: true } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index d0a762229c..575879e0c3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -520,6 +520,12 @@ class MusicService : MediaLibraryService() { } } + serviceScope.launch { + userPreferencesRepository.skipSilenceEnabledFlow.collect { enabled -> + engine.setSkipSilence(enabled) + } + } + serviceScope.launch { userPreferencesRepository.resumeOnHeadsetReconnectFlow.collect { enabled -> resumeOnHeadsetReconnectEnabled = enabled diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt index c8f05dc0a2..5cc09ce3f0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt @@ -249,6 +249,9 @@ class DualPlayerEngine @Inject constructor( private var scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) var hiFiModeEnabled: Boolean = false private set + /** Trims silent passages inside a track (ExoPlayer's SilenceSkippingAudioProcessor). */ + var skipSilenceEnabled: Boolean = false + private set private var audioOffloadEnabled = !shouldDisableAudioOffloadByDefault() private var transitionJob: Job? = null private var bufferingFallbackJob: Job? = null @@ -987,16 +990,16 @@ class DualPlayerEngine @Inject constructor( } /** - * Returns a [DefaultLoadControl] tuned to the device's RAM tier. + * Returns a [DefaultLoadControl] tuned to the device's RAM tier, using the single + * source of truth in [loadControlBufferProfileFor]. * * Low-RAM devices ([ActivityManager.isLowRamDevice]) receive halved buffer ceilings * to prevent memory pressure when both players co-exist during a crossfade. - * [bufferForPlaybackMs] is set to ExoPlayer's documented default of 2 500 ms on both - * tiers — the previous value of 5 000 ms doubled first-audio latency with no benefit. */ private fun buildAdaptiveLoadControl(): DefaultLoadControl { val isLowRam = (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) .isLowRamDevice + val profile = loadControlBufferProfileFor(isLowRam) // setPrioritizeTimeOverSizeThresholds(true): instructs ExoPlayer to use buffered // *duration* (not buffered *bytes*) as the criterion for deciding when to start // playback and when to stop buffering. This is required for correct behaviour with @@ -1005,27 +1008,15 @@ class DualPlayerEngine @Inject constructor( // Without this flag ExoPlayer falls back to a default byte threshold that was // designed for typical compressed audio (~128–320 kbps) and will underperform on // files with bitrates above ~1 Mbps. - return if (isLowRam) { - DefaultLoadControl.Builder() - .setBufferDurationsMs( - /* minBufferMs */ 15_000, - /* maxBufferMs */ 30_000, - /* bufferForPlaybackMs */ 2_500, - /* bufferForPlaybackAfterRebufferMs */ 5_000 - ) - .setPrioritizeTimeOverSizeThresholds(true) - .build() - } else { - DefaultLoadControl.Builder() - .setBufferDurationsMs( - /* minBufferMs */ 30_000, - /* maxBufferMs */ 60_000, - /* bufferForPlaybackMs */ 2_500, - /* bufferForPlaybackAfterRebufferMs */ 5_000 - ) - .setPrioritizeTimeOverSizeThresholds(true) - .build() - } + return DefaultLoadControl.Builder() + .setBufferDurationsMs( + profile.minBufferMs, + profile.maxBufferMs, + profile.bufferForPlaybackMs, + profile.bufferForPlaybackAfterRebufferMs + ) + .setPrioritizeTimeOverSizeThresholds(true) + .build() } private fun buildPlayer(): ExoPlayer { @@ -1145,6 +1136,7 @@ class DualPlayerEngine @Inject constructor( .build() setHandleAudioBecomingNoisy(true) setWakeMode(C.WAKE_MODE_LOCAL) + skipSilenceEnabled = this@DualPlayerEngine.skipSilenceEnabled playWhenReady = false } } @@ -1193,6 +1185,14 @@ class DualPlayerEngine @Inject constructor( rebuildPlayersPreservingMasterState("Hi-Fi mode set to $enabled") } + fun setSkipSilence(enabled: Boolean) { + if (skipSilenceEnabled == enabled) return + skipSilenceEnabled = enabled + if (::playerA.isInitialized) playerA.skipSilenceEnabled = enabled + playerB?.skipSilenceEnabled = enabled + Timber.tag("DualPlayerEngine").d("Skip silence set to %b", enabled) + } + suspend fun resolveCloudUri(uri: Uri): Uri = withContext(Dispatchers.IO) { val uriString = uri.toString() resolvedUriCache.get(uriString)?.let { return@withContext it } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt index dd1672dbc6..22bea4d1bd 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt @@ -856,6 +856,13 @@ fun SettingsCategoryScreen( enabled = uiState.hiFiModeDeviceSupported, leadingIcon = { Icon(painterResource(R.drawable.outline_high_quality_24), null, tint = MaterialTheme.colorScheme.secondary) } ) + SwitchSettingItem( + title = stringResource(R.string.settings_skip_silence_title), + subtitle = stringResource(R.string.settings_skip_silence_subtitle), + checked = uiState.skipSilenceEnabled, + onCheckedChange = { settingsViewModel.setSkipSilenceEnabled(it) }, + leadingIcon = { Icon(painterResource(R.drawable.rounded_align_justify_space_even_24), null, tint = MaterialTheme.colorScheme.secondary) } + ) SwitchSettingItem( title = stringResource(R.string.settings_persistent_shuffle_title), subtitle = stringResource(R.string.settings_persistent_shuffle_subtitle), diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 19bab47bb9..5a3de8f07d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -187,6 +187,14 @@ fun YoutubeScreen( LaunchedEffect(stablePlayer.currentSong?.id) { stablePlayer.currentSong?.let(viewModel::rememberPlayed) } + // Resolve the streams the user is most likely to tap first, so playback is instant. + val warmUpCandidates = rows.ifEmpty { recent }.ifEmpty { uiState.shelves.flatMap { it.items } } + val warmUpKey = remember(warmUpCandidates) { + warmUpCandidates.asSequence().map { it.url }.take(4).joinToString() + } + LaunchedEffect(warmUpKey) { + if (warmUpCandidates.isNotEmpty()) viewModel.warmUpTracks(warmUpCandidates) + } if (uiState.collectionTitle != null) { BackHandler { viewModel.closeCollection() } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt index abba7eaceb..dc82222792 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt @@ -74,6 +74,7 @@ data class SettingsUiState( val isCrossfadeEnabled: Boolean = false, val hiFiModeEnabled: Boolean = false, val hiFiModeDeviceSupported: Boolean = true, + val skipSilenceEnabled: Boolean = false, val crossfadeDuration: Int = 2000, val persistentShuffleEnabled: Boolean = false, val folderBackGestureNavigation: Boolean = true, @@ -160,6 +161,7 @@ private sealed interface SettingsUiUpdate { val showQueueHistory: Boolean, val isCrossfadeEnabled: Boolean, val hiFiModeEnabled: Boolean, + val skipSilenceEnabled: Boolean, val crossfadeDuration: Int, val persistentShuffleEnabled: Boolean, val folderBackGestureNavigation: Boolean, @@ -653,6 +655,7 @@ class SettingsViewModel @Inject constructor( userPreferencesRepository.showQueueHistoryFlow, userPreferencesRepository.isCrossfadeEnabledFlow, userPreferencesRepository.hiFiModeEnabledFlow, + userPreferencesRepository.skipSilenceEnabledFlow, userPreferencesRepository.crossfadeDurationFlow, userPreferencesRepository.persistentShuffleEnabledFlow, userPreferencesRepository.folderBackGestureNavigationFlow, @@ -675,19 +678,20 @@ class SettingsViewModel @Inject constructor( showQueueHistory = values[4] as Boolean, isCrossfadeEnabled = values[5] as Boolean, hiFiModeEnabled = values[6] as Boolean, - crossfadeDuration = values[7] as Int, - persistentShuffleEnabled = values[8] as Boolean, - folderBackGestureNavigation = values[9] as Boolean, - lyricsSourcePreference = values[10] as LyricsSourcePreference, - autoScanLrcFiles = values[11] as Boolean, - blockedDirectories = @Suppress("UNCHECKED_CAST") (values[12] as Set), - hapticsEnabled = values[13] as Boolean, - immersiveLyricsEnabled = values[14] as Boolean, - immersiveLyricsTimeout = values[15] as Long, - animatedLyricsBlurEnabled = values[16] as Boolean, - animatedLyricsBlurStrength = values[17] as Float, - disableBlurAllOver = values[18] as Boolean, - showScrollbar = values[19] as Boolean + skipSilenceEnabled = values[7] as Boolean, + crossfadeDuration = values[8] as Int, + persistentShuffleEnabled = values[9] as Boolean, + folderBackGestureNavigation = values[10] as Boolean, + lyricsSourcePreference = values[11] as LyricsSourcePreference, + autoScanLrcFiles = values[12] as Boolean, + blockedDirectories = @Suppress("UNCHECKED_CAST") (values[13] as Set), + hapticsEnabled = values[14] as Boolean, + immersiveLyricsEnabled = values[15] as Boolean, + immersiveLyricsTimeout = values[16] as Long, + animatedLyricsBlurEnabled = values[17] as Boolean, + animatedLyricsBlurStrength = values[18] as Float, + disableBlurAllOver = values[19] as Boolean, + showScrollbar = values[20] as Boolean ) }.collect { update -> _uiState.update { state -> @@ -699,6 +703,7 @@ class SettingsViewModel @Inject constructor( showQueueHistory = update.showQueueHistory, isCrossfadeEnabled = update.isCrossfadeEnabled, hiFiModeEnabled = update.hiFiModeEnabled, + skipSilenceEnabled = update.skipSilenceEnabled, crossfadeDuration = update.crossfadeDuration, persistentShuffleEnabled = update.persistentShuffleEnabled, folderBackGestureNavigation = update.folderBackGestureNavigation, @@ -988,6 +993,12 @@ class SettingsViewModel @Inject constructor( } } + fun setSkipSilenceEnabled(enabled: Boolean) { + viewModelScope.launch { + userPreferencesRepository.setSkipSilenceEnabled(enabled) + } + } + fun setShowQueueHistory(show: Boolean) { viewModelScope.launch { userPreferencesRepository.setShowQueueHistory(show) diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index c82113372c..416c9beb06 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -28,6 +28,7 @@ class YoutubeClient @Inject constructor( ) { private val downloader = YoutubeDownloader() private val resolveCache = ConcurrentHashMap() + private val inFlightResolves = java.util.Collections.newSetFromMap(ConcurrentHashMap()) @Volatile var feedShelves: List = emptyList() @Volatile var playlistHits: List = emptyList() @Volatile var radioHits: List = emptyList() @@ -571,12 +572,27 @@ class YoutubeClient @Inject constructor( .toList() } + /** + * Resolves a stream ahead of a possible tap so playback can start immediately. + * Errors are swallowed: this is best effort, and a real tap will surface them. + */ + fun prefetchTrack(watchUrl: String) { + val key = watchUrl.trim() + if (key.isBlank() || cachedTrack(key) != null) return + if (!inFlightResolves.add(key)) return + try { + resolveTrack(key) + } catch (error: Exception) { + Timber.d(error, "YouTube: prefetch failed for %s", key) + } finally { + inFlightResolves.remove(key) + } + } + fun resolveTrack(watchUrl: String): YoutubeResolvedTrack { applySession() val key = watchUrl.trim() - resolveCache[key]?.takeIf { - System.currentTimeMillis() - it.cachedAtMs < RESOLVE_CACHE_TTL_MS - }?.let { return it.track } + cachedTrack(key)?.let { return it } val info = StreamInfo.getInfo(youtube(), key) val stream = pickBestAudioStream(info.audioStreams) @@ -594,6 +610,10 @@ class YoutubeClient @Inject constructor( return resolved } + private fun cachedTrack(key: String): YoutubeResolvedTrack? = resolveCache[key] + ?.takeIf { System.currentTimeMillis() - it.cachedAtMs < RESOLVE_CACHE_TTL_MS } + ?.track + fun toSong(track: YoutubeResolvedTrack, watchUrl: String = track.watchUrl): Song { val permalink = watchUrl.ifBlank { track.watchUrl }.trim() val id = songIdForUrl(permalink) diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt index 13562e0fe7..139d628034 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.coroutines.cancellation.CancellationException @@ -44,6 +45,7 @@ class YoutubeViewModel @Inject constructor( val account: StateFlow = session.account private var searchJob: Job? = null private var likedJob: Job? = null + private var warmUpJob: Job? = null init { viewModelScope.launch { @@ -225,6 +227,7 @@ class YoutubeViewModel @Inject constructor( } private fun clearCaches() { + warmUpJob?.cancel() client.resetIdentity() client.likedHits = emptyList() client.feedShelves = emptyList() @@ -300,6 +303,26 @@ class YoutubeViewModel @Inject constructor( } } + /** + * Resolves the first few tracks of a list in the background so tapping one starts + * playing without waiting for the stream lookup. The first resolve also primes the + * extractor's player-script cache, which is what makes the very first tap slow. + */ + fun warmUpTracks(hits: List) { + val tracks = hits.asSequence() + .filter { it.kind == YoutubeHit.Kind.TRACK } + .take(WARM_UP_COUNT) + .toList() + if (tracks.isEmpty()) return + warmUpJob?.cancel() + warmUpJob = viewModelScope.launch(Dispatchers.IO) { + tracks.forEach { hit -> + if (!isActive) return@launch + runCatching { client.prefetchTrack(hit.url) } + } + } + } + fun prefetchAfter(start: YoutubeHit, queue: List, onSong: (Song) -> Unit) { val startIndex = queue.indexOfFirst { it.url == start.url } if (startIndex < 0) return @@ -483,4 +506,8 @@ class YoutubeViewModel @Inject constructor( ) } } + + private companion object { + const val WARM_UP_COUNT = 4 + } } diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 1c6ae4e284..40064c9b9d 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -287,6 +287,8 @@ Hi-Fi Mode Float 32-bit audio output. Disable if playback stutters on your device. Not supported on this device (PCM_FLOAT AudioTrack unavailable). + Skip Silence + Trim silent gaps inside a track so playback keeps moving. Persistent Shuffle Remember shuffle setting even after closing the app. Show queue history diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/player/LoadControlBufferProfileTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/player/LoadControlBufferProfileTest.kt index 0c89e99a91..4c3a1fe58b 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/service/player/LoadControlBufferProfileTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/player/LoadControlBufferProfileTest.kt @@ -11,7 +11,7 @@ class LoadControlBufferProfileTest { assertThat(profile.minBufferMs).isEqualTo(30_000) assertThat(profile.maxBufferMs).isEqualTo(60_000) - assertThat(profile.bufferForPlaybackMs).isEqualTo(2_000) + assertThat(profile.bufferForPlaybackMs).isEqualTo(1_000) assertThat(profile.bufferForPlaybackAfterRebufferMs).isEqualTo(5_000) } From 621698fffc463e15046d517ce9c46f24871fa58a Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 20:40:02 +0300 Subject: [PATCH 56/77] feat: page through liked music instead of capping at 100 Liked Music was a single 100-song fetch, so nothing past the first page was reachable. The client now returns a page plus its continuation token, and the Liked list pulls the next page when the user scrolls near the end, showing a spinner while it loads. Order is YouTube Music's own, newest like first, so the latest likes stay at the top and older ones come in as you scroll. Co-authored-by: Cursor --- .../presentation/screens/YoutubeScreen.kt | 20 ++++- .../pixelplay/youtube/YoutubeClient.kt | 58 ++++++------- .../pixelplay/youtube/YoutubeModels.kt | 6 ++ .../pixelplay/youtube/YoutubeViewModel.kt | 82 +++++++++++++++++-- 4 files changed, 130 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 5a3de8f07d..3fae8c7fe5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -116,6 +117,8 @@ import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean private const val WIDE_SCREEN_DP = 600 +/** How close to the end of the list a row must be to pull the next page. */ +private const val LOAD_MORE_THRESHOLD = 12 @Composable fun YoutubeScreen( @@ -491,7 +494,10 @@ fun YoutubeScreen( ) } } - items(rows, key = { it.url }) { hit -> + itemsIndexed(rows, key = { _, hit -> hit.url }) { index, hit -> + if (uiState.hasMore && index >= rows.size - LOAD_MORE_THRESHOLD) { + LaunchedEffect(rows.size) { viewModel.loadMoreLiked() } + } val song = remember(hit) { placeholderSong(hit) } YoutubeTrackRow( song = song, @@ -504,6 +510,18 @@ fun YoutubeScreen( onSave = { viewModel.toggleSave(hit) }, ) } + if (uiState.isLoadingMore) { + item(key = "loading_more") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + LoadingIndicator() + } + } + } } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index 416c9beb06..4118c5c7d4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -82,26 +82,41 @@ class YoutubeClient @Inject constructor( .toList() } - fun loadLikedVideos(limit: Int = 100): List { + /** + * One page of Liked Music, newest like first (YouTube Music's own order). + * Pass [YoutubePage.continuation] back in to get the next page. + */ + fun loadLikedPage(continuation: String? = null, pageSize: Int = LIKED_PAGE_SIZE): YoutubePage { applySession() - if (!session.isSignedIn()) return emptyList() + if (!session.isSignedIn()) return YoutubePage(emptyList(), null) val hits = linkedSetOf() - val loggedIn = browseLiked("VLLM", hits, limit) - if (hits.isEmpty()) { + var token = continuation + var loggedIn: Boolean? = null + var requests = 0 + do { + val root = musicBrowse( + browseId = if (token.isNullOrBlank()) LIKED_MUSIC_BROWSE_ID else null, + continuation = token, + ) + if (loggedIn == null) loggedIn = loggedInFlag(root) + collectLikedShelf(root, hits, Int.MAX_VALUE) + token = findPlaylistContinuation(root) + requests++ + } while (!token.isNullOrBlank() && hits.size < pageSize && requests < MAX_REQUESTS_PER_PAGE) + if (hits.isEmpty() && continuation.isNullOrBlank()) { throw IllegalStateException( if (loggedIn == false) "YouTube Music did not accept this session. Sign in again from Account." else "YouTube Music returned no liked songs.", ) } - return hits.take(limit).toList() + return YoutubePage(hits.toList(), token?.takeIf { it.isNotBlank() }) } fun loadLikedMusic(limit: Int = 24): List { - applySession() if (!session.isSignedIn()) return emptyList() - val hits = linkedSetOf() - runCatching { browseLiked("VLLM", hits, limit) } - return hits.take(limit).toList() + return runCatching { loadLikedPage(pageSize = limit).hits } + .getOrDefault(emptyList()) + .take(limit) } /** @@ -228,24 +243,6 @@ class YoutubeClient @Inject constructor( return raw.replace("\\/", "/").replace("\\u003d", "=").replace("\\u0026", "&") } - /** Returns null when the response did not say whether the session is logged in. */ - private fun browseLiked( - browseId: String, - hits: MutableSet, - limit: Int, - ): Boolean? { - var loggedIn: Boolean? = null - var continuation: String? = null - repeat(6) { - if (hits.size >= limit) return loggedIn - val root = musicBrowse(browseId = browseId, continuation = continuation) - if (loggedIn == null) loggedIn = loggedInFlag(root) - collectLikedShelf(root, hits, limit) - continuation = findPlaylistContinuation(root) ?: return loggedIn - } - return loggedIn - } - private fun musicBrowse( browseId: String? = null, continuation: String? = null, @@ -562,7 +559,7 @@ class YoutubeClient @Inject constructor( fun loadCollectionTracks(url: String, limit: Int = 80): List { applySession() - if (url.contains("list=LM")) return loadLikedVideos(limit) + if (url.contains("list=LM")) return loadLikedPage(pageSize = limit).hits.take(limit) val info = PlaylistInfo.getInfo(youtube(), url.trim()) return info.relatedItems .asSequence() @@ -886,6 +883,11 @@ class YoutubeClient @Inject constructor( private const val MUSIC_CLIENT_ID = "67" private const val MUSIC_CLIENT_VERSION = "1.20260804.16.00" private const val INNER_TUNE_VISITOR = "CgtsZG1ySnZiQWtSbyiMjuGSBg==" + private const val LIKED_MUSIC_BROWSE_ID = "VLLM" + const val LIKED_PAGE_SIZE = 100 + // A continuation can come back with only a handful of rows, so allow a few + // round trips per page instead of showing the user a nearly empty page. + private const val MAX_REQUESTS_PER_PAGE = 3 private val VISITOR_REGEX = Regex("^Cg[ts]") fun rememberWatchUrl(songId: String, watchUrl: String) { diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt index fa4327d52f..cc7c693735 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt @@ -33,6 +33,12 @@ data class YoutubeHit( enum class Kind { TRACK, COLLECTION } } +/** A page of hits plus the token that fetches the next one, or null at the end. */ +data class YoutubePage( + val hits: List, + val continuation: String?, +) + data class YoutubeResolvedTrack( val watchUrl: String, val title: String, diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt index 139d628034..5348249f5f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -26,6 +26,8 @@ data class YoutubeUiState( val collectionTitle: String? = null, val isLoading: Boolean = false, val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val hasMore: Boolean = false, val error: String? = null, ) @@ -46,6 +48,10 @@ class YoutubeViewModel @Inject constructor( private var searchJob: Job? = null private var likedJob: Job? = null private var warmUpJob: Job? = null + private var likedMoreJob: Job? = null + private var likedContinuation: String? = null + @Volatile + private var loadingMoreLiked = false init { viewModelScope.launch { @@ -69,7 +75,13 @@ class YoutubeViewModel @Inject constructor( if (hasContent) return } _uiState.update { - it.copy(section = section, collectionTitle = null, error = null) + it.copy( + section = section, + collectionTitle = null, + error = null, + isLoadingMore = false, + hasMore = false, + ) } when (section) { YoutubeSection.HOME -> { @@ -228,6 +240,9 @@ class YoutubeViewModel @Inject constructor( private fun clearCaches() { warmUpJob?.cancel() + likedMoreJob?.cancel() + likedContinuation = null + loadingMoreLiked = false client.resetIdentity() client.likedHits = emptyList() client.feedShelves = emptyList() @@ -251,36 +266,48 @@ class YoutubeViewModel @Inject constructor( private fun loadLiked(refreshing: Boolean = false) { likedJob?.cancel() + likedMoreJob?.cancel() + likedContinuation = null + loadingMoreLiked = false val epoch = session.generation() likedJob = viewModelScope.launch { _uiState.update { it.copy( isLoading = !refreshing && it.results.isEmpty(), isRefreshing = refreshing, + isLoadingMore = false, + hasMore = false, error = null, ) } val local = musicStore.liked.value val remote = if (session.isSignedIn() && session.generation() == epoch) { try { - Result.success(withContext(Dispatchers.IO) { client.loadLikedVideos() }) + Result.success(withContext(Dispatchers.IO) { client.loadLikedPage() }) } catch (cancelled: CancellationException) { throw cancelled } catch (error: Exception) { Result.failure(error) } } else { - Result.success(emptyList()) + Result.success(YoutubePage(emptyList(), null)) } if (session.generation() != epoch) return@launch remote.fold( - onSuccess = { accountLikes -> + onSuccess = { page -> if (session.generation() != epoch) return@launch - val account = if (session.isSignedIn()) accountLikes else emptyList() + val account = if (session.isSignedIn()) page.hits else emptyList() val merged = (account + local).distinctBy { it.url } client.likedHits = account + likedContinuation = page.continuation _uiState.update { - it.copy(isLoading = false, isRefreshing = false, error = null, results = merged) + it.copy( + isLoading = false, + isRefreshing = false, + error = null, + results = merged, + hasMore = page.continuation != null, + ) } }, onFailure = { error -> @@ -295,7 +322,7 @@ class YoutubeViewModel @Inject constructor( isLoading = false, isRefreshing = false, results = local, - error = error.message ?: "Couldn't load liked videos", + error = error.message ?: "Couldn't load liked music", ) } }, @@ -303,6 +330,47 @@ class YoutubeViewModel @Inject constructor( } } + /** Appends the next page of Liked Music; safe to call repeatedly while scrolling. */ + fun loadMoreLiked() { + val state = _uiState.value + if (state.section != YoutubeSection.LIKED || state.collectionTitle != null) return + val token = likedContinuation ?: return + // Set synchronously: several rows near the end ask for the next page at once. + if (loadingMoreLiked) return + loadingMoreLiked = true + val epoch = session.generation() + likedMoreJob = viewModelScope.launch { + _uiState.update { it.copy(isLoadingMore = true) } + val page = try { + Result.success(withContext(Dispatchers.IO) { client.loadLikedPage(token) }) + } catch (cancelled: CancellationException) { + loadingMoreLiked = false + throw cancelled + } catch (error: Exception) { + Result.failure(error) + } + loadingMoreLiked = false + if (session.generation() != epoch) return@launch + page.fold( + onSuccess = { loaded -> + likedContinuation = loaded.continuation + client.likedHits = (client.likedHits + loaded.hits).distinctBy { it.url } + _uiState.update { + it.copy( + isLoadingMore = false, + hasMore = loaded.continuation != null, + results = (it.results + loaded.hits).distinctBy { hit -> hit.url }, + ) + } + }, + onFailure = { + // Keep what is already on screen; the next scroll can retry. + _uiState.update { it.copy(isLoadingMore = false) } + }, + ) + } + } + /** * Resolves the first few tracks of a list in the background so tapping one starts * playing without waiting for the stream lookup. The first resolve also primes the From 6f311d3ac6a205124a3c30bb5f79e8fdb8a08b05 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 20:52:48 +0300 Subject: [PATCH 57/77] fix: keep newest likes first and load the rest on scroll The first Liked page now follows YouTube Music order, newest at the top. Local in-app likes no longer get appended, which had buried new songs. Scroll near the end of the list asks InnerTube for the next shelf page using the real playlist continuation, not a random token from the JSON. An empty page ends the list so older likes keep arriving as you go down. Co-authored-by: Cursor --- .../presentation/screens/YoutubeScreen.kt | 35 ++++++-- .../pixelplay/youtube/YoutubeClient.kt | 88 +++++++++++++------ .../pixelplay/youtube/YoutubeViewModel.kt | 25 ++++-- 3 files changed, 110 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 3fae8c7fe5..c5a64d6e6c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -69,6 +70,7 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -113,6 +115,7 @@ import com.theveloper.pixelplay.youtube.YoutubeSearchFilter import com.theveloper.pixelplay.youtube.YoutubeSection import com.theveloper.pixelplay.youtube.YoutubeViewModel import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean @@ -170,10 +173,17 @@ fun YoutubeScreen( YoutubeSection.RADIO -> stringResource(R.string.youtube_radio_title) } val rows = when (browseSection) { - YoutubeSection.LIKED -> (uiState.results + liked).distinctBy { it.url } + // Signed-in likes stay in YouTube Music order (newest first). Local likes + // only appear when there is no account list, so they cannot bury new likes. + YoutubeSection.LIKED -> if (account.isSignedIn && uiState.results.isNotEmpty()) { + uiState.results + } else { + uiState.results.ifEmpty { liked } + } YoutubeSection.SAVED -> saved else -> uiState.results } + val listState = rememberLazyListState() val pullState = rememberPullToRefreshState() LaunchedEffect(section) { @@ -198,6 +208,18 @@ fun YoutubeScreen( LaunchedEffect(warmUpKey) { if (warmUpCandidates.isNotEmpty()) viewModel.warmUpTracks(warmUpCandidates) } + LaunchedEffect(browseSection, listState) { + if (browseSection != YoutubeSection.LIKED) return@LaunchedEffect + snapshotFlow { + val info = listState.layoutInfo + val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: 0 + lastVisible to info.totalItemsCount + }.distinctUntilChanged().collect { (lastVisible, total) -> + if (total > 0 && lastVisible >= total - LOAD_MORE_THRESHOLD) { + viewModel.loadMoreLiked() + } + } + } if (uiState.collectionTitle != null) { BackHandler { viewModel.closeCollection() } @@ -483,7 +505,11 @@ fun YoutubeScreen( } } else -> { - LazyColumn(contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(4.dp)) { + LazyColumn( + state = listState, + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { if (browseSection == YoutubeSection.RADIO) { item(key = "radio_explainer") { Text( @@ -494,10 +520,7 @@ fun YoutubeScreen( ) } } - itemsIndexed(rows, key = { _, hit -> hit.url }) { index, hit -> - if (uiState.hasMore && index >= rows.size - LOAD_MORE_THRESHOLD) { - LaunchedEffect(rows.size) { viewModel.loadMoreLiked() } - } + itemsIndexed(rows, key = { _, hit -> hit.url }) { _, hit -> val song = remember(hit) { placeholderSong(hit) } YoutubeTrackRow( song = song, diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index 4118c5c7d4..28e369e7f7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -94,6 +94,7 @@ class YoutubeClient @Inject constructor( var loggedIn: Boolean? = null var requests = 0 do { + val before = hits.size val root = musicBrowse( browseId = if (token.isNullOrBlank()) LIKED_MUSIC_BROWSE_ID else null, continuation = token, @@ -102,6 +103,11 @@ class YoutubeClient @Inject constructor( collectLikedShelf(root, hits, Int.MAX_VALUE) token = findPlaylistContinuation(root) requests++ + // Empty continuation pages mean we reached the end of Liked Music. + if (hits.size == before) { + token = null + break + } } while (!token.isNullOrBlank() && hits.size < pageSize && requests < MAX_REQUESTS_PER_PAGE) if (hits.isEmpty() && continuation.isNullOrBlank()) { throw IllegalStateException( @@ -483,30 +489,16 @@ class YoutubeClient @Inject constructor( } private fun collectLikedShelf(root: JSONObject, hits: MutableSet, limit: Int) { - val rows = root.optJSONObject("contents") - ?.optJSONObject("twoColumnBrowseResultsRenderer") - ?.optJSONObject("secondaryContents") - ?.optJSONObject("sectionListRenderer") - ?.optJSONArray("contents") - ?.let { contents -> - (0 until contents.length()).firstNotNullOfOrNull { index -> - contents.optJSONObject(index) - ?.optJSONObject("musicPlaylistShelfRenderer") - ?.optJSONArray("contents") - } - } - ?: root.optJSONObject("continuationContents") - ?.optJSONObject("musicPlaylistShelfContinuation") - ?.optJSONArray("contents") + val rows = findLikedRows(root) if (rows == null) { collectVideoHits(root, hits, limit) return } for (index in 0 until rows.length()) { if (hits.size >= limit) return - val renderer = rows.optJSONObject(index) - ?.optJSONObject("musicResponsiveListItemRenderer") - ?: continue + val item = rows.optJSONObject(index) ?: continue + if (item.optJSONObject("continuationItemRenderer") != null) continue + val renderer = item.optJSONObject("musicResponsiveListItemRenderer") ?: continue val videoId = renderer.optJSONObject("playlistItemData")?.optString("videoId").orEmpty() val title = flexColumnText(renderer, 0) if (videoId.length != 11 || title.isBlank()) continue @@ -521,6 +513,41 @@ class YoutubeClient @Inject constructor( } } + /** Desktop two-column Liked Music, mobile single-column, or a continuation page. */ + private fun findLikedRows(root: JSONObject): JSONArray? { + root.optJSONObject("contents") + ?.optJSONObject("twoColumnBrowseResultsRenderer") + ?.optJSONObject("secondaryContents") + ?.optJSONObject("sectionListRenderer") + ?.optJSONArray("contents") + ?.let { contents -> firstShelfContents(contents) } + ?.let { return it } + + root.optJSONObject("contents") + ?.optJSONObject("singleColumnBrowseResultsRenderer") + ?.optJSONArray("tabs") + ?.optJSONObject(0) + ?.optJSONObject("tabRenderer") + ?.optJSONObject("content") + ?.optJSONObject("sectionListRenderer") + ?.optJSONArray("contents") + ?.let { contents -> firstShelfContents(contents) } + ?.let { return it } + + val continuation = root.optJSONObject("continuationContents") ?: return null + return continuation.optJSONObject("musicPlaylistShelfContinuation")?.optJSONArray("contents") + ?: continuation.optJSONObject("musicShelfContinuation")?.optJSONArray("contents") + } + + private fun firstShelfContents(sections: JSONArray): JSONArray? { + for (index in 0 until sections.length()) { + val section = sections.optJSONObject(index) ?: continue + section.optJSONObject("musicPlaylistShelfRenderer")?.optJSONArray("contents")?.let { return it } + section.optJSONObject("musicShelfRenderer")?.optJSONArray("contents")?.let { return it } + } + return null + } + private fun musicVisitorId(): String { visitorId?.takeIf { it.isNotBlank() }?.let { return it } val html = runCatching { @@ -751,13 +778,25 @@ class YoutubeClient @Inject constructor( private fun findPlaylistContinuation(node: JSONObject): String? { val shelf = findNamedObject(node, "musicPlaylistShelfRenderer") + ?: findNamedObject(node, "musicPlaylistShelfContinuation") ?: findNamedObject(node, "musicShelfRenderer") - ?: findNamedObject(node, "playlistPanelRenderer") + ?: findNamedObject(node, "musicShelfContinuation") if (shelf != null) { findContinuation(shelf.opt("continuations"))?.let { return it } - findContinuation(shelf.opt("contents"))?.let { return it } + tokenFromContinuationItems(shelf.optJSONArray("contents"))?.let { return it } } - return findContinuation(node) + return null + } + + /** Next-page tokens sit on continuationItemRenderer, usually the last row of the shelf. */ + private fun tokenFromContinuationItems(contents: JSONArray?): String? { + if (contents == null) return null + for (index in contents.length() - 1 downTo 0) { + val renderer = contents.optJSONObject(index)?.optJSONObject("continuationItemRenderer") + ?: continue + findContinuation(renderer)?.let { return it } + } + return null } private fun findNamedObject(node: Any?, name: String): JSONObject? { @@ -884,10 +923,9 @@ class YoutubeClient @Inject constructor( private const val MUSIC_CLIENT_VERSION = "1.20260804.16.00" private const val INNER_TUNE_VISITOR = "CgtsZG1ySnZiQWtSbyiMjuGSBg==" private const val LIKED_MUSIC_BROWSE_ID = "VLLM" - const val LIKED_PAGE_SIZE = 100 - // A continuation can come back with only a handful of rows, so allow a few - // round trips per page instead of showing the user a nearly empty page. - private const val MAX_REQUESTS_PER_PAGE = 3 + // One InnerTube response per scroll page so older likes arrive as you go down. + const val LIKED_PAGE_SIZE = 40 + private const val MAX_REQUESTS_PER_PAGE = 1 private val VISITOR_REGEX = Regex("^Cg[ts]") fun rememberWatchUrl(songId: String, watchUrl: String) { diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt index 5348249f5f..f37ddf7182 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -96,7 +96,13 @@ class YoutubeViewModel @Inject constructor( } YoutubeSection.LIKED -> { if (client.likedHits.isNotEmpty()) { - _uiState.update { it.copy(results = client.likedHits, isLoading = false) } + _uiState.update { + it.copy( + results = client.likedHits, + isLoading = false, + hasMore = likedContinuation != null, + ) + } } loadLiked(refreshing = client.likedHits.isNotEmpty()) } @@ -296,8 +302,10 @@ class YoutubeViewModel @Inject constructor( remote.fold( onSuccess = { page -> if (session.generation() != epoch) return@launch + // Keep YouTube Music's newest-first order. Local likes only fill in + // when the account list is empty so they cannot jump the queue. val account = if (session.isSignedIn()) page.hits else emptyList() - val merged = (account + local).distinctBy { it.url } + val rows = account.ifEmpty { local } client.likedHits = account likedContinuation = page.continuation _uiState.update { @@ -305,7 +313,7 @@ class YoutubeViewModel @Inject constructor( isLoading = false, isRefreshing = false, error = null, - results = merged, + results = rows, hasMore = page.continuation != null, ) } @@ -353,13 +361,16 @@ class YoutubeViewModel @Inject constructor( if (session.generation() != epoch) return@launch page.fold( onSuccess = { loaded -> - likedContinuation = loaded.continuation - client.likedHits = (client.likedHits + loaded.hits).distinctBy { it.url } + val appended = loaded.hits.filter { hit -> + client.likedHits.none { existing -> existing.url == hit.url } + } + likedContinuation = loaded.continuation.takeIf { appended.isNotEmpty() } + client.likedHits = client.likedHits + appended _uiState.update { it.copy( isLoadingMore = false, - hasMore = loaded.continuation != null, - results = (it.results + loaded.hits).distinctBy { hit -> hit.url }, + hasMore = likedContinuation != null, + results = (it.results + appended).distinctBy { hit -> hit.url }, ) } }, From 3827d6b79e69e3ece4d5f2b0cd4ae3f84d58345b Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Sun, 27 Sep 2026 21:02:46 +0300 Subject: [PATCH 58/77] fix: pause the current YouTube song instead of restarting it Tapping the track that is already loaded now calls playPause so playback keeps its position. A new song still starts from the beginning. Co-authored-by: Cursor --- .../theveloper/pixelplay/presentation/screens/YoutubeScreen.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index c5a64d6e6c..9f2fe89dbe 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -229,6 +229,9 @@ fun YoutubeScreen( val playHit: (YoutubeHit, Boolean) -> Unit = { hit, asRadio -> if (hit.kind == YoutubeHit.Kind.COLLECTION && !asRadio) { viewModel.openCollection(hit) + } else if (currentSongId == YoutubeClient.songIdForUrl(hit.url)) { + // Same track that is already loaded: toggle pause instead of seeking to 0. + playerViewModel.playPause() } else if (!playGate.compareAndSet(false, true)) { Unit } else { From 6146d5776a1412d1208692868f341cb87a7fe427 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 12:02:33 +0300 Subject: [PATCH 59/77] feat: like, playlists, and add-to-playlist via YouTube Music Open Recap-style playlists with InnerTube VL browse like KuroMusic, and send like/like plus like/removelike so hearts actually hit Liked Music. A first tap starts the song; a later tap on the same track pauses or resumes. Long-press selects tracks to add to a library playlist or a new one. About now lists KibetMasi only, unused permissions are gone, and the changelog sheet stays half-height until you scroll it open. Co-authored-by: Cursor --- CHANGELOG.md | 20 ++ app/src/main/AndroidManifest.xml | 27 --- .../components/ChangelogBottomSheet.kt | 28 ++- .../presentation/screens/AboutScreen.kt | 200 +--------------- .../presentation/screens/HomeScreen.kt | 9 +- .../presentation/screens/YoutubeScreen.kt | 215 ++++++++++++++++-- .../presentation/viewmodel/PlayerViewModel.kt | 10 + .../pixelplay/youtube/YoutubeClient.kt | 137 ++++++++++- .../pixelplay/youtube/YoutubeViewModel.kt | 129 ++++++++++- app/src/main/res/values/strings.xml | 7 + 10 files changed, 525 insertions(+), 257 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0a7c961c..f98e293011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.0-youtube] - 2026-09-28 + +### Added +- YouTube Music sign-in, personalized Feed, Liked Music, library playlists, and radio. +- Skip silence playback setting. +- Multi-select songs and add them to a YouTube Music playlist, or create a new one. +- Infinite scroll on Liked Music, newest likes first. + +### Changed +- About lists KibetMasi as the maintainer. +- Changelog sheet stays half-screen until you drag or scroll it open. +- Dropped unused local-library, Cast, and Bluetooth permissions. + +### Fixed +- Authenticated InnerTube session (visitorData + DATASYNC_ID) so likes and home are yours. +- Tapping a playlist such as Recap opens it through YouTube Music browse, not NewPipe. +- Liking a song now calls YouTube Music like/like and like/removelike. +- Tapping the current song pauses or resumes instead of restarting. +- First tap starts playback; a second tap is not required. + ## [0.8.1-soundcloud] - 2026-09-23 ### Added diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2f8032f652..de5958b942 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,38 +3,11 @@ xmlns:tools="http://schemas.android.com/tools"> - - - - - - - - - - - - - - - - - - - Unit> { {} } +@OptIn(ExperimentalMaterial3ExpressiveApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun ChangelogModalSheet(onDismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + val fill = if (sheetState.targetValue == SheetValue.Expanded) 1f else 0.55f + ChangelogBottomSheet( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fill), + onDismiss = onDismiss, + ) + } +} + private const val CHANGELOG_RAW_URL = "https://raw.githubusercontent.com/kibetmasi/PixelPlayer/master/CHANGELOG.md" private const val CHANGELOG_PAGE_URL = @@ -94,10 +117,10 @@ fun ChangelogBottomSheet( loading = false } - Box(modifier = modifier.fillMaxWidth()) { + Box(modifier = modifier.fillMaxWidth().fillMaxHeight()) { Column( modifier = Modifier - .fillMaxWidth() + .fillMaxSize() .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -126,6 +149,7 @@ fun ChangelogBottomSheet( LazyColumn( modifier = Modifier .fillMaxWidth() + .weight(1f, fill = true) .padding(top = 16.dp), verticalArrangement = Arrangement.spacedBy(24.dp), contentPadding = PaddingValues(bottom = 120.dp), diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt index 70a40641e3..9165079cd2 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -102,7 +101,6 @@ import coil.compose.AsyncImagePainter import coil.request.ImageRequest import coil.size.Size import com.theveloper.pixelplay.R -import com.theveloper.pixelplay.data.github.GitHubContributorService import com.theveloper.pixelplay.presentation.components.CollapsibleCommonTopBar import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight import com.theveloper.pixelplay.presentation.components.SmartImage @@ -111,7 +109,6 @@ import com.theveloper.pixelplay.presentation.navigation.navigateSafely import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import kotlinx.coroutines.launch import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape -import timber.log.Timber import kotlin.math.roundToInt private data class Contributor( @@ -128,53 +125,15 @@ private data class Contributor( ) private val CoreMaintainer = Contributor( - id = "theovilardo", - displayName = "Theo Vilardo", - role = "Creator and maintainer", - detail = "Building PixelPlayer with direct community feedback.", - avatarUrl = "https://avatars.githubusercontent.com/u/26845343?v=4", + id = "kibetmasi", + displayName = "KibetMasi", + role = "Maintainer", + detail = "Building PixelPlayer.", + avatarUrl = "https://avatars.githubusercontent.com/kibetmasi", iconRes = R.drawable.round_developer_board_24, - githubUrl = "https://github.com/theovilardo", + githubUrl = "https://github.com/kibetmasi", ) -private val PinnedCommunityMembers = listOf( - Contributor( - id = "lostf1sh", - displayName = "@lostf1sh", - role = "Most active contributor", - detail = "Has contributed enormously across core features, architecture and reliability.", - badge = "Top Impact", - iconRes = R.drawable.rounded_celebration_24, - githubUrl = "https://github.com/lostf1sh", - ), - Contributor( - id = "cromaguy", - displayName = "@cromaguy", - role = "Rhythm developer", - detail = "Developer of Rhythm (another music app) and key community supporter.", - badge = "Community Ally", - iconRes = R.drawable.round_developer_board_24, - githubUrl = "https://github.com/cromaguy", - ), - Contributor( - id = "colbycabrera", - displayName = "@ColbyCabrera", - role = "Early contributor", - detail = "Helped shape PixelPlayer in the first stages of the app.", - badge = "Early Support", - iconRes = R.drawable.round_newspaper_24, - githubUrl = "https://github.com/ColbyCabrera", - ), -) - -private val PinnedAliases = mapOf( - "cromaguy" to setOf("chroma"), -) - -private fun normalizeHandle(handle: String): String { - return handle.trim().removePrefix("@").lowercase() -} - // AboutTopBar removed, replaced by CollapsibleCommonTopBar @androidx.annotation.OptIn(UnstableApi::class) @@ -193,74 +152,6 @@ fun AboutScreen( "N/A" } - var contributors by remember { mutableStateOf>(emptyList()) } - var isLoadingContributors by remember { mutableStateOf(true) } - val githubService = remember { GitHubContributorService() } - - LaunchedEffect(Unit) { - try { - val result = githubService.fetchContributors() - result.onSuccess { githubContributors -> - contributors = githubContributors - .filter { normalizeHandle(it.login) != CoreMaintainer.id } - .map { github -> - Contributor( - id = normalizeHandle(github.login), - displayName = "@${github.login}", - role = "Community contributor", - avatarUrl = github.avatar_url, - iconRes = R.drawable.rounded_person_24, - githubUrl = github.html_url, - contributions = github.contributions, - ) - } - } - result.onFailure { exception -> - Timber.e(exception, "Failed to fetch contributors from GitHub") - contributors = emptyList() - } - } finally { - isLoadingContributors = false - } - } - - val contributorsById = remember(contributors) { - contributors.associateBy { it.id } - } - - val spotlightContributors = remember(contributorsById) { - PinnedCommunityMembers.map { pinned -> - val primaryMatch = contributorsById[pinned.id] - val aliasMatch = PinnedAliases[pinned.id] - ?.firstNotNullOfOrNull { alias -> contributorsById[alias] } - val match = primaryMatch ?: aliasMatch - - if (match == null) { - pinned - } else { - pinned.copy( - avatarUrl = match.avatarUrl ?: pinned.avatarUrl, - contributions = match.contributions ?: pinned.contributions, - githubUrl = match.githubUrl ?: pinned.githubUrl, - ) - } - } - } - - val excludedIds = remember(spotlightContributors) { - buildSet { - add(CoreMaintainer.id) - spotlightContributors.forEach { spotlight -> - add(spotlight.id) - addAll(PinnedAliases[spotlight.id].orEmpty()) - } - } - } - - val communityContributors = remember(contributors, excludedIds) { - contributors.filterNot { it.id in excludedIds } - } - val transitionState = remember { MutableTransitionState(false) } LaunchedEffect(Unit) { transitionState.targetState = true @@ -398,30 +289,6 @@ fun AboutScreen( ) } - item(key = "spotlight_title") { - AboutSectionHeader( - title = stringResource(R.string.about_spotlight_title), - subtitle = stringResource(R.string.about_spotlight_subtitle), - modifier = Modifier.padding(top = 24.dp), - ) - } - - itemsIndexed( - items = spotlightContributors, - key = { _, contributor -> "spotlight_${contributor.id}" }, - ) { index, contributor -> - ContributorCard( - contributor = contributor, - shape = expressiveListShape(index = index, count = spotlightContributors.size), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = if (index == 0) 0.dp else 3.dp), - showContributionCount = true, - onCardClick = contributor.githubUrl?.let { url -> { openUrl(context, url) } }, - ) - } - item(key = "licenses_title") { AboutSectionHeader( title = stringResource(R.string.about_licenses_title), @@ -441,61 +308,6 @@ fun AboutScreen( ) } - item(key = "contributors_title") { - AboutSectionHeader( - title = stringResource(R.string.about_contributors_section_title), - subtitle = stringResource(R.string.about_contributors_section_subtitle), - modifier = Modifier.padding(top = 24.dp), - ) - } - - if (isLoadingContributors) { - item(key = "contributors_loading") { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 28.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - } else if (communityContributors.isEmpty()) { - item(key = "contributors_empty") { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - shape = expressiveListShape(index = 0, count = 1), - color = MaterialTheme.colorScheme.surfaceContainerLow, - tonalElevation = 1.dp, - ) { - Text( - text = stringResource(R.string.about_no_contributors), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else { - itemsIndexed( - items = communityContributors, - key = { _, contributor -> "contributor_${contributor.id}" }, - ) { index, contributor -> - ContributorCard( - contributor = contributor, - shape = expressiveListShape(index = index, count = communityContributors.size), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = if (index == 0) 0.dp else 3.dp), - showContributionCount = true, - onCardClick = contributor.githubUrl?.let { url -> { openUrl(context, url) } }, - ) - } - } - item(key = "bottom_spacer") { Spacer(modifier = Modifier.height(24.dp)) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt index fb3fbb858b..f6bacb15b4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt @@ -87,7 +87,7 @@ import com.theveloper.pixelplay.data.preferences.CollagePattern import com.theveloper.pixelplay.presentation.components.AlbumArtCollage import com.theveloper.pixelplay.presentation.components.BetaInfoBottomSheet import com.theveloper.pixelplay.presentation.components.Beta05CleanInstallDisclaimerDialog -import com.theveloper.pixelplay.presentation.components.ChangelogBottomSheet +import com.theveloper.pixelplay.presentation.components.ChangelogModalSheet import com.theveloper.pixelplay.presentation.netease.dashboard.NeteaseDashboardViewModel import com.theveloper.pixelplay.presentation.jellyfin.dashboard.JellyfinDashboardViewModel import com.theveloper.pixelplay.presentation.navidrome.dashboard.NavidromeDashboardViewModel @@ -534,12 +534,7 @@ fun HomeScreen( } } if (showChangelogBottomSheet) { - ModalBottomSheet( - onDismissRequest = { showChangelogBottomSheet = false }, - sheetState = sheetState - ) { - ChangelogBottomSheet(onDismiss = { showChangelogBottomSheet = false }) - } + ChangelogModalSheet(onDismiss = { showChangelogBottomSheet = false }) } if (showBetaInfoBottomSheet) { ModalBottomSheet( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 9f2fe89dbe..9afc01e10e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -1,13 +1,16 @@ @file:OptIn( androidx.compose.material3.ExperimentalMaterial3Api::class, androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class, + androidx.compose.foundation.ExperimentalFoundationApi::class, ) package com.theveloper.pixelplay.presentation.screens import android.content.Intent +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -45,7 +48,9 @@ import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.DockedSearchBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -57,6 +62,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Surface @@ -104,7 +110,7 @@ import com.theveloper.pixelplay.presentation.navigation.Screen import com.theveloper.pixelplay.presentation.navigation.navigateSafely import com.theveloper.pixelplay.presentation.components.AlbumArtCollage import com.theveloper.pixelplay.presentation.components.BetaInfoBottomSheet -import com.theveloper.pixelplay.presentation.components.ChangelogBottomSheet +import com.theveloper.pixelplay.presentation.components.ChangelogModalSheet import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.presentation.youtube.auth.YoutubeLoginActivity import com.theveloper.pixelplay.ui.theme.GoogleSansRounded @@ -140,6 +146,12 @@ fun YoutubeScreen( val context = LocalContext.current var showChangelog by remember { mutableStateOf(false) } var showBeta by remember { mutableStateOf(false) } + var selecting by remember { mutableStateOf(false) } + var selectedUrls by remember { mutableStateOf(setOf()) } + var showAddToPlaylist by remember { mutableStateOf(false) } + var showCreatePlaylist by remember { mutableStateOf(false) } + var newPlaylistName by remember { mutableStateOf("") } + val libraryPlaylists by viewModel.libraryPlaylists.collectAsStateWithLifecycle() val stablePlayer by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val searchFocusRequester = remember { FocusRequester() } @@ -221,20 +233,35 @@ fun YoutubeScreen( } } - if (uiState.collectionTitle != null) { + LaunchedEffect(viewModel) { + viewModel.notices.collect { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + if (uiState.collectionTitle != null && !selecting) { BackHandler { viewModel.closeCollection() } } + if (selecting) { + BackHandler { + selecting = false + selectedUrls = emptySet() + } + } val playGate = remember { AtomicBoolean(false) } + val playWhenReady = stablePlayer.playWhenReady val playHit: (YoutubeHit, Boolean) -> Unit = { hit, asRadio -> - if (hit.kind == YoutubeHit.Kind.COLLECTION && !asRadio) { - viewModel.openCollection(hit) - } else if (currentSongId == YoutubeClient.songIdForUrl(hit.url)) { - // Same track that is already loaded: toggle pause instead of seeking to 0. - playerViewModel.playPause() - } else if (!playGate.compareAndSet(false, true)) { - Unit - } else { + val thisId = YoutubeClient.songIdForUrl(hit.url) + val isThisSong = currentSongId == thisId + when { + hit.kind == YoutubeHit.Kind.COLLECTION && !asRadio -> viewModel.openCollection(hit) + // Already playing this track: pause. Already paused: resume. Still preparing: ignore. + isThisSong && isPlaying -> playerViewModel.playPause() + isThisSong && !playWhenReady -> playerViewModel.playPause() + isThisSong -> Unit + !playGate.compareAndSet(false, true) -> Unit + else -> { playerViewModel.showPreparingSong(placeholderSong(hit)) scope.launch { try { @@ -275,6 +302,23 @@ fun YoutubeScreen( playGate.set(false) } } + } + } + } + + val onSongClick: (YoutubeHit, Boolean) -> Unit = { hit, asRadio -> + if (selecting && hit.kind == YoutubeHit.Kind.TRACK) { + selectedUrls = if (hit.url in selectedUrls) selectedUrls - hit.url else selectedUrls + hit.url + if (selectedUrls.isEmpty()) selecting = false + } else { + playHit(hit, asRadio) + } + } + val onSongLongPress: (YoutubeHit) -> Unit = { hit -> + if (hit.kind == YoutubeHit.Kind.TRACK) { + selecting = true + selectedUrls = selectedUrls + hit.url + viewModel.ensureLibraryPlaylists() } } @@ -302,6 +346,44 @@ fun YoutubeScreen( onSettings = { navController.navigateSafely(Screen.Settings.route) }, ) }, + bottomBar = { + if (selecting) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 4.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.youtube_selected_count, selectedUrls.size), + modifier = Modifier.weight(1f), + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + ) + TextButton(onClick = { + selecting = false + selectedUrls = emptySet() + }) { + Text(stringResource(R.string.youtube_cancel)) + } + Button( + onClick = { + viewModel.ensureLibraryPlaylists() + showAddToPlaylist = true + }, + enabled = selectedUrls.isNotEmpty() && account.isSignedIn, + ) { + Text(stringResource(R.string.youtube_add_to_playlist)) + } + } + } + } + }, ) { innerPadding -> Column( modifier = Modifier @@ -393,7 +475,7 @@ fun YoutubeScreen( modifier = Modifier.width(168.dp), isCurrent = currentSongId == YoutubeClient.songIdForUrl(hit.url), isPlaying = isPlaying, - onClick = { playHit(hit, false) }, + onClick = { onSongClick(hit, false) }, ) } } @@ -405,7 +487,7 @@ fun YoutubeScreen( song = stringResource(R.string.youtube_todays_mix), onPlayShuffled = { val seed = mixHits.shuffled() - if (seed.isNotEmpty()) playHit(seed.first(), false) + if (seed.isNotEmpty()) onSongClick(seed.first(), false) }, ) } @@ -418,7 +500,7 @@ fun YoutubeScreen( padding = 8.dp, onSongClick = { song -> mixHits.firstOrNull { YoutubeClient.songIdForUrl(it.url) == song.id } - ?.let { playHit(it, false) } + ?.let { onSongClick(it, false) } }, ) } @@ -444,7 +526,7 @@ fun YoutubeScreen( } else { hit.artist }, - onClick = { playHit(hit, shelf.startsRadio) }, + onClick = { onSongClick(hit, shelf.startsRadio) }, ) } } @@ -502,7 +584,7 @@ fun YoutubeScreen( hit = hit, isCurrent = currentSongId == YoutubeClient.songIdForUrl(hit.url), isPlaying = isPlaying, - onClick = { playHit(hit, browseSection == YoutubeSection.RADIO) }, + onClick = { onSongClick(hit, browseSection == YoutubeSection.RADIO) }, ) } } @@ -529,9 +611,12 @@ fun YoutubeScreen( song = song, isPlaying = isPlaying && currentSongId == song.id, isCurrent = currentSongId == song.id, - liked = song.id in likedIds, + liked = song.id in likedIds || viewModel.isLiked(hit), saved = song.id in savedIds, - onPlay = { playHit(hit, browseSection == YoutubeSection.RADIO) }, + selecting = selecting, + selected = hit.url in selectedUrls, + onPlay = { onSongClick(hit, browseSection == YoutubeSection.RADIO) }, + onLongPress = { onSongLongPress(hit) }, onLike = { viewModel.toggleLike(hit) }, onSave = { viewModel.toggleSave(hit) }, ) @@ -557,15 +642,95 @@ fun YoutubeScreen( } } if (showChangelog) { - ModalBottomSheet(onDismissRequest = { showChangelog = false }) { - ChangelogBottomSheet(onDismiss = { showChangelog = false }) - } + ChangelogModalSheet(onDismiss = { showChangelog = false }) } if (showBeta) { ModalBottomSheet(onDismissRequest = { showBeta = false }) { BetaInfoBottomSheet() } } + if (showAddToPlaylist) { + ModalBottomSheet(onDismissRequest = { showAddToPlaylist = false }) { + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp)) { + Text( + text = stringResource(R.string.youtube_add_to_playlist), + style = MaterialTheme.typography.titleLarge, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + TextButton(onClick = { + showAddToPlaylist = false + showCreatePlaylist = true + }) { + Text(stringResource(R.string.youtube_new_playlist), fontFamily = GoogleSansRounded) + } + if (libraryPlaylists.isEmpty()) { + Text( + text = stringResource(R.string.youtube_add_to_playlist_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 16.dp), + ) + } else { + libraryPlaylists.forEach { playlist -> + Surface( + onClick = { + val chosen = rows.filter { it.url in selectedUrls } + viewModel.addHitsToPlaylist(playlist, chosen) + showAddToPlaylist = false + selecting = false + selectedUrls = emptySet() + }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Text( + text = playlist.title, + modifier = Modifier.padding(16.dp), + fontFamily = GoogleSansRounded, + ) + } + } + } + Spacer(Modifier.height(24.dp)) + } + } + } + if (showCreatePlaylist) { + AlertDialog( + onDismissRequest = { showCreatePlaylist = false }, + title = { Text(stringResource(R.string.youtube_new_playlist)) }, + text = { + OutlinedTextField( + value = newPlaylistName, + onValueChange = { newPlaylistName = it }, + label = { Text(stringResource(R.string.youtube_playlist_name)) }, + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { + val chosen = rows.filter { it.url in selectedUrls } + viewModel.createPlaylistAndAdd(newPlaylistName, chosen) + newPlaylistName = "" + showCreatePlaylist = false + selecting = false + selectedUrls = emptySet() + }, + enabled = newPlaylistName.trim().isNotEmpty(), + ) { + Text(stringResource(R.string.youtube_create)) + } + }, + dismissButton = { + TextButton(onClick = { showCreatePlaylist = false }) { + Text(stringResource(R.string.youtube_cancel)) + } + }, + ) + } } @Composable @@ -776,13 +941,21 @@ private fun YoutubeTrackRow( isCurrent: Boolean, liked: Boolean, saved: Boolean, + selecting: Boolean, + selected: Boolean, onPlay: () -> Unit, + onLongPress: () -> Unit, onLike: () -> Unit, onSave: () -> Unit, ) { Row(verticalAlignment = Alignment.CenterVertically) { + if (selecting) { + Checkbox(checked = selected, onCheckedChange = { onPlay() }) + } EnhancedSongListItem( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .combinedClickable(onClick = onPlay, onLongClick = onLongPress), song = song, isPlaying = isPlaying, isCurrentSong = isCurrent, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index fdc7fd1d17..d7cb6ea234 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -218,6 +218,7 @@ class PlayerViewModel @Inject constructor( private val sessionToken: SessionToken, private val mediaControllerFactory: com.theveloper.pixelplay.data.media.MediaControllerFactory, private val youtubeMusicStore: com.theveloper.pixelplay.youtube.YoutubeMusicStore, + private val youtubeClient: com.theveloper.pixelplay.youtube.YoutubeClient, ) : ViewModel() { private val _playerUiState = MutableStateFlow(PlayerUiState()) @@ -2285,6 +2286,15 @@ class PlayerViewModel @Inject constructor( val currentSong = playbackStateHolder.stablePlayerState.value.currentSong ?: return if (currentSong.id.startsWith("yt_")) { youtubeMusicStore.toggleLike(currentSong) + val videoId = com.theveloper.pixelplay.youtube.YoutubeClient.watchUrlForSongId(currentSong.id) + ?.let(com.theveloper.pixelplay.youtube.YoutubeClient::videoIdFromUrl) + .orEmpty() + if (videoId.length == 11) { + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val liked = currentSong.id in youtubeMusicStore.likedSongIds.value + runCatching { youtubeClient.likeVideo(videoId, liked) } + } + } return } viewModelScope.launch { diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index 28e369e7f7..f33b713dad 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -362,8 +362,18 @@ class YoutubeClient @Inject constructor( navigation?.optJSONObject("watchEndpoint")?.let { watch -> val videoId = watch.optString("videoId") val playlistId = watch.optString("playlistId") + if (isCatalogPlaylist(playlistId)) { + return YoutubeHit( + url = "https://music.youtube.com/playlist?list=$playlistId", + title = title, + artist = subtitle.ifBlank { "YouTube Music" }, + durationSec = 0, + thumbnailUrl = thumbnail, + kind = YoutubeHit.Kind.COLLECTION, + ) + } if (videoId.length == 11) { - val isMix = playlistId.startsWith("RD") && !playlistId.startsWith("RDAMVM") + val isMix = isRadioMix(playlistId) return YoutubeHit( url = if (isMix) "https://www.youtube.com/watch?v=$videoId&list=$playlistId" else "https://music.youtube.com/watch?v=$videoId", @@ -584,9 +594,15 @@ class YoutubeClient @Inject constructor( return loadCollectionTracks("https://www.youtube.com/watch?v=$videoId&list=RD$videoId", limit) } - fun loadCollectionTracks(url: String, limit: Int = 80): List { + fun loadCollectionTracks(url: String, limit: Int = 200): List { applySession() - if (url.contains("list=LM")) return loadLikedPage(pageSize = limit).hits.take(limit) + val playlistId = playlistIdFromUrl(url) + if (playlistId == "LM") return loadLikedPage(pageSize = limit).hits.take(limit) + if (playlistId.isNotBlank()) { + val viaMusic = runCatching { loadPlaylistViaInnerTube(playlistId, limit) } + .getOrDefault(emptyList()) + if (viaMusic.isNotEmpty()) return viaMusic + } val info = PlaylistInfo.getInfo(youtube(), url.trim()) return info.relatedItems .asSequence() @@ -596,6 +612,83 @@ class YoutubeClient @Inject constructor( .toList() } + /** + * Same path KuroMusic/InnerTune use: browse `VL` + playlist id on music.youtube.com. + * Recap / official playlists (RDCLAK…) fail in NewPipe but work here. + */ + private fun loadPlaylistViaInnerTube(playlistId: String, limit: Int): List { + val browseId = if (playlistId.startsWith("VL")) playlistId else "VL$playlistId" + val hits = linkedSetOf() + var token: String? = null + var first = true + var requests = 0 + do { + val before = hits.size + val root = musicBrowse( + browseId = if (first) browseId else null, + continuation = token, + ) + first = false + collectLikedShelf(root, hits, limit) + token = findPlaylistContinuation(root) + requests++ + if (hits.size == before) break + } while (!token.isNullOrBlank() && hits.size < limit && requests < 8) + return hits.take(limit) + } + + /** InnerTube like/like and like/removelike — this is what actually updates Liked Music. */ + fun likeVideo(videoId: String, like: Boolean) { + applySession() + require(session.isSignedIn()) { "Sign in to like songs on YouTube Music." } + require(videoId.length == 11) { "Missing video id." } + val endpoint = if (like) "like/like" else "like/removelike" + val body = JSONObject() + .put("context", JSONObject(musicContext())) + .put("target", JSONObject().put("videoId", videoId)) + musicPost(endpoint, body.toString()) + } + + /** Browse/edit_playlist ACTION_ADD_VIDEO, same payload KuroMusic sends. */ + fun addVideosToPlaylist(playlistId: String, videoIds: List) { + applySession() + require(session.isSignedIn()) { "Sign in to add songs to a playlist." } + val ids = videoIds.map { it.trim() }.filter { it.length == 11 }.distinct() + require(ids.isNotEmpty()) { "No songs to add." } + val actions = JSONArray() + ids.forEach { videoId -> + actions.put( + JSONObject() + .put("action", "ACTION_ADD_VIDEO") + .put("addedVideoId", videoId), + ) + } + val body = JSONObject() + .put("context", JSONObject(musicContext())) + .put("playlistId", playlistId.removePrefix("VL")) + .put("actions", actions) + musicPost("browse/edit_playlist", body.toString()) + } + + fun createPlaylist(title: String, videoIds: List = emptyList()): String { + applySession() + require(session.isSignedIn()) { "Sign in to create a playlist." } + val name = title.trim() + require(name.isNotEmpty()) { "Give the playlist a name." } + val body = JSONObject() + .put("context", JSONObject(musicContext())) + .put("title", name) + .put("privacyStatus", "PRIVATE") + val ids = videoIds.map { it.trim() }.filter { it.length == 11 } + if (ids.isNotEmpty()) body.put("videoIds", JSONArray(ids)) + val root = musicPost("playlist/create", body.toString()) + val created = root.optString("playlistId").ifBlank { + root.optJSONObject("playlistId")?.optString("playlistId").orEmpty() + } + require(created.isNotBlank()) { "YouTube Music did not create the playlist." } + return created + } + /** * Resolves a stream ahead of a possible tap so playback can start immediately. * Errors are swallowed: this is best effort, and a real tap will surface them. @@ -938,6 +1031,44 @@ class YoutubeClient @Inject constructor( fun songIdForUrl(url: String): String = "yt_${url.trim().hashCode().toUInt()}" + fun videoIdFromUrl(url: String): String { + val fromQuery = url.substringAfter("v=", "") + .substringBefore('&') + .substringBefore('?') + .trim() + return if (fromQuery.length == 11) fromQuery else "" + } + + fun playlistIdFromUrl(url: String): String { + val fromList = url.substringAfter("list=", "") + .substringBefore('&') + .substringBefore('?') + .trim() + if (fromList.isNotBlank()) return fromList.removePrefix("VL") + return Regex("""browse/(VL)?([^/?&]+)""") + .find(url) + ?.groupValues + ?.getOrNull(2) + .orEmpty() + .removePrefix("VL") + } + + fun isCatalogPlaylist(playlistId: String): Boolean { + if (playlistId.isBlank()) return false + return playlistId.startsWith("PL") || + playlistId.startsWith("OLAK") || + playlistId.startsWith("RDCLAK") || + playlistId.startsWith("RDTMA") || + playlistId == "LM" || + playlistId == "WL" || + playlistId.startsWith("VL") + } + + fun isRadioMix(playlistId: String): Boolean { + if (!playlistId.startsWith("RD")) return false + return !isCatalogPlaylist(playlistId) && !playlistId.startsWith("RDAMVM") + } + fun fullBleedArtwork(url: String?): String? { val raw = url?.trim().orEmpty() if (raw.isEmpty()) return null diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt index f37ddf7182..5aa322be62 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -7,8 +7,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive @@ -39,6 +42,10 @@ class YoutubeViewModel @Inject constructor( ) : ViewModel() { private val _uiState = MutableStateFlow(YoutubeUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _notices = MutableSharedFlow(extraBufferCapacity = 1) + val notices: SharedFlow = _notices.asSharedFlow() + private val _libraryPlaylists = MutableStateFlow>(emptyList()) + val libraryPlaylists: StateFlow> = _libraryPlaylists.asStateFlow() val likedTracks: StateFlow> = musicStore.liked val savedTracks: StateFlow> = musicStore.saved val recentTracks: StateFlow> = musicStore.recent @@ -162,15 +169,21 @@ class YoutubeViewModel @Inject constructor( it.copy(isLoading = true, error = null, collectionTitle = hit.title, results = emptyList()) } val loaded = runCatching { - withContext(Dispatchers.IO) { client.loadCollectionTracks(hit.url) } + withContext(Dispatchers.IO) { client.loadCollectionTracks(hit.url, limit = 200) } } loaded.fold( onSuccess = { tracks -> _uiState.update { it.copy(isLoading = false, results = tracks) } + if (tracks.isEmpty()) { + _uiState.update { it.copy(error = "This playlist did not return any tracks.") } + } }, onFailure = { error -> _uiState.update { - it.copy(isLoading = false, error = error.message ?: "Couldn't open this collection") + it.copy( + isLoading = false, + error = error.message ?: "Couldn't open this playlist", + ) } }, ) @@ -206,8 +219,116 @@ class YoutubeViewModel @Inject constructor( throw lastError ?: IllegalStateException("Couldn't play this track") } + fun isLiked(hit: YoutubeHit): Boolean { + val id = YoutubeClient.songIdForUrl(hit.url) + return id in musicStore.likedSongIds.value || client.likedHits.any { it.url == hit.url } + } + fun toggleLike(hit: YoutubeHit) { - musicStore.toggleLike(hit) + val videoId = YoutubeClient.videoIdFromUrl(hit.url) + val wasLiked = isLiked(hit) + val locallyLiked = YoutubeClient.songIdForUrl(hit.url) in musicStore.likedSongIds.value + if (locallyLiked == wasLiked || !wasLiked) { + musicStore.toggleLike(hit) + } else { + // Liked on the account but not stored locally — drop it from the account list. + client.likedHits = client.likedHits.filterNot { it.url == hit.url } + if (_uiState.value.section == YoutubeSection.LIKED) { + _uiState.update { state -> + state.copy(results = state.results.filterNot { it.url == hit.url }) + } + } + } + if (!session.isSignedIn() || videoId.isBlank()) return + viewModelScope.launch { + runCatching { + withContext(Dispatchers.IO) { client.likeVideo(videoId, like = !wasLiked) } + }.onFailure { error -> + // Roll the optimistic change back. + if (locallyLiked == wasLiked || !wasLiked) musicStore.toggleLike(hit) + else { + client.likedHits = (listOf(hit) + client.likedHits).distinctBy { it.url } + if (_uiState.value.section == YoutubeSection.LIKED) { + _uiState.update { state -> + state.copy(results = (listOf(hit) + state.results).distinctBy { it.url }) + } + } + } + _uiState.update { + it.copy(error = error.message ?: "Couldn't update the like on YouTube Music") + } + }.onSuccess { + if (wasLiked) { + client.likedHits = client.likedHits.filterNot { it.url == hit.url } + if (_uiState.value.section == YoutubeSection.LIKED) { + _uiState.update { state -> + state.copy(results = state.results.filterNot { it.url == hit.url }) + } + } + } else { + client.likedHits = (listOf(hit) + client.likedHits).distinctBy { it.url } + } + } + } + } + + fun ensureLibraryPlaylists() { + if (_libraryPlaylists.value.isNotEmpty() || client.playlistHits.isNotEmpty()) { + if (_libraryPlaylists.value.isEmpty()) _libraryPlaylists.value = client.playlistHits + return + } + viewModelScope.launch { + runCatching { + withContext(Dispatchers.IO) { client.loadLibraryPlaylists() } + }.onSuccess { hits -> + client.playlistHits = hits + _libraryPlaylists.value = hits + } + } + } + fun addHitsToPlaylist(playlist: YoutubeHit, hits: List) { + val playlistId = YoutubeClient.playlistIdFromUrl(playlist.url) + val videoIds = hits.map { YoutubeClient.videoIdFromUrl(it.url) }.filter { it.length == 11 } + if (playlistId.isBlank() || videoIds.isEmpty()) { + _uiState.update { it.copy(error = "Couldn't add those songs to the playlist.") } + return + } + viewModelScope.launch { + runCatching { + withContext(Dispatchers.IO) { client.addVideosToPlaylist(playlistId, videoIds) } + }.onSuccess { + _notices.emit("Added ${videoIds.size} to ${playlist.title}") + }.onFailure { error -> + _uiState.update { + it.copy(error = error.message ?: "Couldn't add songs to that playlist") + } + } + } + } + + fun createPlaylistAndAdd(title: String, hits: List) { + val videoIds = hits.map { YoutubeClient.videoIdFromUrl(it.url) }.filter { it.length == 11 } + viewModelScope.launch { + runCatching { + withContext(Dispatchers.IO) { + val id = client.createPlaylist(title, videoIds) + client.loadLibraryPlaylists() + id + } + }.onSuccess { + val refreshed = runCatching { + withContext(Dispatchers.IO) { client.loadLibraryPlaylists() } + }.getOrDefault(client.playlistHits) + client.playlistHits = refreshed + _libraryPlaylists.value = refreshed + _notices.emit("Created $title") + if (_uiState.value.section == YoutubeSection.PLAYLISTS) loadPlaylists(refreshing = true) + }.onFailure { error -> + _uiState.update { + it.copy(error = error.message ?: "Couldn't create the playlist") + } + } + } } fun toggleSave(hit: YoutubeHit) { @@ -253,6 +374,7 @@ class YoutubeViewModel @Inject constructor( client.likedHits = emptyList() client.feedShelves = emptyList() client.playlistHits = emptyList() + _libraryPlaylists.value = emptyList() client.radioHits = emptyList() } @@ -512,6 +634,7 @@ class YoutubeViewModel @Inject constructor( loaded.fold( onSuccess = { hits -> client.playlistHits = hits + _libraryPlaylists.value = hits _uiState.update { it.copy(isLoading = false, isRefreshing = false, results = hits) } }, onFailure = { error -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6581055404..a17a9ea84c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -60,6 +60,13 @@ That cookie is missing a signed-in YouTube session. Copy the Cookie header from a youtube.com request after you are logged in. Signed in Radio keeps playing songs that sound like the one you start. + Add to playlist + New playlist + Playlist name + Create + Cancel + %1$d selected + No playlists in your library yet. Back From 068173cb271235190f8e8bd80e02681c1d11cbdf Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 12:35:00 +0300 Subject: [PATCH 60/77] fix: stop the app from closing on Pixel Fold launch Opening still started Cast discovery and Bluetooth receivers without those permissions, so Android 14+ killed the process. Co-authored-by: Cursor --- CHANGELOG.md | 3 +- app/src/main/AndroidManifest.xml | 7 + .../data/service/cast/CastOptionsProvider.kt | 2 + .../presentation/screens/SetupScreen.kt | 5 - .../presentation/viewmodel/CastStateHolder.kt | 75 +++++++---- .../viewmodel/ConnectivityStateHolder.kt | 122 +++++++++++------- .../presentation/viewmodel/PlayerViewModel.kt | 8 +- 7 files changed, 138 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f98e293011..26602a86b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - About lists KibetMasi as the maintainer. - Changelog sheet stays half-screen until you drag or scroll it open. -- Dropped unused local-library, Cast, and Bluetooth permissions. +- Dropped unused local-library and Bluetooth runtime permissions. ### Fixed +- Launch no longer crashes on Pixel Fold after permission cleanup (Cast/Wi-Fi init). - Authenticated InnerTube session (visitorData + DATASYNC_ID) so likes and home are yours. - Tapping a playlist such as Recap opens it through YouTube Music browse, not NewPipe. - Liking a song now calls YouTube Music like/like and like/removelike. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index de5958b942..ce699144c3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,10 +4,17 @@ + + + + { pages += SetupPage.ThemeSelection pages += SetupPage.LibraryLayout pages += SetupPage.NavBarLayout - - if (sdkInt >= Build.VERSION_CODES.S) { - pages += SetupPage.AlarmsPermission - } - pages += SetupPage.BatteryOptimization pages += SetupPage.Finish return pages diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt index a9633ec456..5d9f98a46a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt @@ -182,7 +182,7 @@ class CastStateHolder @Inject constructor( } // MediaRouter State - private val mediaRouter: MediaRouter = MediaRouter.getInstance(context) + private val mediaRouter: MediaRouter by lazy { MediaRouter.getInstance(context) } private val mediaRouterCallback = object : MediaRouter.Callback() { override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) { updateRoutes() @@ -234,45 +234,60 @@ class CastStateHolder @Inject constructor( refreshRoutesJob?.cancel() refreshRoutesJob = scope.launch { _isRefreshingRoutes.value = true - mediaRouter.removeCallback(mediaRouterCallback) - val mediaRouteSelector = buildCastRouteSelector() - mediaRouter.addCallback( - mediaRouteSelector, - mediaRouterCallback, - MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY or MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN - ) - updateRoutes() - syncSelectedRouteFromRouter(mediaRouter) + runCatching { + mediaRouter.removeCallback(mediaRouterCallback) + val mediaRouteSelector = buildCastRouteSelector() + mediaRouter.addCallback( + mediaRouteSelector, + mediaRouterCallback, + MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY or MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN + ) + updateRoutes() + syncSelectedRouteFromRouter(mediaRouter) - kotlinx.coroutines.delay(1800) + kotlinx.coroutines.delay(1800) - mediaRouter.removeCallback(mediaRouterCallback) - mediaRouter.addCallback(mediaRouteSelector, mediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY) - updateRoutes() - syncSelectedRouteFromRouter(mediaRouter) + mediaRouter.removeCallback(mediaRouterCallback) + mediaRouter.addCallback(mediaRouteSelector, mediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY) + updateRoutes() + syncSelectedRouteFromRouter(mediaRouter) + }.onFailure { error -> + Timber.tag(CAST_STATE_TAG).w(error, "Cast route refresh unavailable") + } _isRefreshingRoutes.value = false } } fun startDiscovery() { - val mediaRouteSelector = buildCastRouteSelector() - mediaRouter.addCallback(mediaRouteSelector, mediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY) - updateRoutes() - syncSelectedRouteFromRouter(mediaRouter) + runCatching { + val mediaRouteSelector = buildCastRouteSelector() + mediaRouter.addCallback(mediaRouteSelector, mediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY) + updateRoutes() + syncSelectedRouteFromRouter(mediaRouter) + }.onFailure { error -> + Timber.tag(CAST_STATE_TAG).w(error, "Cast discovery unavailable") + } } private fun updateRoutes() { - _castRoutes.value = mediaRouter.routes.filter { it.isCastRoute() }.distinctBy { it.id } + _castRoutes.value = runCatching { + mediaRouter.routes.filter { it.isCastRoute() }.distinctBy { it.id } + }.getOrDefault(emptyList()) } private fun syncSelectedRouteFromRouter(router: MediaRouter) { - val selected = router.selectedRoute - _selectedRoute.value = selected - _routeVolume.value = selected.volume + runCatching { + val selected = router.selectedRoute + _selectedRoute.value = selected + _routeVolume.value = selected.volume + } } fun selectRoute(route: MediaRouter.RouteInfo) { - mediaRouter.selectRoute(route) + runCatching { mediaRouter.selectRoute(route) } + .onFailure { error -> + Timber.tag(CAST_STATE_TAG).w(error, "Cast route select unavailable") + } } fun setRouteVolume(volume: Int) { @@ -281,14 +296,18 @@ class CastStateHolder @Inject constructor( } fun disconnect() { - mediaRouter.selectRoute(mediaRouter.defaultRoute) - syncSelectedRouteFromRouter(mediaRouter) - updateRoutes() + runCatching { + mediaRouter.selectRoute(mediaRouter.defaultRoute) + syncSelectedRouteFromRouter(mediaRouter) + updateRoutes() + }.onFailure { error -> + Timber.tag(CAST_STATE_TAG).w(error, "Cast disconnect unavailable") + } } fun onCleared() { refreshRoutesJob?.cancel() - mediaRouter.removeCallback(mediaRouterCallback) + runCatching { mediaRouter.removeCallback(mediaRouterCallback) } } init { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt index f86f46be33..bbd93ec33d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -126,6 +127,16 @@ class ConnectivityStateHolder @Inject constructor( if (isInitialized) return isInitialized = true + try { + initializeMonitoring() + } catch (error: SecurityException) { + Timber.e(error, "Connectivity monitoring unavailable without runtime permissions") + } catch (error: Exception) { + Timber.e(error, "Connectivity monitoring failed to start") + } + } + + private fun initializeMonitoring() { // Initial state check val activeNetwork = connectivityManager.activeNetwork val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) @@ -134,7 +145,7 @@ class ConnectivityStateHolder @Inject constructor( if (_isWifiEnabled.value) { updateWifiInfo() } - + _isOnline.value = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) @@ -152,15 +163,15 @@ class ConnectivityStateHolder @Inject constructor( override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { val hasInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) val isValidated = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - + if (hasInternet && isValidated) { availableNetworks.add(network) } else { availableNetworks.remove(network) } - + checkConnectivity() - + if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { _isWifiEnabled.value = true updateWifiInfo() @@ -170,71 +181,76 @@ class ConnectivityStateHolder @Inject constructor( override fun onLost(network: Network) { availableNetworks.remove(network) checkConnectivity() - + val currentNetwork = connectivityManager.activeNetwork val caps = connectivityManager.getNetworkCapabilities(currentNetwork) _isWifiEnabled.value = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true if (!_isWifiEnabled.value) _wifiName.value = null } - + private fun checkConnectivity() { _isOnline.value = availableNetworks.isNotEmpty() } } - + val request = NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .build() connectivityManager.registerNetworkCallback(request, networkCallback!!) - // Register receivers - wifiStateReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == WifiManager.WIFI_STATE_CHANGED_ACTION) { - updateWifiRadioState() + if (hasWifiStatePermission()) { + wifiStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == WifiManager.WIFI_STATE_CHANGED_ACTION) { + updateWifiRadioState() + } } } + registerExportedReceiver(wifiStateReceiver, IntentFilter(WifiManager.WIFI_STATE_CHANGED_ACTION)) } - context.registerReceiver(wifiStateReceiver, IntentFilter(WifiManager.WIFI_STATE_CHANGED_ACTION)) - - bluetoothStateReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - BluetoothAdapter.ACTION_STATE_CHANGED -> { - updateBluetoothEnabledState() - if (_isBluetoothEnabled.value) { - updateAudioDevices() - } else { - discoveredBluetoothAudioDevices.clear() - _bluetoothAudioDeviceStates.value = emptyList() - _bluetoothAudioDevices.value = emptyList() - updateBluetoothName(emptyList()) - } - } - BluetoothDevice.ACTION_BOND_STATE_CHANGED, - BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED, - BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED -> updateAudioDevices() - BluetoothDevice.ACTION_FOUND -> { - extractBluetoothDevice(intent) - ?.takeIf { it.isAudioOutputCandidate() } - ?.toBluetoothAudioDeviceState(isConnected = false) - ?.let { deviceState -> - discoveredBluetoothAudioDevices[deviceState.uniqueKey()] = deviceState + + if (hasBluetoothConnectPermission()) { + bluetoothStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + BluetoothAdapter.ACTION_STATE_CHANGED -> { + updateBluetoothEnabledState() + if (_isBluetoothEnabled.value) { updateAudioDevices() + } else { + discoveredBluetoothAudioDevices.clear() + _bluetoothAudioDeviceStates.value = emptyList() + _bluetoothAudioDevices.value = emptyList() + updateBluetoothName(emptyList()) } + } + BluetoothDevice.ACTION_BOND_STATE_CHANGED, + BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED, + BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED -> updateAudioDevices() + BluetoothDevice.ACTION_FOUND -> { + extractBluetoothDevice(intent) + ?.takeIf { it.isAudioOutputCandidate() } + ?.toBluetoothAudioDeviceState(isConnected = false) + ?.let { deviceState -> + discoveredBluetoothAudioDevices[deviceState.uniqueKey()] = deviceState + updateAudioDevices() + } + } } } } + registerExportedReceiver( + bluetoothStateReceiver, + IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED).apply { + addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) + addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED) + addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) + if (hasBluetoothScanPermission()) { + addAction(BluetoothDevice.ACTION_FOUND) + } + } + ) } - context.registerReceiver( - bluetoothStateReceiver, - IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED).apply { - addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) - addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED) - addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) - addAction(BluetoothDevice.ACTION_FOUND) - } - ) // Audio Device Callback audioDeviceCallback = object : android.media.AudioDeviceCallback() { @@ -250,8 +266,13 @@ class ConnectivityStateHolder @Inject constructor( updateAudioDevices() } + private fun registerExportedReceiver(receiver: BroadcastReceiver?, filter: IntentFilter) { + if (receiver == null) return + ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_EXPORTED) + } + private fun updateWifiRadioState() { - _isWifiRadioOn.value = wifiManager?.isWifiEnabled == true + _isWifiRadioOn.value = hasWifiStatePermission() && wifiManager?.isWifiEnabled == true } private fun updateWifiInfo() { @@ -418,6 +439,13 @@ class ConnectivityStateHolder @Inject constructor( } } + private fun hasWifiStatePermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_WIFI_STATE + ) == PackageManager.PERMISSION_GRANTED + } + private fun hasBluetoothConnectPermission(): Boolean { return Build.VERSION.SDK_INT < Build.VERSION_CODES.S || ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index d7cb6ea234..21c6c4631f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -1729,8 +1729,9 @@ class PlayerViewModel @Inject constructor( }, ContextCompat.getMainExecutor(context)) - // Start Cast discovery - castStateHolder.startDiscovery() + // Start Cast discovery. Missing nearby/Bluetooth perms must not kill launch. + runCatching { castStateHolder.startDiscovery() } + .onFailure { Log.w("PlayerViewModel", "Cast discovery skipped", it) } // Observe selection for HTTP server management viewModelScope.launch { @@ -1750,7 +1751,8 @@ class PlayerViewModel @Inject constructor( } // Initialize connectivity monitoring (WiFi/Bluetooth) - connectivityStateHolder.initialize() + runCatching { connectivityStateHolder.initialize() } + .onFailure { Log.w("PlayerViewModel", "Connectivity monitoring skipped", it) } // Initialize sleep timer state holder sleepTimerStateHolder.initialize( From 1096f32f23676bf8649a1f19e54110b1dce0d7a3 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 12:57:33 +0300 Subject: [PATCH 61/77] chore: ignore the local Cursor directory Keep machine-local Cursor rules out of git. Co-authored-by: Cursor --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 986f70d62c..f8a3ed31ae 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ misc.xml CLAUDE.md markdown.xml .vscode/ +.cursor/ .agents/ # Node/tooling dependencies and local env files From 5555ad3f2d62111fd023498a769e67404b95926f Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 12:57:42 +0300 Subject: [PATCH 62/77] feat: peek the next playlist cover on the Playlists tab The Playlists tab shows the current cover in front and a glimpse of the next playlist to the right. Co-authored-by: Cursor --- .../components/PlaylistPeekCarousel.kt | 141 ++++++++++++++++++ .../presentation/screens/YoutubeScreen.kt | 11 ++ 2 files changed, 152 insertions(+) create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistPeekCarousel.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistPeekCarousel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistPeekCarousel.kt new file mode 100644 index 0000000000..cc255c2103 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistPeekCarousel.kt @@ -0,0 +1,141 @@ +package com.theveloper.pixelplay.presentation.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.QueueMusic +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.theveloper.pixelplay.ui.theme.GoogleSansRounded +import com.theveloper.pixelplay.youtube.YoutubeHit +import kotlinx.coroutines.launch + +/** + * Playlist covers as a stacked peek: the current art is in front, and the next + * playlist peeks from the right — same idea as a card deck. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PlaylistPeekCarousel( + playlists: List, + onPlaylistClick: (YoutubeHit) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), +) { + if (playlists.isEmpty()) return + + val pagerState = rememberPagerState(pageCount = { playlists.size }) + val scope = rememberCoroutineScope() + val focused = playlists.getOrNull(pagerState.currentPage) ?: playlists.first() + val peek = 72.dp + val spacing = 12.dp + val coverShape = RoundedCornerShape(28.dp) + + Column(modifier = modifier.padding(contentPadding)) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + val hasPeek = playlists.size > 1 + val cover = minOf( + maxWidth - if (hasPeek) peek else 0.dp, + 360.dp, + ) + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .height(cover), + contentPadding = PaddingValues(end = (maxWidth - cover).coerceAtLeast(0.dp)), + pageSpacing = if (hasPeek) spacing else 0.dp, + pageSize = PageSize.Fixed(cover), + beyondViewportPageCount = 1, + userScrollEnabled = hasPeek, + ) { page -> + val hit = playlists[page] + val isFocused = pagerState.currentPage == page + Surface( + onClick = { + if (isFocused) { + onPlaylistClick(hit) + } else { + scope.launch { pagerState.animateScrollToPage(page) } + } + }, + modifier = Modifier.fillMaxSize(), + shape = coverShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shadowElevation = if (isFocused) 2.dp else 0.dp, + tonalElevation = if (isFocused) 2.dp else 0.dp, + ) { + if (hit.thumbnailUrl.isNullOrBlank()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.QueueMusic, + contentDescription = hit.title, + modifier = Modifier.fillMaxSize(0.36f), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + AsyncImage( + model = hit.thumbnailUrl, + contentDescription = hit.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + } + } + } + Spacer(Modifier.height(18.dp)) + AnimatedContent( + targetState = focused.url, + label = "playlistPeekMeta", + ) { focusedUrl -> + val hit = playlists.firstOrNull { it.url == focusedUrl } ?: focused + Column { + Text( + text = hit.title, + style = MaterialTheme.typography.headlineSmall, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (hit.artist.isNotBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = hit.artist, + style = MaterialTheme.typography.bodyLarge, + fontFamily = GoogleSansRounded, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 9afc01e10e..04074956e9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -111,6 +111,7 @@ import com.theveloper.pixelplay.presentation.navigation.navigateSafely import com.theveloper.pixelplay.presentation.components.AlbumArtCollage import com.theveloper.pixelplay.presentation.components.BetaInfoBottomSheet import com.theveloper.pixelplay.presentation.components.ChangelogModalSheet +import com.theveloper.pixelplay.presentation.components.PlaylistPeekCarousel import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.presentation.youtube.auth.YoutubeLoginActivity import com.theveloper.pixelplay.ui.theme.GoogleSansRounded @@ -539,6 +540,16 @@ fun YoutubeScreen( LoadingIndicator() } } + browseSection == YoutubeSection.PLAYLISTS && + uiState.collectionTitle == null && + rows.isNotEmpty() -> { + PlaylistPeekCarousel( + playlists = rows, + onPlaylistClick = { onSongClick(it, false) }, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + ) + } rows.isEmpty() -> { Column( modifier = Modifier.fillMaxSize(), From 23f4e155087386dc203104a926b4f07c8482cd19 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 16:20:58 +0300 Subject: [PATCH 63/77] fix: stop update install from crashing without permission Tapping Download called canRequestPackageInstalls after that permission was removed, which killed the app on Pixel. Co-authored-by: Cursor --- app/src/main/AndroidManifest.xml | 1 + .../com/theveloper/pixelplay/MainActivity.kt | 27 ++++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ce699144c3..c179cf813a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + if (GitHubReleaseUpdateService.isAllowedApkDownload(url)) { - if (!packageManager.canRequestPackageInstalls()) { + if (!canRequestPackageInstallsSafely()) { updateAnnouncement = updateAnnouncement.copy( body = getString(R.string.update_dialog_install_permission), ) - startActivity( - Intent( - Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, - "package:$packageName".toUri(), - ), - ) + if (!openUnknownSourcesSettings()) { + openReleasePage(url) + } return@PlayStoreAnnouncementDialog } if (updateDownloadBusy) return@PlayStoreAnnouncementDialog From 5c7eb2f07ef72b5d16cc885faf8b318f0f9d71d8 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 16:47:43 +0300 Subject: [PATCH 64/77] fix: harden install checks and keep unit tests green Extract a safe package-install query, parse shared artwork paths, and register the lyrics screen-off receiver so the debug suite and assemble pass. Co-authored-by: Cursor --- .../com/theveloper/pixelplay/MainActivity.kt | 6 ++- .../pixelplay/data/database/AlbumEntity.kt | 1 + .../presentation/components/LyricsSheet.kt | 12 ++++- .../presentation/screens/LibraryScreen.kt | 8 +-- .../pixelplay/utils/LocalArtworkUri.kt | 22 ++++++--- .../pixelplay/utils/PackageInstallAccess.kt | 14 ++++++ .../data/backup/model/BackupSectionTest.kt | 5 +- .../github/GitHubReleaseUpdateServiceTest.kt | 41 ++++++++++++++++ .../viewmodel/LyricsStateHolderTest.kt | 49 ++++++++++++------- .../viewmodel/PlayerViewModelTest.kt | 8 ++- .../pixelplay/utils/AudioMetaUtilsTest.kt | 14 +----- .../pixelplay/utils/LocalArtworkUriTest.kt | 9 ++++ .../utils/PackageInstallAccessTest.kt | 34 +++++++++++++ 13 files changed, 174 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/utils/PackageInstallAccess.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateServiceTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/utils/PackageInstallAccessTest.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt index d6f3f27b03..8894504393 100644 --- a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt @@ -149,6 +149,7 @@ import com.theveloper.pixelplay.ui.theme.LocalShowScrollbar import com.theveloper.pixelplay.utils.CrashHandler import com.theveloper.pixelplay.utils.AppLocaleManager import com.theveloper.pixelplay.utils.LogUtils +import com.theveloper.pixelplay.utils.PackageInstallAccess import dagger.hilt.android.AndroidEntryPoint import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay @@ -501,8 +502,9 @@ class MainActivity : ComponentActivity() { } private fun canRequestPackageInstallsSafely(): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true - return runCatching { packageManager.canRequestPackageInstalls() }.getOrDefault(false) + return PackageInstallAccess.canRequestInstalls { + packageManager.canRequestPackageInstalls() + } } private fun openUnknownSourcesSettings(): Boolean { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/AlbumEntity.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/AlbumEntity.kt index a540d99bad..d6702bafcc 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/AlbumEntity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/AlbumEntity.kt @@ -44,6 +44,7 @@ fun AlbumEntity.toAlbum(): Album { LocalArtworkUri.looksLikeVolatileArtworkUri(this.albumArtUriString) -> LocalArtworkUri.parseSongIdFromVolatileArtworkUri(this.albumArtUriString) ?.let { LocalArtworkUri.buildSongUri(it) } + ?: this.albumArtUriString else -> this.albumArtUriString } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt index 9787826e8f..6f795d02f7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt @@ -119,6 +119,7 @@ import com.theveloper.pixelplay.presentation.components.subcomps.LyricsMoreBotto import android.content.BroadcastReceiver import android.content.Intent import android.content.IntentFilter +import androidx.core.content.ContextCompat import androidx.compose.ui.platform.LocalView import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map @@ -482,8 +483,15 @@ fun LyricsSheet( } } } - context.registerReceiver(receiver, IntentFilter(Intent.ACTION_SCREEN_OFF)) - onDispose { context.unregisterReceiver(receiver) } + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(Intent.ACTION_SCREEN_OFF), + ContextCompat.RECEIVER_EXPORTED, + ) + onDispose { + runCatching { context.unregisterReceiver(receiver) } + } } // Auto-hide controls logic diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index eb6f5ab154..548479c02e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt @@ -2820,10 +2820,10 @@ internal fun resolveFolderNavigationDirection(initialPath: String?, targetPath: } private fun isDescendantFolderPath(ancestorPath: String, candidatePath: String): Boolean { - val normalizedAncestor = ancestorPath.trimEnd(File.separatorChar) - val normalizedCandidate = candidatePath.trimEnd(File.separatorChar) - if (normalizedAncestor == normalizedCandidate) return false - return normalizedCandidate.startsWith("$normalizedAncestor${File.separatorChar}") + val ancestor = ancestorPath.trimEnd('/', '\\') + val candidate = candidatePath.trimEnd('/', '\\') + if (ancestor == candidate) return false + return candidate.startsWith("$ancestor/") || candidate.startsWith("$ancestor\\") } @OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3ExpressiveApi::class) diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/LocalArtworkUri.kt b/app/src/main/java/com/theveloper/pixelplay/utils/LocalArtworkUri.kt index ee405d4c71..d039377d01 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/LocalArtworkUri.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/LocalArtworkUri.kt @@ -46,15 +46,23 @@ object LocalArtworkUri { if (!looksLikeVolatileArtworkUri(uriString)) return null val fileName = uriString.substringAfterLast('/').substringBefore('?') - if (!fileName.startsWith("song_art_")) { - return null + if (fileName.startsWith("song_art_")) { + return fileName + .removePrefix("song_art_") + .substringBefore('_') + .substringBefore('.') + .toLongOrNull() } - return fileName - .removePrefix("song_art_") - .substringBefore('_') - .substringBefore('.') - .toLongOrNull() + val sharedMarker = ".artwork/song/" + val markerIndex = uriString.lowercase().indexOf(sharedMarker) + if (markerIndex >= 0) { + return uriString.substring(markerIndex + sharedMarker.length) + .substringBefore('?') + .substringBefore('/') + .toLongOrNull() + } + return null } fun extractCacheBustToken(uriString: String?): String? { diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/PackageInstallAccess.kt b/app/src/main/java/com/theveloper/pixelplay/utils/PackageInstallAccess.kt new file mode 100644 index 0000000000..5d4eddcf96 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/utils/PackageInstallAccess.kt @@ -0,0 +1,14 @@ +package com.theveloper.pixelplay.utils + +import android.os.Build + +/** + * Package-install queries throw [SecurityException] on Android 8+ when + * REQUEST_INSTALL_PACKAGES is missing from the manifest. + */ +object PackageInstallAccess { + fun canRequestInstalls(sdkInt: Int = Build.VERSION.SDK_INT, query: () -> Boolean): Boolean { + if (sdkInt < Build.VERSION_CODES.O) return true + return runCatching(query).getOrDefault(false) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/backup/model/BackupSectionTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/backup/model/BackupSectionTest.kt index aca06c6528..033d76cbe8 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/backup/model/BackupSectionTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/backup/model/BackupSectionTest.kt @@ -33,6 +33,7 @@ class BackupSectionTest { assertEquals(BackupSection.QUICK_FILL, BackupSection.fromKey("quick_fill")) assertEquals(BackupSection.ARTIST_IMAGES, BackupSection.fromKey("artist_images")) assertEquals(BackupSection.EQUALIZER, BackupSection.fromKey("equalizer")) + assertEquals(BackupSection.AI_USAGE_LOGS, BackupSection.fromKey("ai_usage_logs")) } @Test @@ -47,8 +48,8 @@ class BackupSectionTest { } @Test - fun `there are exactly 11 backup sections`() { - assertEquals(11, BackupSection.entries.size) + fun `there are exactly 12 backup sections`() { + assertEquals(12, BackupSection.entries.size) } @Test diff --git a/app/src/test/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateServiceTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateServiceTest.kt new file mode 100644 index 0000000000..4592bf6848 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateServiceTest.kt @@ -0,0 +1,41 @@ +package com.theveloper.pixelplay.data.github + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class GitHubReleaseUpdateServiceTest { + + @Test + fun releasePage_allowsThisRepoOnly() { + assertThat( + GitHubReleaseUpdateService.isAllowedReleasePage( + "https://github.com/kibetmasi/PixelPlayer/releases/tag/vcontinuous-20260928-23f4e155", + ), + ).isTrue() + assertThat( + GitHubReleaseUpdateService.isAllowedReleasePage("https://evil.example/releases"), + ).isFalse() + assertThat( + GitHubReleaseUpdateService.isAllowedReleasePage("http://github.com/kibetmasi/PixelPlayer/releases"), + ).isFalse() + } + + @Test + fun apkDownload_allowsThisRepoApkOnly() { + assertThat( + GitHubReleaseUpdateService.isAllowedApkDownload( + "https://github.com/kibetmasi/PixelPlayer/releases/download/v1/app-arm64-v8a-release.apk", + ), + ).isTrue() + assertThat( + GitHubReleaseUpdateService.isAllowedApkDownload( + "https://github.com/kibetmasi/PixelPlayer/releases/download/v1/notes.txt", + ), + ).isFalse() + assertThat( + GitHubReleaseUpdateService.isAllowedApkDownload( + "https://github.com/someone/else/releases/download/v1/app.apk", + ), + ).isFalse() + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt index dfafcc3bce..e846687312 100644 --- a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt @@ -9,14 +9,15 @@ import com.theveloper.pixelplay.data.repository.MusicRepository import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.delay import org.junit.Test -@OptIn(ExperimentalCoroutinesApi::class) class LyricsStateHolderTest { @Test @@ -55,7 +56,8 @@ class LyricsStateHolderTest { userPreferencesRepository = userPreferencesRepository, songMetadataEditor = songMetadataEditor ) - val scope = TestScope(StandardTestDispatcher()) + val job = SupervisorJob() + val scope = CoroutineScope(job + Dispatchers.Unconfined) val callback = RecordingLyricsLoadCallback() val state = MutableStateFlow(StablePlayerState()) val song = testSong(albumArtUriString = "content://art/song_art_1.jpg").copy( @@ -63,20 +65,31 @@ class LyricsStateHolderTest { ) val storedLyrics = Lyrics(plain = listOf("Stored lyrics"), areFromRemote = false) - holder.initialize(scope, callback, state) - coEvery { musicRepository.getStoredLyrics(song) } returns (storedLyrics to "Stored lyrics") + try { + coEvery { userPreferencesRepository.getLyricsSyncOffset(any()) } returns 0 + coEvery { musicRepository.getStoredLyrics(song) } returns (storedLyrics to "Stored lyrics") + holder.initialize(scope, callback, state) - holder.fetchLyricsForSong( - song = song, - forcePickResults = false, - sourcePreference = com.theveloper.pixelplay.data.model.LyricsSourcePreference.API_FIRST - ) { "Lyrics already available" } - scope.advanceUntilIdle() + holder.fetchLyricsForSong( + song = song, + forcePickResults = false, + sourcePreference = com.theveloper.pixelplay.data.model.LyricsSourcePreference.API_FIRST + ) { "Lyrics already available" } + runBlocking { + withTimeout(5_000) { + while (holder.searchUiState.value !is LyricsSearchUiState.Success) { + delay(10) + } + } + } - assertThat(holder.searchUiState.value).isEqualTo(LyricsSearchUiState.Success(storedLyrics)) - coVerify(exactly = 1) { musicRepository.getStoredLyrics(song) } - coVerify(exactly = 0) { musicRepository.getLyricsFromRemote(any()) } - coVerify(exactly = 0) { musicRepository.searchRemoteLyrics(any()) } + assertThat(holder.searchUiState.value).isEqualTo(LyricsSearchUiState.Success(storedLyrics)) + coVerify(exactly = 1) { musicRepository.getStoredLyrics(song) } + coVerify(exactly = 0) { musicRepository.getLyricsFromRemote(any()) } + coVerify(exactly = 0) { musicRepository.searchRemoteLyrics(any()) } + } finally { + job.cancel() + } } private fun testSong(albumArtUriString: String?): Song { diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt index 7996811c67..9eb962a194 100644 --- a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt @@ -99,6 +99,8 @@ class PlayerViewModelTest { private val mockThemeStateHolder: ThemeStateHolder = mockk(relaxed = true) private val mockMultiSelectionStateHolder: MultiSelectionStateHolder = mockk(relaxed = true) private val mockPlaylistSelectionStateHolder: PlaylistSelectionStateHolder = mockk(relaxed = true) + private val mockYoutubeMusicStore: com.theveloper.pixelplay.youtube.YoutubeMusicStore = mockk(relaxed = true) + private val mockYoutubeClient: com.theveloper.pixelplay.youtube.YoutubeClient = mockk(relaxed = true) private val mockMediaMapper: com.theveloper.pixelplay.data.media.MediaMapper = mockk(relaxed = true) private lateinit var mockMediaControllerFactory: com.theveloper.pixelplay.data.media.MediaControllerFactory @@ -167,6 +169,8 @@ class PlayerViewModelTest { every { mockLibraryStateHolder.currentFolderSortOption } returns MutableStateFlow(SortOption.FolderNameAZ) every { mockLibraryStateHolder.currentFavoriteSortOption } returns MutableStateFlow(SortOption.LikedSongTitleAZ) every { mockLibraryStateHolder.currentStorageFilter } returns MutableStateFlow(StorageFilter.ALL) + every { mockYoutubeMusicStore.likedSongIds } returns MutableStateFlow(emptySet()) + every { mockYoutubeMusicStore.savedSongIds } returns MutableStateFlow(emptySet()) every { mockSearchStateHolder.searchHistory } returns _searchHistoryFlow every { mockSearchStateHolder.searchResults } returns _searchResultsFlow @@ -316,7 +320,9 @@ class PlayerViewModelTest { playbackDispatchStateHolder, mediaControllerSyncStateHolder, sessionToken, - mockMediaControllerFactory + mockMediaControllerFactory, + mockYoutubeMusicStore, + mockYoutubeClient ) } diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/AudioMetaUtilsTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/AudioMetaUtilsTest.kt index ba20d0baf7..2cfef02875 100644 --- a/app/src/test/java/com/theveloper/pixelplay/utils/AudioMetaUtilsTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/utils/AudioMetaUtilsTest.kt @@ -11,13 +11,7 @@ class AudioMetaUtilsTest { assertEquals("m4a", AudioMetaUtils.mimeTypeToFormat("audio/m4a")) assertEquals("m4a", AudioMetaUtils.mimeTypeToFormat("audio/x-m4a")) assertEquals("m4a", AudioMetaUtils.mimeTypeToFormat("audio/mp4a-latm")) - @Test - fun mimeTypeToFormat_mapsUniversalFormats() { - assertEquals("aiff", AudioMetaUtils.mimeTypeToFormat("audio/x-aiff")) - assertEquals("ac3", AudioMetaUtils.mimeTypeToFormat("audio/ac3")) - assertEquals("dts", AudioMetaUtils.mimeTypeToFormat("audio/vnd.dts")) } -} @Test fun mimeTypeToFormat_mapsSamsungFormats() { @@ -30,13 +24,8 @@ class AudioMetaUtilsTest { assertEquals("qcelp", AudioMetaUtils.mimeTypeToFormat("audio/x-qcelp")) assertEquals("ima", AudioMetaUtils.mimeTypeToFormat("audio/x-ima-adpcm")) assertEquals("ima", AudioMetaUtils.mimeTypeToFormat("audio/ima-adpcm")) - @Test - fun mimeTypeToFormat_mapsUniversalFormats() { - assertEquals("aiff", AudioMetaUtils.mimeTypeToFormat("audio/x-aiff")) - assertEquals("ac3", AudioMetaUtils.mimeTypeToFormat("audio/ac3")) - assertEquals("dts", AudioMetaUtils.mimeTypeToFormat("audio/vnd.dts")) } -} + @Test fun mimeTypeToFormat_mapsUniversalFormats() { assertEquals("aiff", AudioMetaUtils.mimeTypeToFormat("audio/x-aiff")) @@ -44,4 +33,3 @@ class AudioMetaUtilsTest { assertEquals("dts", AudioMetaUtils.mimeTypeToFormat("audio/vnd.dts")) } } - diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/LocalArtworkUriTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/LocalArtworkUriTest.kt index 1cff308e01..128d78608c 100644 --- a/app/src/test/java/com/theveloper/pixelplay/utils/LocalArtworkUriTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/utils/LocalArtworkUriTest.kt @@ -67,6 +67,15 @@ class LocalArtworkUriTest { assertThat(songId).isEqualTo(77L) } + @Test + fun parseSongIdFromVolatileArtworkUri_readsSharedArtworkPath() { + val songId = LocalArtworkUri.parseSongIdFromVolatileArtworkUri( + "content://com.theveloper.pixelplay.artwork/song/99?t=1234" + ) + + assertThat(songId).isEqualTo(99L) + } + @Test fun extractCacheBustToken_readsTimestampQuery() { val cacheBustToken = LocalArtworkUri.extractCacheBustToken( diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/PackageInstallAccessTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/PackageInstallAccessTest.kt new file mode 100644 index 0000000000..046e0d0809 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/utils/PackageInstallAccessTest.kt @@ -0,0 +1,34 @@ +package com.theveloper.pixelplay.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class PackageInstallAccessTest { + + @Test + fun preOreo_doesNotQueryAndAllowsInstall() { + val allowed = PackageInstallAccess.canRequestInstalls(sdkInt = 25) { + error("must not query canRequestPackageInstalls before Oreo") + } + assertThat(allowed).isTrue() + } + + @Test + fun oreoPlus_granted_returnsTrue() { + assertThat(PackageInstallAccess.canRequestInstalls(sdkInt = 26) { true }).isTrue() + assertThat(PackageInstallAccess.canRequestInstalls(sdkInt = 36) { true }).isTrue() + } + + @Test + fun oreoPlus_denied_returnsFalse() { + assertThat(PackageInstallAccess.canRequestInstalls(sdkInt = 36) { false }).isFalse() + } + + @Test + fun missingManifestPermission_doesNotCrash() { + val allowed = PackageInstallAccess.canRequestInstalls(sdkInt = 36) { + throw SecurityException("Need to declare android.permission.REQUEST_INSTALL_PACKAGES to call this api") + } + assertThat(allowed).isFalse() + } +} From 1cc59c60e19e6401647eff3d30d54c5f6a979370 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 19:05:55 +0300 Subject: [PATCH 65/77] fix: peek the next song on now playing, not playlists The Playlists tab is the original list again. Now Playing shows the next queue cover by default. Co-authored-by: Cursor --- .../preferences/UserPreferencesRepository.kt | 2 +- .../components/AlbumCarouselSelection.kt | 2 +- .../components/PlaylistPeekCarousel.kt | 141 ------------------ .../presentation/screens/YoutubeScreen.kt | 11 -- .../presentation/viewmodel/PlayerViewModel.kt | 4 +- .../viewmodel/SettingsViewModel.kt | 2 +- 6 files changed, 5 insertions(+), 157 deletions(-) delete mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistPeekCarousel.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt index e7c224f32b..f21fbde8da 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt @@ -994,7 +994,7 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) { } val carouselStyleFlow: Flow = - pref { it[PreferencesKeys.CAROUSEL_STYLE] ?: CarouselStyle.NO_PEEK } + pref { it[PreferencesKeys.CAROUSEL_STYLE] ?: CarouselStyle.ONE_PEEK } suspend fun setCarouselStyle(style: String) { dataStore.edit { it[PreferencesKeys.CAROUSEL_STYLE] = style } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumCarouselSelection.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumCarouselSelection.kt index 8166aa3262..ed17048312 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumCarouselSelection.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumCarouselSelection.kt @@ -47,7 +47,7 @@ fun AlbumCarouselSection( onSongSelected: (Song, Int) -> Unit, onAlbumClick: (Song) -> Unit = {}, modifier: Modifier = Modifier, - carouselStyle: String = CarouselStyle.NO_PEEK, + carouselStyle: String = CarouselStyle.ONE_PEEK, itemSpacing: Dp = 8.dp, albumArtQuality: AlbumArtQuality = AlbumArtQuality.MEDIUM ) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistPeekCarousel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistPeekCarousel.kt deleted file mode 100644 index cc255c2103..0000000000 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistPeekCarousel.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.theveloper.pixelplay.presentation.components - -import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PageSize -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.QueueMusic -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import com.theveloper.pixelplay.ui.theme.GoogleSansRounded -import com.theveloper.pixelplay.youtube.YoutubeHit -import kotlinx.coroutines.launch - -/** - * Playlist covers as a stacked peek: the current art is in front, and the next - * playlist peeks from the right — same idea as a card deck. - */ -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun PlaylistPeekCarousel( - playlists: List, - onPlaylistClick: (YoutubeHit) -> Unit, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(), -) { - if (playlists.isEmpty()) return - - val pagerState = rememberPagerState(pageCount = { playlists.size }) - val scope = rememberCoroutineScope() - val focused = playlists.getOrNull(pagerState.currentPage) ?: playlists.first() - val peek = 72.dp - val spacing = 12.dp - val coverShape = RoundedCornerShape(28.dp) - - Column(modifier = modifier.padding(contentPadding)) { - BoxWithConstraints(Modifier.fillMaxWidth()) { - val hasPeek = playlists.size > 1 - val cover = minOf( - maxWidth - if (hasPeek) peek else 0.dp, - 360.dp, - ) - HorizontalPager( - state = pagerState, - modifier = Modifier - .fillMaxWidth() - .height(cover), - contentPadding = PaddingValues(end = (maxWidth - cover).coerceAtLeast(0.dp)), - pageSpacing = if (hasPeek) spacing else 0.dp, - pageSize = PageSize.Fixed(cover), - beyondViewportPageCount = 1, - userScrollEnabled = hasPeek, - ) { page -> - val hit = playlists[page] - val isFocused = pagerState.currentPage == page - Surface( - onClick = { - if (isFocused) { - onPlaylistClick(hit) - } else { - scope.launch { pagerState.animateScrollToPage(page) } - } - }, - modifier = Modifier.fillMaxSize(), - shape = coverShape, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shadowElevation = if (isFocused) 2.dp else 0.dp, - tonalElevation = if (isFocused) 2.dp else 0.dp, - ) { - if (hit.thumbnailUrl.isNullOrBlank()) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.QueueMusic, - contentDescription = hit.title, - modifier = Modifier.fillMaxSize(0.36f), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } else { - AsyncImage( - model = hit.thumbnailUrl, - contentDescription = hit.title, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } - } - } - } - Spacer(Modifier.height(18.dp)) - AnimatedContent( - targetState = focused.url, - label = "playlistPeekMeta", - ) { focusedUrl -> - val hit = playlists.firstOrNull { it.url == focusedUrl } ?: focused - Column { - Text( - text = hit.title, - style = MaterialTheme.typography.headlineSmall, - fontFamily = GoogleSansRounded, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - if (hit.artist.isNotBlank()) { - Spacer(Modifier.height(4.dp)) - Text( - text = hit.artist, - style = MaterialTheme.typography.bodyLarge, - fontFamily = GoogleSansRounded, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - } -} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 04074956e9..9afc01e10e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -111,7 +111,6 @@ import com.theveloper.pixelplay.presentation.navigation.navigateSafely import com.theveloper.pixelplay.presentation.components.AlbumArtCollage import com.theveloper.pixelplay.presentation.components.BetaInfoBottomSheet import com.theveloper.pixelplay.presentation.components.ChangelogModalSheet -import com.theveloper.pixelplay.presentation.components.PlaylistPeekCarousel import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.presentation.youtube.auth.YoutubeLoginActivity import com.theveloper.pixelplay.ui.theme.GoogleSansRounded @@ -540,16 +539,6 @@ fun YoutubeScreen( LoadingIndicator() } } - browseSection == YoutubeSection.PLAYLISTS && - uiState.collectionTitle == null && - rows.isNotEmpty() -> { - PlaylistPeekCarousel( - playlists = rows, - onPlaylistClick = { onSongClick(it, false) }, - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - ) - } rows.isEmpty() -> { Column( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index 21c6c4631f..9d67832acc 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -488,7 +488,7 @@ class PlayerViewModel @Inject constructor( .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), - initialValue = CarouselStyle.NO_PEEK + initialValue = CarouselStyle.ONE_PEEK ) val hasActiveAiProviderApiKey: StateFlow = combine( @@ -1330,7 +1330,7 @@ class PlayerViewModel @Inject constructor( data class PlayerConfigSlice( val navBarCornerRadius: Int = 32, val navBarStyle: String = NavBarStyle.DEFAULT, - val carouselStyle: String = CarouselStyle.NO_PEEK, + val carouselStyle: String = CarouselStyle.ONE_PEEK, val fullPlayerLoadingTweaks: FullPlayerLoadingTweaks = FullPlayerLoadingTweaks(), val tapBackgroundClosesPlayer: Boolean = false, val useSmoothCorners: Boolean = true, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt index dc82222792..b93151dce0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt @@ -63,7 +63,7 @@ data class SettingsUiState( val navBarCornerRadius: Int = 32, val navBarStyle: String = NavBarStyle.DEFAULT, val navBarCompactMode: Boolean = false, - val carouselStyle: String = CarouselStyle.NO_PEEK, + val carouselStyle: String = CarouselStyle.ONE_PEEK, val libraryNavigationMode: String = LibraryNavigationMode.TAB_ROW, val launchTab: String = LaunchTab.HOME, val keepPlayingInBackground: Boolean = true, From 48e9cc56ba614804eb4cca58928974a41517901c Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 19:29:05 +0300 Subject: [PATCH 66/77] feat: play youtube lists in order with library song actions Tapping a playlist song queues that page from there. Long-press opens the same play all, queue, next, and playlist sheet. Co-authored-by: Cursor --- .../components/MultiSelectionBottomSheet.kt | 101 +++++----- .../presentation/screens/YoutubeScreen.kt | 172 +++++++++++++----- .../pixelplay/utils/MediaItemBuilder.kt | 10 + .../pixelplay/youtube/YoutubeClient.kt | 53 +++++- .../pixelplay/youtube/YoutubePlaybackQueue.kt | 14 ++ .../pixelplay/youtube/YoutubeViewModel.kt | 6 + app/src/main/res/values/strings.xml | 2 + .../youtube/YoutubePlaybackQueueTest.kt | 59 ++++++ 8 files changed, 318 insertions(+), 99 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueue.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueueTest.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt index c2fa601beb..ed014546da 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt @@ -106,7 +106,9 @@ fun MultiSelectionBottomSheet( onToggleLikeAll: (shouldLike: Boolean) -> Unit, onShareAll: () -> Unit, onDeleteAll: (activity: Activity, onResult: (Boolean) -> Unit) -> Unit, - onBatchEdit: () -> Unit + onBatchEdit: () -> Unit, + showBatchEdit: Boolean = true, + showDelete: Boolean = true, ) { val context = LocalContext.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -216,23 +218,24 @@ fun MultiSelectionBottomSheet( Spacer(Modifier.width(10.dp)) - //Batch edit button - FilledTonalIconButton( - modifier = Modifier - .height(74.dp), - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceBright, - contentColor = MaterialTheme.colorScheme.onSurface - ), - onClick = { - onBatchEdit() - }, - ) { - Icon( - modifier = Modifier.padding(horizontal = 8.dp), - imageVector = Icons.Rounded.Edit, - contentDescription = stringResource(R.string.song_info_cd_edit_metadata) - ) + if (showBatchEdit) { + FilledTonalIconButton( + modifier = Modifier + .height(74.dp), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + contentColor = MaterialTheme.colorScheme.onSurface + ), + onClick = { + onBatchEdit() + }, + ) { + Icon( + modifier = Modifier.padding(horizontal = 8.dp), + imageVector = Icons.Rounded.Edit, + contentDescription = stringResource(R.string.song_info_cd_edit_metadata) + ) + } } } @@ -414,7 +417,7 @@ fun MultiSelectionBottomSheet( ) { FilledTonalButton( modifier = Modifier - .weight(0.5f) + .weight(if (showDelete) 0.5f else 1f) .heightIn(min = 66.dp), colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, @@ -440,38 +443,40 @@ fun MultiSelectionBottomSheet( lineHeight = 20.sp ) } - - FilledTonalButton( - modifier = Modifier - .weight(0.5f) - .heightIn(min = 66.dp), - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ), - contentPadding = PaddingValues(horizontal = 10.dp), - shape = CircleShape, - onClick = { - val activity = (context as? Activity) - if (activity != null) { - onDeleteAll(activity) { success -> - if (success) onDismiss() + + if (showDelete) { + FilledTonalButton( + modifier = Modifier + .weight(0.5f) + .heightIn(min = 66.dp), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + contentPadding = PaddingValues(horizontal = 10.dp), + shape = CircleShape, + onClick = { + val activity = (context as? Activity) + if (activity != null) { + onDeleteAll(activity) { success -> + if (success) onDismiss() + } } } + ) { + Icon( + Icons.Rounded.Delete, + contentDescription = stringResource(R.string.song_info_action_delete_all) + ) + Spacer(Modifier.width(6.dp)) + TightWrapText( + text = stringResource(R.string.song_info_action_delete_all), + modifier = Modifier.padding(end = 4.dp), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + lineHeight = 20.sp + ) } - ) { - Icon( - Icons.Rounded.Delete, - contentDescription = stringResource(R.string.song_info_action_delete_all) - ) - Spacer(Modifier.width(6.dp)) - TightWrapText( - text = stringResource(R.string.song_info_action_delete_all), - modifier = Modifier.padding(end = 4.dp), - overflow = TextOverflow.Ellipsis, - maxLines = 2, - lineHeight = 20.sp - ) } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 9afc01e10e..6c51d2aaff 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -10,6 +10,7 @@ import android.content.Intent import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -47,6 +48,7 @@ import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -96,15 +98,18 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight +import com.theveloper.pixelplay.presentation.components.MultiSelectionBottomSheet import com.theveloper.pixelplay.presentation.components.resolveNavBarOccupiedHeight import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIcon +import com.theveloper.pixelplay.presentation.components.subcomps.SelectionCountPill import androidx.navigation.NavController import com.theveloper.pixelplay.presentation.navigation.Screen import com.theveloper.pixelplay.presentation.navigation.navigateSafely @@ -117,6 +122,7 @@ import com.theveloper.pixelplay.ui.theme.GoogleSansRounded import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import com.theveloper.pixelplay.youtube.YoutubeClient import com.theveloper.pixelplay.youtube.YoutubeHit +import com.theveloper.pixelplay.youtube.YoutubePlaybackQueue import com.theveloper.pixelplay.youtube.YoutubeSearchFilter import com.theveloper.pixelplay.youtube.YoutubeSection import com.theveloper.pixelplay.youtube.YoutubeViewModel @@ -150,6 +156,7 @@ fun YoutubeScreen( var selectedUrls by remember { mutableStateOf(setOf()) } var showAddToPlaylist by remember { mutableStateOf(false) } var showCreatePlaylist by remember { mutableStateOf(false) } + var showMultiSelectionSheet by remember { mutableStateOf(false) } var newPlaylistName by remember { mutableStateOf("") } val libraryPlaylists by viewModel.libraryPlaylists.collectAsStateWithLifecycle() val stablePlayer by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() @@ -269,30 +276,34 @@ fun YoutubeScreen( runCatching { viewModel.radioTracks(hit) } .onSuccess { queue -> val playable = queue.ifEmpty { listOf(hit) } + val queued = viewModel.songsFromContext(playable.first(), playable) val start = viewModel.resolvePlayable(playable.first()) playerViewModel.playSongs( - songsToPlay = listOf(start), + songsToPlay = listOf(start) + queued.drop(1), startSong = start, queueName = "YouTube Radio", ) - viewModel.prefetchAfter(playable.first(), playable) { queued -> - playerViewModel.addSongToQueue(queued) - } + viewModel.warmUpTracks(playable) } .onFailure { error -> viewModel.reportError(error.message ?: "Couldn't start radio") } } else { - runCatching { viewModel.resolvePlayable(hit) } - .onSuccess { song -> + runCatching { + val contextHits = YoutubePlaybackQueue.remainingTracks(hit, rows) + val queued = contextHits.map(viewModel::listSong) + val start = viewModel.resolvePlayable(hit) + start to (listOf(start) + queued.drop(1)) + } + .onSuccess { (start, songs) -> playerViewModel.playSongs( - songsToPlay = listOf(song), - startSong = song, - queueName = "YouTube Music", + songsToPlay = songs, + startSong = start, + queueName = uiState.collectionTitle ?: "YouTube Music", + ) + viewModel.warmUpTracks( + YoutubePlaybackQueue.remainingTracks(hit, rows), ) - viewModel.prefetchAfter(hit, rows) { queued -> - playerViewModel.addSongToQueue(queued) - } } .onFailure { error -> viewModel.reportError(error.message ?: "Couldn't play this track") @@ -309,7 +320,10 @@ fun YoutubeScreen( val onSongClick: (YoutubeHit, Boolean) -> Unit = { hit, asRadio -> if (selecting && hit.kind == YoutubeHit.Kind.TRACK) { selectedUrls = if (hit.url in selectedUrls) selectedUrls - hit.url else selectedUrls + hit.url - if (selectedUrls.isEmpty()) selecting = false + if (selectedUrls.isEmpty()) { + selecting = false + showMultiSelectionSheet = false + } } else { playHit(hit, asRadio) } @@ -318,9 +332,19 @@ fun YoutubeScreen( if (hit.kind == YoutubeHit.Kind.TRACK) { selecting = true selectedUrls = selectedUrls + hit.url + showMultiSelectionSheet = true viewModel.ensureLibraryPlaylists() } } + val selectedHits = remember(selectedUrls, rows) { + rows.filter { it.url in selectedUrls && it.kind == YoutubeHit.Kind.TRACK } + } + val selectedSongs = remember(selectedHits) { selectedHits.map(viewModel::listSong) } + fun clearYoutubeSelection() { + selecting = false + selectedUrls = emptySet() + showMultiSelectionSheet = false + } val headerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) Scaffold( @@ -344,45 +368,19 @@ fun YoutubeScreen( onChangelog = { showChangelog = true }, onAccount = { navController.navigateSafely(Screen.YoutubeSettings.route) }, onSettings = { navController.navigateSafely(Screen.Settings.route) }, - ) - }, - bottomBar = { - if (selecting) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 4.dp, + onCreatePlaylist = if ( + browseSection == YoutubeSection.PLAYLISTS && + uiState.collectionTitle == null && + account.isSignedIn ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.youtube_selected_count, selectedUrls.size), - modifier = Modifier.weight(1f), - fontFamily = GoogleSansRounded, - fontWeight = FontWeight.Bold, - ) - TextButton(onClick = { - selecting = false - selectedUrls = emptySet() - }) { - Text(stringResource(R.string.youtube_cancel)) - } - Button( - onClick = { - viewModel.ensureLibraryPlaylists() - showAddToPlaylist = true - }, - enabled = selectedUrls.isNotEmpty() && account.isSignedIn, - ) { - Text(stringResource(R.string.youtube_add_to_playlist)) - } + { + selectedUrls = emptySet() + showCreatePlaylist = true } - } - } + } else { + null + }, + ) }, ) { innerPadding -> Column( @@ -405,6 +403,7 @@ fun YoutubeScreen( smoothnessAsPercentBR = 60, ), ) { + Box(Modifier.fillMaxSize()) { PullToRefreshBox( isRefreshing = uiState.isRefreshing, onRefresh = viewModel::refresh, @@ -638,6 +637,17 @@ fun YoutubeScreen( } } } + if (selecting && selectedUrls.isNotEmpty()) { + SelectionCountPill( + selectedCount = selectedUrls.size, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = MiniPlayerHeight + 16.dp) + .zIndex(2f) + .clickable { showMultiSelectionSheet = true }, + ) + } + } } } } @@ -731,6 +741,59 @@ fun YoutubeScreen( }, ) } + if (showMultiSelectionSheet && selectedSongs.isNotEmpty()) { + MultiSelectionBottomSheet( + selectedSongs = selectedSongs, + favoriteSongIds = likedIds + selectedHits.filter(viewModel::isLiked).map { + YoutubeClient.songIdForUrl(it.url) + }.toSet(), + onDismiss = { showMultiSelectionSheet = false }, + onPlayAll = { + playerViewModel.playSongs( + songsToPlay = selectedSongs, + startSong = selectedSongs.first(), + queueName = uiState.collectionTitle ?: "YouTube Music", + ) + clearYoutubeSelection() + }, + onAddToQueue = { + playerViewModel.addSelectedToQueue(selectedSongs) + clearYoutubeSelection() + }, + onPlayNext = { + playerViewModel.addSelectedAsNext(selectedSongs) + clearYoutubeSelection() + }, + onAddToPlaylist = { + viewModel.ensureLibraryPlaylists() + showAddToPlaylist = true + showMultiSelectionSheet = false + }, + onToggleLikeAll = { shouldLike -> + selectedHits.forEach { hit -> + if (viewModel.isLiked(hit) != shouldLike) viewModel.toggleLike(hit) + } + showMultiSelectionSheet = false + }, + onShareAll = { + val text = selectedHits.joinToString("\n") { it.url } + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + }, + selectedHits.first().title, + ) + ) + showMultiSelectionSheet = false + }, + onDeleteAll = { _, onResult -> onResult(false) }, + onBatchEdit = { showMultiSelectionSheet = false }, + showBatchEdit = false, + showDelete = false, + ) + } } @Composable @@ -749,6 +812,7 @@ private fun YoutubeCompactHeader( onChangelog: () -> Unit, onAccount: () -> Unit, onSettings: () -> Unit, + onCreatePlaylist: (() -> Unit)? = null, ) { val headerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) Column(Modifier.fillMaxWidth().background(headerColor)) { @@ -795,6 +859,14 @@ private fun YoutubeCompactHeader( } else { Spacer(Modifier.weight(1f)) } + if (onCreatePlaylist != null) { + FilledIconButton(onClick = onCreatePlaylist) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(R.string.youtube_new_playlist), + ) + } + } FilledIconButton(onClick = onAccount) { Icon( imageVector = if (signedIn) Icons.Rounded.AccountCircle else Icons.Rounded.AccountCircle, diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt b/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt index 910cbe1850..4f108b3466 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt @@ -332,12 +332,22 @@ object MediaItemBuilder { putInt(EXTERNAL_EXTRA_SAMPLE_RATE, song.sampleRate ?: 0) putString(EXTERNAL_EXTRA_FILE_PATH, song.path) song.navidromeId?.let { putString(EXTERNAL_EXTRA_NAVIDROME_ID, it) } + youtubeWatchUrlForSong(song)?.let { watchUrl -> + putString(YoutubeClient.EXTRA_WATCH_URL, watchUrl) + } } metadataBuilder.setExtras(extras) return metadataBuilder.build() } + private fun youtubeWatchUrlForSong(song: Song): String? { + YoutubeClient.watchUrlForSongId(song.id)?.let { return it } + val candidate = song.contentUriString.takeIf { it.startsWith("http") } + ?: song.path.takeIf { it.startsWith("http") } + return candidate?.takeIf { YoutubeClient.videoIdFromUrl(it).length == 11 } + } + private fun normalizeArtworkUri( rawArtworkUri: String?, supportedSchemes: Set diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index f33b713dad..18dbfa67ff 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -504,6 +504,19 @@ class YoutubeClient @Inject constructor( collectVideoHits(root, hits, limit) return } + addResponsiveListHits(rows, hits, limit) + } + + private fun collectPlaylistShelf(root: JSONObject, hits: MutableSet, limit: Int) { + val rows = findPlaylistRows(root) + if (rows == null) { + collectVideoHits(root, hits, limit) + return + } + addResponsiveListHits(rows, hits, limit) + } + + private fun addResponsiveListHits(rows: JSONArray, hits: MutableSet, limit: Int) { for (index in 0 until rows.length()) { if (hits.size >= limit) return val item = rows.optJSONObject(index) ?: continue @@ -523,6 +536,44 @@ class YoutubeClient @Inject constructor( } } + /** Playlist pages only: the playlist shelf, not related mixes below it. */ + private fun findPlaylistRows(root: JSONObject): JSONArray? { + root.optJSONObject("contents") + ?.optJSONObject("twoColumnBrowseResultsRenderer") + ?.optJSONObject("secondaryContents") + ?.optJSONObject("sectionListRenderer") + ?.optJSONArray("contents") + ?.let { contents -> firstPlaylistShelfContents(contents) } + ?.let { return it } + + root.optJSONObject("contents") + ?.optJSONObject("singleColumnBrowseResultsRenderer") + ?.optJSONArray("tabs") + ?.optJSONObject(0) + ?.optJSONObject("tabRenderer") + ?.optJSONObject("content") + ?.optJSONObject("sectionListRenderer") + ?.optJSONArray("contents") + ?.let { contents -> firstPlaylistShelfContents(contents) } + ?.let { return it } + + return root.optJSONObject("continuationContents") + ?.optJSONObject("musicPlaylistShelfContinuation") + ?.optJSONArray("contents") + } + + private fun firstPlaylistShelfContents(sections: JSONArray): JSONArray? { + for (index in 0 until sections.length()) { + val section = sections.optJSONObject(index) ?: continue + section.optJSONObject("musicPlaylistShelfRenderer")?.optJSONArray("contents")?.let { return it } + } + for (index in 0 until sections.length()) { + val section = sections.optJSONObject(index) ?: continue + section.optJSONObject("musicShelfRenderer")?.optJSONArray("contents")?.let { return it } + } + return null + } + /** Desktop two-column Liked Music, mobile single-column, or a continuation page. */ private fun findLikedRows(root: JSONObject): JSONArray? { root.optJSONObject("contents") @@ -629,7 +680,7 @@ class YoutubeClient @Inject constructor( continuation = token, ) first = false - collectLikedShelf(root, hits, limit) + collectPlaylistShelf(root, hits, limit) token = findPlaylistContinuation(root) requests++ if (hits.size == before) break diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueue.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueue.kt new file mode 100644 index 0000000000..896e20e126 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueue.kt @@ -0,0 +1,14 @@ +package com.theveloper.pixelplay.youtube + +/** + * Queue for a YouTube list is the tracks on that page, from the song you tapped + * through the end. Related / radio tracks are never mixed in. + */ +object YoutubePlaybackQueue { + fun remainingTracks(start: YoutubeHit, source: List): List { + if (start.kind != YoutubeHit.Kind.TRACK) return emptyList() + val tracks = source.filter { it.kind == YoutubeHit.Kind.TRACK } + val index = tracks.indexOfFirst { it.url == start.url } + return if (index >= 0) tracks.drop(index) else listOf(start) + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt index 5aa322be62..d59324251a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -524,6 +524,12 @@ class YoutubeViewModel @Inject constructor( } } + fun songsFromContext(start: YoutubeHit, source: List): List { + return YoutubePlaybackQueue.remainingTracks(start, source).map(client::listSong) + } + + fun listSong(hit: YoutubeHit): Song = client.listSong(hit) + fun prefetchAfter(start: YoutubeHit, queue: List, onSong: (Song) -> Unit) { val startIndex = queue.indexOfFirst { it.url == start.url } if (startIndex < 0) return diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a17a9ea84c..690aac77fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -67,6 +67,8 @@ Cancel %1$d selected No playlists in your library yet. + Play next + Select Back diff --git a/app/src/test/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueueTest.kt b/app/src/test/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueueTest.kt new file mode 100644 index 0000000000..410469bcaf --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueueTest.kt @@ -0,0 +1,59 @@ +package com.theveloper.pixelplay.youtube + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class YoutubePlaybackQueueTest { + + @Test + fun remainingTracks_startsAtTappedSongAndKeepsListOrder() { + val tracks = listOf( + track("a", "One"), + track("b", "Two"), + track("c", "Three"), + ) + + val remaining = YoutubePlaybackQueue.remainingTracks(tracks[1], tracks) + + assertThat(remaining.map { it.title }).containsExactly("Two", "Three").inOrder() + } + + @Test + fun remainingTracks_skipsCollectionRows() { + val mixed = listOf( + YoutubeHit( + url = "pl", + title = "Album", + artist = "", + durationSec = 0, + thumbnailUrl = null, + kind = YoutubeHit.Kind.COLLECTION, + ), + track("a", "One"), + track("b", "Two"), + ) + + val remaining = YoutubePlaybackQueue.remainingTracks(mixed[1], mixed) + + assertThat(remaining.map { it.title }).containsExactly("One", "Two").inOrder() + } + + @Test + fun remainingTracks_usesOnlyTheTappedSongWhenItIsNotOnThePage() { + val remaining = YoutubePlaybackQueue.remainingTracks( + start = track("z", "Other"), + source = listOf(track("a", "One")), + ) + + assertThat(remaining.map { it.title }).containsExactly("Other") + } + + private fun track(id: String, title: String) = YoutubeHit( + url = "https://music.youtube.com/watch?v=$id", + title = title, + artist = "Artist", + durationSec = 0, + thumbnailUrl = null, + kind = YoutubeHit.Kind.TRACK, + ) +} From 2e26ceeb3bd3d46d53ee626d3e26f9d96c25cbd4 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 19:55:26 +0300 Subject: [PATCH 67/77] fix: make youtube song selection and queue actions work Long-press was ignored on fold tiles and nested row clicks. Select, overflow, and library-style multi-select now open the queue sheet; one song plays the list, many play only the checked tracks. Co-authored-by: Cursor --- .../components/MultiSelectionBottomSheet.kt | 8 +- .../subcomps/EnhancedSongListItem.kt | 37 +-- .../presentation/screens/YoutubeScreen.kt | 238 +++++++++++++----- 3 files changed, 210 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt index ed014546da..97f956fe16 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt @@ -277,7 +277,13 @@ fun MultiSelectionBottomSheet( ) Spacer(Modifier.width(6.dp)) TightWrapText( - text = stringResource(R.string.song_info_action_play_all), + text = stringResource( + if (selectedSongs.size <= 1) { + R.string.song_info_action_play + } else { + R.string.song_info_action_play_all + } + ), modifier = Modifier.padding(end = 4.dp), overflow = TextOverflow.Ellipsis, maxLines = 2, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt index 30abec5879..c2ef9297a0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -77,6 +78,7 @@ private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float { * @param customShape Optional custom shape for the surface * @param isSelected Whether this item is selected in multi-selection mode * @param isSelectionMode Whether multi-selection mode is active + * @param handleGestures When false, the caller owns click / long-press on [modifier] * @param onLongPress Callback for long press gesture (activates selection) * @param onMoreOptionsClick Callback for more options button * @param onClick Callback for tap gesture @@ -97,11 +99,14 @@ fun EnhancedSongListItem( selectionIndex: Int? = null, isSelectionMode: Boolean = false, showMoreOptionsButton: Boolean = true, + handleGestures: Boolean = true, onLongPress: () -> Unit = {}, onMoreOptionsClick: (Song) -> Unit, onClick: () -> Unit ) { val albumArtTargetSizePx = with(LocalDensity.current) { albumArtSize.roundToPx() } + val currentOnClick = rememberUpdatedState(onClick) + val currentOnLongPress = rememberUpdatedState(onLongPress) val isHighlighted = isCurrentSong && !isLoading val transition = updateTransition( targetState = EnhancedSongAnimationTarget( @@ -254,22 +259,24 @@ fun EnhancedSongListItem( Modifier } ) - .pointerInput(isSelectionMode) { - detectTapGestures( - onTap = { - if (isSelectionMode) { - // In selection mode, tap toggles selection - onLongPress() - } else { - onClick() - } - }, - onLongPress = { - // Long press always activates/toggles selection - onLongPress() + .then( + if (handleGestures) { + Modifier.pointerInput(isSelectionMode) { + detectTapGestures( + onTap = { + if (isSelectionMode) { + currentOnLongPress.value() + } else { + currentOnClick.value() + } + }, + onLongPress = { currentOnLongPress.value() }, + ) } - ) - }, + } else { + Modifier + } + ), shape = surfaceShape, color = containerColor, ) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 6c51d2aaff..575c8843ab 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -10,7 +10,6 @@ import android.content.Intent import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -50,9 +49,11 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.SelectAll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.DockedSearchBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -89,6 +90,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration @@ -109,6 +112,7 @@ import com.theveloper.pixelplay.presentation.components.MultiSelectionBottomShee import com.theveloper.pixelplay.presentation.components.resolveNavBarOccupiedHeight import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIcon +import com.theveloper.pixelplay.presentation.components.subcomps.SelectionActionRow import com.theveloper.pixelplay.presentation.components.subcomps.SelectionCountPill import androidx.navigation.NavController import com.theveloper.pixelplay.presentation.navigation.Screen @@ -150,10 +154,11 @@ fun YoutubeScreen( val savedIds by viewModel.savedSongIds.collectAsStateWithLifecycle() val account by viewModel.account.collectAsStateWithLifecycle() val context = LocalContext.current + val haptic = LocalHapticFeedback.current var showChangelog by remember { mutableStateOf(false) } var showBeta by remember { mutableStateOf(false) } var selecting by remember { mutableStateOf(false) } - var selectedUrls by remember { mutableStateOf(setOf()) } + var selectedUrls by remember { mutableStateOf(listOf()) } var showAddToPlaylist by remember { mutableStateOf(false) } var showCreatePlaylist by remember { mutableStateOf(false) } var showMultiSelectionSheet by remember { mutableStateOf(false) } @@ -172,7 +177,7 @@ fun YoutubeScreen( start = 16.dp, end = 16.dp, top = 8.dp, - bottom = bottomBarHeight + MiniPlayerHeight + 30.dp, + bottom = bottomBarHeight + MiniPlayerHeight + 30.dp + if (selecting) 72.dp else 0.dp, ) val currentSongId = stablePlayer.currentSong?.id val isPlaying = stablePlayer.isPlaying @@ -252,7 +257,8 @@ fun YoutubeScreen( if (selecting) { BackHandler { selecting = false - selectedUrls = emptySet() + selectedUrls = emptyList() + showMultiSelectionSheet = false } } @@ -318,31 +324,48 @@ fun YoutubeScreen( } val onSongClick: (YoutubeHit, Boolean) -> Unit = { hit, asRadio -> - if (selecting && hit.kind == YoutubeHit.Kind.TRACK) { - selectedUrls = if (hit.url in selectedUrls) selectedUrls - hit.url else selectedUrls + hit.url - if (selectedUrls.isEmpty()) { - selecting = false - showMultiSelectionSheet = false + if (selecting) { + if (hit.isSelectableTrack()) { + selectedUrls = selectedUrls.toggleUrl(hit.url) + if (selectedUrls.isEmpty()) { + selecting = false + showMultiSelectionSheet = false + } } } else { playHit(hit, asRadio) } } val onSongLongPress: (YoutubeHit) -> Unit = { hit -> - if (hit.kind == YoutubeHit.Kind.TRACK) { + if (hit.isSelectableTrack()) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + selecting = true + selectedUrls = selectedUrls.toggleUrl(hit.url) + if (selectedUrls.isEmpty()) { + selecting = false + showMultiSelectionSheet = false + } + viewModel.ensureLibraryPlaylists() + } + } + val onSongActions: (YoutubeHit) -> Unit = { hit -> + if (hit.isSelectableTrack()) { selecting = true - selectedUrls = selectedUrls + hit.url + selectedUrls = listOf(hit.url) showMultiSelectionSheet = true viewModel.ensureLibraryPlaylists() } } val selectedHits = remember(selectedUrls, rows) { - rows.filter { it.url in selectedUrls && it.kind == YoutubeHit.Kind.TRACK } + selectedUrls.mapNotNull { url -> + rows.firstOrNull { it.url == url && it.isSelectableTrack() } + } } val selectedSongs = remember(selectedHits) { selectedHits.map(viewModel::listSong) } + val canSelectTracks = rows.any { it.isSelectableTrack() } fun clearYoutubeSelection() { selecting = false - selectedUrls = emptySet() + selectedUrls = emptyList() showMultiSelectionSheet = false } @@ -362,7 +385,13 @@ fun YoutubeScreen( searchFocusRequester = searchFocusRequester, searchFilter = uiState.filter, onFilter = viewModel::setFilter, - onBack = if (uiState.collectionTitle != null) viewModel::closeCollection else null, + onBack = when { + selecting -> { + { clearYoutubeSelection() } + } + uiState.collectionTitle != null -> viewModel::closeCollection + else -> null + }, signedIn = account.isSignedIn, onBeta = { showBeta = true }, onChangelog = { showChangelog = true }, @@ -374,12 +403,17 @@ fun YoutubeScreen( account.isSignedIn ) { { - selectedUrls = emptySet() + selectedUrls = emptyList() showCreatePlaylist = true } } else { null }, + onSelectTracks = if (canSelectTracks && !selecting) { + { selecting = true } + } else { + null + }, ) }, ) { innerPadding -> @@ -474,7 +508,10 @@ fun YoutubeScreen( modifier = Modifier.width(168.dp), isCurrent = currentSongId == YoutubeClient.songIdForUrl(hit.url), isPlaying = isPlaying, + selecting = selecting, + selected = hit.url in selectedUrls, onClick = { onSongClick(hit, false) }, + onLongPress = { onSongLongPress(hit) }, ) } } @@ -525,7 +562,10 @@ fun YoutubeScreen( } else { hit.artist }, + selecting = selecting, + selected = hit.url in selectedUrls, onClick = { onSongClick(hit, shelf.startsRadio) }, + onLongPress = { onSongLongPress(hit) }, ) } } @@ -570,7 +610,10 @@ fun YoutubeScreen( } } } - isWideScreen && rows.any { it.kind == YoutubeHit.Kind.COLLECTION || browseSection == YoutubeSection.HOME } -> { + isWideScreen && + uiState.collectionTitle == null && + (browseSection == YoutubeSection.HOME || + rows.any { it.kind == YoutubeHit.Kind.COLLECTION }) -> { val columns = if (configuration.screenWidthDp >= 840) 4 else 3 LazyVerticalGrid( columns = GridCells.Fixed(columns), @@ -583,7 +626,10 @@ fun YoutubeScreen( hit = hit, isCurrent = currentSongId == YoutubeClient.songIdForUrl(hit.url), isPlaying = isPlaying, + selecting = selecting, + selected = hit.url in selectedUrls, onClick = { onSongClick(hit, browseSection == YoutubeSection.RADIO) }, + onLongPress = { onSongLongPress(hit) }, ) } } @@ -613,9 +659,10 @@ fun YoutubeScreen( liked = song.id in likedIds || viewModel.isLiked(hit), saved = song.id in savedIds, selecting = selecting, - selected = hit.url in selectedUrls, + selected = selectedUrls.indexOf(hit.url).let { if (it >= 0) it + 1 else null }, onPlay = { onSongClick(hit, browseSection == YoutubeSection.RADIO) }, onLongPress = { onSongLongPress(hit) }, + onActions = { onSongActions(hit) }, onLike = { viewModel.toggleLike(hit) }, onSave = { viewModel.toggleSave(hit) }, ) @@ -637,15 +684,44 @@ fun YoutubeScreen( } } } - if (selecting && selectedUrls.isNotEmpty()) { + if (selecting) { SelectionCountPill( selectedCount = selectedUrls.size, modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = MiniPlayerHeight + 16.dp) - .zIndex(2f) - .clickable { showMultiSelectionSheet = true }, + .align(Alignment.TopCenter) + .padding(top = 12.dp) + .zIndex(2f), ) + Card( + shape = AbsoluteSmoothCornerShape(28.dp, 60), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding( + start = 16.dp, + end = 16.dp, + bottom = MiniPlayerHeight + 16.dp, + ) + .zIndex(2f), + ) { + SelectionActionRow( + selectedCount = selectedUrls.size, + onSelectAll = { + selectedUrls = rows.filter { it.isSelectableTrack() }.map { it.url } + }, + onDeselect = { clearYoutubeSelection() }, + onOptionsClick = { + if (selectedHits.isNotEmpty()) { + showMultiSelectionSheet = true + viewModel.ensureLibraryPlaylists() + } + }, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + ) + } } } } @@ -689,7 +765,7 @@ fun YoutubeScreen( viewModel.addHitsToPlaylist(playlist, chosen) showAddToPlaylist = false selecting = false - selectedUrls = emptySet() + selectedUrls = emptyList() }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), shape = RoundedCornerShape(16.dp), @@ -727,7 +803,7 @@ fun YoutubeScreen( newPlaylistName = "" showCreatePlaylist = false selecting = false - selectedUrls = emptySet() + selectedUrls = emptyList() }, enabled = newPlaylistName.trim().isNotEmpty(), ) { @@ -749,11 +825,15 @@ fun YoutubeScreen( }.toSet(), onDismiss = { showMultiSelectionSheet = false }, onPlayAll = { - playerViewModel.playSongs( - songsToPlay = selectedSongs, - startSong = selectedSongs.first(), - queueName = uiState.collectionTitle ?: "YouTube Music", - ) + if (selectedHits.size == 1) { + playHit(selectedHits.first(), browseSection == YoutubeSection.RADIO) + } else { + playerViewModel.playSongs( + songsToPlay = selectedSongs, + startSong = selectedSongs.first(), + queueName = uiState.collectionTitle ?: "YouTube Music", + ) + } clearYoutubeSelection() }, onAddToQueue = { @@ -813,6 +893,7 @@ private fun YoutubeCompactHeader( onAccount: () -> Unit, onSettings: () -> Unit, onCreatePlaylist: (() -> Unit)? = null, + onSelectTracks: (() -> Unit)? = null, ) { val headerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) Column(Modifier.fillMaxWidth().background(headerColor)) { @@ -859,6 +940,14 @@ private fun YoutubeCompactHeader( } else { Spacer(Modifier.weight(1f)) } + if (onSelectTracks != null) { + FilledIconButton(onClick = onSelectTracks) { + Icon( + imageVector = Icons.Rounded.SelectAll, + contentDescription = stringResource(R.string.youtube_select), + ) + } + } if (onCreatePlaylist != null) { FilledIconButton(onClick = onCreatePlaylist) { Icon( @@ -1014,16 +1103,14 @@ private fun YoutubeTrackRow( liked: Boolean, saved: Boolean, selecting: Boolean, - selected: Boolean, + selected: Int?, onPlay: () -> Unit, onLongPress: () -> Unit, + onActions: () -> Unit, onLike: () -> Unit, onSave: () -> Unit, ) { Row(verticalAlignment = Alignment.CenterVertically) { - if (selecting) { - Checkbox(checked = selected, onCheckedChange = { onPlay() }) - } EnhancedSongListItem( modifier = Modifier .weight(1f) @@ -1031,23 +1118,30 @@ private fun YoutubeTrackRow( song = song, isPlaying = isPlaying, isCurrentSong = isCurrent, - showMoreOptionsButton = false, - onMoreOptionsClick = {}, + isSelected = selected != null, + selectionIndex = selected, + isSelectionMode = selecting, + showMoreOptionsButton = !selecting, + handleGestures = false, + onLongPress = onLongPress, + onMoreOptionsClick = { onActions() }, onClick = onPlay, ) - IconButton(onClick = onLike) { - Icon( - imageVector = if (liked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, - contentDescription = stringResource(if (liked) R.string.youtube_unlike else R.string.youtube_like), - tint = if (liked) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - IconButton(onClick = onSave) { - Icon( - imageVector = if (saved) Icons.Rounded.Bookmark else Icons.Rounded.BookmarkBorder, - contentDescription = stringResource(if (saved) R.string.youtube_unsave else R.string.youtube_save), - tint = if (saved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (!selecting) { + IconButton(onClick = onLike) { + Icon( + imageVector = if (liked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, + contentDescription = stringResource(if (liked) R.string.youtube_unlike else R.string.youtube_like), + tint = if (liked) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onSave) { + Icon( + imageVector = if (saved) Icons.Rounded.Bookmark else Icons.Rounded.BookmarkBorder, + contentDescription = stringResource(if (saved) R.string.youtube_unsave else R.string.youtube_save), + tint = if (saved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } @@ -1059,16 +1153,22 @@ private fun YoutubeTile( isPlaying: Boolean, subtitle: String = hit.artist, modifier: Modifier = Modifier, + selecting: Boolean = false, + selected: Boolean = false, onClick: () -> Unit, + onLongPress: () -> Unit = {}, ) { + val selectable = hit.isSelectableTrack() Surface( - onClick = onClick, - modifier = modifier, + modifier = modifier.combinedClickable( + onClick = onClick, + onLongClick = { if (selectable) onLongPress() }, + ), shape = RoundedCornerShape(24.dp), - color = if (isCurrent) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerLow + color = when { + selected -> MaterialTheme.colorScheme.primaryContainer + isCurrent -> MaterialTheme.colorScheme.primaryContainer + else -> MaterialTheme.colorScheme.surfaceContainerLow }, ) { Column(Modifier.padding(10.dp)) { @@ -1082,7 +1182,26 @@ private fun YoutubeTile( .clip(RoundedCornerShape(18.dp)), contentScale = ContentScale.Crop, ) - if (isCurrent && isPlaying) { + if (selecting && selectable) { + Box( + modifier = Modifier + .matchParentSize() + .background( + MaterialTheme.colorScheme.primary.copy( + alpha = if (selected) 0.45f else 0.18f, + ), + ), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Rounded.SelectAll, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } else if (isCurrent && isPlaying) { PlayingEqIcon( modifier = Modifier .align(Alignment.BottomEnd) @@ -1096,7 +1215,7 @@ private fun YoutubeTile( Text( text = hit.title, fontFamily = GoogleSansRounded, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -1156,3 +1275,8 @@ private fun placeholderSong(hit: YoutubeHit): Song { sampleRate = null, ) } + +private fun YoutubeHit.isSelectableTrack(): Boolean = kind == YoutubeHit.Kind.TRACK + +private fun List.toggleUrl(url: String): List = + if (url in this) this - url else this + url From 0dcdb80d5c4cb192c9e992dec6e8f2a5a91d1d39 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 19:56:08 +0300 Subject: [PATCH 68/77] fix: show recently played under your mix on feed The hero mix stays first. Recents sit after the collage, before the rest of the home shelves. Co-authored-by: Cursor --- .../presentation/screens/YoutubeScreen.kt | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 575c8843ab..d646bc3e4d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -492,31 +492,6 @@ fun YoutubeScreen( mixHits.take(8).map { placeholderSong(it) }.toImmutableList() } LazyColumn(contentPadding = contentPadding) { - if (recent.isNotEmpty()) { - item(key = "recent") { - Text( - text = stringResource(R.string.youtube_recent_title), - modifier = Modifier.padding(bottom = 8.dp, top = 4.dp), - style = MaterialTheme.typography.titleLarge, - fontFamily = GoogleSansRounded, - fontWeight = FontWeight.Bold, - ) - LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - items(recent, key = { "recent-${it.url}" }) { hit -> - YoutubeTile( - hit = hit, - modifier = Modifier.width(168.dp), - isCurrent = currentSongId == YoutubeClient.songIdForUrl(hit.url), - isPlaying = isPlaying, - selecting = selecting, - selected = hit.url in selectedUrls, - onClick = { onSongClick(hit, false) }, - onLongPress = { onSongLongPress(hit) }, - ) - } - } - } - } if (mixHits.isNotEmpty()) { item(key = "your_mix") { YourMixHeader( @@ -542,6 +517,31 @@ fun YoutubeScreen( } } } + if (recent.isNotEmpty()) { + item(key = "recent") { + Text( + text = stringResource(R.string.youtube_recent_title), + modifier = Modifier.padding(bottom = 8.dp, top = 12.dp), + style = MaterialTheme.typography.titleLarge, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(recent, key = { "recent-${it.url}" }) { hit -> + YoutubeTile( + hit = hit, + modifier = Modifier.width(168.dp), + isCurrent = currentSongId == YoutubeClient.songIdForUrl(hit.url), + isPlaying = isPlaying, + selecting = selecting, + selected = hit.url in selectedUrls, + onClick = { onSongClick(hit, false) }, + onLongPress = { onSongLongPress(hit) }, + ) + } + } + } + } items(uiState.shelves, key = { it.id }) { shelf -> Text( text = shelf.title, From 5e69d8b9475c3cf51ce6c93e433a64f1340144eb Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 19:58:06 +0300 Subject: [PATCH 69/77] feat: open listening stats from the bottom of feed A material 3 expressive action at the end of the feed replaces the old stats tab so plays and time stay reachable. Co-authored-by: Cursor --- .../presentation/screens/YoutubeScreen.kt | 73 ++++++++++++++++++- app/src/main/res/values/strings.xml | 1 + 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index d646bc3e4d..9707027111 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.padding @@ -40,6 +41,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.outlined.AutoGraph import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.Bookmark import androidx.compose.material.icons.rounded.BookmarkBorder @@ -62,6 +64,7 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeExtendedFloatingActionButton import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -478,7 +481,23 @@ fun YoutubeScreen( LoadingIndicator() } } else if (uiState.shelves.isEmpty() && recent.isEmpty()) { - EmptyYoutubeState(message = stringResource(R.string.youtube_home_empty)) + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + EmptyYoutubeState(message = stringResource(R.string.youtube_home_empty)) + } + FeedListeningStatsButton( + onClick = { navController.navigateSafely(Screen.Stats.route) }, + ) + } } else { val mixHits = uiState.shelves .firstOrNull { it.id == "yours" } @@ -570,6 +589,11 @@ fun YoutubeScreen( } } } + item(key = "listening_stats") { + FeedListeningStatsButton( + onClick = { navController.navigateSafely(Screen.Stats.route) }, + ) + } } } } @@ -1231,6 +1255,53 @@ private fun YoutubeTile( } } +@Composable +private fun FeedListeningStatsButton(onClick: () -> Unit) { + val shape = AbsoluteSmoothCornerShape( + cornerRadiusTL = 32.dp, + smoothnessAsPercentTR = 60, + cornerRadiusBR = 32.dp, + smoothnessAsPercentTL = 60, + cornerRadiusBL = 32.dp, + smoothnessAsPercentBR = 60, + cornerRadiusTR = 32.dp, + smoothnessAsPercentBL = 60, + ) + LargeExtendedFloatingActionButton( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp, bottom = 8.dp) + .heightIn(min = 72.dp), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + shape = shape, + ) { + Icon( + imageVector = Icons.Outlined.AutoGraph, + contentDescription = null, + modifier = Modifier.size(28.dp), + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.stats_title), + style = MaterialTheme.typography.titleMedium, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.youtube_feed_stats_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f), + fontFamily = GoogleSansRounded, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + @Composable private fun EmptyYoutubeState(message: String) { Column( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 690aac77fc..04b5e437d7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,6 +18,7 @@ Feed Recently played + Plays, time, and top tracks Trending, radio, and playlists Radio Playlists From 474d89a914895b4cd4d77678edbcd8176c8eba85 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 20:07:27 +0300 Subject: [PATCH 70/77] fix: drop the unused youtube bookmark save Heart still likes a track. Bookmark was a local list with no screen to open it, so the row and mini player icons are gone. Co-authored-by: Cursor --- .../components/UnifiedPlayerSheetLayers.kt | 7 ---- .../components/UnifiedPlayerSheetShared.kt | 40 +------------------ .../presentation/screens/YoutubeScreen.kt | 18 --------- .../presentation/viewmodel/PlayerViewModel.kt | 9 ----- .../pixelplay/youtube/YoutubeModels.kt | 1 - .../pixelplay/youtube/YoutubeMusicStore.kt | 17 +------- .../pixelplay/youtube/YoutubeViewModel.kt | 9 ----- app/src/main/res/values/strings.xml | 5 --- .../viewmodel/PlayerViewModelTest.kt | 1 - 9 files changed, 3 insertions(+), 104 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt index 97a9eca362..1865960e37 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt @@ -127,7 +127,6 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( val soundCloudPrefs = hiltViewModel() val likedPermalinks by soundCloudPrefs.likedPermalinks.collectAsStateWithLifecycle() val soundCloudPermalink = soundCloudPrefs.permalinkFor(currentSongNonNull.id) - val youtubeSavedIds by playerViewModel.youtubeSavedSongIds.collectAsStateWithLifecycle() val youtubeLiked by playerViewModel.isCurrentSongFavorite.collectAsStateWithLifecycle() val isYoutubeSong = currentSongNonNull.id.startsWith("yt_") val context = LocalContext.current @@ -159,12 +158,6 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( } } }, - youtubeSaved = currentSongNonNull.id in youtubeSavedIds, - onYoutubeSave = if (isYoutubeSong) { - { playerViewModel.toggleYoutubeSave() } - } else { - null - }, canScroll = isMiniPlayerVisible && infrequentPlayerState.isPlaying, modifier = Modifier.fillMaxSize() ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt index fefc86d1d8..43eb0c7376 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt @@ -20,8 +20,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Bookmark -import androidx.compose.material.icons.rounded.BookmarkBorder import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.Pause @@ -77,8 +75,6 @@ internal fun MiniPlayerContentInternal( onNext: () -> Unit, soundCloudLiked: Boolean = false, onSoundCloudLike: (() -> Unit)? = null, - youtubeSaved: Boolean = false, - onYoutubeSave: (() -> Unit)? = null, modifier: Modifier = Modifier, canScroll: Boolean = true ) { @@ -179,8 +175,8 @@ internal fun MiniPlayerContentInternal( imageVector = if (soundCloudLiked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, contentDescription = stringResource( when { - onYoutubeSave != null && soundCloudLiked -> R.string.youtube_unlike - onYoutubeSave != null -> R.string.youtube_like + song.id.startsWith("yt_") && soundCloudLiked -> R.string.youtube_unlike + song.id.startsWith("yt_") -> R.string.youtube_like soundCloudLiked -> R.string.soundcloud_unlike else -> R.string.soundcloud_like }, @@ -196,38 +192,6 @@ internal fun MiniPlayerContentInternal( Spacer(modifier = Modifier.width(4.dp)) } - if (onYoutubeSave != null) { - val saveInteraction = remember(song.id) { MutableInteractionSource() } - Box( - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .clickable( - interactionSource = saveInteraction, - indication = miniPlayerIndication, - enabled = controlsEnabled, - ) { - hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) - onYoutubeSave() - }, - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = if (youtubeSaved) Icons.Rounded.Bookmark else Icons.Rounded.BookmarkBorder, - contentDescription = stringResource( - if (youtubeSaved) R.string.youtube_unsave else R.string.youtube_save, - ), - tint = if (youtubeSaved) { - LocalMaterialTheme.current.primary - } else { - LocalMaterialTheme.current.onPrimaryContainer - }, - modifier = Modifier.size(22.dp), - ) - } - Spacer(modifier = Modifier.width(4.dp)) - } - Box( modifier = Modifier .size(36.dp) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 9707027111..498bd2ffd0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -43,8 +43,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.outlined.AutoGraph import androidx.compose.material.icons.rounded.AccountCircle -import androidx.compose.material.icons.rounded.Bookmark -import androidx.compose.material.icons.rounded.BookmarkBorder import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.Close @@ -151,10 +149,8 @@ fun YoutubeScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val liked by viewModel.likedTracks.collectAsStateWithLifecycle() - val saved by viewModel.savedTracks.collectAsStateWithLifecycle() val recent by viewModel.recentTracks.collectAsStateWithLifecycle() val likedIds by viewModel.likedSongIds.collectAsStateWithLifecycle() - val savedIds by viewModel.savedSongIds.collectAsStateWithLifecycle() val account by viewModel.account.collectAsStateWithLifecycle() val context = LocalContext.current val haptic = LocalHapticFeedback.current @@ -195,7 +191,6 @@ fun YoutubeScreen( YoutubeSection.HOME -> stringResource(R.string.youtube_home_title) YoutubeSection.SEARCH -> stringResource(R.string.youtube_search_title) YoutubeSection.LIKED -> stringResource(R.string.youtube_liked_title) - YoutubeSection.SAVED -> stringResource(R.string.youtube_saved_title) YoutubeSection.PLAYLISTS -> stringResource(R.string.youtube_playlists_title) YoutubeSection.RADIO -> stringResource(R.string.youtube_radio_title) } @@ -207,7 +202,6 @@ fun YoutubeScreen( } else { uiState.results.ifEmpty { liked } } - YoutubeSection.SAVED -> saved else -> uiState.results } val listState = rememberLazyListState() @@ -619,7 +613,6 @@ fun YoutubeScreen( YoutubeSection.HOME -> stringResource( if (uiState.collectionTitle != null) R.string.youtube_playlist_empty else R.string.youtube_home_empty, ) - YoutubeSection.SAVED -> stringResource(R.string.youtube_saved_empty) } ) if (browseSection == YoutubeSection.LIKED && !account.isSignedIn) { @@ -681,14 +674,12 @@ fun YoutubeScreen( isPlaying = isPlaying && currentSongId == song.id, isCurrent = currentSongId == song.id, liked = song.id in likedIds || viewModel.isLiked(hit), - saved = song.id in savedIds, selecting = selecting, selected = selectedUrls.indexOf(hit.url).let { if (it >= 0) it + 1 else null }, onPlay = { onSongClick(hit, browseSection == YoutubeSection.RADIO) }, onLongPress = { onSongLongPress(hit) }, onActions = { onSongActions(hit) }, onLike = { viewModel.toggleLike(hit) }, - onSave = { viewModel.toggleSave(hit) }, ) } if (uiState.isLoadingMore) { @@ -1125,14 +1116,12 @@ private fun YoutubeTrackRow( isPlaying: Boolean, isCurrent: Boolean, liked: Boolean, - saved: Boolean, selecting: Boolean, selected: Int?, onPlay: () -> Unit, onLongPress: () -> Unit, onActions: () -> Unit, onLike: () -> Unit, - onSave: () -> Unit, ) { Row(verticalAlignment = Alignment.CenterVertically) { EnhancedSongListItem( @@ -1159,13 +1148,6 @@ private fun YoutubeTrackRow( tint = if (liked) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, ) } - IconButton(onClick = onSave) { - Icon( - imageVector = if (saved) Icons.Rounded.Bookmark else Icons.Rounded.BookmarkBorder, - contentDescription = stringResource(if (saved) R.string.youtube_unsave else R.string.youtube_save), - tint = if (saved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index 9d67832acc..d2bf5caea1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -1202,8 +1202,6 @@ class PlayerViewModel @Inject constructor( .getFavoriteSongIdsFlow() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) - val youtubeSavedSongIds: StateFlow> = youtubeMusicStore.savedSongIds - val isCurrentSongFavorite: StateFlow = combine( stablePlayerState .map { it.currentSong } @@ -2306,13 +2304,6 @@ class PlayerViewModel @Inject constructor( } } - fun toggleYoutubeSave() { - val currentSong = playbackStateHolder.stablePlayerState.value.currentSong ?: return - if (currentSong.id.startsWith("yt_")) { - youtubeMusicStore.toggleSave(currentSong) - } - } - fun toggleFavoriteSpecificSong(song: Song, removing: Boolean = false) { viewModelScope.launch { val favoriteSongId = resolveFavoriteSongId(song) ?: return@launch diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt index cc7c693735..efd338aff2 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt @@ -4,7 +4,6 @@ enum class YoutubeSection { HOME, SEARCH, LIKED, - SAVED, PLAYLISTS, RADIO, } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt index 077745212f..2c50260c81 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt @@ -12,7 +12,7 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Liked and saved YouTube Music tracks. These lists are not device files. + * Liked YouTube Music tracks. These lists are not device files. */ @Singleton class YoutubeMusicStore @Inject constructor( @@ -20,16 +20,12 @@ class YoutubeMusicStore @Inject constructor( ) { private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val _liked = MutableStateFlow(read(KEY_LIKED)) - private val _saved = MutableStateFlow(read(KEY_SAVED)) private val _recent = MutableStateFlow(read(KEY_RECENT)) private val _likedSongIds = MutableStateFlow(idsOf(_liked.value)) - private val _savedSongIds = MutableStateFlow(idsOf(_saved.value)) val liked: StateFlow> = _liked.asStateFlow() - val saved: StateFlow> = _saved.asStateFlow() val recent: StateFlow> = _recent.asStateFlow() val likedSongIds: StateFlow> = _likedSongIds.asStateFlow() - val savedSongIds: StateFlow> = _savedSongIds.asStateFlow() fun toggleLike(hit: YoutubeHit) { _liked.value = toggle(_liked.value, hit) @@ -37,20 +33,10 @@ class YoutubeMusicStore @Inject constructor( persist(KEY_LIKED, _liked.value) } - fun toggleSave(hit: YoutubeHit) { - _saved.value = toggle(_saved.value, hit) - _savedSongIds.value = idsOf(_saved.value) - persist(KEY_SAVED, _saved.value) - } - fun toggleLike(song: Song) { hitFromSong(song)?.let(::toggleLike) } - fun toggleSave(song: Song) { - hitFromSong(song)?.let(::toggleSave) - } - fun rememberRecent(hit: YoutubeHit) { if (hit.url.isBlank() || hit.kind != YoutubeHit.Kind.TRACK) return val next = listOf(hit) + _recent.value.filterNot { it.url == hit.url } @@ -131,7 +117,6 @@ class YoutubeMusicStore @Inject constructor( companion object { private const val PREFS = "youtube_music" private const val KEY_LIKED = "liked" - private const val KEY_SAVED = "saved" private const val KEY_RECENT = "recent" private const val MAX_TRACKS = 200 private const val MAX_RECENT = 24 diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt index d59324251a..8ae085e94c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -47,10 +47,8 @@ class YoutubeViewModel @Inject constructor( private val _libraryPlaylists = MutableStateFlow>(emptyList()) val libraryPlaylists: StateFlow> = _libraryPlaylists.asStateFlow() val likedTracks: StateFlow> = musicStore.liked - val savedTracks: StateFlow> = musicStore.saved val recentTracks: StateFlow> = musicStore.recent val likedSongIds: StateFlow> = musicStore.likedSongIds - val savedSongIds: StateFlow> = musicStore.savedSongIds val account: StateFlow = session.account private var searchJob: Job? = null private var likedJob: Job? = null @@ -76,7 +74,6 @@ class YoutubeViewModel @Inject constructor( val hasContent = when (section) { YoutubeSection.HOME -> current.shelves.isNotEmpty() YoutubeSection.SEARCH -> current.query.trim().length < 2 || current.results.isNotEmpty() - YoutubeSection.SAVED -> true else -> current.results.isNotEmpty() } if (hasContent) return @@ -125,7 +122,6 @@ class YoutubeViewModel @Inject constructor( } loadRadioStations(refreshing = client.radioHits.isNotEmpty()) } - YoutubeSection.SAVED -> _uiState.update { it.copy(results = emptyList(), shelves = emptyList(), isLoading = false) } } } @@ -158,7 +154,6 @@ class YoutubeViewModel @Inject constructor( YoutubeSection.LIKED -> loadLiked(refreshing = true) YoutubeSection.PLAYLISTS -> loadPlaylists(refreshing = true) YoutubeSection.RADIO -> loadRadioStations(refreshing = true) - YoutubeSection.SAVED -> Unit } } @@ -331,10 +326,6 @@ class YoutubeViewModel @Inject constructor( } } - fun toggleSave(hit: YoutubeHit) { - musicStore.toggleSave(hit) - } - fun rememberPlayed(song: Song) { musicStore.rememberRecent(song) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 04b5e437d7..b5e0a3c07b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,14 +24,10 @@ Playlists Search Liked - Saved Home Liked - Saved Like Unlike - Save - Remove save Songs, albums, playlists Clear search Songs @@ -43,7 +39,6 @@ Sign out Done Finish signing in, then tap Done. - Saved songs show up here. Search YouTube Music. The feed is unavailable right now. This playlist did not return any tracks. diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt index 9eb962a194..a155c92d02 100644 --- a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt @@ -170,7 +170,6 @@ class PlayerViewModelTest { every { mockLibraryStateHolder.currentFavoriteSortOption } returns MutableStateFlow(SortOption.LikedSongTitleAZ) every { mockLibraryStateHolder.currentStorageFilter } returns MutableStateFlow(StorageFilter.ALL) every { mockYoutubeMusicStore.likedSongIds } returns MutableStateFlow(emptySet()) - every { mockYoutubeMusicStore.savedSongIds } returns MutableStateFlow(emptySet()) every { mockSearchStateHolder.searchHistory } returns _searchHistoryFlow every { mockSearchStateHolder.searchResults } returns _searchResultsFlow From a1a83f68b93758d90542f241a7f82d3c6b853ec4 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 20:58:53 +0300 Subject: [PATCH 71/77] fix: pin YouTube selection actions to the list header The select row was floating over the bottom of the screen, so the player and navigation covered it. The count chip sat on the tracks. Selection now uses the library action row at the top of the list. --- .../presentation/screens/YoutubeScreen.kt | 74 ++++++++----------- 1 file changed, 30 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 498bd2ffd0..db25f4544c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -9,6 +9,11 @@ package com.theveloper.pixelplay.presentation.screens import android.content.Intent import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -52,8 +57,6 @@ import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.SelectAll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.DockedSearchBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -102,7 +105,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage @@ -114,7 +116,6 @@ import com.theveloper.pixelplay.presentation.components.resolveNavBarOccupiedHei import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIcon import com.theveloper.pixelplay.presentation.components.subcomps.SelectionActionRow -import com.theveloper.pixelplay.presentation.components.subcomps.SelectionCountPill import androidx.navigation.NavController import com.theveloper.pixelplay.presentation.navigation.Screen import com.theveloper.pixelplay.presentation.navigation.navigateSafely @@ -176,7 +177,7 @@ fun YoutubeScreen( start = 16.dp, end = 16.dp, top = 8.dp, - bottom = bottomBarHeight + MiniPlayerHeight + 30.dp + if (selecting) 72.dp else 0.dp, + bottom = bottomBarHeight + MiniPlayerHeight + 30.dp, ) val currentSongId = stablePlayer.currentSong?.id val isPlaying = stablePlayer.isPlaying @@ -434,7 +435,30 @@ fun YoutubeScreen( smoothnessAsPercentBR = 60, ), ) { - Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = selecting, + enter = slideInHorizontally { -it } + fadeIn(), + exit = slideOutHorizontally { it } + fadeOut(), + ) { + SelectionActionRow( + selectedCount = selectedUrls.size, + onSelectAll = { + selectedUrls = rows.filter { it.isSelectableTrack() }.map { it.url } + }, + onDeselect = { clearYoutubeSelection() }, + onOptionsClick = { + if (selectedHits.isNotEmpty()) { + showMultiSelectionSheet = true + viewModel.ensureLibraryPlaylists() + } + }, + modifier = Modifier + .padding(top = 6.dp, start = 10.dp, end = 10.dp, bottom = 4.dp) + .heightIn(min = 56.dp), + ) + } + Box(Modifier.weight(1f).fillMaxWidth()) { PullToRefreshBox( isRefreshing = uiState.isRefreshing, onRefresh = viewModel::refresh, @@ -699,44 +723,6 @@ fun YoutubeScreen( } } } - if (selecting) { - SelectionCountPill( - selectedCount = selectedUrls.size, - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = 12.dp) - .zIndex(2f), - ) - Card( - shape = AbsoluteSmoothCornerShape(28.dp, 60), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - modifier = Modifier - .align(Alignment.BottomCenter) - .padding( - start = 16.dp, - end = 16.dp, - bottom = MiniPlayerHeight + 16.dp, - ) - .zIndex(2f), - ) { - SelectionActionRow( - selectedCount = selectedUrls.size, - onSelectAll = { - selectedUrls = rows.filter { it.isSelectableTrack() }.map { it.url } - }, - onDeselect = { clearYoutubeSelection() }, - onOptionsClick = { - if (selectedHits.isNotEmpty()) { - showMultiSelectionSheet = true - viewModel.ensureLibraryPlaylists() - } - }, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), - ) - } } } } From de143ed90fe7f0c584a6064d507c5d36c7364172 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 21:40:53 +0300 Subject: [PATCH 72/77] fix: open feed selection options for shelf tracks Feed selections live on home shelves, not the results list, so the options button had no tracks to act on. --- .../presentation/screens/YoutubeScreen.kt | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index db25f4544c..8a3722515c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -205,6 +205,15 @@ fun YoutubeScreen( } else -> uiState.results } + // Home shelves and recent plays are not in results, so selection actions + // have to resolve those tracks or the options button has nothing to open. + val selectableHits = if (browseSection == YoutubeSection.HOME && uiState.collectionTitle == null) { + (recent + uiState.shelves.flatMap { it.items }) + .filter { it.isSelectableTrack() } + .distinctBy { it.url } + } else { + rows.filter { it.isSelectableTrack() } + } val listState = rememberLazyListState() val pullState = rememberPullToRefreshState() @@ -354,13 +363,12 @@ fun YoutubeScreen( viewModel.ensureLibraryPlaylists() } } - val selectedHits = remember(selectedUrls, rows) { - selectedUrls.mapNotNull { url -> - rows.firstOrNull { it.url == url && it.isSelectableTrack() } - } + val selectedHits = remember(selectedUrls, selectableHits) { + val byUrl = selectableHits.associateBy { it.url } + selectedUrls.mapNotNull { byUrl[it] } } val selectedSongs = remember(selectedHits) { selectedHits.map(viewModel::listSong) } - val canSelectTracks = rows.any { it.isSelectableTrack() } + val canSelectTracks = selectableHits.isNotEmpty() fun clearYoutubeSelection() { selecting = false selectedUrls = emptyList() @@ -444,7 +452,7 @@ fun YoutubeScreen( SelectionActionRow( selectedCount = selectedUrls.size, onSelectAll = { - selectedUrls = rows.filter { it.isSelectableTrack() }.map { it.url } + selectedUrls = selectableHits.map { it.url } }, onDeselect = { clearYoutubeSelection() }, onOptionsClick = { @@ -762,8 +770,7 @@ fun YoutubeScreen( libraryPlaylists.forEach { playlist -> Surface( onClick = { - val chosen = rows.filter { it.url in selectedUrls } - viewModel.addHitsToPlaylist(playlist, chosen) + viewModel.addHitsToPlaylist(playlist, selectedHits) showAddToPlaylist = false selecting = false selectedUrls = emptyList() @@ -799,8 +806,7 @@ fun YoutubeScreen( confirmButton = { TextButton( onClick = { - val chosen = rows.filter { it.url in selectedUrls } - viewModel.createPlaylistAndAdd(newPlaylistName, chosen) + viewModel.createPlaylistAndAdd(newPlaylistName, selectedHits) newPlaylistName = "" showCreatePlaylist = false selecting = false From 785cfd8b0c794c51ec72f74b37caa3d446a3b5cd Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 23:36:43 +0300 Subject: [PATCH 73/77] fix: name YouTube plays in listening stats Stats only looked up local library songs, so YouTube plays became Unknown Artist, Unknown Album, and missing tracks. Played YouTube songs are now kept and included in stats. --- .../pixelplay/data/service/MusicService.kt | 19 ++ .../data/stats/PlaybackStatsRepository.kt | 8 + .../presentation/viewmodel/StatsViewModel.kt | 33 +++- .../pixelplay/youtube/YoutubeClient.kt | 2 +- .../pixelplay/youtube/YoutubeModels.kt | 1 + .../pixelplay/youtube/YoutubeMusicStore.kt | 180 ++++++++++++++++++ .../pixelplay/youtube/YoutubeViewModel.kt | 6 + .../data/stats/PlaybackStatsRepositoryTest.kt | 48 ++++- 8 files changed, 288 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index 575879e0c3..649cc0ef49 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -176,6 +176,8 @@ class MusicService : MediaLibraryService() { @Inject lateinit var youtubeClient: com.theveloper.pixelplay.youtube.YoutubeClient @Inject + lateinit var youtubeMusicStore: com.theveloper.pixelplay.youtube.YoutubeMusicStore + @Inject @AppScope lateinit var appScope: CoroutineScope @@ -382,6 +384,7 @@ class MusicService : MediaLibraryService() { return } + rememberYoutubePlayback(mediaItem) val positionMs = player.currentPosition.coerceAtLeast(0L) val durationMs = player.duration val fallbackDurationMs = mediaItem.mediaMetadata.extras @@ -407,6 +410,22 @@ class MusicService : MediaLibraryService() { } } + private fun rememberYoutubePlayback(mediaItem: MediaItem?) { + val item = mediaItem ?: return + val songId = item.mediaId ?: return + if (!songId.startsWith("yt_")) return + val metadata = item.mediaMetadata + val title = metadata.title?.toString()?.trim().orEmpty() + if (title.isBlank()) return + youtubeMusicStore.rememberPlayback( + songId = songId, + title = title, + artist = metadata.artist?.toString().orEmpty(), + album = metadata.albumTitle?.toString().orEmpty(), + artworkUrl = metadata.artworkUri?.toString(), + ) + } + override fun onCreate() { // Media3's Cast SDK callback path (MediaSessionImpl$$ExternalSyntheticLambda → // Util.postOrRun → MediaNotificationManager.updateNotificationInternal) calls diff --git a/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt index 3e53509658..d8a5dd3610 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt @@ -1149,3 +1149,11 @@ enum class StatsTimeRange(val displayName: String) { YEAR("Year to Date"), ALL("All Time") } + +/** Library songs win when an id exists in both lists. */ +internal fun mergeStatsSongs(library: List, streamed: List): List { + if (streamed.isEmpty()) return library + val known = HashSet(library.size) + library.forEach { known += it.id } + return library + streamed.filter { it.id !in known && it.title.isNotBlank() } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt index 9f26f7c15e..c0482641dc 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt @@ -7,6 +7,8 @@ import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.data.stats.PlaybackStatsRepository import com.theveloper.pixelplay.data.stats.PlaybackStatsRepository.PlaybackStatsSummary import com.theveloper.pixelplay.data.stats.StatsTimeRange +import com.theveloper.pixelplay.data.stats.mergeStatsSongs +import com.theveloper.pixelplay.youtube.YoutubeMusicStore import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers @@ -23,7 +25,8 @@ import timber.log.Timber @HiltViewModel class StatsViewModel @Inject constructor( private val playbackStatsRepository: PlaybackStatsRepository, - private val musicRepository: MusicRepository + private val musicRepository: MusicRepository, + private val youtubeMusicStore: YoutubeMusicStore, ) : ViewModel() { data class StatsUiState( @@ -47,6 +50,7 @@ class StatsViewModel @Inject constructor( private var cachedSongs: List? = null init { + observeYoutubeCatalog() observeStatsRefreshFlow() refreshRange( range = StatsTimeRange.WEEK, @@ -167,13 +171,30 @@ class StatsViewModel @Inject constructor( playbackStatsRepository.requestRefresh() } + private fun observeYoutubeCatalog() { + viewModelScope.launch { + youtubeMusicStore.catalogVersion + .drop(1) + .collectLatest { + val selectedRange = _uiState.value.selectedRange + refreshRange( + range = selectedRange, + showLoading = false, + updateWeeklyOverview = selectedRange == StatsTimeRange.WEEK + ) + if (selectedRange != StatsTimeRange.WEEK) { + refreshWeeklyOverview() + } + refreshHomeOverview() + } + } + } + private suspend fun loadSongs(): List { - cachedSongs?.let { existing -> - if (existing.isNotEmpty()) return existing + val library = cachedSongs?.takeIf { it.isNotEmpty() } ?: musicRepository.getAllSongsOnce().also { loaded -> + if (loaded.isNotEmpty()) cachedSongs = loaded } - val songs = musicRepository.getAllSongsOnce() - cachedSongs = songs - return songs + return mergeStatsSongs(library, youtubeMusicStore.songsForStats()) } private fun PlaybackStatsSummary.hasListeningActivity(): Boolean { diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index 18dbfa67ff..83bc05ebfd 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -815,7 +815,7 @@ class YoutubeClient @Inject constructor( artist = hit.artist, artistId = -1L, artists = emptyList(), - album = "YouTube Music", + album = hit.album.ifBlank { "YouTube Music" }, albumId = -1L, albumArtist = hit.artist, path = hit.url, diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt index efd338aff2..1b6bce8cc9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt @@ -28,6 +28,7 @@ data class YoutubeHit( val durationSec: Long, val thumbnailUrl: String?, val kind: Kind, + val album: String = "", ) { enum class Kind { TRACK, COLLECTION } } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt index 2c50260c81..f2ab5bc7c3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt @@ -26,8 +26,19 @@ class YoutubeMusicStore @Inject constructor( val liked: StateFlow> = _liked.asStateFlow() val recent: StateFlow> = _recent.asStateFlow() val likedSongIds: StateFlow> = _likedSongIds.asStateFlow() + private val catalog = LinkedHashMap() + private val _catalogVersion = MutableStateFlow(0) + val catalogVersion: StateFlow = _catalogVersion.asStateFlow() + + init { + synchronized(catalog) { + readCatalog().forEach { track -> catalog[track.songId] = track } + } + rememberHits(_liked.value + _recent.value) + } fun toggleLike(hit: YoutubeHit) { + rememberHits(listOf(hit)) _liked.value = toggle(_liked.value, hit) _likedSongIds.value = idsOf(_liked.value) persist(KEY_LIKED, _liked.value) @@ -42,12 +53,63 @@ class YoutubeMusicStore @Inject constructor( val next = listOf(hit) + _recent.value.filterNot { it.url == hit.url } _recent.value = next.take(MAX_RECENT) persist(KEY_RECENT, _recent.value) + rememberHits(listOf(hit)) } fun rememberRecent(song: Song) { hitFromSong(song)?.let(::rememberRecent) } + /** Keep titles and artists for tracks that are not in the on-device library. */ + fun rememberHits(hits: List) { + val tracks = hits.filter { it.kind == YoutubeHit.Kind.TRACK && it.url.isNotBlank() } + if (tracks.isEmpty()) return + var changed = false + synchronized(catalog) { + tracks.forEach { hit -> + val (artist, album) = credits(hit) + if (upsert( + CatalogTrack( + songId = YoutubeClient.songIdForUrl(hit.url), + title = hit.title.trim(), + artist = artist, + album = album, + artworkUrl = hit.thumbnailUrl, + ) + ) + ) { + changed = true + } + } + if (changed) persistCatalog() + } + if (changed) _catalogVersion.value += 1 + } + + fun rememberPlayback(songId: String, title: String, artist: String, album: String, artworkUrl: String?) { + val id = songId.trim() + if (!id.startsWith("yt_") || title.isBlank()) return + val changed = synchronized(catalog) { + val updated = upsert( + CatalogTrack( + songId = id, + title = title.trim(), + artist = cleanName(artist), + album = cleanAlbum(album), + artworkUrl = artworkUrl, + ) + ) + if (updated) persistCatalog() + updated + } + if (changed) _catalogVersion.value += 1 + } + + fun songsForStats(): List { + val tracks = synchronized(catalog) { catalog.values.toList() } + return tracks.map { it.toSong() } + } + private fun idsOf(hits: List): Set = hits.map { YoutubeClient.songIdForUrl(it.url) }.toSet() @@ -66,6 +128,7 @@ class YoutubeMusicStore @Inject constructor( durationSec = song.duration.coerceAtLeast(0L) / 1000L, thumbnailUrl = song.albumArtUriString, kind = YoutubeHit.Kind.TRACK, + album = song.album, ) } @@ -88,6 +151,7 @@ class YoutubeMusicStore @Inject constructor( .put("artist", hit.artist) .put("durationSec", hit.durationSec) .put("thumbnailUrl", hit.thumbnailUrl ?: "") + .put("album", hit.album) ) } return array.toString() @@ -108,17 +172,133 @@ class YoutubeMusicStore @Inject constructor( durationSec = item.optLong("durationSec"), thumbnailUrl = item.optString("thumbnailUrl").ifBlank { null }, kind = YoutubeHit.Kind.TRACK, + album = item.optString("album"), ) ) } } } + private fun upsert(track: CatalogTrack): Boolean { + if (track.songId.isBlank() || track.title.isBlank()) return false + val merged = catalog[track.songId]?.merge(track) ?: track + if (merged == catalog[track.songId]) return false + catalog.remove(track.songId) + catalog[track.songId] = merged + while (catalog.size > MAX_CATALOG) { + val eldest = catalog.keys.firstOrNull() ?: break + catalog.remove(eldest) + } + return true + } + + private fun persistCatalog() { + val array = JSONArray() + catalog.values.forEach { track -> + array.put( + JSONObject() + .put("songId", track.songId) + .put("title", track.title) + .put("artist", track.artist) + .put("album", track.album) + .put("artworkUrl", track.artworkUrl ?: "") + ) + } + prefs.edit().putString(KEY_CATALOG, array.toString()).apply() + } + + private fun readCatalog(): List { + val raw = prefs.getString(KEY_CATALOG, null) ?: return emptyList() + return runCatching { + val array = JSONArray(raw) + buildList { + for (index in 0 until array.length()) { + val item = array.getJSONObject(index) + val songId = item.optString("songId") + val title = item.optString("title") + if (songId.isBlank() || title.isBlank()) continue + add( + CatalogTrack( + songId = songId, + title = title, + artist = item.optString("artist"), + album = item.optString("album"), + artworkUrl = item.optString("artworkUrl").ifBlank { null }, + ) + ) + } + } + }.getOrDefault(emptyList()) + } + + private fun credits(hit: YoutubeHit): Pair { + val parts = hit.artist.split('•', '·') + .map { it.trim() } + .filter { it.isNotBlank() } + val artist = cleanName(parts.firstOrNull().orEmpty().ifBlank { hit.artist }) + val fromByline = parts.getOrNull(1).orEmpty() + val album = cleanAlbum(hit.album).ifBlank { cleanAlbum(fromByline) } + return artist to album + } + + private fun cleanName(value: String): String { + val trimmed = value.trim() + if (trimmed.isBlank() || trimmed.equals("Unknown Artist", ignoreCase = true)) return "" + return trimmed + } + + private fun cleanAlbum(value: String): String { + val trimmed = value.trim() + if (trimmed.isBlank()) return "" + if (trimmed.equals("YouTube Music", ignoreCase = true)) return "" + if (trimmed.equals("Unknown Album", ignoreCase = true)) return "" + if (trimmed.contains("view", ignoreCase = true)) return "" + if (DURATION.matches(trimmed)) return "" + return trimmed + } + + private data class CatalogTrack( + val songId: String, + val title: String, + val artist: String, + val album: String, + val artworkUrl: String?, + ) { + fun merge(incoming: CatalogTrack): CatalogTrack = copy( + title = incoming.title.ifBlank { title }, + artist = incoming.artist.ifBlank { artist }, + album = incoming.album.ifBlank { album }, + artworkUrl = incoming.artworkUrl ?: artworkUrl, + ) + + fun toSong(): Song = Song( + id = songId, + title = title, + artist = artist.ifBlank { "YouTube Music" }, + artistId = -1L, + artists = emptyList(), + album = album.ifBlank { "YouTube Music" }, + albumId = -1L, + albumArtist = artist.ifBlank { null }, + path = "", + contentUriString = "", + albumArtUriString = artworkUrl, + duration = 0L, + genre = "YouTube Music", + mimeType = null, + bitrate = null, + sampleRate = null, + ) + } + companion object { private const val PREFS = "youtube_music" private const val KEY_LIKED = "liked" private const val KEY_RECENT = "recent" + private const val KEY_CATALOG = "catalog" private const val MAX_TRACKS = 200 private const val MAX_RECENT = 24 + private const val MAX_CATALOG = 2000 + private val DURATION = Regex("""^\d+:\d{2}(?::\d{2})?$""") } } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt index 8ae085e94c..a734d1cc61 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt @@ -59,6 +59,12 @@ class YoutubeViewModel @Inject constructor( private var loadingMoreLiked = false init { + viewModelScope.launch { + uiState.collect { state -> + val hits = state.results + state.shelves.flatMap { it.items } + if (hits.isNotEmpty()) musicStore.rememberHits(hits) + } + } viewModelScope.launch { session.account.collect { account -> if (_uiState.value.section == YoutubeSection.LIKED) { diff --git a/app/src/test/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepositoryTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepositoryTest.kt index ad2faa3eb1..cf747590de 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepositoryTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepositoryTest.kt @@ -223,6 +223,49 @@ class PlaybackStatsRepositoryTest { assertThat(summary.topGenres.single().uniqueArtists).isEqualTo(2) } + @Test + fun `streamed songs supply artist album and title when missing from the library`() = runTest { + val repository = createRepository() + val zoneId = ZoneId.systemDefault() + val start = LocalDate.of(2026, 4, 10) + .atTime(16, 0) + .atZone(zoneId) + .toInstant() + .toEpochMilli() + val durationMs = 90_000L + val event = PlaybackStatsRepository.PlaybackEvent( + songId = "yt_super_shy", + timestamp = start + durationMs, + durationMs = durationMs, + startTimestamp = start, + endTimestamp = start + durationMs + ) + val streamed = song( + songId = "yt_super_shy", + artist = "NewJeans", + album = "NewJeans" + ).copy(title = "Super Shy") + + val summary = repository.buildSummaryFromEvents( + range = StatsTimeRange.DAY, + songs = mergeStatsSongs(emptyList(), listOf(streamed)), + allEvents = listOf(event), + nowMillis = start + durationMs + 1_000L + ) + + assertThat(summary.topArtists.map { it.artist }).containsExactly("NewJeans") + assertThat(summary.topAlbums.map { it.album }).containsExactly("NewJeans") + assertThat(summary.songs.map { it.title }).containsExactly("Super Shy") + } + + @Test + fun `mergeStatsSongs keeps the library copy when ids collide`() { + val library = song(songId = "yt_1", artist = "Library Artist") + val streamed = song(songId = "yt_1", artist = "Stream Artist") + val merged = mergeStatsSongs(listOf(library), listOf(streamed)) + assertThat(merged).containsExactly(library) + } + private fun createRepository(): PlaybackStatsRepository { val uniqueDir = createTempDirectory( "playback-stats-test-${Instant.now().toEpochMilli()}-" @@ -237,14 +280,15 @@ class PlaybackStatsRepositoryTest { durationMs: Long = 5 * 60 * 1000L, artist: String = "Artist", artists: List = emptyList(), - genre: String? = null + genre: String? = null, + album: String = "Album", ): Song = Song( id = songId, title = "Song $songId", artist = artist, artistId = 1L, artists = artists, - album = "Album", + album = album, albumId = 1L, path = "/music/$songId.mp3", contentUriString = "content://media/external/audio/media/$songId", From 6969e5169fdf7266eb58605f5a190f483b726b40 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Mon, 28 Sep 2026 23:47:41 +0300 Subject: [PATCH 74/77] fix: use real YouTube artist and album names Stop labeling YouTube plays as YouTube Music. Read the artist and album from the track, and look them up when a row omits them. --- .../pixelplay/data/service/MusicService.kt | 34 ++- .../presentation/screens/YoutubeScreen.kt | 4 +- .../presentation/viewmodel/StatsViewModel.kt | 19 ++ .../pixelplay/youtube/YoutubeClient.kt | 205 +++++++++++++++--- .../pixelplay/youtube/YoutubeModels.kt | 6 + .../pixelplay/youtube/YoutubeMusicStore.kt | 76 ++++++- 6 files changed, 302 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index 649cc0ef49..741752da66 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -417,13 +417,41 @@ class MusicService : MediaLibraryService() { val metadata = item.mediaMetadata val title = metadata.title?.toString()?.trim().orEmpty() if (title.isBlank()) return + val artist = metadata.artist?.toString().orEmpty() + val album = metadata.albumTitle?.toString().orEmpty() + val artworkUrl = metadata.artworkUri?.toString() + val videoId = metadata.extras + ?.getString(com.theveloper.pixelplay.youtube.YoutubeClient.EXTRA_WATCH_URL) + ?.let(com.theveloper.pixelplay.youtube.YoutubeClient::videoIdFromUrl) + .orEmpty() youtubeMusicStore.rememberPlayback( songId = songId, title = title, - artist = metadata.artist?.toString().orEmpty(), - album = metadata.albumTitle?.toString().orEmpty(), - artworkUrl = metadata.artworkUri?.toString(), + artist = artist, + album = album, + artworkUrl = artworkUrl, + videoId = videoId, ) + val missingCredit = artist.isBlank() || album.isBlank() || + artist.equals("YouTube Music", ignoreCase = true) || + album.equals("YouTube Music", ignoreCase = true) + if (missingCredit && videoId.length == 11 && youtubeMusicStore.claimCreditLookup(videoId)) { + appScope.launch(Dispatchers.IO) { + val credits = runCatching { youtubeClient.creditsFor(videoId) }.getOrNull() + if (credits == null) { + youtubeMusicStore.releaseCreditLookup(videoId) + return@launch + } + youtubeMusicStore.rememberPlayback( + songId = songId, + title = title, + artist = credits.artist.ifBlank { artist }, + album = credits.album.ifBlank { album }, + artworkUrl = artworkUrl, + videoId = videoId, + ) + } + } } override fun onCreate() { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 8a3722515c..371f1ace37 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -1308,13 +1308,13 @@ private fun placeholderSong(hit: YoutubeHit): Song { artist = hit.artist, artistId = -1L, artists = emptyList(), - album = "YouTube Music", + album = hit.album, albumId = -1L, path = hit.url, contentUriString = hit.url, albumArtUriString = hit.thumbnailUrl, duration = hit.durationSec.coerceAtLeast(0L) * 1000L, - genre = "YouTube Music", + genre = null, mimeType = null, bitrate = null, sampleRate = null, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt index c0482641dc..22d8faed98 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt @@ -8,6 +8,7 @@ import com.theveloper.pixelplay.data.stats.PlaybackStatsRepository import com.theveloper.pixelplay.data.stats.PlaybackStatsRepository.PlaybackStatsSummary import com.theveloper.pixelplay.data.stats.StatsTimeRange import com.theveloper.pixelplay.data.stats.mergeStatsSongs +import com.theveloper.pixelplay.youtube.YoutubeClient import com.theveloper.pixelplay.youtube.YoutubeMusicStore import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -27,6 +28,7 @@ class StatsViewModel @Inject constructor( private val playbackStatsRepository: PlaybackStatsRepository, private val musicRepository: MusicRepository, private val youtubeMusicStore: YoutubeMusicStore, + private val youtubeClient: YoutubeClient, ) : ViewModel() { data class StatsUiState( @@ -52,6 +54,7 @@ class StatsViewModel @Inject constructor( init { observeYoutubeCatalog() observeStatsRefreshFlow() + enrichYoutubeCredits() refreshRange( range = StatsTimeRange.WEEK, showLoading = true, @@ -162,6 +165,21 @@ class StatsViewModel @Inject constructor( } } + private fun enrichYoutubeCredits() { + viewModelScope.launch(Dispatchers.IO) { + runCatching { + val played = playbackStatsRepository.exportEventsForBackup() + .asSequence() + .map { it.songId } + .filter { it.startsWith("yt_") } + .toSet() + youtubeClient.fillPlayedCredits(youtubeMusicStore, played) + }.onFailure { throwable -> + Timber.e(throwable, "Failed to load YouTube artist and album names") + } + } + } + fun requestStatsRefresh() { playbackStatsRepository.requestRefresh() } @@ -176,6 +194,7 @@ class StatsViewModel @Inject constructor( youtubeMusicStore.catalogVersion .drop(1) .collectLatest { + enrichYoutubeCredits() val selectedRange = _uiState.value.selectedRange refreshRange( range = selectedRange, diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index 83bc05ebfd..5c14421750 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -249,6 +249,140 @@ class YoutubeClient @Inject constructor( return raw.replace("\\/", "/").replace("\\u003d", "=").replace("\\u0026", "&") } + /** + * Artist and album for one video, from YouTube Music's watch-next panel. + * Shelf rows often only carry the artist, so playback and stats call this + * when the album is still missing. + */ + fun creditsFor(videoId: String): YoutubeCredits { + val id = videoId.trim() + if (id.length != 11) return YoutubeCredits("", "") + applySession() + val body = JSONObject() + .put("context", JSONObject(musicContext())) + .put("videoId", id) + .put("playlistId", "RDAMVM$id") + val root = musicPost("next", body.toString()) + val panels = mutableListOf() + collectCreditRenderers(root, panels) + val match = panels.firstOrNull { renderer -> + renderer.optString("videoId") == id || firstVideoId(renderer) == id + } ?: panels.firstOrNull() ?: return YoutubeCredits("", "") + val (artist, album) = creditsFrom(match) + return YoutubeCredits(artist, album) + } + + fun fillPlayedCredits(store: YoutubeMusicStore, playedSongIds: Set, limit: Int = 40) { + if (playedSongIds.isEmpty()) return + store.creditGaps(playedSongIds, limit).forEach { gap -> + if (!store.claimCreditLookup(gap.videoId)) return@forEach + val credits = runCatching { creditsFor(gap.videoId) }.getOrNull() + if (credits == null) { + store.releaseCreditLookup(gap.videoId) + return@forEach + } + val artist = credits.artist.ifBlank { gap.artist } + val album = credits.album.ifBlank { gap.album } + if (artist == gap.artist && album == gap.album) return@forEach + store.rememberPlayback( + songId = gap.songId, + title = gap.title, + artist = artist, + album = album, + artworkUrl = gap.artworkUrl, + videoId = gap.videoId, + ) + } + } + + private fun collectCreditRenderers(node: Any?, found: MutableList) { + when (node) { + is JSONObject -> { + node.optJSONObject("playlistPanelVideoRenderer")?.let { found += it } + node.optJSONObject("musicResponsiveListItemRenderer")?.let { found += it } + val keys = node.keys() + while (keys.hasNext()) collectCreditRenderers(node.opt(keys.next()), found) + } + is JSONArray -> { + for (index in 0 until node.length()) collectCreditRenderers(node.opt(index), found) + } + } + } + + private fun creditsFrom(renderer: JSONObject): Pair { + val runs = buildList { + addAll(textRuns(renderer.optJSONObject("longBylineText"))) + addAll(textRuns(renderer.optJSONObject("shortBylineText"))) + addAll(textRuns(renderer.optJSONObject("subtitle"))) + addAll(textRuns(renderer.optJSONObject("secondSubtitle"))) + val flex = renderer.optJSONArray("flexColumns") ?: return@buildList + for (index in 1 until flex.length()) { + val text = flex.optJSONObject(index) + ?.optJSONObject("musicResponsiveListItemFlexColumnRenderer") + ?.optJSONObject("text") + val parsed = textRuns(text) + if (parsed.isNotEmpty()) addAll(parsed) else addAll(splitCreditRuns(jsonText(text))) + } + } + return pickCredits(runs) + } + + private fun splitCredits(value: String): Pair = pickCredits(splitCreditRuns(value)) + + private fun splitCreditRuns(value: String): List> = + value.split('•', '·') + .map { it.trim() to "" } + .filter { it.first.isNotBlank() } + + private fun textRuns(node: JSONObject?): List> { + if (node == null) return emptyList() + val runs = node.optJSONArray("runs") + if (runs == null) return splitCreditRuns(node.optString("simpleText")) + return buildList { + for (index in 0 until runs.length()) { + val run = runs.optJSONObject(index) ?: continue + val text = run.optString("text").trim() + if (text.isBlank() || text == "•" || text == "·") continue + val browseId = run.optJSONObject("navigationEndpoint") + ?.optJSONObject("browseEndpoint") + ?.optString("browseId") + .orEmpty() + if (browseId.isEmpty() && (text.contains('•') || text.contains('·'))) { + addAll(splitCreditRuns(text)) + } else { + add(text to browseId) + } + } + } + } + + private fun pickCredits(runs: List>): Pair { + val useful = runs.filter { (text, _) -> isCreditLabel(text) } + val artist = useful.firstOrNull { it.second.startsWith("UC") }?.first + ?: useful.firstOrNull()?.first + .orEmpty() + .removeSuffix(" - Topic") + .trim() + val album = useful.firstOrNull { it.second.startsWith("MPRE") }?.first + ?: useful.firstOrNull { (text, id) -> + !text.equals(artist, ignoreCase = true) && !id.startsWith("UC") + }?.first + .orEmpty() + return artist to album + } + + private fun isCreditLabel(text: String): Boolean { + val trimmed = text.trim() + if (trimmed.isBlank()) return false + if (trimmed.equals("YouTube Music", ignoreCase = true)) return false + if (trimmed.equals("Song", ignoreCase = true)) return false + if (trimmed.equals("Single", ignoreCase = true)) return false + if (trimmed.equals("EP", ignoreCase = true)) return false + if (trimmed.contains("view", ignoreCase = true)) return false + if (CREDIT_DURATION.matches(trimmed) || CREDIT_YEAR.matches(trimmed)) return false + return true + } + private fun musicBrowse( browseId: String? = null, continuation: String? = null, @@ -366,7 +500,7 @@ class YoutubeClient @Inject constructor( return YoutubeHit( url = "https://music.youtube.com/playlist?list=$playlistId", title = title, - artist = subtitle.ifBlank { "YouTube Music" }, + artist = subtitle, durationSec = 0, thumbnailUrl = thumbnail, kind = YoutubeHit.Kind.COLLECTION, @@ -374,14 +508,16 @@ class YoutubeClient @Inject constructor( } if (videoId.length == 11) { val isMix = isRadioMix(playlistId) + val (artist, album) = if (isMix) subtitle to "" else splitCredits(subtitle) return YoutubeHit( url = if (isMix) "https://www.youtube.com/watch?v=$videoId&list=$playlistId" else "https://music.youtube.com/watch?v=$videoId", title = title, - artist = subtitle.ifBlank { "YouTube Music" }, + artist = artist, durationSec = 0, thumbnailUrl = if (isMix) thumbnail else fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"), kind = if (isMix) YoutubeHit.Kind.COLLECTION else YoutubeHit.Kind.TRACK, + album = album, ) } } @@ -391,7 +527,7 @@ class YoutubeClient @Inject constructor( return YoutubeHit( url = "https://music.youtube.com/playlist?list=$playlistId", title = title, - artist = subtitle.ifBlank { "YouTube Music" }, + artist = subtitle, durationSec = 0, thumbnailUrl = thumbnail, kind = YoutubeHit.Kind.COLLECTION, @@ -414,7 +550,7 @@ class YoutubeClient @Inject constructor( return YoutubeHit( url = "https://music.youtube.com/playlist?list=$playlistId", title = title, - artist = subtitle.ifBlank { "YouTube Music" }, + artist = subtitle, durationSec = 0, thumbnailUrl = thumbnail, kind = YoutubeHit.Kind.COLLECTION, @@ -430,13 +566,15 @@ class YoutubeClient @Inject constructor( renderer.optJSONObject("navigationEndpoint")?.optJSONObject("watchEndpoint")?.optString("videoId").orEmpty() } if (videoId.length == 11) { + val (artist, album) = creditsFrom(renderer) return YoutubeHit( url = "https://music.youtube.com/watch?v=$videoId", title = title, - artist = flexColumnText(renderer, 1).ifBlank { "YouTube Music" }, + artist = artist, durationSec = 0, thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"), kind = YoutubeHit.Kind.TRACK, + album = album, ) } val browseId = renderer.optJSONObject("navigationEndpoint") @@ -449,7 +587,7 @@ class YoutubeClient @Inject constructor( return YoutubeHit( url = "https://music.youtube.com/playlist?list=$playlistId", title = title, - artist = flexColumnText(renderer, 1).ifBlank { "YouTube Music" }, + artist = flexColumnText(renderer, 1), durationSec = 0, thumbnailUrl = lastThumbnail( renderer.optJSONObject("thumbnail")?.optJSONObject("musicThumbnailRenderer")?.optJSONObject("thumbnail"), @@ -525,13 +663,15 @@ class YoutubeClient @Inject constructor( val videoId = renderer.optJSONObject("playlistItemData")?.optString("videoId").orEmpty() val title = flexColumnText(renderer, 0) if (videoId.length != 11 || title.isBlank()) continue + val (artist, album) = creditsFrom(renderer) hits += YoutubeHit( url = "https://music.youtube.com/watch?v=$videoId", title = title, - artist = flexColumnText(renderer, 1).ifBlank { "YouTube Music" }, + artist = artist, durationSec = 0, thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"), kind = YoutubeHit.Kind.TRACK, + album = album, ) } } @@ -765,14 +905,23 @@ class YoutubeClient @Inject constructor( val info = StreamInfo.getInfo(youtube(), key) val stream = pickBestAudioStream(info.audioStreams) ?: throw IllegalStateException("No playable audio stream for this track.") + val videoId = videoIdFromUrl(info.url.orEmpty()).ifBlank { videoIdFromUrl(key) } + val credits = if (videoId.length == 11) { + runCatching { creditsFor(videoId) }.getOrDefault(YoutubeCredits("", "")) + } else { + YoutubeCredits("", "") + } val resolved = YoutubeResolvedTrack( watchUrl = info.url.orEmpty().ifBlank { key }, title = info.name.orEmpty().ifBlank { "Unknown title" }, - artist = info.uploaderName.orEmpty().ifBlank { "Unknown artist" }, + artist = credits.artist.ifBlank { + info.uploaderName.orEmpty().removeSuffix(" - Topic").trim() + }, durationMs = info.duration.coerceAtLeast(0L) * 1000L, streamUrl = stream.content, mimeType = stream.format?.mimeType, artworkUrl = fullBleedArtwork(info.thumbnails.maxByOrNull { it.height }?.url), + album = credits.album, ) resolveCache[key] = CachedResolve(resolved, System.currentTimeMillis()) return resolved @@ -792,14 +941,14 @@ class YoutubeClient @Inject constructor( artist = track.artist, artistId = -1L, artists = emptyList(), - album = "YouTube Music", + album = track.album, albumId = -1L, albumArtist = track.artist, path = track.streamUrl, contentUriString = track.streamUrl, albumArtUriString = track.artworkUrl, duration = track.durationMs, - genre = "YouTube Music", + genre = null, mimeType = track.mimeType, bitrate = null, sampleRate = null, @@ -815,14 +964,14 @@ class YoutubeClient @Inject constructor( artist = hit.artist, artistId = -1L, artists = emptyList(), - album = hit.album.ifBlank { "YouTube Music" }, + album = hit.album, albumId = -1L, albumArtist = hit.artist, path = hit.url, contentUriString = hit.url, albumArtUriString = hit.thumbnailUrl, duration = hit.durationSec.coerceAtLeast(0L) * 1000L, - genre = "YouTube Music", + genre = null, mimeType = null, bitrate = null, sampleRate = null, @@ -863,19 +1012,19 @@ class YoutubeClient @Inject constructor( if (node.has("flexColumns")) firstVideoId(node.optJSONObject("overlay")) else "" } if (videoId.length == 11 && looksLikeTrack(node)) { - val title = videoTitle(node).ifBlank { "YouTube Music" } - hits += YoutubeHit( - url = "https://music.youtube.com/watch?v=$videoId", - title = title, - artist = jsonText(node.optJSONObject("longBylineText")) - .ifBlank { jsonText(node.optJSONObject("shortBylineText")) } - .ifBlank { jsonText(node.optJSONObject("ownerText")) } - .ifBlank { flexColumnText(node, 1) } - .ifBlank { "YouTube Music" }, - durationSec = 0, - thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"), - kind = YoutubeHit.Kind.TRACK, - ) + val title = videoTitle(node) + if (title.isNotBlank()) { + val (artist, album) = creditsFrom(node) + hits += YoutubeHit( + url = "https://music.youtube.com/watch?v=$videoId", + title = title, + artist = artist, + durationSec = 0, + thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"), + kind = YoutubeHit.Kind.TRACK, + album = album, + ) + } } val keys = node.keys() while (keys.hasNext() && hits.size < limit) { @@ -1020,7 +1169,7 @@ class YoutubeClient @Inject constructor( is StreamInfoItem -> YoutubeHit( url = url.orEmpty(), title = name.orEmpty().ifBlank { "Unknown title" }, - artist = uploaderName.orEmpty().ifBlank { "YouTube Music" }, + artist = uploaderName.orEmpty().removeSuffix(" - Topic").trim(), durationSec = duration.coerceAtLeast(0L), thumbnailUrl = fullBleedArtwork(thumbnails.maxByOrNull { it.height }?.url), kind = YoutubeHit.Kind.TRACK, @@ -1028,7 +1177,7 @@ class YoutubeClient @Inject constructor( is PlaylistInfoItem -> YoutubeHit( url = url.orEmpty(), title = name.orEmpty().ifBlank { "Playlist" }, - artist = uploaderName.orEmpty().ifBlank { "YouTube Music" }, + artist = uploaderName.orEmpty().removeSuffix(" - Topic").trim(), durationSec = 0L, thumbnailUrl = fullBleedArtwork(thumbnails.maxByOrNull { it.height }?.url), kind = YoutubeHit.Kind.COLLECTION, @@ -1071,6 +1220,8 @@ class YoutubeClient @Inject constructor( const val LIKED_PAGE_SIZE = 40 private const val MAX_REQUESTS_PER_PAGE = 1 private val VISITOR_REGEX = Regex("^Cg[ts]") + private val CREDIT_DURATION = Regex("""^\d+:\d{2}(?::\d{2})?$""") + private val CREDIT_YEAR = Regex("""^\d{4}$""") fun rememberWatchUrl(songId: String, watchUrl: String) { val id = songId.trim() diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt index 1b6bce8cc9..1a93458d59 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt @@ -47,4 +47,10 @@ data class YoutubeResolvedTrack( val streamUrl: String, val mimeType: String?, val artworkUrl: String?, + val album: String = "", +) + +data class YoutubeCredits( + val artist: String, + val album: String, ) diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt index f2ab5bc7c3..f6d83a1a15 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt @@ -27,6 +27,7 @@ class YoutubeMusicStore @Inject constructor( val recent: StateFlow> = _recent.asStateFlow() val likedSongIds: StateFlow> = _likedSongIds.asStateFlow() private val catalog = LinkedHashMap() + private val creditLookups = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) private val _catalogVersion = MutableStateFlow(0) val catalogVersion: StateFlow = _catalogVersion.asStateFlow() @@ -75,6 +76,7 @@ class YoutubeMusicStore @Inject constructor( artist = artist, album = album, artworkUrl = hit.thumbnailUrl, + videoId = YoutubeClient.videoIdFromUrl(hit.url), ) ) ) { @@ -86,7 +88,14 @@ class YoutubeMusicStore @Inject constructor( if (changed) _catalogVersion.value += 1 } - fun rememberPlayback(songId: String, title: String, artist: String, album: String, artworkUrl: String?) { + fun rememberPlayback( + songId: String, + title: String, + artist: String, + album: String, + artworkUrl: String?, + videoId: String = "", + ) { val id = songId.trim() if (!id.startsWith("yt_") || title.isBlank()) return val changed = synchronized(catalog) { @@ -97,6 +106,7 @@ class YoutubeMusicStore @Inject constructor( artist = cleanName(artist), album = cleanAlbum(album), artworkUrl = artworkUrl, + videoId = videoId.trim(), ) ) if (updated) persistCatalog() @@ -105,6 +115,37 @@ class YoutubeMusicStore @Inject constructor( if (changed) _catalogVersion.value += 1 } + fun creditGaps(playedSongIds: Set, limit: Int): List { + if (playedSongIds.isEmpty() || limit <= 0) return emptyList() + return synchronized(catalog) { + catalog.values.asSequence() + .filter { track -> + track.songId in playedSongIds && + track.videoId.length == 11 && + track.videoId !in creditLookups && + (track.artist.isBlank() || track.album.isBlank()) + } + .take(limit) + .map { track -> + CatalogCreditGap( + songId = track.songId, + title = track.title, + artist = track.artist, + album = track.album, + artworkUrl = track.artworkUrl, + videoId = track.videoId, + ) + } + .toList() + } + } + + fun claimCreditLookup(videoId: String): Boolean = creditLookups.add(videoId) + + fun releaseCreditLookup(videoId: String) { + creditLookups.remove(videoId) + } + fun songsForStats(): List { val tracks = synchronized(catalog) { catalog.values.toList() } return tracks.map { it.toSong() } @@ -124,7 +165,7 @@ class YoutubeMusicStore @Inject constructor( return YoutubeHit( url = url, title = song.title.ifBlank { "Unknown title" }, - artist = song.displayArtist.ifBlank { "YouTube Music" }, + artist = song.displayArtist, durationSec = song.duration.coerceAtLeast(0L) / 1000L, thumbnailUrl = song.albumArtUriString, kind = YoutubeHit.Kind.TRACK, @@ -168,7 +209,7 @@ class YoutubeMusicStore @Inject constructor( YoutubeHit( url = url, title = item.optString("title").ifBlank { "Unknown title" }, - artist = item.optString("artist").ifBlank { "YouTube Music" }, + artist = item.optString("artist"), durationSec = item.optLong("durationSec"), thumbnailUrl = item.optString("thumbnailUrl").ifBlank { null }, kind = YoutubeHit.Kind.TRACK, @@ -202,6 +243,7 @@ class YoutubeMusicStore @Inject constructor( .put("artist", track.artist) .put("album", track.album) .put("artworkUrl", track.artworkUrl ?: "") + .put("videoId", track.videoId) ) } prefs.edit().putString(KEY_CATALOG, array.toString()).apply() @@ -221,9 +263,10 @@ class YoutubeMusicStore @Inject constructor( CatalogTrack( songId = songId, title = title, - artist = item.optString("artist"), - album = item.optString("album"), + artist = cleanName(item.optString("artist")), + album = cleanAlbum(item.optString("album")), artworkUrl = item.optString("artworkUrl").ifBlank { null }, + videoId = item.optString("videoId"), ) ) } @@ -242,8 +285,10 @@ class YoutubeMusicStore @Inject constructor( } private fun cleanName(value: String): String { - val trimmed = value.trim() - if (trimmed.isBlank() || trimmed.equals("Unknown Artist", ignoreCase = true)) return "" + val trimmed = value.trim().removeSuffix(" - Topic").trim() + if (trimmed.isBlank()) return "" + if (trimmed.equals("Unknown Artist", ignoreCase = true)) return "" + if (trimmed.equals("YouTube Music", ignoreCase = true)) return "" return trimmed } @@ -263,28 +308,30 @@ class YoutubeMusicStore @Inject constructor( val artist: String, val album: String, val artworkUrl: String?, + val videoId: String = "", ) { fun merge(incoming: CatalogTrack): CatalogTrack = copy( title = incoming.title.ifBlank { title }, artist = incoming.artist.ifBlank { artist }, album = incoming.album.ifBlank { album }, artworkUrl = incoming.artworkUrl ?: artworkUrl, + videoId = incoming.videoId.ifBlank { videoId }, ) fun toSong(): Song = Song( id = songId, title = title, - artist = artist.ifBlank { "YouTube Music" }, + artist = artist, artistId = -1L, artists = emptyList(), - album = album.ifBlank { "YouTube Music" }, + album = album, albumId = -1L, albumArtist = artist.ifBlank { null }, path = "", contentUriString = "", albumArtUriString = artworkUrl, duration = 0L, - genre = "YouTube Music", + genre = null, mimeType = null, bitrate = null, sampleRate = null, @@ -302,3 +349,12 @@ class YoutubeMusicStore @Inject constructor( private val DURATION = Regex("""^\d+:\d{2}(?::\d{2})?$""") } } + +data class CatalogCreditGap( + val songId: String, + val title: String, + val artist: String, + val album: String, + val artworkUrl: String?, + val videoId: String, +) From b14166034bffebb69f3dca03880babf748ba3884 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Tue, 29 Sep 2026 16:20:58 +0300 Subject: [PATCH 75/77] fix: keep played YouTube playlist tracks and stop skipping songs Playlist playback dropped every track before the current song, and the queue hid songs after they finished. The full playlist now stays queued. Later tracks were queued as watch pages. When no direct audio file was found, playback failed and the player skipped ahead. Those tracks now resolve to a direct stream before they start, and a rejected URL is tried once more. --- .../pixelplay/data/service/MusicService.kt | 48 ++++- .../data/service/player/DualPlayerEngine.kt | 15 +- .../components/QueueBottomSheet.kt | 3 +- .../presentation/screens/YoutubeScreen.kt | 13 +- .../viewmodel/PlaybackDispatchStateHolder.kt | 39 +++- .../presentation/viewmodel/PlayerUiState.kt | 1 + .../presentation/viewmodel/PlayerViewModel.kt | 15 +- .../pixelplay/youtube/YoutubeClient.kt | 190 ++++++++++++++++-- .../pixelplay/youtube/YoutubeDownloader.kt | 2 +- .../pixelplay/youtube/YoutubePlaybackQueue.kt | 11 +- .../pixelplay/youtube/YoutubeStreamPicker.kt | 49 +++++ .../youtube/YoutubePlaybackQueueTest.kt | 23 +++ .../youtube/YoutubeStreamPickerTest.kt | 51 +++++ 13 files changed, 417 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStreamPicker.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/youtube/YoutubeStreamPickerTest.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index 741752da66..c5400ace15 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -210,7 +210,8 @@ class MusicService : MediaLibraryService() { private var countedPlayListener: Player.Listener? = null private var consecutivePlaybackErrorSkips = 0 private val soundCloudRefreshAttempted = mutableSetOf() - private val youtubeRefreshAttempted = mutableSetOf() + private val youtubeRefreshAttempts = mutableMapOf() + private val youtubePrepareStarted = mutableSetOf() private val alarmManager by lazy { getSystemService(Context.ALARM_SERVICE) as AlarmManager } @@ -281,6 +282,7 @@ class MusicService : MediaLibraryService() { private const val MEDIA_SESSION_BUTTON_DEBOUNCE_MS = 250L private const val DEFERRED_SERVICE_STARTUP_WORK_DELAY_MS = 1_000L private const val MAX_PLAYBACK_ERROR_SKIPS = 8 + private const val MAX_YOUTUBE_REFRESH_ATTEMPTS = 2 private const val PAUSED_RESTORE_PREPARE_QUEUE_LIMIT = 50 private val pendingMediaButtonForegroundStarts = AtomicInteger(0) @@ -1406,7 +1408,7 @@ class MusicService : MediaLibraryService() { (mediaSession?.player ?: engine.masterPlayer).currentMediaItem?.mediaId ?.let { soundCloudRefreshAttempted.remove(it) - youtubeRefreshAttempted.remove(it) + youtubeRefreshAttempts.remove(it) } } if (playbackState == Player.STATE_ENDED) { @@ -1437,6 +1439,7 @@ class MusicService : MediaLibraryService() { if (nextIndex != androidx.media3.common.C.INDEX_UNSET) { runCatching { replayGainProcessor.prefetch(player.getMediaItemAt(nextIndex)) } } + prepareUpcomingYoutubeItem(player) } override fun onPositionDiscontinuity( @@ -1518,6 +1521,7 @@ class MusicService : MediaLibraryService() { if (nextIndex != androidx.media3.common.C.INDEX_UNSET) { runCatching { replayGainProcessor.prefetch(player.getMediaItemAt(nextIndex)) } } + prepareUpcomingYoutubeItem(player) // Optimization: Don't force-update widgets on every rapid skip. // Let the debounced updater handle it to prevent UI freezes. widgetUpdateManager.requestFullUpdate(false) @@ -1574,7 +1578,9 @@ class MusicService : MediaLibraryService() { return } val watchUrl = youtubeWatchUrl(currentItem) - if (currentItem != null && !watchUrl.isNullOrBlank() && youtubeRefreshAttempted.add(mediaId)) { + val attempts = youtubeRefreshAttempts[mediaId] ?: 0 + if (currentItem != null && !watchUrl.isNullOrBlank() && attempts < MAX_YOUTUBE_REFRESH_ATTEMPTS) { + youtubeRefreshAttempts[mediaId] = attempts + 1 serviceScope.launch { val refreshed = refreshYoutubeItem(currentItem, watchUrl) withContext(Dispatchers.Main.immediate) { @@ -2014,10 +2020,42 @@ class MusicService : MediaLibraryService() { }.awaitAll() } + private fun prepareUpcomingYoutubeItem(player: Player) { + val nextIndex = player.nextMediaItemIndex + if (nextIndex == C.INDEX_UNSET || nextIndex == player.currentMediaItemIndex) return + val next = runCatching { player.getMediaItemAt(nextIndex) }.getOrNull() ?: return + val watchUrl = youtubeWatchUrl(next) ?: return + val currentUri = next.localConfiguration?.uri?.toString().orEmpty() + if (isYoutubeStreamUrl(currentUri)) return + val mediaId = next.mediaId + if (!youtubePrepareStarted.add(mediaId)) return + serviceScope.launch { + val refreshed = refreshYoutubeItem(next, watchUrl) + withContext(Dispatchers.Main.immediate) { + val index = (0 until player.mediaItemCount).firstOrNull { itemIndex -> + runCatching { player.getMediaItemAt(itemIndex).mediaId }.getOrNull() == mediaId + } ?: return@withContext + if (index == player.currentMediaItemIndex) return@withContext + val existing = player.getMediaItemAt(index).localConfiguration?.uri + val updated = refreshed.localConfiguration?.uri + if (updated != null && updated != existing) { + player.replaceMediaItem(index, refreshed) + } + } + } + } + + private fun isYoutubeStreamUrl(uri: String): Boolean { + return uri.contains("googlevideo.com") || uri.contains("videoplayback") + } + private suspend fun refreshYoutubeItem(item: MediaItem, watchUrl: String): MediaItem = withContext(Dispatchers.IO) { - val streamUrl = runCatching { youtubeClient.resolveTrack(watchUrl).streamUrl }.getOrNull() - if (streamUrl.isNullOrBlank()) return@withContext item + val currentUri = item.localConfiguration?.uri?.toString() + val streamUrl = runCatching { + youtubeClient.resolveTrack(watchUrl, avoidStreamUrl = currentUri).streamUrl + }.getOrNull() + if (streamUrl.isNullOrBlank() || streamUrl == currentUri) return@withContext item val refreshed = item.buildUpon().setUri(streamUrl).build() MediaItemBuilder.withYoutubeWatchUrl(refreshed) } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt index 5cc09ce3f0..8d7b7f005b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt @@ -63,6 +63,7 @@ import com.theveloper.pixelplay.data.navidrome.NavidromeStreamProxy import com.theveloper.pixelplay.data.qqmusic.QqMusicStreamProxy import androidx.core.net.toUri import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics +import com.theveloper.pixelplay.youtube.YoutubeDownloader data class ActiveDecoderInfo( val name: String, @@ -1091,15 +1092,16 @@ class DualPlayerEngine @Inject constructor( override fun resolveDataSpec(dataSpec: DataSpec): DataSpec { val uri = dataSpec.uri val scheme = uri.scheme + val withAgent = youtubePlaybackSpec(dataSpec) if (scheme in CLOUD_PROXY_SCHEMES) { val originalUri = uri.toString() val resolved = resolvedUriCache.get(originalUri) if (resolved != null) { - return dataSpec.buildUpon().setUri(resolved).build() + return withAgent.buildUpon().setUri(resolved).build() } Timber.tag("DualPlayerEngine").d("resolveDataSpec: Cache MISS for %s — using original URI", scheme) } - return dataSpec + return withAgent } } @@ -1269,6 +1271,15 @@ class DualPlayerEngine @Inject constructor( gdriveStreamProxy.resolveGDriveUri(uriString)?.toUri() } + private fun youtubePlaybackSpec(dataSpec: DataSpec): DataSpec { + val host = dataSpec.uri.host.orEmpty() + val youtube = host.contains("googlevideo.com") || host.endsWith("youtube.com") + if (!youtube) return dataSpec + val headers = HashMap(dataSpec.httpRequestHeaders) + headers["User-Agent"] = YoutubeDownloader.USER_AGENT + return dataSpec.buildUpon().setHttpRequestHeaders(headers).build() + } + suspend fun resolveMediaItem(mediaItem: MediaItem): MediaItem { val uri = mediaItem.localConfiguration?.uri ?: return mediaItem val scheme = uri.scheme diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt index a3f564732a..cea0f44d7e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt @@ -288,7 +288,8 @@ fun QueueBottomSheet( // Read show queue history preference val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle() - val showQueueHistory = settingsState.showQueueHistory + val playerUiState by viewModel.playerUiState.collectAsStateWithLifecycle() + val showQueueHistory = settingsState.showQueueHistory || playerUiState.keepPlayedSongsInQueue // Offset to convert display indices to queue indices when history is hidden. val queueIndexOffset = if (showQueueHistory || currentSongIndex < 0) 0 else currentSongIndex diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt index 371f1ace37..bce144fff4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt @@ -303,16 +303,23 @@ fun YoutubeScreen( } } else { runCatching { - val contextHits = YoutubePlaybackQueue.remainingTracks(hit, rows) + val inPlaylist = uiState.collectionTitle != null + val contextHits = if (inPlaylist) { + YoutubePlaybackQueue.playlistTracks(hit, rows) + } else { + YoutubePlaybackQueue.remainingTracks(hit, rows) + } val queued = contextHits.map(viewModel::listSong) val start = viewModel.resolvePlayable(hit) - start to (listOf(start) + queued.drop(1)) + val songs = queued.map { song -> if (song.id == start.id) start else song } + Triple(start, songs, inPlaylist) } - .onSuccess { (start, songs) -> + .onSuccess { (start, songs, inPlaylist) -> playerViewModel.playSongs( songsToPlay = songs, startSong = start, queueName = uiState.collectionTitle ?: "YouTube Music", + keepPlayedSongs = inPlaylist, ) viewModel.warmUpTracks( YoutubePlaybackQueue.remainingTracks(hit, rows), diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackDispatchStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackDispatchStateHolder.kt index 8e346ebc43..596092e143 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackDispatchStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackDispatchStateHolder.kt @@ -495,7 +495,13 @@ class PlaybackDispatchStateHolder @Inject constructor( } } - fun playSongs(songsToPlay: List, startSong: Song, queueName: String = "None", playlistId: String? = null) { + fun playSongs( + songsToPlay: List, + startSong: Song, + queueName: String = "None", + playlistId: String? = null, + keepPlayedSongs: Boolean = false, + ) { cancelPendingFullQueuePlayback() val requestToken = beginDirectPlaybackRequest() directPlaybackJob = cb.scope.launch { @@ -566,7 +572,7 @@ class PlaybackDispatchStateHolder @Inject constructor( throwIfDirectPlaybackRequestIsStale(requestToken) // Send the final list (shuffled or not) to the player engine - internalPlaySongs(finalSongsToPlay, validStartSong, queueName, playlistId) + internalPlaySongs(finalSongsToPlay, validStartSong, queueName, playlistId, keepPlayedSongs) if (requestToken == directPlaybackToken) { directPlaybackJob = null } @@ -804,7 +810,13 @@ class PlaybackDispatchStateHolder @Inject constructor( } } - suspend fun internalPlaySongs(songsToPlay: List, startSong: Song, queueName: String = "None", playlistId: String? = null) { + suspend fun internalPlaySongs( + songsToPlay: List, + startSong: Song, + queueName: String = "None", + playlistId: String? = null, + keepPlayedSongs: Boolean = false, + ) { if (songsToPlay.isEmpty()) { clearPreparingSongIfMatching() return @@ -835,7 +847,13 @@ class PlaybackDispatchStateHolder @Inject constructor( return } - cb.updateUiState { it.copy(currentPlaybackQueue = songsToPlay.toPlaybackQueue(), currentQueueSourceName = queueName) } + cb.updateUiState { + it.copy( + currentPlaybackQueue = songsToPlay.toPlaybackQueue(), + currentQueueSourceName = queueName, + keepPlayedSongsInQueue = keepPlayedSongs, + ) + } playbackStateHolder.updateStablePlayerState { it.copy( currentSong = effectiveStartSong, @@ -850,7 +868,8 @@ class PlaybackDispatchStateHolder @Inject constructor( cb.updateUiState { it.copy( currentPlaybackQueue = songsToPlay.toPlaybackQueue(), - currentQueueSourceName = queueName + currentQueueSourceName = queueName, + keepPlayedSongsInQueue = keepPlayedSongs, ) } playbackStateHolder.updateStablePlayerState { @@ -1049,7 +1068,12 @@ class PlaybackDispatchStateHolder @Inject constructor( castTransferStateHolder.lastRemoteQueue.size ) cb.scope.launch { - internalPlaySongs(localQueue, startSong, cb.getUiState().currentQueueSourceName) + internalPlaySongs( + localQueue, + startSong, + cb.getUiState().currentQueueSourceName, + keepPlayedSongs = cb.getUiState().keepPlayedSongsInQueue, + ) } } else if (remoteHasQueue) { // No local queue available to reconcile; fallback to resuming remote queue. @@ -1084,7 +1108,8 @@ class PlaybackDispatchStateHolder @Inject constructor( internalPlaySongs( currentQueue.toList(), currentSong, - cb.getUiState().currentQueueSourceName + cb.getUiState().currentQueueSourceName, + keepPlayedSongs = cb.getUiState().keepPlayedSongsInQueue, ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt index 7da60dfe46..9748d5c7f8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt @@ -32,6 +32,7 @@ data class PlayerUiState( val filteredSongs: ImmutableList = persistentListOf(), // For search filtering within lists val isFiltering: Boolean = false, val showDismissUndoBar: Boolean = false, + val keepPlayedSongsInQueue: Boolean = false, val dismissedSong: Song? = null, val dismissedQueue: ImmutableList = persistentListOf(), val dismissedQueueName: String = "", diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index d2bf5caea1..d92ea0219e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -2214,8 +2214,19 @@ class PlayerViewModel @Inject constructor( } // rebuildPlayerQueue functionality moved to PlaybackStateHolder (simplified) - fun playSongs(songsToPlay: List, startSong: Song, queueName: String = "None", playlistId: String? = null) = - playbackDispatchStateHolder.playSongs(songsToPlay, startSong, queueName, playlistId) + fun playSongs( + songsToPlay: List, + startSong: Song, + queueName: String = "None", + playlistId: String? = null, + keepPlayedSongs: Boolean = false, + ) = playbackDispatchStateHolder.playSongs( + songsToPlay, + startSong, + queueName, + playlistId, + keepPlayedSongs, + ) fun playSongsShuffled( songsToPlay: List, diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt index 5c14421750..8d7bb9abed 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt @@ -897,11 +897,33 @@ class YoutubeClient @Inject constructor( } } - fun resolveTrack(watchUrl: String): YoutubeResolvedTrack { + fun resolveTrack(watchUrl: String, avoidStreamUrl: String? = null): YoutubeResolvedTrack { applySession() val key = watchUrl.trim() - cachedTrack(key)?.let { return it } + val avoided = avoidStreamUrl?.trim().orEmpty() + cachedTrack(key)?.takeIf { it.streamUrl != avoided }?.let { return it } + + val extracted = runCatching { extractResolved(key) }.getOrNull() + ?.takeIf { it.streamUrl.isNotBlank() && it.streamUrl != avoided } + if (extracted != null) { + resolveCache[key] = CachedResolve(extracted, System.currentTimeMillis()) + return extracted + } + val videoId = videoIdFromUrl(key) + val fallback = if (videoId.length == 11) { + runCatching { audioFromPlayerClients(videoId, avoided) }.getOrNull() + } else { + null + } + if (fallback != null) { + resolveCache[key] = CachedResolve(fallback, System.currentTimeMillis()) + return fallback + } + throw IllegalStateException("No playable audio stream for this track.") + } + + private fun extractResolved(key: String): YoutubeResolvedTrack { val info = StreamInfo.getInfo(youtube(), key) val stream = pickBestAudioStream(info.audioStreams) ?: throw IllegalStateException("No playable audio stream for this track.") @@ -911,7 +933,7 @@ class YoutubeClient @Inject constructor( } else { YoutubeCredits("", "") } - val resolved = YoutubeResolvedTrack( + return YoutubeResolvedTrack( watchUrl = info.url.orEmpty().ifBlank { key }, title = info.name.orEmpty().ifBlank { "Unknown title" }, artist = credits.artist.ifBlank { @@ -923,8 +945,139 @@ class YoutubeClient @Inject constructor( artworkUrl = fullBleedArtwork(info.thumbnails.maxByOrNull { it.height }?.url), album = credits.album, ) - resolveCache[key] = CachedResolve(resolved, System.currentTimeMillis()) - return resolved + } + + /** + * NewPipe's web player has no audio file for some YouTube Music tracks + * (the link is SABR-only, or the catalog video is blocked on that client). + * Those are the songs a playlist skips. A music or embedded player response + * still carries a direct audio URL for them. + */ + private fun audioFromPlayerClients(videoId: String, avoidUrl: String): YoutubeResolvedTrack? { + val responses = listOf( + runCatching { musicPlayer(videoId) }, + runCatching { embeddedPlayer(videoId) }, + runCatching { androidVrPlayer(videoId) }, + ) + for (response in responses) { + val root = response.getOrNull() ?: continue + val status = root.optJSONObject("playabilityStatus")?.optString("status").orEmpty() + if (status.isNotEmpty() && status != "OK") continue + val picked = pickPlayableAudio(audioCandidates(root), avoidUrl.ifBlank { null }) ?: continue + val details = root.optJSONObject("videoDetails") + val title = details?.optString("title").orEmpty().ifBlank { "Unknown title" } + val author = details?.optString("author").orEmpty().removeSuffix(" - Topic").trim() + val length = details?.optString("lengthSeconds")?.toLongOrNull() ?: 0L + val thumb = details?.optJSONObject("thumbnail") + ?.optJSONArray("thumbnails") + ?.let { thumbs -> + (0 until thumbs.length()) + .mapNotNull { thumbs.optJSONObject(it)?.optString("url") } + .lastOrNull { it.isNotBlank() } + } + return YoutubeResolvedTrack( + watchUrl = "https://music.youtube.com/watch?v=$videoId", + title = title, + artist = author, + durationMs = length.coerceAtLeast(0L) * 1000L, + streamUrl = picked.url, + mimeType = picked.mimeType.substringBefore(';').trim().ifBlank { null }, + artworkUrl = fullBleedArtwork(thumb), + ) + } + return null + } + + private fun musicPlayer(videoId: String): JSONObject { + val body = JSONObject() + .put("context", JSONObject(musicContext())) + .put("videoId", videoId) + .put("contentCheckOk", true) + .put("racyCheckOk", true) + return musicPost("player", body.toString()) + } + + private fun embeddedPlayer(videoId: String): JSONObject = innertubePlayer( + videoId = videoId, + clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER", + clientVersion = "2.0", + clientId = "85", + userAgent = MUSIC_USER_AGENT, + ) + + private fun androidVrPlayer(videoId: String): JSONObject = innertubePlayer( + videoId = videoId, + clientName = "ANDROID_VR", + clientVersion = "1.65.10", + clientId = "28", + userAgent = "com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12; eureka-user Build/SQ3A.220605.009.A1) gzip", + extraClient = JSONObject() + .put("deviceMake", "Oculus") + .put("deviceModel", "Quest 3") + .put("androidSdkVersion", 32) + .put("osName", "Android") + .put("osVersion", "12"), + ) + + private fun innertubePlayer( + videoId: String, + clientName: String, + clientVersion: String, + clientId: String, + userAgent: String, + extraClient: JSONObject? = null, + ): JSONObject { + val client = JSONObject() + .put("clientName", clientName) + .put("clientVersion", clientVersion) + .put("hl", "en") + .put("gl", "US") + extraClient?.keys()?.let { keys -> + while (keys.hasNext()) { + val name = keys.next() + client.put(name, extraClient.get(name)) + } + } + val body = JSONObject() + .put("context", JSONObject().put("client", client)) + .put("videoId", videoId) + .put("contentCheckOk", true) + .put("racyCheckOk", true) + val json = downloader.postJson( + url = "https://www.youtube.com/youtubei/v1/player?prettyPrint=false", + json = body.toString(), + origin = "https://www.youtube.com", + clientName = clientId, + clientVersion = clientVersion, + userAgent = userAgent, + ) + return JSONObject(json) + } + + private fun audioCandidates(root: JSONObject): List { + val streaming = root.optJSONObject("streamingData") ?: return emptyList() + return buildList { + addAll(candidatesFrom(streaming.optJSONArray("adaptiveFormats"))) + addAll(candidatesFrom(streaming.optJSONArray("formats"))) + } + } + + private fun candidatesFrom(formats: JSONArray?): List { + if (formats == null) return emptyList() + return buildList { + for (index in 0 until formats.length()) { + val format = formats.optJSONObject(index) ?: continue + val url = format.optString("url") + if (!url.startsWith("http")) continue + add( + YoutubeAudioCandidate( + url = url, + mimeType = format.optString("mimeType"), + bitrate = format.optInt("bitrate"), + ), + ) + } + } } private fun cachedTrack(key: String): YoutubeResolvedTrack? = resolveCache[key] @@ -1187,21 +1340,18 @@ class YoutubeClient @Inject constructor( private fun pickBestAudioStream(streams: List): AudioStream? { if (streams.isEmpty()) return null - val progressive = streams.filter { it.deliveryMethod == DeliveryMethod.PROGRESSIVE_HTTP } - if (progressive.isNotEmpty()) { - return progressive.maxWithOrNull( - compareBy { it.averageBitrate }.thenBy { it.bitrate }, - ) - } - val hls = streams.filter { - it.deliveryMethod == DeliveryMethod.HLS && !it.content.contains("encrypted") - } - if (hls.isNotEmpty()) { - return hls.maxWithOrNull( - compareBy { it.averageBitrate }.thenBy { it.bitrate }, - ) - } - return streams.firstOrNull { !it.content.contains("encrypted") } + val picked = pickPlayableAudio( + streams.map { stream -> + val mime = stream.format?.mimeType + ?: if (stream.deliveryMethod == DeliveryMethod.HLS) "application/vnd.apple.mpegurl" else "" + YoutubeAudioCandidate( + url = stream.content.orEmpty(), + mimeType = mime, + bitrate = stream.averageBitrate.takeIf { it > 0 } ?: stream.bitrate, + ) + }, + ) ?: return null + return streams.firstOrNull { it.content == picked.url } } companion object { diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt index 037e5df44a..924871401c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt @@ -116,7 +116,7 @@ internal class YoutubeDownloader : Downloader() { companion object { private val JSON = "application/json".toMediaType() - private const val USER_AGENT = + internal const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0" private fun authorization(cookies: String, origin: String): String? { diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueue.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueue.kt index 896e20e126..82afb46f30 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueue.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueue.kt @@ -1,10 +1,17 @@ package com.theveloper.pixelplay.youtube /** - * Queue for a YouTube list is the tracks on that page, from the song you tapped - * through the end. Related / radio tracks are never mixed in. + * A playlist keeps every track, including ones already played. Playback still + * starts at the song you tapped. [remainingTracks] is only the upcoming suffix, + * used to warm streams ahead of the current song. */ object YoutubePlaybackQueue { + fun playlistTracks(start: YoutubeHit, source: List): List { + if (start.kind != YoutubeHit.Kind.TRACK) return emptyList() + val tracks = source.filter { it.kind == YoutubeHit.Kind.TRACK } + return if (tracks.any { it.url == start.url }) tracks else listOf(start) + } + fun remainingTracks(start: YoutubeHit, source: List): List { if (start.kind != YoutubeHit.Kind.TRACK) return emptyList() val tracks = source.filter { it.kind == YoutubeHit.Kind.TRACK } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStreamPicker.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStreamPicker.kt new file mode 100644 index 0000000000..273a5b04c7 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStreamPicker.kt @@ -0,0 +1,49 @@ +package com.theveloper.pixelplay.youtube + +internal data class YoutubeAudioCandidate( + val url: String, + val mimeType: String, + val bitrate: Int, +) + +/** + * Chooses a URL ExoPlayer can open. + * YouTube Music tracks often expose a SABR or HLS link next to a plain audio file. + * Playing the manifest first makes that song fail and the playlist skips it. + */ +internal fun pickPlayableAudio( + candidates: List, + avoidUrl: String? = null, +): YoutubeAudioCandidate? { + val ranked = candidates + .filter(::isPlayableAudioCandidate) + .sortedByDescending(::audioCandidateScore) + return ranked.firstOrNull { it.url != avoidUrl } +} + +private fun isPlayableAudioCandidate(candidate: YoutubeAudioCandidate): Boolean { + val url = candidate.url.trim() + if (!url.startsWith("http")) return false + if (url.contains("encrypted", ignoreCase = true)) return false + val lower = url.lowercase() + if ("/sabr" in lower || "source=sabr" in lower) return false + val mime = candidate.mimeType.lowercase() + val audio = mime.startsWith("audio/") + val muxedAudio = mime.startsWith("video/") && ( + "mp4a" in mime || "opus" in mime || "vorbis" in mime + ) + val hls = "mpegurl" in mime || lower.contains(".m3u8") + return audio || muxedAudio || hls +} + +private fun audioCandidateScore(candidate: YoutubeAudioCandidate): Int { + val mime = candidate.mimeType.lowercase() + val kind = when { + mime.startsWith("audio/mp4") || mime.startsWith("audio/m4a") -> 4_000_000 + mime.startsWith("audio/webm") -> 3_000_000 + mime.startsWith("audio/") -> 2_000_000 + mime.startsWith("video/") -> 1_000_000 + else -> 0 + } + return kind + candidate.bitrate.coerceAtLeast(0) +} diff --git a/app/src/test/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueueTest.kt b/app/src/test/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueueTest.kt index 410469bcaf..a5aed6f5de 100644 --- a/app/src/test/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueueTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueueTest.kt @@ -38,6 +38,29 @@ class YoutubePlaybackQueueTest { assertThat(remaining.map { it.title }).containsExactly("One", "Two").inOrder() } + @Test + fun playlistTracks_keepsSongsBeforeTheOneThatIsPlaying() { + val tracks = listOf( + track("a", "One"), + track("b", "Two"), + track("c", "Three"), + ) + + val playlist = YoutubePlaybackQueue.playlistTracks(tracks[1], tracks) + + assertThat(playlist.map { it.title }).containsExactly("One", "Two", "Three").inOrder() + } + + @Test + fun playlistTracks_keepsTheTappedSongWhenItIsNotOnThePage() { + val playlist = YoutubePlaybackQueue.playlistTracks( + start = track("z", "Other"), + source = listOf(track("a", "One")), + ) + + assertThat(playlist.map { it.title }).containsExactly("Other") + } + @Test fun remainingTracks_usesOnlyTheTappedSongWhenItIsNotOnThePage() { val remaining = YoutubePlaybackQueue.remainingTracks( diff --git a/app/src/test/java/com/theveloper/pixelplay/youtube/YoutubeStreamPickerTest.kt b/app/src/test/java/com/theveloper/pixelplay/youtube/YoutubeStreamPickerTest.kt new file mode 100644 index 0000000000..bb6cc09ebe --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/youtube/YoutubeStreamPickerTest.kt @@ -0,0 +1,51 @@ +package com.theveloper.pixelplay.youtube + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class YoutubeStreamPickerTest { + + @Test + fun pickPlayableAudio_prefersDirectAudioOverHlsAndVideo() { + val picked = pickPlayableAudio( + listOf( + candidate("https://cdn.example/playlist.m3u8", "application/vnd.apple.mpegurl", 1_000_000), + candidate("https://cdn.example/video.mp4", "video/mp4; codecs=\"avc1, mp4a.40.2\"", 800_000), + candidate("https://cdn.example/audio.webm", "audio/webm; codecs=\"opus\"", 160_000), + candidate("https://cdn.example/audio.m4a", "audio/mp4; codecs=\"mp4a.40.2\"", 128_000), + ), + ) + + assertThat(picked?.url).isEqualTo("https://cdn.example/audio.m4a") + } + + @Test + fun pickPlayableAudio_skipsSabreAndTheUrlThatJustFailed() { + val picked = pickPlayableAudio( + candidates = listOf( + candidate("https://cdn.example/sabr/audio", "audio/mp4", 200_000), + candidate("https://cdn.example/failed.m4a", "audio/mp4", 160_000), + candidate("https://cdn.example/backup.m4a", "audio/webm", 96_000), + ), + avoidUrl = "https://cdn.example/failed.m4a", + ) + + assertThat(picked?.url).isEqualTo("https://cdn.example/backup.m4a") + } + + @Test + fun pickPlayableAudio_returnsNullWhenEveryUrlAlreadyFailed() { + val picked = pickPlayableAudio( + candidates = listOf(candidate("https://cdn.example/failed.m4a", "audio/mp4", 128_000)), + avoidUrl = "https://cdn.example/failed.m4a", + ) + + assertThat(picked).isNull() + } + + private fun candidate(url: String, mimeType: String, bitrate: Int) = YoutubeAudioCandidate( + url = url, + mimeType = mimeType, + bitrate = bitrate, + ) +} From 37d02154dc1e6cdbdf5d3754653f335c74fc5184 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Tue, 29 Sep 2026 17:48:18 +0300 Subject: [PATCH 76/77] fix: fill missing stats names with the saved AI key Listening stats still say Unknown Artist or Unknown Album when YouTube has no credits for a played song. If an AI key is already saved, those songs are named once from the title and stored for later stats. No key means no request. A failed request is retried on the next open. --- .../theveloper/pixelplay/data/ai/AiHandler.kt | 2 +- .../pixelplay/data/ai/AiSystemPromptEngine.kt | 16 +++ .../presentation/viewmodel/StatsViewModel.kt | 3 + .../pixelplay/youtube/YoutubeMusicStore.kt | 46 ++++++ .../pixelplay/youtube/YoutubeStatsCredits.kt | 135 ++++++++++++++++++ .../youtube/YoutubeStatsCreditsTest.kt | 44 ++++++ 6 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStatsCredits.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/youtube/YoutubeStatsCreditsTest.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt index d9da7840b3..f7f62c4e95 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt @@ -166,7 +166,7 @@ class AiHandler @Inject constructor( val effectiveTemperature = if (params.temperature == 0.7f) { if (temperature == 0.7f) { when (type) { - AiSystemPromptType.METADATA -> 0.1f + AiSystemPromptType.METADATA, AiSystemPromptType.STATS_CREDITS -> 0.1f AiSystemPromptType.MOOD_ANALYSIS -> 0.2f AiSystemPromptType.TAGGING -> 0.4f AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> 0.6f diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt index 9edf9e4042..29feaf00d2 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt @@ -11,6 +11,7 @@ enum class AiSystemPromptType { MOOD_ANALYSIS, PERSONA, DAILY_MIX, + STATS_CREDITS, GENERAL } @@ -182,6 +183,21 @@ class AiSystemPromptEngine @Inject constructor() { $dailyMixPersonaPrompt """.trimIndent() + AiSystemPromptType.STATS_CREDITS -> """ + Music credits editor. You name the artist and album for songs that are missing them. + + - Each song gives an id, title, and any artist or album already known. + - Fill only the blank side. Keep a name that is already present. + - Use the well-known recording when the title identifies it. + - When the recording is not clear, use an empty string. + - Never answer Unknown Artist, Unknown Album, or YouTube Music. + + + Return ONLY a raw JSON array. One object per song, in the same order: + [{"id":"...","artist":"...","album":"..."}] + + """.trimIndent() + AiSystemPromptType.GENERAL -> """ PixelPlayer Assistant — a knowledgeable music companion. diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt index 22d8faed98..974c3b5e97 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt @@ -10,6 +10,7 @@ import com.theveloper.pixelplay.data.stats.StatsTimeRange import com.theveloper.pixelplay.data.stats.mergeStatsSongs import com.theveloper.pixelplay.youtube.YoutubeClient import com.theveloper.pixelplay.youtube.YoutubeMusicStore +import com.theveloper.pixelplay.youtube.YoutubeStatsCreditAi import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers @@ -29,6 +30,7 @@ class StatsViewModel @Inject constructor( private val musicRepository: MusicRepository, private val youtubeMusicStore: YoutubeMusicStore, private val youtubeClient: YoutubeClient, + private val youtubeStatsCreditAi: YoutubeStatsCreditAi, ) : ViewModel() { data class StatsUiState( @@ -174,6 +176,7 @@ class StatsViewModel @Inject constructor( .filter { it.startsWith("yt_") } .toSet() youtubeClient.fillPlayedCredits(youtubeMusicStore, played) + youtubeStatsCreditAi.fill(youtubeMusicStore, played) }.onFailure { throwable -> Timber.e(throwable, "Failed to load YouTube artist and album names") } diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt index f6d83a1a15..08abfa3685 100644 --- a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt @@ -107,6 +107,7 @@ class YoutubeMusicStore @Inject constructor( album = cleanAlbum(album), artworkUrl = artworkUrl, videoId = videoId.trim(), + aiAsked = false, ) ) if (updated) persistCatalog() @@ -115,6 +116,47 @@ class YoutubeMusicStore @Inject constructor( if (changed) _catalogVersion.value += 1 } + fun creditGapsForAi(playedSongIds: Set, limit: Int): List { + if (playedSongIds.isEmpty() || limit <= 0) return emptyList() + return synchronized(catalog) { + val tracks = catalog.values.filter { track -> + track.songId in playedSongIds && + !track.aiAsked && + track.title.isNotBlank() && + (track.artist.isBlank() || track.album.isBlank()) + }.take(limit) + if (tracks.isEmpty()) return@synchronized emptyList() + tracks.forEach { track -> + catalog[track.songId] = track.copy(aiAsked = true) + } + persistCatalog() + tracks.map { track -> + CatalogCreditGap( + songId = track.songId, + title = track.title, + artist = track.artist, + album = track.album, + artworkUrl = track.artworkUrl, + videoId = track.videoId, + ) + } + } + } + + fun releaseAiCreditGaps(songIds: Set) { + if (songIds.isEmpty()) return + synchronized(catalog) { + var changed = false + songIds.forEach { songId -> + val track = catalog[songId] ?: return@forEach + if (!track.aiAsked) return@forEach + catalog[songId] = track.copy(aiAsked = false) + changed = true + } + if (changed) persistCatalog() + } + } + fun creditGaps(playedSongIds: Set, limit: Int): List { if (playedSongIds.isEmpty() || limit <= 0) return emptyList() return synchronized(catalog) { @@ -244,6 +286,7 @@ class YoutubeMusicStore @Inject constructor( .put("album", track.album) .put("artworkUrl", track.artworkUrl ?: "") .put("videoId", track.videoId) + .put("aiAsked", track.aiAsked) ) } prefs.edit().putString(KEY_CATALOG, array.toString()).apply() @@ -267,6 +310,7 @@ class YoutubeMusicStore @Inject constructor( album = cleanAlbum(item.optString("album")), artworkUrl = item.optString("artworkUrl").ifBlank { null }, videoId = item.optString("videoId"), + aiAsked = item.optBoolean("aiAsked"), ) ) } @@ -309,6 +353,7 @@ class YoutubeMusicStore @Inject constructor( val album: String, val artworkUrl: String?, val videoId: String = "", + val aiAsked: Boolean = false, ) { fun merge(incoming: CatalogTrack): CatalogTrack = copy( title = incoming.title.ifBlank { title }, @@ -316,6 +361,7 @@ class YoutubeMusicStore @Inject constructor( album = incoming.album.ifBlank { album }, artworkUrl = incoming.artworkUrl ?: artworkUrl, videoId = incoming.videoId.ifBlank { videoId }, + aiAsked = aiAsked || incoming.aiAsked, ) fun toSong(): Song = Song( diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStatsCredits.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStatsCredits.kt new file mode 100644 index 0000000000..57386708db --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStatsCredits.kt @@ -0,0 +1,135 @@ +package com.theveloper.pixelplay.youtube + +import com.theveloper.pixelplay.data.ai.AiHandler +import com.theveloper.pixelplay.data.ai.AiResponseCleaner +import com.theveloper.pixelplay.data.ai.AiSystemPromptType +import javax.inject.Inject +import javax.inject.Singleton +import timber.log.Timber + +internal data class StatsCreditGuess( + val songId: String, + val artist: String, + val album: String, +) + +internal fun statsCreditPrompt(gaps: List): String = buildString { + appendLine("Name the artist and album for these played songs.") + gaps.forEach { gap -> + append("id=").append(gap.songId) + append(" title=\"").append(gap.title.replace("\"", "'")) + append("\" artist=\"").append(gap.artist.replace("\"", "'")) + append("\" album=\"").append(gap.album.replace("\"", "'")) + appendLine("\"") + } +} + +internal fun parseStatsCredits(raw: String): List { + val body = AiResponseCleaner.extractJsonArray(raw) ?: return emptyList() + val guesses = mutableListOf() + var index = 0 + while (index < body.length) { + val start = body.indexOf('{', index) + if (start < 0) break + val end = matchingBrace(body, start) + if (end < 0) break + val obj = body.substring(start, end + 1) + val songId = jsonField(obj, "id").ifBlank { jsonField(obj, "songId") } + if (songId.isNotBlank()) { + guesses += StatsCreditGuess( + songId = songId, + artist = cleanStatsCredit(jsonField(obj, "artist"), album = false), + album = cleanStatsCredit(jsonField(obj, "album"), album = true), + ) + } + index = end + 1 + } + return guesses +} + +internal fun cleanStatsCredit(value: String, album: Boolean): String { + val trimmed = value.trim().removeSuffix(" - Topic").trim() + if (trimmed.isBlank()) return "" + if (trimmed.equals("Unknown Artist", ignoreCase = true)) return "" + if (trimmed.equals("Unknown Album", ignoreCase = true)) return "" + if (trimmed.equals("YouTube Music", ignoreCase = true)) return "" + if (album && trimmed.contains("view", ignoreCase = true)) return "" + return trimmed +} + +private fun jsonField(obj: String, name: String): String { + val pattern = Regex(""""$name"\s*:\s*"((?:\\.|[^"\\])*)"""") + val raw = pattern.find(obj)?.groupValues?.getOrNull(1) ?: return "" + return raw.replace("\\\"", "\"").replace("\\\\", "\\") +} + +private fun matchingBrace(text: String, start: Int): Int { + var depth = 0 + var inString = false + var escaped = false + for (index in start until text.length) { + val char = text[index] + if (escaped) { + escaped = false + continue + } + if (inString) { + if (char == '\\') escaped = true + else if (char == '"') inString = false + continue + } + when (char) { + '\\' -> escaped = true + '"' -> inString = true + '{' -> depth++ + '}' -> { + depth-- + if (depth == 0) return index + } + } + } + return -1 +} + +/** + * Fills artist and album names that YouTube did not provide, using the AI + * provider the user already configured. Songs are asked once. + */ +@Singleton +class YoutubeStatsCreditAi @Inject constructor( + private val aiHandler: AiHandler, +) { + suspend fun fill(store: YoutubeMusicStore, playedSongIds: Set, limit: Int = 20) { + val gaps = store.creditGapsForAi(playedSongIds, limit) + if (gaps.isEmpty()) return + val response = runCatching { + aiHandler.generateContent( + prompt = statsCreditPrompt(gaps), + type = AiSystemPromptType.STATS_CREDITS, + temperature = 0.1f, + ) + }.getOrElse { error -> + store.releaseAiCreditGaps(gaps.map { it.songId }.toSet()) + val message = error.message.orEmpty() + if (!message.contains("No API key", ignoreCase = true)) { + Timber.w(error, "AI credits lookup failed") + } + return + } + val byId = parseStatsCredits(response).associateBy { it.songId } + gaps.forEach { gap -> + val guess = byId[gap.songId] ?: return@forEach + val artist = gap.artist.ifBlank { guess.artist } + val album = gap.album.ifBlank { guess.album } + if (artist == gap.artist && album == gap.album) return@forEach + store.rememberPlayback( + songId = gap.songId, + title = gap.title, + artist = artist, + album = album, + artworkUrl = gap.artworkUrl, + videoId = gap.videoId, + ) + } + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/youtube/YoutubeStatsCreditsTest.kt b/app/src/test/java/com/theveloper/pixelplay/youtube/YoutubeStatsCreditsTest.kt new file mode 100644 index 0000000000..043d9de6c6 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/youtube/YoutubeStatsCreditsTest.kt @@ -0,0 +1,44 @@ +package com.theveloper.pixelplay.youtube + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class YoutubeStatsCreditsTest { + + @Test + fun parseStatsCredits_readsArtistAndAlbumAndDropsPlaceholders() { + val raw = """ + Here you go: + [ + {"id":"yt_1","artist":"NewJeans","album":"Get Up"}, + {"id":"yt_2","artist":"Unknown Artist","album":"YouTube Music"} + ] + """.trimIndent() + + val guesses = parseStatsCredits(raw) + + assertThat(guesses).containsExactly( + StatsCreditGuess("yt_1", "NewJeans", "Get Up"), + StatsCreditGuess("yt_2", "", ""), + ).inOrder() + } + + @Test + fun statsCreditPrompt_includesTheTitleTheModelHasToIdentify() { + val prompt = statsCreditPrompt( + listOf( + CatalogCreditGap( + songId = "yt_1", + title = "Super Shy", + artist = "", + album = "", + artworkUrl = null, + videoId = "abcdefghijk", + ), + ), + ) + + assertThat(prompt).contains("Super Shy") + assertThat(prompt).contains("yt_1") + } +} From 2208df76077c970e0e71740d1bd2ea35dd39c1dc Mon Sep 17 00:00:00 2001 From: KibetMasi Date: Fri, 2 Oct 2026 16:25:32 +0300 Subject: [PATCH 77/77] feat: foldable UI support --- app/build.gradle.kts | 1 + .../components/player/FoldPlayerLayout.kt | 93 +++++++++ .../components/player/FoldPlayerQueue.kt | 73 ++++++++ .../components/player/FullPlayerContent.kt | 176 ++++++++++++++++-- .../components/player/FoldPlayerLayoutTest.kt | 37 ++++ gradle/libs.versions.toml | 2 + 6 files changed, 366 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayout.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerQueue.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayoutTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6af8596c5e..4b40905cd6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -253,6 +253,7 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.lifecycleprocess) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.window) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayout.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayout.kt new file mode 100644 index 0000000000..f92a238925 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayout.kt @@ -0,0 +1,93 @@ +package com.theveloper.pixelplay.presentation.components.player + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import androidx.window.layout.WindowLayoutInfo + +/** + * How the expanded player should sit on the current window. + * [Phone] is every window that does not cross a hinge: a normal phone in + * either orientation, and the Pixel Fold cover display. + */ +internal enum class FoldPlayerLayout { + Phone, + Tabletop, + Book, + FlatWide, +} + +internal data class FoldSignal( + val halfOpened: Boolean, + val flat: Boolean, + val horizontal: Boolean, + val vertical: Boolean, +) + +internal data class FoldObservation( + val layout: FoldPlayerLayout, + val hingeThickness: Dp, +) + +internal fun foldPlayerLayout(signal: FoldSignal?, isLandscape: Boolean): FoldPlayerLayout { + if (signal == null) return FoldPlayerLayout.Phone + return when { + signal.halfOpened && signal.horizontal -> FoldPlayerLayout.Tabletop + signal.halfOpened && signal.vertical -> FoldPlayerLayout.Book + signal.flat && isLandscape -> FoldPlayerLayout.FlatWide + else -> FoldPlayerLayout.Phone + } +} + +@Composable +internal fun rememberFoldObservation(isLandscape: Boolean): FoldObservation { + val context = LocalContext.current + val activity = remember(context) { context.findActivity() } + if (activity == null) { + return FoldObservation(FoldPlayerLayout.Phone, 0.dp) + } + val layoutInfo = WindowInfoTracker.getOrCreate(activity) + .windowLayoutInfo(activity) + .collectAsStateWithLifecycle(initialValue = WindowLayoutInfo(emptyList())) + .value + val fold = layoutInfo.displayFeatures.filterIsInstance().firstOrNull() + val signal = fold?.let { + FoldSignal( + halfOpened = it.state == FoldingFeature.State.HALF_OPENED, + flat = it.state == FoldingFeature.State.FLAT, + horizontal = it.orientation == FoldingFeature.Orientation.HORIZONTAL, + vertical = it.orientation == FoldingFeature.Orientation.VERTICAL, + ) + } + val thickness = foldThickness(fold) + return FoldObservation(foldPlayerLayout(signal, isLandscape), thickness) +} + +@Composable +private fun foldThickness(fold: FoldingFeature?): Dp { + if (fold == null) return 0.dp + val px = if (fold.orientation == FoldingFeature.Orientation.HORIZONTAL) { + fold.bounds.height() + } else { + fold.bounds.width() + } + return with(LocalDensity.current) { px.toDp() }.coerceAtLeast(0.dp) +} + +private fun Context.findActivity(): Activity? { + var current: Context = this + while (current is ContextWrapper) { + if (current is Activity) return current + current = current.baseContext + } + return null +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerQueue.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerQueue.kt new file mode 100644 index 0000000000..e1e247ca07 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerQueue.kt @@ -0,0 +1,73 @@ +package com.theveloper.pixelplay.presentation.components.player + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem +import com.theveloper.pixelplay.ui.theme.GoogleSansRounded +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun FoldPlayerQueue( + queue: ImmutableList, + currentSongId: String?, + queueName: String, + onSongClick: (Song, Int) -> Unit, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + val currentIndex = queue.indexOfFirst { it.id == currentSongId } + LaunchedEffect(currentSongId, queue.size) { + if (currentIndex >= 0) listState.animateScrollToItem(currentIndex) + } + Column(modifier) { + Text( + text = queueName.ifBlank { stringResource(R.string.queue_source_fallback_label) }, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp), + style = MaterialTheme.typography.titleMedium, + fontFamily = GoogleSansRounded, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (queue.isEmpty()) { + Text( + text = stringResource(R.string.queue_empty_label), + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = GoogleSansRounded, + ) + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + ) { + itemsIndexed(queue, key = { index, song -> "${song.id}-$index" }) { index, song -> + val current = song.id == currentSongId + EnhancedSongListItem( + song = song, + isPlaying = current, + isCurrentSong = current, + showMoreOptionsButton = false, + onMoreOptionsClick = {}, + onClick = { onSongClick(song, index) }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt index 8a30f6f41d..b697464eea 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt @@ -326,6 +326,7 @@ fun FullPlayerContent( val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val fold = rememberFoldObservation(isLandscape) // Lógica para el botón de Lyrics en el reproductor expandido @@ -497,7 +498,7 @@ fun FullPlayerContent( Unit } - val albumCoverSection: @Composable (Modifier) -> Unit = { modifier -> + val albumCoverSection: @Composable (Modifier, Dp) -> Unit = { modifier, cap -> FullPlayerAlbumCoverSection( song = song, currentPlaybackQueue = currentPlaybackQueue, @@ -517,6 +518,7 @@ fun FullPlayerContent( onAlbumClick = { albumSong -> playerViewModel.triggerAlbumNavigationFromPlayer(albumSong.albumId) }, + maxCarouselSide = cap, modifier = modifier ) } @@ -899,8 +901,8 @@ fun FullPlayerContent( } ) { paddingValues -> // MD3: 方向变化时先 alpha=0 再淡入新布局,避免双布局同时测量导致错位 - var contentVisible by remember(isLandscape) { mutableStateOf(false) } - LaunchedEffect(isLandscape) { contentVisible = true } + var contentVisible by remember(isLandscape, fold.layout) { mutableStateOf(false) } + LaunchedEffect(isLandscape, fold.layout) { contentVisible = true } val contentAlpha by animateFloatAsState( targetValue = if (contentVisible) 1f else 0f, animationSpec = tween(durationMillis = 380, easing = FastOutSlowInEasing), @@ -911,22 +913,62 @@ fun FullPlayerContent( .fillMaxSize() .graphicsLayer { alpha = contentAlpha } ) { - if (isLandscape) { - FullPlayerLandscapeContent( + when (fold.layout) { + FoldPlayerLayout.Tabletop -> FullPlayerTabletopContent( paddingValues = paddingValues, + hingeThickness = fold.hingeThickness, albumCoverSection = albumCoverSection, - songMetadataSection = landscapeSongMetadataSection, + songMetadataSection = portraitSongMetadataSection, playerProgressSection = playerProgressSection, - controlsSection = controlsSection + controlsSection = controlsSection, + queue = { + FoldPlayerQueue( + queue = currentPlaybackQueue, + currentSongId = song.id, + queueName = currentQueueSourceName, + onSongClick = onAlbumSongSelected, + modifier = it, + ) + }, ) - } else { - FullPlayerPortraitContent( + FoldPlayerLayout.Book, FoldPlayerLayout.FlatWide -> FullPlayerBookContent( paddingValues = paddingValues, + hingeThickness = if (fold.layout == FoldPlayerLayout.FlatWide) { + fold.hingeThickness.coerceAtMost(8.dp) + } else { + fold.hingeThickness.coerceAtLeast(12.dp) + }, albumCoverSection = albumCoverSection, - songMetadataSection = portraitSongMetadataSection, + songMetadataSection = landscapeSongMetadataSection, playerProgressSection = playerProgressSection, - controlsSection = controlsSection + controlsSection = controlsSection, + queue = { + FoldPlayerQueue( + queue = currentPlaybackQueue, + currentSongId = song.id, + queueName = currentQueueSourceName, + onSongClick = onAlbumSongSelected, + modifier = it, + ) + }, ) + FoldPlayerLayout.Phone -> if (isLandscape) { + FullPlayerLandscapeContent( + paddingValues = paddingValues, + albumCoverSection = albumCoverSection, + songMetadataSection = landscapeSongMetadataSection, + playerProgressSection = playerProgressSection, + controlsSection = controlsSection, + ) + } else { + FullPlayerPortraitContent( + paddingValues = paddingValues, + albumCoverSection = albumCoverSection, + songMetadataSection = portraitSongMetadataSection, + playerProgressSection = playerProgressSection, + controlsSection = controlsSection, + ) + } } } } @@ -1018,6 +1060,7 @@ private fun FullPlayerAlbumCoverSection( requestedScrollIndex: Int?, onSongSelected: (Song, Int) -> Unit, onAlbumClick: (Song) -> Unit, + maxCarouselSide: Dp = Dp.Unspecified, modifier: Modifier = Modifier ) { val shouldDelay = loadingTweaks.delayAll || loadingTweaks.delayAlbumCarousel @@ -1038,12 +1081,17 @@ private fun FullPlayerAlbumCoverSection( .fillMaxWidth() .padding(vertical = 8.dp) ) { - val carouselHeight = when (carouselStyle) { + val preferredHeight = when (carouselStyle) { CarouselStyle.NO_PEEK -> maxWidth CarouselStyle.ONE_PEEK -> maxWidth * 0.8f CarouselStyle.TWO_PEEK -> maxWidth * 0.6f else -> maxWidth * 0.8f } + val carouselHeight = if (maxCarouselSide != Dp.Unspecified) { + preferredHeight.coerceAtMost(maxCarouselSide) + } else { + preferredHeight + } DelayedContent( shouldDelay = shouldDelay, @@ -1373,7 +1421,7 @@ private fun FullPlayerSongMetadataSection( @Composable private fun FullPlayerPortraitContent( paddingValues: PaddingValues, - albumCoverSection: @Composable (Modifier) -> Unit, + albumCoverSection: @Composable (Modifier, Dp) -> Unit, songMetadataSection: @Composable () -> Unit, playerProgressSection: @Composable () -> Unit, controlsSection: @Composable () -> Unit @@ -1389,7 +1437,7 @@ private fun FullPlayerPortraitContent( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceAround ) { - albumCoverSection(Modifier) + albumCoverSection(Modifier, Dp.Unspecified) Column( modifier = Modifier.fillMaxWidth(), @@ -1408,7 +1456,7 @@ private fun FullPlayerPortraitContent( @Composable private fun FullPlayerLandscapeContent( paddingValues: PaddingValues, - albumCoverSection: @Composable (Modifier) -> Unit, + albumCoverSection: @Composable (Modifier, Dp) -> Unit, songMetadataSection: @Composable () -> Unit, playerProgressSection: @Composable () -> Unit, controlsSection: @Composable () -> Unit @@ -1426,7 +1474,8 @@ private fun FullPlayerLandscapeContent( albumCoverSection( Modifier .fillMaxHeight() - .weight(1f) + .weight(1f), + Dp.Unspecified, ) Spacer(Modifier.width(9.dp)) Column( @@ -1447,6 +1496,101 @@ private fun FullPlayerLandscapeContent( } } +@SuppressLint("UnusedBoxWithConstraintsScope") +@Composable +private fun FullPlayerTabletopContent( + paddingValues: PaddingValues, + hingeThickness: Dp, + albumCoverSection: @Composable (Modifier, Dp) -> Unit, + songMetadataSection: @Composable () -> Unit, + playerProgressSection: @Composable () -> Unit, + controlsSection: @Composable () -> Unit, + queue: @Composable (Modifier) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + albumCoverSection(Modifier, maxHeight.coerceAtMost(maxWidth)) + } + songMetadataSection() + } + Spacer(Modifier.height(hingeThickness.coerceAtLeast(12.dp))) + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) { + queue(Modifier.weight(1f)) + playerProgressSection() + controlsSection() + } + } +} + +@SuppressLint("UnusedBoxWithConstraintsScope") +@Composable +private fun FullPlayerBookContent( + paddingValues: PaddingValues, + hingeThickness: Dp, + albumCoverSection: @Composable (Modifier, Dp) -> Unit, + songMetadataSection: @Composable () -> Unit, + playerProgressSection: @Composable () -> Unit, + controlsSection: @Composable () -> Unit, + queue: @Composable (Modifier) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(start = 24.dp, end = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceEvenly, + ) { + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + albumCoverSection(Modifier, minOf(maxWidth, maxHeight)) + } + songMetadataSection() + playerProgressSection() + controlsSection() + } + Spacer(Modifier.width(hingeThickness)) + queue( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(end = 12.dp), + ) + } +} + @androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayoutTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayoutTest.kt new file mode 100644 index 0000000000..491728bd60 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayoutTest.kt @@ -0,0 +1,37 @@ +package com.theveloper.pixelplay.presentation.components.player + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FoldPlayerLayoutTest { + + @Test + fun noHingeStaysOnThePhonePlayerInEitherOrientation() { + assertEquals(FoldPlayerLayout.Phone, foldPlayerLayout(null, isLandscape = false)) + assertEquals(FoldPlayerLayout.Phone, foldPlayerLayout(null, isLandscape = true)) + } + + @Test + fun flatPortraitStaysOnThePhonePlayer() { + val flat = FoldSignal(halfOpened = false, flat = true, horizontal = true, vertical = false) + assertEquals(FoldPlayerLayout.Phone, foldPlayerLayout(flat, isLandscape = false)) + } + + @Test + fun halfOpenHorizontalIsTabletop() { + val tabletop = FoldSignal(halfOpened = true, flat = false, horizontal = true, vertical = false) + assertEquals(FoldPlayerLayout.Tabletop, foldPlayerLayout(tabletop, isLandscape = false)) + } + + @Test + fun halfOpenVerticalIsBook() { + val book = FoldSignal(halfOpened = true, flat = false, horizontal = false, vertical = true) + assertEquals(FoldPlayerLayout.Book, foldPlayerLayout(book, isLandscape = true)) + } + + @Test + fun flatLandscapeUsesTheWideInnerLayout() { + val flat = FoldSignal(halfOpened = false, flat = true, horizontal = false, vertical = true) + assertEquals(FoldPlayerLayout.FlatWide, foldPlayerLayout(flat, isLandscape = true)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f3701381aa..5815de4310 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -104,6 +104,7 @@ uiautomator = "2.3.0" benchmarkMacroJunit4 = "1.4.1" baselineprofile = "1.5.0-alpha06" profileinstaller = "1.4.1" +window = "1.4.0" pagingCommon = "3.3.6" # Wear OS @@ -181,6 +182,7 @@ junit-jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-para androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-window = { group = "androidx.window", name = "window", version.ref = "window" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "composeUi" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "composeUi" }