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 0000000000..64d928892b
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_soundcloud_24.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_soundcloud_filled_24.png b/app/src/main/res/drawable-hdpi/ic_soundcloud_filled_24.png
new file mode 100644
index 0000000000..64d928892b
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_soundcloud_filled_24.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_soundcloud_24.png b/app/src/main/res/drawable-mdpi/ic_soundcloud_24.png
new file mode 100644
index 0000000000..37c166524a
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_soundcloud_24.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_soundcloud_filled_24.png b/app/src/main/res/drawable-mdpi/ic_soundcloud_filled_24.png
new file mode 100644
index 0000000000..37c166524a
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_soundcloud_filled_24.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_soundcloud_24.png b/app/src/main/res/drawable-xhdpi/ic_soundcloud_24.png
new file mode 100644
index 0000000000..c78ff5708a
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_soundcloud_24.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_soundcloud_filled_24.png b/app/src/main/res/drawable-xhdpi/ic_soundcloud_filled_24.png
new file mode 100644
index 0000000000..c78ff5708a
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_soundcloud_filled_24.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_soundcloud_24.png b/app/src/main/res/drawable-xxhdpi/ic_soundcloud_24.png
new file mode 100644
index 0000000000..0fd7bc38f0
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_soundcloud_24.png differ
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 0000000000..0fd7bc38f0
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_soundcloud_filled_24.png differ
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 0000000000..1d6e5194c5
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_soundcloud_24.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_soundcloud_filled_24.png b/app/src/main/res/drawable-xxxhdpi/ic_soundcloud_filled_24.png
new file mode 100644
index 0000000000..1d6e5194c5
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_soundcloud_filled_24.png differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2cb4e6d908..eb01707ddf 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -9,8 +9,48 @@
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(\\..*)?")
}
}
}