From f6631b5e8864e20a1e03531d0b22b63b503c7884 Mon Sep 17 00:00:00 2001 From: kibetmasi Date: Wed, 23 Sep 2026 16:55:48 +0300 Subject: [PATCH] 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(\\..*)?") } } }